// Vitest unit coverage for `api/synthetic-report.ts`. The decoder // mirrors `pkg/model/report.Report` JSON (as emitted by the Go CLI // `tools/local-dev/legacy-report/cmd/legacy-report-to-json`) into the // in-game-shell `GameReport` shape. The tests assert the decoder // flattens all four planet kinds, looks the local player's tech // levels up by race name, defaults missing routes to empty, and // rejects malformed input. import "@testing-library/jest-dom/vitest"; import { describe, expect, test } from "vitest"; import { SYNTHETIC_GAME_ID_PREFIX, SyntheticReportError, getSyntheticReport, isSyntheticGameId, loadSyntheticReportFromJSON, } from "../src/api/synthetic-report"; import type { BattleReport } from "../src/api/battle-fetch"; import { lookupSyntheticBattle, resetSyntheticBattles, } from "../src/api/synthetic-battle"; function syntheticJSON(extra: Record = {}): unknown { return { turn: 39, mapWidth: 800, mapHeight: 800, mapPlanets: 700, race: "KnightErrants", votes: 16.02, voteFor: "KnightErrants", player: [ { name: "KnightErrants", drive: 13.25, weapons: 6.11, shields: 7.09, cargo: 1, population: 16015.04, industry: 13668.76, planets: 22, relation: "-", votes: 16.02, extinct: false, }, { name: "Other", drive: 9.5, weapons: 4.01, shields: 4.69, cargo: 1, population: 0, industry: 0, planets: 0, relation: "War", votes: 0, extinct: false, }, ], localPlanet: [ { number: 17, name: "Castle", x: 171.05, y: 700.24, size: 1000, population: 1000, industry: 1000, resources: 10, production: "Drive_Research", capital: 0, material: 0.68, colonists: 88.78, freeIndustry: 1000, }, ], otherPlanet: [ { owner: "Monstrai", number: 12, name: "Skarabei", x: 303.84, y: 579.23, size: 500, population: 500, industry: 500, resources: 10, production: "Capital", capital: 0, material: 70.99, colonists: 20.03, freeIndustry: 341.78, }, ], uninhabitedPlanet: [ { number: 9, name: "Dw2", x: 117.87, y: 795.21, size: 500, resources: 10, capital: 0, material: 500, }, ], unidentifiedPlanet: [ { number: 0, x: 738.08, y: 600.26 }, { number: 1, x: 579.12, y: 489.37 }, ], localShipClass: [ { name: "Frontier", drive: 11.37, armament: 0, weapons: 0, shields: 0, cargo: 1, mass: 12.37, }, ], ...extra, }; } describe("loadSyntheticReportFromJSON", () => { test("flattens all four planet kinds with kind-specific nullables", () => { const { gameId, report } = loadSyntheticReportFromJSON(syntheticJSON()); expect(isSyntheticGameId(gameId)).toBe(true); expect(gameId.startsWith(SYNTHETIC_GAME_ID_PREFIX)).toBe(true); expect(report.turn).toBe(39); expect(report.mapWidth).toBe(800); expect(report.mapHeight).toBe(800); expect(report.planetCount).toBe(700); expect(report.race).toBe("KnightErrants"); expect(report.planets).toHaveLength(5); const local = report.planets.find((p) => p.kind === "local")!; expect(local.name).toBe("Castle"); expect(local.industryStockpile).toBe(0); expect(local.materialsStockpile).toBe(0.68); expect(local.industry).toBe(1000); expect(local.production).toBe("Drive_Research"); const other = report.planets.find((p) => p.kind === "other")!; expect(other.owner).toBe("Monstrai"); expect(other.name).toBe("Skarabei"); const uninhab = report.planets.find((p) => p.kind === "uninhabited")!; expect(uninhab.name).toBe("Dw2"); // Uninhabited planets carry size/resources/stockpiles but no // industry / population / production. expect(uninhab.size).toBe(500); expect(uninhab.industry).toBeNull(); expect(uninhab.population).toBeNull(); expect(uninhab.production).toBeNull(); const unident = report.planets.filter((p) => p.kind === "unidentified"); expect(unident).toHaveLength(2); expect(unident[0]!.name).toBe(""); expect(unident[0]!.size).toBeNull(); }); test("derives local player tech from the matching player row", () => { const { report } = loadSyntheticReportFromJSON(syntheticJSON()); expect(report.localPlayerDrive).toBe(13.25); expect(report.localPlayerWeapons).toBe(6.11); expect(report.localPlayerShields).toBe(7.09); expect(report.localPlayerCargo).toBe(1); }); test("returns zeros when the local race row is missing", () => { const { report } = loadSyntheticReportFromJSON( syntheticJSON({ race: "GhostRace" }), ); expect(report.localPlayerDrive).toBe(0); expect(report.localPlayerWeapons).toBe(0); expect(report.localPlayerShields).toBe(0); expect(report.localPlayerCargo).toBe(0); }); test("emits empty routes (legacy format has no routes section)", () => { const { report } = loadSyntheticReportFromJSON(syntheticJSON()); expect(report.routes).toEqual([]); }); test("registers the report under the returned game id", () => { const { gameId, report } = loadSyntheticReportFromJSON(syntheticJSON()); expect(getSyntheticReport(gameId)).toBe(report); }); test("two loads produce distinct ids", () => { const a = loadSyntheticReportFromJSON(syntheticJSON()); const b = loadSyntheticReportFromJSON(syntheticJSON()); expect(a.gameId).not.toBe(b.gameId); }); test("rejects non-object input", () => { expect(() => loadSyntheticReportFromJSON(null)).toThrow( SyntheticReportError, ); expect(() => loadSyntheticReportFromJSON(42)).toThrow( SyntheticReportError, ); expect(() => loadSyntheticReportFromJSON("a string")).toThrow( SyntheticReportError, ); }); test("ship classes survive with truncated armament", () => { const { report } = loadSyntheticReportFromJSON( syntheticJSON({ localShipClass: [ { name: "Bow105", drive: 74.77, armament: 105, weapons: 1, shields: 19.72, cargo: 1, mass: 148.49, }, ], }), ); expect(report.localShipClass).toHaveLength(1); expect(report.localShipClass[0]!.name).toBe("Bow105"); expect(report.localShipClass[0]!.armament).toBe(105); }); }); describe("isSyntheticGameId", () => { test("recognises the synthetic prefix", () => { expect(isSyntheticGameId("synthetic-abc")).toBe(true); expect(isSyntheticGameId("00000000-0000-0000-0000-000000000000")).toBe( false, ); expect(isSyntheticGameId("")).toBe(false); }); }); describe("getSyntheticReport", () => { test("returns undefined for unknown ids", () => { expect(getSyntheticReport("synthetic-missing")).toBeUndefined(); }); }); describe("envelope shape (v1)", () => { test("forwards battles to the synthetic-battle registry", () => { resetSyntheticBattles(); const battle: BattleReport = { id: "11111111-1111-1111-1111-111111111111", planet: 17, planetName: "Castle", races: { "0": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }, ships: { "0": { race: "KnightErrants", className: "Drone", tech: { DRIVE: 1 }, num: 1, numLeft: 0, loadType: "", loadQuantity: 0, inBattle: true, }, }, protocol: [], }; const envelope = { version: 1, report: syntheticJSON(), battles: { [battle.id]: battle }, }; const { gameId, report } = loadSyntheticReportFromJSON(envelope); expect(gameId.startsWith(SYNTHETIC_GAME_ID_PREFIX)).toBe(true); expect(report.turn).toBe(39); expect(lookupSyntheticBattle(battle.id)).toEqual(battle); }); test("missing battles field leaves the registry untouched", () => { resetSyntheticBattles(); const envelope = { version: 1, report: syntheticJSON(), }; loadSyntheticReportFromJSON(envelope); expect(lookupSyntheticBattle("any")).toBeNull(); }); test("bare Report (no envelope) still loads — backward compat", () => { resetSyntheticBattles(); const { report } = loadSyntheticReportFromJSON(syntheticJSON()); expect(report.turn).toBe(39); expect(lookupSyntheticBattle("any")).toBeNull(); }); });