diff --git a/src/app/features/search/search-ranking.spec.ts b/src/app/features/search/search-ranking.spec.ts new file mode 100644 index 0000000..85d3423 --- /dev/null +++ b/src/app/features/search/search-ranking.spec.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSearchIndex, rankSearchResults, scoreSearchMatch, SearchEntry } from './search-ranking'; + +function star(name: string): SearchEntry { + return { kind: 'star', name, subtitle: 'G2V', starId: name.length }; +} +function body(name: string): SearchEntry { + return { kind: 'body', name, subtitle: 'moon', bodyId: name }; +} +function exoplanet(name: string): SearchEntry { + return { kind: 'exoplanet', name, subtitle: 'host', bodyId: name }; +} + +function rank(entries: readonly SearchEntry[], query: string, limit = 8): string[] { + return rankSearchResults(buildSearchIndex(entries), query, limit).map((entry) => entry.name); +} + +describe('scoreSearchMatch', () => { + it('ranks an exact match above a prefix, a prefix above a word start, and that above a substring', () => { + const exact = scoreSearchMatch('Io', 'Io'); + const prefix = scoreSearchMatch('Iot Cas', 'Io'); + const wordStart = scoreSearchMatch('Alpha Ionis', 'Io'); + const substring = scoreSearchMatch('Bellion', 'io'); + + expect(exact).toBeGreaterThan(prefix); + expect(prefix).toBeGreaterThan(wordStart); + expect(wordStart).toBeGreaterThan(substring); + expect(substring).toBeGreaterThan(0); + }); + + it('ignores case', () => { + expect(scoreSearchMatch('Sirius', 'sirius')).toBe(scoreSearchMatch('Sirius', 'Sirius')); + }); + + it('ignores punctuation and spacing for an exact match', () => { + // HYG writes "Gl 357"; a user may well type "gl357". + expect(scoreSearchMatch('Gl 357', 'gl357')).toBe(scoreSearchMatch('Gl 357', 'Gl 357')); + }); + + it('treats a hyphenated part as its own word', () => { + // "Kepler-9 c" should be reachable by its designation as well as its catalogue name. + expect(scoreSearchMatch('Kepler-9 c', '9')).toBeGreaterThan(0); + }); + + it('does not match an unrelated name', () => { + expect(scoreSearchMatch('Sirius', 'zzz')).toBe(0); + }); + + it('does not match an empty query', () => { + expect(scoreSearchMatch('Sirius', '')).toBe(0); + expect(scoreSearchMatch('Sirius', ' ')).toBe(0); + }); +}); + +describe('rankSearchResults', () => { + it('surfaces an exact match that the old index-order scan could never reach', () => { + // The moon Io sits behind 8750 stars in the index, so a scan that stopped at the first + // 8 substring hits returned only stars named "Iot ..." and never reached it. + const entries = [...Array.from({ length: 30 }, (_, i) => star(`Iot Star ${i}`)), body('Io')]; + + expect(rank(entries, 'Io')[0]).toBe('Io'); + }); + + it('keeps the rest of the matches after the exact one', () => { + const entries = [star('Iot Cas'), body('Io'), star('Iot Boo')]; + expect(rank(entries, 'Io')).toEqual(['Io', 'Iot Boo', 'Iot Cas']); + }); + + it('prefers a prefix match over a mid-name one', () => { + expect(rank([star('Bellion'), star('Ionis')], 'io')).toEqual(['Ionis', 'Bellion']); + }); + + it('orders equally-scored matches by kind, bodies first then stars then exoplanets', () => { + const entries = [exoplanet('Cen x'), star('Cen y'), body('Cen z')]; + expect(rank(entries, 'Cen')).toEqual(['Cen z', 'Cen y', 'Cen x']); + }); + + it('puts the host star ahead of its own planets', () => { + const entries = [exoplanet('Proxima Cen b'), exoplanet('Proxima Cen d'), star('Proxima Centauri')]; + expect(rank(entries, 'Proxima')[0]).toBe('Proxima Centauri'); + }); + + it('breaks remaining ties by name length, then alphabetically', () => { + const entries = [exoplanet('Kepler-1292 b'), exoplanet('Kepler-9 c'), exoplanet('Kepler-9 b'), exoplanet('Kepler-15 b')]; + expect(rank(entries, 'Kepler')).toEqual(['Kepler-9 b', 'Kepler-9 c', 'Kepler-15 b', 'Kepler-1292 b']); + }); + + it('is independent of the order entries were indexed in', () => { + const entries = [star('Iot Cas'), body('Io'), exoplanet('Iota b')]; + expect(rank(entries, 'Io')).toEqual(rank([...entries].reverse(), 'Io')); + }); + + it('respects the limit', () => { + const entries = Array.from({ length: 50 }, (_, i) => star(`Test ${i}`)); + expect(rank(entries, 'Test', 8)).toHaveLength(8); + }); + + it('returns nothing for a limit of zero or less', () => { + expect(rank([star('Sirius')], 'Sirius', 0)).toEqual([]); + expect(rank([star('Sirius')], 'Sirius', -1)).toEqual([]); + }); + + it('returns nothing for an empty query rather than everything', () => { + expect(rank([star('Sirius'), body('Io')], '')).toEqual([]); + expect(rank([star('Sirius'), body('Io')], ' ')).toEqual([]); + }); + + it('returns nothing when there is no match', () => { + expect(rank([star('Sirius')], 'zzz')).toEqual([]); + }); + + it('handles an empty index', () => { + expect(rank([], 'anything')).toEqual([]); + }); + + it('keeps both stars that genuinely share a name', () => { + // 17 HYG names are shared by two records — binary components are separate, visitable stars. + const entries = [star('Iot Pic'), star('Iot Pic')]; + expect(rank(entries, 'Iot Pic')).toHaveLength(2); + }); + + it('carries the full entry through, not just the name', () => { + const [result] = rankSearchResults(buildSearchIndex([body('Io')]), 'Io', 1); + expect(result).toMatchObject({ kind: 'body', name: 'Io', bodyId: 'Io' }); + }); +}); + +describe('buildSearchIndex', () => { + it('preserves every entry', () => { + const entries = [star('A'), body('B'), exoplanet('C')]; + expect(buildSearchIndex(entries).map((indexed) => indexed.entry)).toEqual(entries); + }); + + it('precomputes the forms matching needs', () => { + const [indexed] = buildSearchIndex([star('Alpha Cen-B')]); + + expect(indexed.normalizedName).toBe('alpha cen-b'); + expect(indexed.compactName).toBe('alphacenb'); + expect(indexed.words).toEqual(['alpha', 'cen', 'b']); + }); + + it('handles an empty list', () => { + expect(buildSearchIndex([])).toEqual([]); + }); +}); diff --git a/src/app/features/search/search-ranking.ts b/src/app/features/search/search-ranking.ts new file mode 100644 index 0000000..45fce77 --- /dev/null +++ b/src/app/features/search/search-ranking.ts @@ -0,0 +1,139 @@ +export type SearchResultKind = 'star' | 'body' | 'exoplanet'; + +export interface SearchEntry { + kind: SearchResultKind; + name: string; + subtitle: string; + /** HYG star id, for `kind: 'star'` results. */ + starId?: number; + /** `bodies.json`/`exoplanets.json` id, for `kind: 'body' | 'exoplanet'` results. */ + bodyId?: string; +} + +/** + * How well a name matches, best first. The gaps are what matter: any exact match outranks every + * prefix match, and so on, so a better kind of match can never be crowded out by a worse one. + */ +const MATCH_EXACT = 4; +const MATCH_PREFIX = 3; +const MATCH_WORD_START = 2; +const MATCH_SUBSTRING = 1; +const NO_MATCH = 0; + +/** + * Order for results that match equally well. Solar-system bodies are eighteen famous objects + * and win ties outright; a star outranks an exoplanet because searching a name like "Proxima" + * is usually an attempt to reach the system rather than one particular planet in it. + */ +const KIND_PRIORITY: Readonly> = { + body: 0, + star: 1, + exoplanet: 2 +}; + +/** Catalogue names separate their parts with spaces, hyphens, underscores and slashes. */ +const WORD_SEPARATORS = /[\s\-_/]+/; + +function normalize(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, ' '); +} + +/** Strips everything but letters and digits, so "gj581" can match "GJ 581". */ +function compact(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +/** + * A search entry with its name pre-broken into the forms matching needs. + * + * Built once via {@link buildSearchIndex} rather than derived per keystroke. Normalising 15,000 + * names on every character typed costs about 11 ms — most of a frame — and the search runs on + * the same thread as the render loop, so doing it live visibly stutters the scene. + */ +export interface IndexedSearchEntry { + readonly entry: SearchEntry; + readonly normalizedName: string; + readonly compactName: string; + readonly words: readonly string[]; +} + +export function buildSearchIndex(entries: readonly SearchEntry[]): IndexedSearchEntry[] { + return entries.map((entry) => { + const normalizedName = normalize(entry.name); + return { + entry, + normalizedName, + compactName: compact(entry.name), + words: normalizedName.split(WORD_SEPARATORS) + }; + }); +} + +function scoreIndexed(indexed: IndexedSearchEntry, normalizedQuery: string, compactQuery: string): number { + // Punctuation-insensitive as well as case-insensitive, because the catalogues are + // inconsistent about it: HYG writes "Gl 357" where a user may well type "gl357". + if (indexed.normalizedName === normalizedQuery || indexed.compactName === compactQuery) { + return MATCH_EXACT; + } + if (indexed.normalizedName.startsWith(normalizedQuery)) { + return MATCH_PREFIX; + } + if (indexed.words.some((word) => word.startsWith(normalizedQuery))) { + return MATCH_WORD_START; + } + if (indexed.normalizedName.includes(normalizedQuery)) { + return MATCH_SUBSTRING; + } + return NO_MATCH; +} + +/** + * How well `name` matches `query`, or {@link NO_MATCH}. The readable, allocation-per-call form + * of {@link scoreIndexed}, kept for tests and for callers scoring a single name. + */ +export function scoreSearchMatch(name: string, query: string): number { + const normalizedQuery = normalize(query); + if (normalizedQuery === '') { + return NO_MATCH; + } + return scoreIndexed(buildSearchIndex([{ kind: 'star', name, subtitle: '' }])[0], normalizedQuery, compact(query)); +} + +/** + * The `limit` best matches for `query`, best first. + * + * Replaces a scan that took the first `limit` substring matches in index order — stars, then + * bodies, then exoplanets. With 8750 stars ahead of 18 bodies, that let a worse match hide a + * better one *and* an exact match: searching "Io" returned eight stars named "Iot ..." and + * never reached the moon Io at all, because the scan had already filled up. + * + * Everything is scored before anything is taken, so the best matches win regardless of where + * they sit in the index. Ties break on kind, then on name length — a shorter name containing + * the query is the closer match — and finally alphabetically, so the order is fully determined + * rather than dependent on the input order. + */ +export function rankSearchResults(index: readonly IndexedSearchEntry[], query: string, limit: number): SearchEntry[] { + const normalizedQuery = normalize(query); + if (limit <= 0 || normalizedQuery === '') { + return []; + } + const compactQuery = compact(query); + + const scored: { entry: SearchEntry; score: number }[] = []; + for (const indexed of index) { + const score = scoreIndexed(indexed, normalizedQuery, compactQuery); + if (score > NO_MATCH) { + scored.push({ entry: indexed.entry, score }); + } + } + + scored.sort( + (a, b) => + b.score - a.score || + KIND_PRIORITY[a.entry.kind] - KIND_PRIORITY[b.entry.kind] || + a.entry.name.length - b.entry.name.length || + a.entry.name.localeCompare(b.entry.name) + ); + + return scored.slice(0, limit).map((match) => match.entry); +} diff --git a/src/app/features/search/search.component.spec.ts b/src/app/features/search/search.component.spec.ts new file mode 100644 index 0000000..f5eeb7f --- /dev/null +++ b/src/app/features/search/search.component.spec.ts @@ -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 { + return Promise.resolve({ stars: STARS, positions: new Float32Array(STARS.length * 3) }); + } + loadBodies(): Promise { + return Promise.resolve([IO]); + } + loadExoplanets(): Promise { + return Promise.resolve([PROXIMA_B]); + } +} + +describe('SearchComponent', () => { + let fixture: ComponentFixture; + let element: HTMLElement; + let navigationStore: NavigationStore; + let router: { navigate: ReturnType }; + + async function type(query: string): Promise { + 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('[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('[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('[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(); + }); +}); diff --git a/src/app/features/search/search.component.ts b/src/app/features/search/search.component.ts index 1405174..92863dd 100644 --- a/src/app/features/search/search.component.ts +++ b/src/app/features/search/search.component.ts @@ -3,18 +3,7 @@ import { Router } from '@angular/router'; import { DataLoaderService } from '../../core/data/data-loader.service'; import { NavigationStore } from '../../shared/state/navigation.store'; - -type SearchResultKind = 'star' | 'body' | 'exoplanet'; - -interface SearchEntry { - kind: SearchResultKind; - name: string; - subtitle: string; - /** HYG star id, for `kind: 'star'` results. */ - starId?: number; - /** `bodies.json`/`exoplanets.json` id, for `kind: 'body' | 'exoplanet'` results. */ - bodyId?: string; -} +import { buildSearchIndex, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking'; const MAX_RESULTS = 8; const MIN_QUERY_LENGTH = 2; @@ -70,23 +59,15 @@ const KIND_LABELS: Record = { }) export class SearchComponent { readonly query = signal(''); - private readonly index = signal([]); + /** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */ + private readonly index = signal([]); readonly results = computed(() => { - const query = this.query().trim().toLowerCase(); + const query = this.query().trim(); if (query.length < MIN_QUERY_LENGTH) { return []; } - const matches: SearchEntry[] = []; - for (const entry of this.index()) { - if (entry.name.toLowerCase().includes(query)) { - matches.push(entry); - if (matches.length >= MAX_RESULTS) { - break; - } - } - } - return matches; + return rankSearchResults(this.index(), query, MAX_RESULTS); }); constructor( @@ -133,7 +114,7 @@ export class SearchComponent { ...exoplanets.map((exoplanet): SearchEntry => ({ kind: 'exoplanet', name: exoplanet.name, subtitle: exoplanet.hostStarName, bodyId: exoplanet.id })) ]; - this.index.set(entries); + this.index.set(buildSearchIndex(entries)); } catch (error) { console.error('Failed to build the search index.', error); }