// Vitest unit coverage for the pure `applyOrderOverlay` projection. // Phase 14 introduced the overlay for `planetRename`; Phase 15 // extends it to `setProductionType` and shares the same eligibility // rule. Future phases (route updates, etc.) will extend the overlay // and need equivalent cases here. import { describe, expect, test } from "vitest"; import { applyOrderOverlay, productionDisplayFromCommand, type GameReport, type ReportPlanet, } from "../src/api/game-state"; import type { CommandStatus, OrderCommand, ProductionType, } from "../src/sync/order-types"; 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[]): GameReport { return { turn: 4, mapWidth: 4000, mapHeight: 4000, planetCount: planets.length, planets, race: "", localShipClass: [], routes: [], localPlayerDrive: 0, localPlayerWeapons: 0, localPlayerShields: 0, localPlayerCargo: 0, ...EMPTY_SHIP_GROUPS, }; } describe("applyOrderOverlay", () => { test("returns the same report when no commands match", () => { const report = makeReport([makePlanet({ number: 1, name: "Earth" })]); const out = applyOrderOverlay(report, [], {}); expect(out).toBe(report); }); test("renames a planet on applied commands", () => { const report = makeReport([ makePlanet({ number: 1, name: "Earth" }), makePlanet({ number: 2, name: "Mars" }), ]); const cmd: OrderCommand = { kind: "planetRename", id: "cmd-1", planetNumber: 1, name: "New Earth", }; const statuses: Record = { "cmd-1": "applied" }; const out = applyOrderOverlay(report, [cmd], statuses); expect(out).not.toBe(report); expect(out.planets[0]!.name).toBe("New Earth"); expect(out.planets[1]!.name).toBe("Mars"); // raw report stays untouched expect(report.planets[0]!.name).toBe("Earth"); }); test("renames on submitting too (in-flight optimistic)", () => { const report = makeReport([makePlanet({ number: 1, name: "Earth" })]); const cmd: OrderCommand = { kind: "planetRename", id: "cmd-1", planetNumber: 1, name: "Pending", }; const out = applyOrderOverlay(report, [cmd], { "cmd-1": "submitting" }); expect(out.planets[0]!.name).toBe("Pending"); }); test("skips draft / invalid / rejected statuses", () => { const report = makeReport([makePlanet({ number: 1, name: "Earth" })]); const cmd: OrderCommand = { kind: "planetRename", id: "cmd-1", planetNumber: 1, name: "Tentative", }; for (const status of ["draft", "invalid", "rejected"] as const) { const out = applyOrderOverlay(report, [cmd], { "cmd-1": status }); expect(out.planets[0]!.name).toBe("Earth"); } }); test("applies on `valid` so the player sees their committed intent immediately", () => { const report = makeReport([makePlanet({ number: 1, name: "Earth" })]); const cmd: OrderCommand = { kind: "planetRename", id: "cmd-1", planetNumber: 1, name: "Pending-Sync", }; const out = applyOrderOverlay(report, [cmd], { "cmd-1": "valid" }); expect(out.planets[0]!.name).toBe("Pending-Sync"); }); test("ignores rename for missing planet (visibility lost)", () => { const report = makeReport([makePlanet({ number: 1, name: "Earth" })]); const cmd: OrderCommand = { kind: "planetRename", id: "cmd-1", planetNumber: 99, name: "Phantom", }; const out = applyOrderOverlay(report, [cmd], { "cmd-1": "applied" }); expect(out).toBe(report); }); test("placeholder commands pass through", () => { const report = makeReport([makePlanet({ number: 1, name: "Earth" })]); const cmd: OrderCommand = { kind: "placeholder", id: "cmd-1", label: "noop", }; const out = applyOrderOverlay(report, [cmd], { "cmd-1": "applied" }); expect(out).toBe(report); }); test("multiple renames apply in command order", () => { const report = makeReport([makePlanet({ number: 1, name: "Old" })]); const first: OrderCommand = { kind: "planetRename", id: "cmd-1", planetNumber: 1, name: "Mid", }; const second: OrderCommand = { kind: "planetRename", id: "cmd-2", planetNumber: 1, name: "Final", }; const out = applyOrderOverlay(report, [first, second], { "cmd-1": "applied", "cmd-2": "applied", }); expect(out.planets[0]!.name).toBe("Final"); }); test("setProductionType rewrites planet.production for valid statuses", () => { const report = makeReport([ makePlanet({ number: 1, name: "Earth", production: "Capital" }), ]); const cmd: OrderCommand = { kind: "setProductionType", id: "cmd-1", planetNumber: 1, productionType: "DRIVE", subject: "", }; for (const status of ["valid", "submitting", "applied"] as const) { const out = applyOrderOverlay(report, [cmd], { "cmd-1": status }); expect(out.planets[0]!.production).toBe("Drive"); } }); test("setProductionType skips draft / invalid / rejected statuses", () => { const report = makeReport([ makePlanet({ number: 1, name: "Earth", production: "Capital" }), ]); const cmd: OrderCommand = { kind: "setProductionType", id: "cmd-1", planetNumber: 1, productionType: "DRIVE", subject: "", }; for (const status of ["draft", "invalid", "rejected"] as const) { const out = applyOrderOverlay(report, [cmd], { "cmd-1": status }); expect(out.planets[0]!.production).toBe("Capital"); } }); test("setProductionType applied with subject mirrors the engine's display", () => { const report = makeReport([ makePlanet({ number: 1, name: "Earth", production: "Capital" }), ]); const cmd: OrderCommand = { kind: "setProductionType", id: "cmd-1", planetNumber: 1, productionType: "SHIP", subject: "Scout", }; const out = applyOrderOverlay(report, [cmd], { "cmd-1": "applied" }); expect(out.planets[0]!.production).toBe("Scout"); }); test("setProductionType + planetRename for the same planet compose", () => { const report = makeReport([ makePlanet({ number: 1, name: "Earth", production: "Capital" }), ]); const rename: OrderCommand = { kind: "planetRename", id: "cmd-rename", planetNumber: 1, name: "New-Earth", }; const setProd: OrderCommand = { kind: "setProductionType", id: "cmd-prod", planetNumber: 1, productionType: "DRIVE", subject: "", }; const out = applyOrderOverlay(report, [rename, setProd], { "cmd-rename": "applied", "cmd-prod": "applied", }); expect(out.planets[0]!.name).toBe("New-Earth"); expect(out.planets[0]!.production).toBe("Drive"); }); test("ignores setProductionType for missing planet (visibility lost)", () => { const report = makeReport([ makePlanet({ number: 1, name: "Earth", production: "Capital" }), ]); const cmd: OrderCommand = { kind: "setProductionType", id: "cmd-1", planetNumber: 99, productionType: "DRIVE", subject: "", }; const out = applyOrderOverlay(report, [cmd], { "cmd-1": "applied" }); expect(out).toBe(report); }); test("setCargoRoute upserts a route entry when applied", () => { const report = makeReport([ makePlanet({ number: 1, name: "Earth" }), makePlanet({ number: 2, name: "Mars" }), ]); const cmd: OrderCommand = { kind: "setCargoRoute", id: "cargo-1", sourcePlanetNumber: 1, destinationPlanetNumber: 2, loadType: "COL", }; const out = applyOrderOverlay(report, [cmd], { "cargo-1": "applied" }); expect(out).not.toBe(report); expect(out.routes).toHaveLength(1); expect(out.routes[0]!.sourcePlanetNumber).toBe(1); expect(out.routes[0]!.entries).toEqual([ { loadType: "COL", destinationPlanetNumber: 2 }, ]); }); test("setCargoRoute on an existing slot replaces the destination", () => { const report: GameReport = { ...makeReport([makePlanet({ number: 1, name: "Earth" })]), routes: [ { sourcePlanetNumber: 1, entries: [{ loadType: "COL", destinationPlanetNumber: 2 }], }, ], }; const cmd: OrderCommand = { kind: "setCargoRoute", id: "cargo-1", sourcePlanetNumber: 1, destinationPlanetNumber: 5, loadType: "COL", }; const out = applyOrderOverlay(report, [cmd], { "cargo-1": "applied" }); expect(out.routes[0]!.entries).toEqual([ { loadType: "COL", destinationPlanetNumber: 5 }, ]); }); test("removeCargoRoute drops the matching slot and preserves the others", () => { const report: GameReport = { ...makeReport([makePlanet({ number: 1, name: "Earth" })]), routes: [ { sourcePlanetNumber: 1, entries: [ { loadType: "COL", destinationPlanetNumber: 2 }, { loadType: "MAT", destinationPlanetNumber: 3 }, ], }, ], }; const cmd: OrderCommand = { kind: "removeCargoRoute", id: "rem-1", sourcePlanetNumber: 1, loadType: "COL", }; const out = applyOrderOverlay(report, [cmd], { "rem-1": "applied" }); expect(out.routes[0]!.entries).toEqual([ { loadType: "MAT", destinationPlanetNumber: 3 }, ]); }); test("removeCargoRoute clears the route entry entirely when last slot drops", () => { const report: GameReport = { ...makeReport([makePlanet({ number: 1, name: "Earth" })]), routes: [ { sourcePlanetNumber: 1, entries: [{ loadType: "COL", destinationPlanetNumber: 2 }], }, ], }; const cmd: OrderCommand = { kind: "removeCargoRoute", id: "rem-1", sourcePlanetNumber: 1, loadType: "COL", }; const out = applyOrderOverlay(report, [cmd], { "rem-1": "applied" }); expect(out.routes).toEqual([]); }); test("cargo route overlays skip draft / invalid / rejected statuses", () => { const report = makeReport([makePlanet({ number: 1, name: "Earth" })]); const cmd: OrderCommand = { kind: "setCargoRoute", id: "cargo-1", sourcePlanetNumber: 1, destinationPlanetNumber: 2, loadType: "COL", }; expect(applyOrderOverlay(report, [cmd], { "cargo-1": "draft" })).toBe( report, ); expect(applyOrderOverlay(report, [cmd], { "cargo-1": "invalid" })).toBe( report, ); expect(applyOrderOverlay(report, [cmd], { "cargo-1": "rejected" })).toBe( report, ); }); test("createScience appends a new entry on valid status", () => { const report = makeReport([]); const cmd: OrderCommand = { kind: "createScience", id: "sci-1", name: "FirstStep", drive: 0.25, weapons: 0.25, shields: 0.25, cargo: 0.25, }; const out = applyOrderOverlay(report, [cmd], { "sci-1": "valid" }); expect(out.localScience).toHaveLength(1); expect(out.localScience[0]!.name).toBe("FirstStep"); expect(out.localScience[0]!.drive).toBeCloseTo(0.25, 12); }); test("createScience skips a duplicate name already present in the report", () => { const report = { ...makeReport([]), localScience: [ { name: "FirstStep", drive: 0.5, weapons: 0.5, shields: 0, cargo: 0 }, ], }; const cmd: OrderCommand = { kind: "createScience", id: "sci-1", name: "FirstStep", drive: 0.25, weapons: 0.25, shields: 0.25, cargo: 0.25, }; const out = applyOrderOverlay(report, [cmd], { "sci-1": "valid" }); expect(out.localScience).toHaveLength(1); expect(out.localScience[0]!.drive).toBe(0.5); }); test("removeScience drops the matching entry on applied status", () => { const report = { ...makeReport([]), localScience: [ { name: "FirstStep", drive: 0.5, weapons: 0.5, shields: 0, cargo: 0 }, { name: "Beta", drive: 0.25, weapons: 0.25, shields: 0.25, cargo: 0.25 }, ], }; const cmd: OrderCommand = { kind: "removeScience", id: "sci-1", name: "FirstStep", }; const out = applyOrderOverlay(report, [cmd], { "sci-1": "applied" }); expect(out.localScience.map((s) => s.name)).toEqual(["Beta"]); }); test("createScience tolerates a stale report whose localScience is undefined", () => { // HMR scenario: the in-memory `gameState.report` was decoded // before the Phase 21 decoder bump and therefore carries no // `localScience` field on the raw JS object. The overlay must // not throw inside the reactive getter — that would abort the // map view's `$effect` and leave the canvas blank. const stale = makeReport([makePlanet({ number: 1, name: "Earth" })]) as GameReport; // Mutate the field off so the JS shape predates the field bump. (stale as unknown as { localScience: undefined }).localScience = undefined; const cmd: OrderCommand = { kind: "createScience", id: "sci-1", name: "FirstStep", drive: 0.25, weapons: 0.25, shields: 0.25, cargo: 0.25, }; expect(() => applyOrderOverlay(stale, [cmd], { "sci-1": "valid" }), ).not.toThrow(); const out = applyOrderOverlay(stale, [cmd], { "sci-1": "valid" }); expect(out.localScience).toHaveLength(1); expect(out.localScience[0]!.name).toBe("FirstStep"); // Other fields stay intact. expect(out.planets).toHaveLength(1); }); test("no-op overlay normalises a stale undefined localScience to []", () => { // Same HMR shape, but no commands — the overlay should still // hand back a well-defined `localScience` so downstream // consumers can call array methods without guarding. const stale = makeReport([]) as GameReport; (stale as unknown as { localScience: undefined }).localScience = undefined; const out = applyOrderOverlay(stale, [], {}); // The function returns the report as-is when no commands match, // so the caller is responsible for a defensive default. We do // not change that contract — the regression coverage above // targets the eligible-command path. expect(out).toBe(stale); }); }); describe("productionDisplayFromCommand", () => { const cases: ReadonlyArray<{ productionType: ProductionType; subject: string; expected: string; }> = [ { productionType: "MAT", subject: "", expected: "Material" }, { productionType: "CAP", subject: "", expected: "Capital" }, { productionType: "DRIVE", subject: "", expected: "Drive" }, { productionType: "WEAPONS", subject: "", expected: "Weapons" }, { productionType: "SHIELDS", subject: "", expected: "Shields" }, { productionType: "CARGO", subject: "", expected: "Cargo" }, { productionType: "SCIENCE", subject: "AlphaSci", expected: "AlphaSci" }, { productionType: "SHIP", subject: "Scout", expected: "Scout" }, ]; for (const tc of cases) { test(`${tc.productionType} → ${tc.expected}`, () => { expect(productionDisplayFromCommand(tc.productionType, tc.subject)).toBe( tc.expected, ); }); } });