Files
star-map/src/app/shared/astro/routing.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

113 lines
5.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { jumpLinkSegments, minimumRangeBetween, routeBetween } from './jump-links';
import { answerRouting, indexCatalogue } from './routing';
import { StarNeighbourhood, StarPoint } from './star-neighbourhood';
/** Stars a parsec apart along x, then a gap of 5 pc to one more. */
const POINTS = [...Array.from({ length: 5 }, (_, i) => ({ id: 10 + i, x: i, y: 0, z: 0 })), { id: 99, x: 9, y: 0, z: 0 }];
function catalogue() {
return {
kind: 'catalogue' as const,
ids: Int32Array.from(POINTS, (point) => point.id),
positions: Float32Array.from(POINTS.flatMap((point) => [point.x, point.y, point.z]))
};
}
describe('indexCatalogue', () => {
it('indexes the catalogue as it was packed, id by id', () => {
const index = indexCatalogue(catalogue());
for (const point of POINTS) {
expect(index.point(point.id)).toEqual(point);
}
});
});
/** The same index, counting the neighbour queries a search makes through it. */
class CountingNeighbourhood extends StarNeighbourhood {
queries = 0;
override forEachWithin(id: number, radiusPc: number, visit: (neighbour: StarPoint, distancePc: number) => void): void {
this.queries++;
super.forEachWithin(id, radiusPc, visit);
}
}
describe('answerRouting', () => {
const index = indexCatalogue(catalogue());
const direct = new StarNeighbourhood(POINTS);
it('answers a route the range allows, with nothing to raise it to', () => {
const answer = answerRouting(index, { kind: 'route', requestId: 7, fromId: 10, toId: 14, rangePc: 1.5, ceilingPc: 8 });
expect(answer).toEqual({ kind: 'route', requestId: 7, route: routeBetween(direct, 10, 14, 1.5).route, neededRangePc: null, gaveUp: false, least: true });
});
it('answers a route the range does not allow with the range that would', () => {
const answer = answerRouting(index, { kind: 'route', requestId: 8, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 8 });
expect(answer).toEqual({ kind: 'route', requestId: 8, route: null, neededRangePc: minimumRangeBetween(direct, 10, 99, 8).rangePc, gaveUp: false, least: true });
expect(answer.kind === 'route' && answer.neededRangePc).toBeCloseTo(5, 1);
});
it('passes on that the search gave up, rather than reporting no route', () => {
// A crowd larger than a search's budget around the departure, and a destination nothing reaches:
// the answer is "it gave up", and the scene has to be able to tell that from "there is none".
let seed = 5;
const random = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) * 10 - 5;
const crowd: StarPoint[] = Array.from({ length: 45000 }, (_, i) => ({ id: 1000 + i, x: random(), y: random(), z: random() }));
const knot = new StarNeighbourhood([{ id: 0, x: 0, y: 0, z: 0 }, ...crowd, { id: 99, x: 500, y: 0, z: 0 }], 0.5);
const answer = answerRouting(knot, { kind: 'route', requestId: 12, fromId: 0, toId: 99, rangePc: 0.5, ceilingPc: 0.5 });
expect(answer).toMatchObject({ route: null, neededRangePc: null, gaveUp: true });
});
it('asks the ceiling its question once, rather than searching it again to answer it', () => {
// At the panel's widest range the refused route and the range search are the same question, run
// with the same arguments over the same index: the second pays the whole budget for the answer
// the first already gave.
const counting = new CountingNeighbourhood(POINTS);
const oneSearch = new CountingNeighbourhood(POINTS);
routeBetween(oneSearch, 10, 99, 3);
const answer = answerRouting(counting, { kind: 'route', requestId: 11, fromId: 10, toId: 99, rangePc: 3, ceilingPc: 3 });
expect(answer).toMatchObject({ route: null, neededRangePc: null });
expect(counting.queries).toBe(oneSearch.queries);
});
it('offers nothing to raise to when even the ceiling does not reach', () => {
const answer = answerRouting(index, { kind: 'route', requestId: 9, fromId: 10, toId: 99, rangePc: 1.5, ceilingPc: 3 });
expect(answer).toMatchObject({ route: null, neededRangePc: null });
});
it('answers the graph as the segments it draws', () => {
const answer = answerRouting(index, { kind: 'links', requestId: 3, rangePc: 1.5, drawn: Uint32Array.from(POINTS.keys()) });
expect(answer.kind).toBe('links');
expect(answer.requestId).toBe(3);
expect(answer.kind === 'links' && linkEnds(answer.segments)).toEqual(linkEnds(jumpLinkSegments(direct, 1.5)));
});
it('links only the drawn stars, including a pair exactly the range apart', () => {
// Stars at x = 0, 1, 2 and 4 drawn; the one at 3, which would bridge 2 and 4, is not. At 1 pc
// every link is exactly the range long, and the cells are exactly the range wide.
const answer = answerRouting(index, { kind: 'links', requestId: 4, rangePc: 1, drawn: Uint32Array.of(0, 1, 2, 4) });
expect(answer.kind === 'links' && linkEnds(answer.segments)).toEqual(['0-1', '1-2']);
});
});
/** Each link as its two ends' x, lower first, in order: the pairs, whatever order they were walked in. */
function linkEnds(segments: Float32Array): string[] {
const ends: string[] = [];
for (let at = 0; at < segments.length; at += 6) {
ends.push([segments[at], segments[at + 3]].sort((a, b) => a - b).join('-'));
}
return ends.sort();
}