Files
star-map/src/app/features/hud/hud-dock.component.spec.ts
T
SenrokaiandClaude Opus 5 365252f534 Let the Routes panel be clicked as soon as it is back, and stop it departing from elsewhere
From the review of #24. Two defects, both in a real browser.

The panel keeps its entries across a trip to another tab, but still replayed the acquire wipe on
the way back, and for the 380 ms that runs, its clip path swallows clicks: type "Siri", leave for
Readout, come back and click the Sirius suggestion, and the click lands on the star field behind
it — measured, the element under the pointer is the canvas, and the field stays "Siri". The wipe is
gone from this one panel: it is not acquiring anything it did not already have.

The departure field fell back to the star the view is in whenever nothing had been chosen, text in
the field or not. So a field reading "Sol" that was never resolved plotted from Barnard's Star:
"1 Barnard's Star, 2 Sol, 3 Sirius", the panel naming one departure and the route leaving from
another. Text nobody chose is no longer a departure, and the button waits until it is one or the
field is empty again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-09-18 13:42:11 +02:00

328 lines
13 KiB
TypeScript

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DataLoaderService } from '../../core/data/data-loader.service';
import { BookmarksStore } from '../../shared/state/bookmarks.store';
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component';
class EmptyDataLoaderService {
loadStars() {
return Promise.resolve({ stars: [], positions: new Float32Array(0) });
}
loadBodies() {
return Promise.resolve([]);
}
loadExoplanets() {
return Promise.resolve([]);
}
}
describe('HudDockComponent', () => {
let fixture: ComponentFixture<HudDockComponent>;
function host(): HTMLElement {
return fixture.nativeElement as HTMLElement;
}
function tabNames(): string[] {
return [...host().querySelectorAll('[role="tab"]')].map((tab) => tab.textContent?.trim() ?? '');
}
function tab(name: string): HTMLButtonElement {
const found = [...host().querySelectorAll<HTMLButtonElement>('[role="tab"]')].find((t) => t.textContent?.trim() === name);
if (!found) {
throw new Error(`no "${name}" tab`);
}
return found;
}
function setReadout(): void {
fixture.componentRef.setInput('eyebrow', 'Galactic Scale');
fixture.componentRef.setInput('title', 'Milky Way');
fixture.componentRef.setInput('subtitle', 'Barred spiral');
fixture.componentRef.setInput('readouts', [{ label: 'Arms', value: '5' }, { label: 'Luminosity', value: '1 L☉', derived: true }]);
fixture.componentRef.setInput('note', 'Illustrative model.');
fixture.componentRef.setInput('range', '21.5 kpc');
}
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [HudDockComponent],
providers: [
{ provide: DataLoaderService, useClass: EmptyDataLoaderService },
{ provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
]
}).compileComponents();
fixture = TestBed.createComponent(HudDockComponent);
});
it('offers the search and what has been kept, when it has nothing else', () => {
// Bookmarks are always offered: it is the only place that says the map can keep anything.
fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Bookmarks']);
expect(host().querySelector('[role="tabpanel"]')).toBeNull();
});
it('grows a tab per thing it has been given', () => {
setReadout();
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Readout', 'Bookmarks', 'Display']);
fixture.componentRef.setInput('routing', true);
fixture.detectChanges();
expect(tabNames()).toEqual(['Search', 'Readout', 'Routes', 'Bookmarks', 'Display']);
});
it('opens the default tab on mount and renders the readout from its inputs', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
const text = host().textContent ?? '';
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', 'Derived, not catalogued', '21.5 kpc']) {
expect(text).toContain(expected);
}
expect(host().querySelector('[data-testid="hud-title"]')?.textContent?.trim()).toBe('Milky Way');
});
it('leaves out the optional lines it was given nothing for', () => {
fixture.componentRef.setInput('title', 'Local Stars');
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
expect(host().querySelector('dl')).toBeNull();
expect(host().textContent).not.toContain('undefined');
});
it('keeps the range on the strip whichever panel is open, and hides it when there is none', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'search');
fixture.detectChanges();
expect(host().textContent).toContain('21.5 kpc');
fixture.componentRef.setInput('range', '');
fixture.detectChanges();
expect(host().textContent).not.toContain('Range');
});
it('toggles a tab closed when it is clicked while open', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
tab('Readout').click();
fixture.detectChanges();
expect(host().querySelector('[role="tabpanel"]')).toBeNull();
expect(tab('Readout').getAttribute('aria-selected')).toBe('false');
tab('Search').click();
fixture.detectChanges();
expect(host().querySelector('input[type="text"]')).not.toBeNull();
});
it('emits the flipped layer and nothing else', () => {
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
fixture.componentRef.setInput('defaultTab', 'display');
fixture.detectChanges();
const emitted: HudDisplay[] = [];
fixture.componentInstance.displayChange.subscribe((display) => emitted.push(display));
const orbits = [...host().querySelectorAll<HTMLButtonElement>('[aria-pressed]')].find((b) => b.textContent?.includes('Orbits'));
orbits?.click();
expect(emitted).toEqual([{ ...DEFAULT_HUD_DISPLAY, orbits: false }]);
});
it('reflects the layer state it is given as pressed buttons', () => {
fixture.componentRef.setInput('display', { ...DEFAULT_HUD_DISPLAY, grid: false });
fixture.componentRef.setInput('defaultTab', 'display');
fixture.detectChanges();
const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`);
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true', 'Systems=true', 'Jump links=false', 'Plan view=false']);
});
it('says how to keep a place, rather than showing an empty list', () => {
fixture.componentRef.setInput('defaultTab', 'bookmarks');
fixture.detectChanges();
expect(host().querySelector('#dock-panel-bookmarks ul')).toBeNull();
expect(host().textContent).toContain('Nothing kept yet');
});
it('lists what has been kept, newest first, and says which kind each is', () => {
const bookmarks = TestBed.inject(BookmarksStore);
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
bookmarks.toggle({ kind: 'body', id: 'earth', name: 'Earth' });
fixture.componentRef.setInput('defaultTab', 'bookmarks');
fixture.detectChanges();
const rows = [...host().querySelectorAll('#dock-panel-bookmarks li')].map((li) => li.textContent?.replace(/\s+/g, ' ').trim());
expect(rows[0]).toContain('Earth');
expect(rows[0]).toContain('Body');
expect(rows[1]).toContain('Sirius');
expect(rows[1]).toContain('System');
});
it('hands back the place that was chosen, whole', () => {
const bookmarks = TestBed.inject(BookmarksStore);
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
fixture.componentRef.setInput('defaultTab', 'bookmarks');
fixture.detectChanges();
const chosen: unknown[] = [];
fixture.componentInstance.bookmarkChosen.subscribe((bookmark) => chosen.push(bookmark));
host().querySelector<HTMLButtonElement>('#dock-panel-bookmarks li button')?.click();
expect(chosen).toEqual([{ kind: 'star', id: 7, name: 'Sirius' }]);
});
it('forgets one without disturbing the rest', () => {
const bookmarks = TestBed.inject(BookmarksStore);
bookmarks.toggle({ kind: 'star', id: 7, name: 'Sirius' });
bookmarks.toggle({ kind: 'body', id: 'earth', name: 'Earth' });
fixture.componentRef.setInput('defaultTab', 'bookmarks');
fixture.detectChanges();
host().querySelector<HTMLButtonElement>('[aria-label="Forget Earth"]')?.click();
fixture.detectChanges();
expect(bookmarks.bookmarks().map((bookmark) => bookmark.name)).toEqual(['Sirius']);
expect(host().textContent).not.toContain('Earth');
});
it('keeps the star the readout is about, and says so on the control', () => {
const bookmarks = TestBed.inject(BookmarksStore);
setReadout();
fixture.componentRef.setInput('title', 'Sirius');
fixture.componentRef.setInput('keepableStarId', 7);
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
const keep = host().querySelector<HTMLButtonElement>('[aria-label="Keep Sirius"]');
expect(keep?.getAttribute('aria-pressed')).toBe('false');
keep?.click();
fixture.detectChanges();
expect(bookmarks.has('star', 7)).toBe(true);
expect(host().querySelector('[aria-label="Forget Sirius"]')?.getAttribute('aria-pressed')).toBe('true');
});
it('keeps the one system whose catalogue id is zero, which truthiness would have lost', () => {
// The Sun is star 0. A `@if (id; as ...)` reads that as "no star" and hides the control.
const bookmarks = TestBed.inject(BookmarksStore);
setReadout();
fixture.componentRef.setInput('title', 'Sol');
fixture.componentRef.setInput('keepableStarId', 0);
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
host().querySelector<HTMLButtonElement>('[aria-label="Keep Sol"]')?.click();
fixture.detectChanges();
expect(bookmarks.has('star', 0)).toBe(true);
});
it('offers nothing to keep where the readout is a scale rather than a place', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
expect(host().querySelector('[aria-label^="Keep"]')).toBeNull();
});
it('opens the search on "/" from anywhere but a text field', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'readout');
fixture.detectChanges();
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true }));
fixture.detectChanges();
expect(tab('Search').getAttribute('aria-selected')).toBe('true');
});
it('hands the panel back to the readout once a result is picked', () => {
setReadout();
fixture.componentRef.setInput('defaultTab', 'search');
fixture.detectChanges();
fixture.componentInstance.onPicked();
fixture.detectChanges();
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
});
it('will not plot from a departure that was typed but never chosen', () => {
setReadout();
fixture.componentRef.setInput('routing', true);
fixture.componentRef.setInput('currentStar', { id: 3, name: "Barnard's Star", subtitle: '1.8 pc' });
fixture.componentRef.setInput('routeOptions', [{ id: 7, name: 'Sirius', subtitle: '2.6 pc' }]);
fixture.componentRef.setInput('defaultTab', 'routes');
fixture.detectChanges();
const plot = () => [...host().querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.includes('Plot route'))!;
const type = (field: string, value: string) => {
const input = host().querySelector<HTMLInputElement>(`#route-${field}`)!;
input.value = value;
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
};
type('to', 'Sir');
host().querySelector<HTMLButtonElement>('#dock-panel-routes ul button')!.click();
fixture.detectChanges();
// With the departure field empty, the view's own star stands in for it.
expect(plot().disabled).toBe(false);
// Text that names no chosen star is not a departure: plotting from the view's star instead
// would name one place and leave from another.
type('from', 'Sol');
expect(plot().disabled).toBe(true);
type('from', '');
expect(plot().disabled).toBe(false);
});
it('does not replay the acquire wipe over the Routes panel, whose entries survive the trip', () => {
setReadout();
fixture.componentRef.setInput('routing', true);
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
fixture.componentRef.setInput('defaultTab', 'routes');
fixture.detectChanges();
// The wipe clips its panel for 380 ms, which swallows clicks on entries that are already there.
expect(host().querySelector('#dock-panel-routes')?.classList.contains('hud-acquire')).toBe(false);
tab('Display').click();
fixture.detectChanges();
expect(host().querySelector('#dock-panel-display')?.classList.contains('hud-acquire')).toBe(true);
});
it('keeps what the Routes panel was set to across a trip to another tab', () => {
setReadout();
fixture.componentRef.setInput('routing', true);
fixture.componentRef.setInput('routeOptions', [{ id: 7, name: 'Sirius', subtitle: '2.6 pc' }]);
fixture.componentRef.setInput('defaultTab', 'routes');
fixture.detectChanges();
const destination = host().querySelector<HTMLInputElement>('#route-to')!;
destination.value = 'Sir';
destination.dispatchEvent(new Event('input'));
fixture.detectChanges();
host().querySelector<HTMLButtonElement>('#dock-panel-routes ul button')!.click();
const range = host().querySelector<HTMLInputElement>('#route-range')!;
range.value = '6';
range.dispatchEvent(new Event('input'));
fixture.detectChanges();
tab('Readout').click();
fixture.detectChanges();
expect(host().querySelector<HTMLElement>('#dock-panel-routes')!.hidden).toBe(true);
tab('Routes').click();
fixture.detectChanges();
expect(host().querySelector<HTMLElement>('#dock-panel-routes')!.hidden).toBe(false);
expect(host().querySelector<HTMLInputElement>('#route-to')!.value).toBe('Sirius');
expect(host().querySelector<HTMLInputElement>('#route-range')!.value).toBe('6');
});
});