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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user