Tell a search that gave up from a route that is not there
The route search stops after MAX_VISITED stars and returned null, which everything downstream read as "the catalogue holds no chain". On the real catalogue that was wrong for real questions: Sol to HD 120147 (136 pc) at 5 pc is 50 jumps, and the panel said there was no route. The budget also sat under what the shipped catalogue needs, so it is now 40 000 rather than 20 000: both that route and a star at 170 pc are found, and Sol to HD 2626 at 6 pc, which used to be refused after 4.7 s, plots 56 jumps in about 2 s. A search now reports whether it gave up. The range search no longer counts a give-up as proof that nothing routes below it — that is what reported ranges up to 29% too wide — and it stops after two of them, since those are the probes that cost the most and settle the least: for HD 2626 at 3 pc it offers 5.92 pc in about 4 s, against 6.13 pc in 4.7 s. At the panel's widest range the refused route and the range search are the same question, so it is asked once. Where nothing can be said, the panel says "Too many stars to search at this range." rather than claiming there is no route. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -423,7 +423,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
const refocus = vi.spyOn(StarFieldRenderer.prototype, 'refocus');
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null });
|
||||
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }, { id: PROXIMA.id, name: 'Proxima Centauri' }], totalPc: 1.3, neededRangePc: null, gaveUp: false });
|
||||
await advanceFrames(engine, 0.3);
|
||||
|
||||
// As catalogue indices: the Sun is the first entry of STARS, Proxima the third.
|
||||
@@ -473,7 +473,7 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
expect(links.mock.calls[1][1]).not.toBe(links.mock.calls[0][1]);
|
||||
|
||||
// A route re-chooses the drawn stars around its pins, and here they come out the same: no new graph.
|
||||
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }], totalPc: 0, neededRangePc: null });
|
||||
component.routeResult.set({ stars: [{ id: SUN.id, name: 'Sol' }], totalPc: 0, neededRangePc: null, gaveUp: false });
|
||||
await advanceFrames(engine, 0.3);
|
||||
await settle();
|
||||
expect(links).toHaveBeenCalledTimes(2);
|
||||
@@ -595,12 +595,12 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
});
|
||||
|
||||
it('shows the answer to the latest route asked for, whatever order the answers arrive in', async () => {
|
||||
type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null };
|
||||
type Answer = { route: { stars: number[]; totalPc: number; longestHopPc: number } | null; neededRangePc: number | null; gaveUp: boolean };
|
||||
const answers: Array<(answer: Answer) => void> = [];
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
routing: { route(): Promise<Answer>; links(): Promise<Float32Array>; dispose(): void };
|
||||
routePending(): boolean;
|
||||
routeResult(): { stars: { id: number }[] } | null;
|
||||
routeResult(): { stars: { id: number }[]; gaveUp: boolean } | null;
|
||||
onRouteRequested(request: { fromId: number; toId: number; rangePc: number }): void;
|
||||
};
|
||||
component.routing = {
|
||||
@@ -613,13 +613,19 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
component.onRouteRequested({ fromId: SUN.id, toId: PROXIMA.id, rangePc: 2 });
|
||||
expect(component.routePending()).toBe(true);
|
||||
|
||||
answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null });
|
||||
answers[1]({ route: { stars: [SUN.id, PROXIMA.id], totalPc: 1.3, longestHopPc: 1.3 }, neededRangePc: null, gaveUp: false });
|
||||
await flushAsync();
|
||||
answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null });
|
||||
answers[0]({ route: { stars: [SUN.id, ALPHA_CENTAURI.id], totalPc: 1.34, longestHopPc: 1.34 }, neededRangePc: null, gaveUp: false });
|
||||
await flushAsync();
|
||||
|
||||
expect(component.routeResult()?.stars.map((star) => star.id)).toEqual([SUN.id, PROXIMA.id]);
|
||||
expect(component.routePending()).toBe(false);
|
||||
|
||||
// "It gave up" travels to the panel, which says something else for it than for "there is none".
|
||||
component.onRouteRequested({ fromId: SUN.id, toId: ALPHA_CENTAURI.id, rangePc: 0.5 });
|
||||
answers[2]({ route: null, neededRangePc: null, gaveUp: true });
|
||||
await flushAsync();
|
||||
expect(component.routeResult()).toMatchObject({ stars: [], gaveUp: true });
|
||||
});
|
||||
|
||||
it('releases the routes panel when a route cannot be worked out, so it can be tried again', async () => {
|
||||
|
||||
@@ -1528,7 +1528,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
const request = ++this.routeRequest;
|
||||
this.routePending.set(true);
|
||||
void this.routing.route(fromId, toId, rangePc, ROUTE_RANGE_CEILING_PC).then(
|
||||
({ route, neededRangePc }) => {
|
||||
({ route, neededRangePc, gaveUp }) => {
|
||||
if (request !== this.routeRequest) {
|
||||
return;
|
||||
}
|
||||
@@ -1536,7 +1536,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.routeResult.set({
|
||||
stars: route ? route.stars.map((id) => ({ id, name: this.starsById.get(id)?.name ?? `Star ${id}` })) : [],
|
||||
totalPc: route?.totalPc ?? 0,
|
||||
neededRangePc
|
||||
neededRangePc,
|
||||
gaveUp
|
||||
});
|
||||
this.jumpLinks?.setRoute(route?.stars ?? [], (id) => this.starsById.get(id));
|
||||
},
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('RoutingClient without a worker', () => {
|
||||
it('answers a route from the index it was given', async () => {
|
||||
const client = new RoutingClient(STARS, POSITIONS, index);
|
||||
|
||||
await expect(client.route(100, 104, 1.5, 8)).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5), neededRangePc: null });
|
||||
await expect(client.route(100, 104, 1.5, 8)).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false });
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
@@ -143,8 +143,8 @@ describe('RoutingClient with a worker', () => {
|
||||
await expect(first).resolves.toHaveLength(6);
|
||||
|
||||
const routeRequest = worker.requests[1];
|
||||
worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4 });
|
||||
await expect(route).resolves.toEqual({ route: null, neededRangePc: 4 });
|
||||
worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4, gaveUp: false });
|
||||
await expect(route).resolves.toEqual({ route: null, neededRangePc: 4, gaveUp: false });
|
||||
await flush();
|
||||
|
||||
expect(worker.requests.map((request) => (request.kind === 'links' ? request.rangePc : request.kind))).toEqual([5, 'route', 8]);
|
||||
@@ -161,14 +161,14 @@ describe('RoutingClient with a worker', () => {
|
||||
const widerRange = client.route(100, 104, 2.5, 8);
|
||||
|
||||
expect(worker.requests).toHaveLength(1);
|
||||
worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: 4 });
|
||||
worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: 4, gaveUp: false });
|
||||
|
||||
expect(await again).toEqual(await once);
|
||||
await flush();
|
||||
// The same two stars at another range is another question.
|
||||
expect(worker.requests.map((request) => request.rangePc)).toEqual([1.5, 2.5]);
|
||||
worker.answer({ kind: 'route', requestId: worker.requests[1].requestId, route: null, neededRangePc: null });
|
||||
await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null });
|
||||
worker.answer({ kind: 'route', requestId: worker.requests[1].requestId, route: null, neededRangePc: null, gaveUp: false });
|
||||
await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: false });
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
@@ -218,6 +218,16 @@ describe('RoutingClient with a worker', () => {
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
it('hands on that the search gave up, along with the answer it did give', async () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
const answer = client.route(100, 105, 1.5, 8);
|
||||
|
||||
worker.answer({ kind: 'route', requestId: worker.requests[0].requestId, route: null, neededRangePc: null, gaveUp: true });
|
||||
|
||||
await expect(answer).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: true });
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
it('rejects a request the worker failed on, and goes on to the next', async () => {
|
||||
const { client, worker } = clientWithFake();
|
||||
const failing = client.route(100, 104, 1.5, 8).catch((error: unknown) => error);
|
||||
@@ -240,7 +250,7 @@ describe('RoutingClient with a worker', () => {
|
||||
|
||||
worker.fail();
|
||||
|
||||
await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5), neededRangePc: null });
|
||||
await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false });
|
||||
expect(Array.from(await graph)).toEqual([0, 0, 0, 1, 0, 0]);
|
||||
await expect(client.route(100, 105, 1.5, 8)).resolves.toMatchObject({ route: null });
|
||||
expect(worker.terminated).toBe(true);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { StarRecord } from '../../shared/models/star.model';
|
||||
export interface RouteAnswer {
|
||||
readonly route: Route | null;
|
||||
readonly neededRangePc: number | null;
|
||||
/** True when the search gave up rather than ruling a route out; see `routeBetween`. */
|
||||
readonly gaveUp: boolean;
|
||||
}
|
||||
|
||||
/** A request dropped before it was sent, because a newer one of the same kind replaced it. */
|
||||
@@ -104,7 +106,9 @@ export class RoutingClient {
|
||||
|
||||
route(fromId: number, toId: number, rangePc: number, ceilingPc: number): Promise<RouteAnswer> {
|
||||
return this.ask({ kind: 'route', requestId: this.nextRequestId++, fromId, toId, rangePc, ceilingPc }).then((response) =>
|
||||
response.kind === 'route' ? { route: response.route, neededRangePc: response.neededRangePc } : { route: null, neededRangePc: null }
|
||||
response.kind === 'route'
|
||||
? { route: response.route, neededRangePc: response.neededRangePc, gaveUp: response.gaveUp }
|
||||
: { route: null, neededRangePc: null, gaveUp: false }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -253,6 +253,26 @@ describe('HudDockComponent', () => {
|
||||
expect(tab('Readout').getAttribute('aria-selected')).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');
|
||||
const summary = () => host().querySelector('[data-testid="route-summary"]')?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
|
||||
|
||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: true });
|
||||
fixture.detectChanges();
|
||||
expect(summary()).toBe('Too many stars to search at this range.');
|
||||
|
||||
// Having looked everywhere the range reaches is a different answer, and one that can be stated.
|
||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: null, gaveUp: false });
|
||||
fixture.detectChanges();
|
||||
expect(summary()).toContain('No chain of jumps up to');
|
||||
|
||||
// A range a chain was found at is worth offering whether or not anything shorter was ruled out.
|
||||
fixture.componentRef.setInput('routeResult', { stars: [], totalPc: 0, neededRangePc: 6.4, gaveUp: true });
|
||||
fixture.detectChanges();
|
||||
expect(summary()).toBe('No route at this range. 6.40 pc would reach.');
|
||||
});
|
||||
|
||||
it('keeps what the Routes panel was set to across a trip to another tab', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('routing', true);
|
||||
|
||||
@@ -20,8 +20,10 @@ export interface RouteResult {
|
||||
/** The chain, departure first. Empty when there is no route at the range asked for. */
|
||||
readonly stars: readonly { id: number; name: string }[];
|
||||
readonly totalPc: number;
|
||||
/** The shortest range that would open a route, where none was found at the one asked for. */
|
||||
/** A range that would open a route, where none was found at the one asked for. */
|
||||
readonly neededRangePc: number | null;
|
||||
/** True when the search gave up rather than showing there is no route at this range. */
|
||||
readonly gaveUp: boolean;
|
||||
}
|
||||
|
||||
export interface RouteRequest {
|
||||
@@ -113,7 +115,11 @@ type Field = 'from' | 'to';
|
||||
</p>
|
||||
} @else {
|
||||
<p data-testid="route-summary" class="text-sm text-muted">
|
||||
No route at this range.
|
||||
@if (plotted.gaveUp && plotted.neededRangePc === null) {
|
||||
Too many stars to search at this range.
|
||||
} @else {
|
||||
No route at this range.
|
||||
}
|
||||
@if (plotted.neededRangePc !== null) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -122,7 +128,7 @@ type Field = 'from' | 'to';
|
||||
>
|
||||
{{ format(plotted.neededRangePc) }} would reach.
|
||||
</button>
|
||||
} @else {
|
||||
} @else if (!plotted.gaveUp) {
|
||||
No chain of jumps up to {{ format(maxRangePc) }} reaches it.
|
||||
}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user