Merge pull request #32 from avalon-vanguard/fix/routes-panel-honesty

Let the Routes panel be clicked as soon as it is back, and stop it departing from elsewhere
This commit is contained in:
2026-09-18 19:08:37 +02:00
3 changed files with 73 additions and 5 deletions
@@ -253,6 +253,65 @@ describe('HudDockComponent', () => {
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);
// A space is not text that names a star: the field looks empty, the scene offers nothing to
// choose for it, and the button going dead would have nothing on screen to explain it.
type('from', ' ');
expect(plot().disabled).toBe(false);
// The offer beside a refusal is the same request by another route, so it is held to the same
// test: moving the range with nothing to plot leaves the panel contradicting itself.
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 1.8, gaveUp: false, least: true });
fixture.detectChanges();
const offer = () => host().querySelector<HTMLButtonElement>('[data-testid="route-summary"] button')!;
expect(offer().disabled).toBe(false);
type('from', 'Sol');
expect(offer().disabled).toBe(true);
});
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('says the search gave up rather than that there is no route, when that is what happened', () => {
fixture.componentRef.setInput('routing', true);
fixture.componentRef.setInput('defaultTab', 'routes');
+4 -2
View File
@@ -186,9 +186,11 @@ function isWideViewport(): boolean {
}
<!-- Hidden rather than unmounted: the departure, destination and range it holds would
otherwise reset on every trip to another tab, while the scene kept drawing the graph at
the old range. Hiding still replays the acquire wipe when it is shown again. -->
the old range. No acquire wipe, unlike the panels around it: this is the one that comes
back with what it had, so it is not acquiring anything — and for the 380 ms the wipe
runs, its clip path swallows clicks on the suggestions it just brought back. -->
@if (routing()) {
<section id="dock-panel-routes" role="tabpanel" aria-labelledby="dock-tab-routes" [hidden]="activeTab() !== 'routes'" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3">
<section id="dock-panel-routes" role="tabpanel" aria-labelledby="dock-tab-routes" [hidden]="activeTab() !== 'routes'" class="hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-xl px-4 py-3">
<app-routes-panel
[result]="routeResult()"
[pending]="routePending()"
+10 -3
View File
@@ -125,8 +125,9 @@ type Field = 'from' | 'to';
@if (plotted.neededRangePc !== null) {
<button
type="button"
[disabled]="!canPlot() || pending()"
(click)="raiseTo(plotted.neededRangePc)"
class="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent"
class="text-accent underline decoration-accent/40 underline-offset-2 disabled:opacity-40 disabled:no-underline enabled:hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent"
>
{{ format(plotted.neededRangePc) }} would reach.
</button>
@@ -184,8 +185,14 @@ export class RoutesPanelComponent {
private readonly chosen = signal<Record<Field, RouteStarOption | null>>({ from: null, to: null });
private readonly typed = signal<Record<Field, string>>({ from: '', to: '' });
/** Departure falls back to wherever the view already is, so one field is usually enough. */
private readonly departure = computed(() => this.chosen().from ?? this.currentStar());
/**
* Departure falls back to wherever the view already is, so one field is usually enough — but only
* while the field is empty. Text left in it that names no chosen star used to fall back all the
* same, so the panel read "Sol" and the route left from whatever the view had since flown to.
*/
// Trimmed, as the scene trims the same text before offering matches for it: a field holding one
// space looks empty, offers nothing to choose, and would otherwise count as a departure.
private readonly departure = computed(() => this.chosen().from ?? (this.typed().from.trim() ? null : this.currentStar()));
readonly canPlot = computed(() => this.departure() !== null && this.chosen().to !== null);
readonly rangeLabel = computed(() => formatParsecs(this.rangePc()));