Files
star-map/src/app/features/search/search.component.spec.ts
T
SenrokaiandClaude Opus 5.5 18faa6d0b2 Classify a star for the rows that list it, not every star each time the index is built
The search index and the route index gave every one of the 455 571 stars a subtitle up front
through spectralClassification, which filtered the dwarf table afresh on each call; the star
field's tints read the same table once per BP−RP star. Both indices now carry the star itself
and classify it only for the rows shown (entrySubtitle): eight search results, a few route
options. The two filtered columns of the table are built once.

Node, over the shipped catalogue, five runs: classifying every star 121-167 ms before, 56-62
after hoisting the table alone; reading the sequence at every BP−RP colour 187-216 ms, now
111-127. In the app on :4302, two cold loads each, the long task when the Search tab opens was
264-380 ms (median 280) before and 164-276 ms (median 171) after; the longest boot task 863 and
875 ms before, 654 and 741 after.

Tests: the search spec checks its index holds no classification and the row still reads
"Star · ~M8"; the scene spec now reads the route options, and checks its index holds none either.
Controls: classifying every star in the search index, doing so in the route index, and route
options printing the index's subtitle each fail the named test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 23:30:53 +02:00

165 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DataLoaderService, StarField } from '../../core/data/data-loader.service';
import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store';
import { SearchComponent } from './search.component';
function starRecord(id: number, name: string): StarRecord {
return { id, name, x: 0, y: 0, z: 0, magnitude: 5, spectralType: 'G2V', colorIndex: 0.65 };
}
/** Enough "Iot ..." stars to fill the result list ahead of the moon Io, as the real index does. */
const STARS: StarRecord[] = [
...Array.from({ length: 12 }, (_, i) => starRecord(100 + i, `Iot Star ${i}`)),
starRecord(1, 'Proxima Centauri'),
// As the ETL files a Gaia star: no type, a BP−RP colour; and a star with neither.
{ ...starRecord(2, 'TRAPPIST-1'), spectralType: 'Unknown', colorIndex: 4.902, colorSystem: 'BP-RP' },
{ ...starRecord(3, 'KMT-2016-BLG-1107L'), spectralType: 'Unknown', colorIndex: null }
];
const IO: BodyRecord = {
id: 'io',
systemStarId: 0,
name: 'Io',
kind: 'moon',
parentBodyId: 'jupiter',
radiusKm: 1821,
orbit: {
semiMajorAxisAu: 0.002819,
eccentricity: 0.004,
inclinationDeg: 0,
longitudeOfAscendingNodeDeg: 0,
argumentOfPeriapsisDeg: 0,
meanAnomalyAtEpochDeg: 0,
epochJd: 2451545.0
}
};
const PROXIMA_B: ExoplanetRecord = {
id: 'Proxima Cen b',
hostStarId: 1,
hostStarName: 'Proxima Centauri',
name: 'Proxima Cen b',
orbit: { semiMajorAxisAu: 0.0485, eccentricity: 0.02 }
};
class FakeDataLoaderService {
loadStars(): Promise<StarField> {
return Promise.resolve({ stars: STARS, positions: new Float32Array(STARS.length * 3) });
}
loadBodies(): Promise<BodyRecord[]> {
return Promise.resolve([IO]);
}
loadExoplanets(): Promise<ExoplanetRecord[]> {
return Promise.resolve([PROXIMA_B]);
}
}
describe('SearchComponent', () => {
let fixture: ComponentFixture<SearchComponent>;
let element: HTMLElement;
let navigationStore: NavigationStore;
let router: { navigate: ReturnType<typeof vi.fn> };
async function type(query: string): Promise<void> {
fixture.componentInstance.query.set(query);
await fixture.whenStable();
}
function resultNames(): string[] {
return [...element.querySelectorAll('[data-testid="search-results"] button')].map((button) =>
(button.querySelector('span')?.textContent ?? '').trim()
);
}
beforeEach(async () => {
router = { navigate: vi.fn().mockResolvedValue(true) };
await TestBed.configureTestingModule({
imports: [SearchComponent],
providers: [
{ provide: DataLoaderService, useClass: FakeDataLoaderService },
{ provide: Router, useValue: router }
]
}).compileComponents();
fixture = TestBed.createComponent(SearchComponent);
navigationStore = TestBed.inject(NavigationStore);
await fixture.whenStable();
element = fixture.nativeElement as HTMLElement;
});
it('shows no results until the query is long enough', async () => {
await type('I');
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
});
it('finds an exact match that sits behind thousands of stars in the index', async () => {
// The regression this ranking exists for: the moon Io is indexed after every star, so the
// old first-8-substring-hits scan filled up on "Iot ..." names and never reached it.
await type('Io');
expect(resultNames()[0]).toBe('Io');
});
it('puts a host star ahead of its own planets', async () => {
await type('Proxima');
expect(resultNames()[0]).toBe('Proxima Centauri');
expect(resultNames()).toContain('Proxima Cen b');
});
it("lists a star by the type its colour gives it where it has none, and never as \"Unknown\"", async () => {
const kindLine = (): string => (element.querySelector('[data-testid="search-results"] button span:last-child')?.textContent ?? '').trim();
await type('TRAPPIST-1');
expect(kindLine()).toBe('Star · ~M8');
await type('KMT-2016-BLG-1107L');
expect(kindLine()).toBe('Star');
// Classified for the rows shown only: over all 455 571 stars, each opening of the tab spent 140-230 ms on it.
const index = (fixture.componentInstance as unknown as { index(): { entry: { kind: string; subtitle: string } }[] }).index();
expect(index.filter(({ entry }) => entry.kind === 'star').every(({ entry }) => entry.subtitle === '')).toBe(true);
});
it('shows nothing for a query that matches nothing', async () => {
await type('zzzzz');
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
});
it('selects a star and returns to the galaxy route', async () => {
await type('Proxima Centauri');
element.querySelector<HTMLButtonElement>('[data-testid="search-results"] button')!.click();
await fixture.whenStable();
expect(navigationStore.selectedStarId()).toBe(1);
expect(router.navigate).toHaveBeenCalledWith(['/']);
});
it('navigates straight to a body detail route', async () => {
await type('Io');
element.querySelector<HTMLButtonElement>('[data-testid="search-results"] button')!.click();
await fixture.whenStable();
expect(router.navigate).toHaveBeenCalledWith(['/body', 'io']);
});
it('clears the query after a selection, so the list closes', async () => {
await type('Io');
element.querySelector<HTMLButtonElement>('[data-testid="search-results"] button')!.click();
await fixture.whenStable();
expect(fixture.componentInstance.query()).toBe('');
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
});
it('clears the query on demand', async () => {
await type('Io');
fixture.componentInstance.clear();
await fixture.whenStable();
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
});
});