ui/phase-21: harden applyOrderOverlay against HMR-stale localScience

Fixes a black-canvas regression on /map after creating a science in
DEV: when Vite hot-reloads the decoder bump that adds the
`localScience` field, the live in-memory `gameState.report` keeps
its older shape with no such field, so the overlay's
`[...report.localScience]` throws inside the reactive getter and
silently aborts the map view's `$effect`. The fix wraps the spread
and the final return in `?? []` defaults — and matches the
ship-class branches for symmetry — so the overlay stays well-defined
for any partial report shape upstream consumers may carry across an
HMR boundary.

Also adds order-overlay regression tests covering the createScience /
removeScience branches plus the explicit HMR-stale shape, and a
Playwright e2e (sciences-map-regress.spec.ts) replaying the
user-reported flow: /map → /designer/science → save → /map, asserting
no map-mount-error overlay and no console errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-10 22:00:03 +02:00
parent f674c86e4b
commit e55355a2cf
3 changed files with 357 additions and 6 deletions
+98
View File
@@ -365,6 +365,104 @@ describe("applyOrderOverlay", () => {
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", () => {