ui/phase-15: planet inspector production controls + order-draft collapse

Adds the second end-to-end command (`setProductionType`) with a
collapse-by-`planetNumber` rule on the order draft, the segmented
production-controls component on the planet inspector, the FBS
encoder/decoder pair for `CommandPlanetProduce`, and the
`localShipClass` projection on `GameReport`. Forecast number is
deferred and tracked in the new `ui/docs/calc-bridge.md`.
This commit is contained in:
Ilia Denisov
2026-05-09 15:54:30 +02:00
parent c4f1409329
commit 915b4372dd
31 changed files with 2200 additions and 76 deletions
+124 -4
View File
@@ -1,16 +1,22 @@
// Vitest unit coverage for the pure `applyOrderOverlay` projection.
// Phase 14 understands `planetRename` only; future phases (set
// production, route updates) will extend the overlay and need
// equivalent cases here.
// 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 } from "../src/sync/order-types";
import type {
CommandStatus,
OrderCommand,
ProductionType,
} from "../src/sync/order-types";
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
return {
@@ -41,6 +47,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
planetCount: planets.length,
planets,
race: "",
localShipClass: [],
};
}
@@ -153,4 +160,117 @@ describe("applyOrderOverlay", () => {
});
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);
});
});
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,
);
});
}
});