Files
star-map/src/app/features/galaxy-system/routing-client.spec.ts
T
SenrokaiandClaude Opus 5 1a53f26474 Answer the review: say which search gave up, and let the bisection earn its offer
The panel printed "No route at this range." beside the range it was offering —
which is the sentence this branch exists to stop it printing. It was gated on
there being no offer, and a search that gives up usually has one: Sol to
HD 120147 at 4.5 pc spends the budget, offers 4.83 pc, and says there is no
route where a 71-jump route exists. The wording now follows the search at the
range that was asked for, and nothing else.

That needs the two give-ups kept apart, so `least` travels beside `gaveUp` to
the panel: one says the asked range was not searched out, the other that the
search for a range that would work was. HIP 69445 at 3 pc — asked-range search
exhaustive in 44 ms, ceiling probe out of budget — used to read "Too many stars
to search at this range." and now reads "No route at this range.", with nothing
claimed after it.

Two more from the same review. The budget flag was read off the settled count,
so a search that proved a dead end with the last star it was allowed reported a
give-up; it now records why the loop stopped. And the bisection's cap could fire
before a single probe had narrowed anything, leaving the ceiling route's own
longest hop as the answer: star 1000115173 at 3 pc was told to go to 8.00 pc,
the control's maximum, for a crossing that works at 6. It now offers 6.93.

Measured in the app, all three: "Too many stars to search at this range.
4.90 pc would reach.", "No route at this range." alone, and 7.00 pc in place of
8.00. The duplicated dead-end test now asks the question it was named for —
exactly the budget's worth of stars reaching each other and none of them the
destination — and each fix kills its own mutant.

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

