Rank search results instead of taking the first eight
Search scanned the index in construction order — 8750 stars, then 18 bodies, then 6319 exoplanets — collected substring matches, and stopped at eight. With no scoring, position in the index decided everything. Typing "Io" returned eight stars named "Iot Cas", "Iot Eri" and so on, and never reached the moon Io — despite Io being an *exact* match, because the scan had already filled up 8750 entries before the bodies begin. "Kepler" returned Kepler-939 b, Kepler-1292 b, Kepler-223 d and five more in whatever order the archive happened to list them. Everything is now scored before anything is taken, so where an entry sits in the index cannot hide a better match. Exact beats prefix beats word-start beats substring, with the gaps wide enough that a worse kind of match can never outrank a better one. Ties break on kind — the eighteen solar-system bodies first, then stars, then exoplanets, so "Proxima" offers the star before its own planets — then on name length, then alphabetically, so the order is fully determined rather than inherited from the input. Matching is punctuation insensitive as well as case insensitive, so "gl357" finds "Gl 357". "Io" now returns the moon first. "Kepler" returns Kepler-4 b through Kepler-9 d. The index is pre-normalised once on load rather than per keystroke. Lowercasing, stripping punctuation and splitting 15,000 names on every character typed costs about 11 ms, which is most of a frame, and search shares a thread with the render loop — so it stuttered the scene while typing. Precomputing takes a broad query down to 2.5 ms and a narrow one to 0.5 ms, for one 15 ms build during the existing data load. Adds the first tests this component has had, alongside the ranking's own. Tests: 237 passing, up from 206. Verified in a real browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
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')
|
||||
];
|
||||
|
||||
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('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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user