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>
165 lines
6.0 KiB
TypeScript
165 lines
6.0 KiB
TypeScript
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();
|
||
});
|
||
});
|