260 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { jumpLinkSegments, routeBetween } from '../../shared/astro/jump-links';
import { RoutingRequest, RoutingResponse } from '../../shared/astro/routing';
import { StarNeighbourhood } from '../../shared/astro/star-neighbourhood';
import { StarRecord } from '../../shared/models/star.model';
import { RoutingClient, SupersededRequest } from './routing-client';
const STARS: StarRecord[] = Array.from({ length: 6 }, (_, i) => ({
id: 100 + i,
name: `star-${i}`,
x: i < 5 ? i : 9,
y: 0,
z: 0,
magnitude: 5,
spectralType: 'G2V',
colorIndex: 0.6
}));
const POSITIONS = Float32Array.from(STARS.flatMap((star) => [star.x, star.y, star.z]));
const index = new StarNeighbourhood(STARS);
/** Every star drawn. */
const ALL = Uint32Array.from(STARS.keys());
/** Flushes settled promises and their handlers. */
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A worker that records what it is sent and answers only when told to. */
class FakeWorker {
readonly sent: Array<RoutingRequest | { kind: 'catalogue' }> = [];
readonly transferred: ArrayBufferLike[] = [];
private readonly listeners: Record<string, Array<(event: { data?: unknown }) => void>> = {};
terminated = false;
postMessage(message: RoutingRequest | { kind: 'catalogue' }, transfer: Transferable[] = []): void {
this.sent.push(message);
this.transferred.push(...(transfer as ArrayBufferLike[]));
}
addEventListener(type: string, listener: (event: { data?: unknown }) => void): void {
(this.listeners[type] ??= []).push(listener);
}
terminate(): void {
this.terminated = true;
}
/** The requests sent so far, catalogue aside. */
get requests(): RoutingRequest[] {
return this.sent.filter((message): message is RoutingRequest => message.kind !== 'catalogue');
}
answer(response: RoutingResponse): void {
for (const listener of this.listeners['message'] ?? []) listener({ data: response });
}
fail(): void {
for (const listener of this.listeners['error'] ?? []) listener({});
}
}
function clientWithFake(): { client: RoutingClient; worker: FakeWorker } {
const worker = new FakeWorker();
const client = new RoutingClient(STARS, POSITIONS, index, () => worker as unknown as Worker);
return { client, worker };
}
// The unit tests' DOM has no Worker, which is exactly the case the client answers in place.
describe('RoutingClient without a worker', () => {
it('has no Worker to use here, so the in-place answers are what is being tested', () => {
expect(typeof Worker).toBe('undefined');
});
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).route, neededRangePc: null, gaveUp: false, least: true });
client.dispose();
});
it('answers a refused route with the range that would open it', async () => {
const client = new RoutingClient(STARS, POSITIONS, index);
const answer = await client.route(100, 105, 1.5, 8);
expect(answer.route).toBeNull();
expect(answer.neededRangePc).toBeCloseTo(5, 1);
client.dispose();
});
it('answers the graph as segments', async () => {
const client = new RoutingClient(STARS, POSITIONS, index);
expect(Array.from(await client.links(1.5, ALL))).toEqual(Array.from(jumpLinkSegments(index, 1.5)));
client.dispose();
});
it('keeps only the links its budget holds, nearest the centre first', async () => {
const client = new RoutingClient(STARS, POSITIONS, index);
// Stars at x = 0 to 4 a parsec apart: from a centre at 3.9, one and a half parsecs is the link 3-4 alone.
const segments = await client.links(1.5, ALL, { centre: { x: 3.9, y: 0, z: 0 }, lengthPc: 1.5 });
expect(Array.from(segments)).toEqual([3, 0, 0, 4, 0, 0]);
client.dispose();
});
it('links only the stars it is told are drawn', async () => {
const client = new RoutingClient(STARS, POSITIONS, index);
// Stars at x = 0, 1 and 3: only the first two are within 1.5 pc of each other.
expect(Array.from(await client.links(1.5, Uint32Array.of(0, 1, 3)))).toEqual([0, 0, 0, 1, 0, 0]);
client.dispose();
});
});
describe('RoutingClient with a worker', () => {
it('sends the catalogue first, then one request at a time', () => {
const { client, worker } = clientWithFake();
void client.links(8, ALL);
void client.links(3, ALL);
expect(worker.sent[0].kind).toBe('catalogue');
expect(worker.requests).toHaveLength(1);
client.dispose();
});
// A graph at 8 pc is seconds of work the worker cannot drop once started. Every pause on the
// range slider used to queue another, and a route asked for after them waited behind them all.
it('replaces a waiting graph with the newer one before it is ever built, and sends a route ahead of it', async () => {
const { client, worker } = clientWithFake();
const first = client.links(5, ALL);
const superseded = client.links(6, ALL).catch((error: unknown) => error);
const latest = client.links(8, ALL);
const route = client.route(100, 104, 1.5, 8);
const building = worker.requests[0];
worker.answer({ kind: 'links', requestId: building.requestId, segments: new Float32Array(6) });
await flush();
expect(await superseded).toBeInstanceOf(SupersededRequest);
expect(worker.requests.map((request) => request.kind)).toEqual(['links', 'route']);
await expect(first).resolves.toHaveLength(6);
const routeRequest = worker.requests[1];
worker.answer({ kind: 'route', requestId: routeRequest.requestId, route: null, neededRangePc: 4, gaveUp: false, least: true });
await expect(route).resolves.toEqual({ route: null, neededRangePc: 4, gaveUp: false, least: true });
await flush();
expect(worker.requests.map((request) => (request.kind === 'links' ? request.rangePc : request.kind))).toEqual([5, 'route', 8]);
const lastGraph = worker.requests[2];
worker.answer({ kind: 'links', requestId: lastGraph.requestId, segments: new Float32Array(12) });
await expect(latest).resolves.toHaveLength(12);
client.dispose();
});
it('shares the answer to a route already on its way rather than asking it twice', async () => {
const { client, worker } = clientWithFake();
const once = client.route(100, 104, 1.5, 8);
const again = client.route(100, 104, 1.5, 8);
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, gaveUp: false, least: true });
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, gaveUp: false, least: true });
await expect(widerRange).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: false, least: true });
client.dispose();
});
// Turning the layer off and on again while the worker is busy asks for the same graph twice. Were
// the second to replace the first, the first's rejection would wipe the scene's record of the second.
it('shares a graph already on its way for the same range and the same list of drawn stars', async () => {
const { client, worker } = clientWithFake();
const drawn = Uint32Array.of(0, 1, 2);
const building = client.links(3, drawn);
const waiting = client.links(5, drawn);
const again = client.links(5, drawn);
const sameAsBuilding = client.links(3, drawn);
worker.answer({ kind: 'links', requestId: worker.requests[0].requestId, segments: new Float32Array(6) });
await expect(building).resolves.toHaveLength(6);
await expect(sameAsBuilding).resolves.toHaveLength(6);
await flush();
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(12) });
await expect(waiting).resolves.toHaveLength(12);
await expect(again).resolves.toHaveLength(12);
expect(worker.requests.map((request) => request.kind === 'links' && request.rangePc)).toEqual([3, 5]);
client.dispose();
});
it('builds a graph for each set of drawn stars asked about, and never gives the list away', async () => {
const { client, worker } = clientWithFake();
const near = Uint32Array.of(0, 1, 2);
const far = Uint32Array.of(3, 4, 5);
void client.links(3, near);
const second = client.links(3, far);
worker.answer({ kind: 'links', requestId: worker.requests[0].requestId, segments: new Float32Array(6) });
await flush();
expect(worker.requests.map((request) => request.kind === 'links' && Array.from(request.drawn))).toEqual([[0, 1, 2], [3, 4, 5]]);
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(12) });
await expect(second).resolves.toHaveLength(12);
// The same list at a different budget is a different graph.
void client.links(3, far, { centre: { x: 1, y: 0, z: 0 }, lengthPc: 10 });
void client.links(3, far, { centre: { x: 1, y: 0, z: 0 }, lengthPc: 20 });
worker.answer({ kind: 'links', requestId: worker.requests[2].requestId, segments: new Float32Array(0) });
await flush();
expect(worker.requests.map((request) => request.kind === 'links' && request.budget?.lengthPc)).toEqual([undefined, undefined, 10, 20]);
// The star field goes on drawing and picking from these lists, so they are copied, not moved.
expect(worker.transferred).not.toContain(near.buffer);
expect(worker.transferred).not.toContain(far.buffer);
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, least: false });
await expect(answer).resolves.toEqual({ route: null, neededRangePc: null, gaveUp: true, least: false });
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);
const next = client.links(3, ALL);
worker.answer({ kind: 'failed', requestId: worker.requests[0].requestId, message: 'out of memory' });
expect(((await failing) as Error).message).toBe('out of memory');
await flush();
expect(worker.requests.map((request) => request.kind)).toEqual(['route', 'links']);
worker.answer({ kind: 'links', requestId: worker.requests[1].requestId, segments: new Float32Array(0) });
await expect(next).resolves.toHaveLength(0);
client.dispose();
});
it('answers in place what a worker that failed to load left outstanding, and everything after', async () => {
const { client, worker } = clientWithFake();
const route = client.route(100, 104, 1.5, 8);
const graph = client.links(1.5, Uint32Array.of(0, 1, 3));
worker.fail();
await expect(route).resolves.toEqual({ route: routeBetween(index, 100, 104, 1.5).route, neededRangePc: null, gaveUp: false, least: true });
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);
client.dispose();
});
});