// Pure-function coverage for `map/cargo-routes.ts.buildCargoRouteLines`. // The renderer turns each `ReportRouteEntry` into one shaft plus two // arrowhead wings; the tests assert geometry on a flat fixture, on a // torus seam-crossing fixture, and the per-load-type style/priority // mapping. Pixi-free — the helper is a pure projection of the report. import { describe, expect, test } from "vitest"; import type { GameReport, ReportPlanet, ReportRouteEntry, } from "../src/api/game-state"; import { ROUTE_LINE_ID_PREFIX, buildCargoRouteLines, } from "../src/map/cargo-routes"; import { DARK_THEME, LIGHT_THEME } from "../src/map/world"; import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups"; function makePlanet(overrides: Partial): ReportPlanet { return { number: 0, name: "", x: 0, y: 0, kind: "local", owner: null, size: null, resources: null, industryStockpile: null, materialsStockpile: null, industry: null, population: null, colonists: null, production: null, freeIndustry: null, ...overrides, }; } function makeReport( planets: ReportPlanet[], source: number, entries: ReportRouteEntry[], mapWidth = 1000, mapHeight = 1000, ): GameReport { return { turn: 1, mapWidth, mapHeight, planetCount: planets.length, planets, race: "Earthlings", localShipClass: [], routes: [{ sourcePlanetNumber: source, entries }], localPlayerDrive: 1, localPlayerWeapons: 0, localPlayerShields: 0, localPlayerCargo: 0, ...EMPTY_SHIP_GROUPS, }; } describe("buildCargoRouteLines", () => { test("emits one shaft + two wings per route entry", () => { const report = makeReport( [ makePlanet({ number: 1, x: 100, y: 100 }), makePlanet({ number: 2, x: 300, y: 100 }), ], 1, [{ loadType: "COL", destinationPlanetNumber: 2 }], ); const lines = buildCargoRouteLines(report); expect(lines.length).toBe(3); expect(lines.every((l) => l.kind === "line")).toBe(true); }); test("shaft endpoints follow the no-wrap straight line", () => { const report = makeReport( [ makePlanet({ number: 1, x: 100, y: 100 }), makePlanet({ number: 2, x: 300, y: 100 }), ], 1, [{ loadType: "COL", destinationPlanetNumber: 2 }], ); const [shaft] = buildCargoRouteLines(report); expect(shaft).toBeDefined(); if (shaft === undefined) return; expect(shaft.x1).toBe(100); expect(shaft.y1).toBe(100); expect(shaft.x2).toBe(300); expect(shaft.y2).toBe(100); }); test("shaft uses the torus-shortest delta on the seam", () => { // Source at x=950, dest at x=50 in a world 1000 wide. The // shorter wrap is +100 (right past x=1000 to x=1050), not // −900 (left to x=50). const report = makeReport( [ makePlanet({ number: 1, x: 950, y: 500 }), makePlanet({ number: 2, x: 50, y: 500 }), ], 1, [{ loadType: "MAT", destinationPlanetNumber: 2 }], 1000, 1000, ); const [shaft] = buildCargoRouteLines(report); expect(shaft).toBeDefined(); if (shaft === undefined) return; expect(shaft.x1).toBe(950); expect(shaft.x2).toBe(1050); // 950 + 100 expect(shaft.y2).toBe(500); }); test("each load type maps to the documented style and priority", () => { const report = makeReport( [ makePlanet({ number: 1, x: 100, y: 100 }), makePlanet({ number: 2, x: 200, y: 100 }), makePlanet({ number: 3, x: 300, y: 100 }), makePlanet({ number: 4, x: 400, y: 100 }), makePlanet({ number: 5, x: 500, y: 100 }), ], 1, [ { loadType: "COL", destinationPlanetNumber: 2 }, { loadType: "CAP", destinationPlanetNumber: 3 }, { loadType: "MAT", destinationPlanetNumber: 4 }, { loadType: "EMP", destinationPlanetNumber: 5 }, ], ); const lines = buildCargoRouteLines(report); expect(lines.length).toBe(12); // F8-12 / #4 follow-up: shafts and wings now use different // Style objects so the arrowhead wings can carry // `softLengthAnchor: "start"`. Colour / priority remain shared // across both, which is what the de-dupe loop here verifies. const colourByPriority = new Map(); const softLengthByLineId = new Map(); for (const line of lines) { const existing = colourByPriority.get(line.priority); if (existing === undefined) { colourByPriority.set(line.priority, line.style.strokeColor); } else { expect(line.style.strokeColor).toBe(existing); } softLengthByLineId.set(line.id & 0xf, line.style.softLengthAnchor); } // Shaft (offset 0) stays linear; wings (offsets 1/2) get the // new softening anchor so the arrowhead grows sub-linearly. expect(softLengthByLineId.get(0)).toBeUndefined(); expect(softLengthByLineId.get(1)).toBe("start"); expect(softLengthByLineId.get(2)).toBe("start"); // Default (dark) palette colours, one per load type. expect(colourByPriority.get(8)).toBe(DARK_THEME.routeCol); expect(colourByPriority.get(7)).toBe(DARK_THEME.routeCap); expect(colourByPriority.get(6)).toBe(DARK_THEME.routeMat); expect(colourByPriority.get(5)).toBe(DARK_THEME.routeEmp); }); test("uses the supplied palette's stroke colours", () => { const report = makeReport( [ makePlanet({ number: 1, x: 100, y: 100 }), makePlanet({ number: 2, x: 200, y: 100 }), ], 1, [{ loadType: "COL", destinationPlanetNumber: 2 }], ); const [shaft] = buildCargoRouteLines(report, undefined, LIGHT_THEME); expect(shaft.style.strokeColor).toBe(LIGHT_THEME.routeCol); expect(LIGHT_THEME.routeCol).not.toBe(DARK_THEME.routeCol); }); test("line ids carry the ROUTE_LINE_ID_PREFIX high bit", () => { const report = makeReport( [ makePlanet({ number: 1, x: 100, y: 100 }), makePlanet({ number: 2, x: 200, y: 100 }), ], 1, [{ loadType: "COL", destinationPlanetNumber: 2 }], ); const lines = buildCargoRouteLines(report); for (const line of lines) { expect((line.id & ROUTE_LINE_ID_PREFIX) !== 0).toBe(true); } // Three distinct ids — one per segment. const ids = new Set(lines.map((l) => l.id)); expect(ids.size).toBe(3); }); test("skips routes whose source or destination is missing", () => { const report = makeReport( [makePlanet({ number: 1, x: 100, y: 100 })], 1, [ { loadType: "COL", destinationPlanetNumber: 999 }, // unknown dest ], ); expect(buildCargoRouteLines(report).length).toBe(0); }); test("skips zero-length routes (source == destination coords)", () => { const report = makeReport( [ makePlanet({ number: 1, x: 100, y: 100 }), makePlanet({ number: 2, x: 100, y: 100 }), ], 1, [{ loadType: "COL", destinationPlanetNumber: 2 }], ); expect(buildCargoRouteLines(report).length).toBe(0); }); test("returns an empty array when no routes are configured", () => { const report: GameReport = { turn: 1, mapWidth: 1000, mapHeight: 1000, planetCount: 1, planets: [makePlanet({ number: 1, x: 100, y: 100 })], race: "Earthlings", localShipClass: [], routes: [], localPlayerDrive: 1, localPlayerWeapons: 0, localPlayerShields: 0, localPlayerCargo: 0, ...EMPTY_SHIP_GROUPS, }; expect(buildCargoRouteLines(report)).toEqual([]); }); test("arrowhead wings symmetric around the shaft direction", () => { const report = makeReport( [ makePlanet({ number: 1, x: 0, y: 0 }), makePlanet({ number: 2, x: 100, y: 0 }), ], 1, [{ loadType: "COL", destinationPlanetNumber: 2 }], ); const [shaft, leftWing, rightWing] = buildCargoRouteLines(report); expect(shaft).toBeDefined(); expect(leftWing).toBeDefined(); expect(rightWing).toBeDefined(); if ( shaft === undefined || leftWing === undefined || rightWing === undefined ) return; // Both wings start at the head. expect(leftWing.x1).toBe(shaft.x2); expect(leftWing.y1).toBe(shaft.y2); expect(rightWing.x1).toBe(shaft.x2); expect(rightWing.y1).toBe(shaft.y2); // And land symmetrically around the y axis (shaft along +x). expect(leftWing.y2 + rightWing.y2).toBeCloseTo(0); expect(leftWing.x2).toBeCloseTo(rightWing.x2); }); });