ui/phase-14: regression tests for routes registry + overlay reactivity

The owner reported two symptoms after pulling the Phase 14 stack:

1. user.games.order.get answered with `unimplemented: message_type
   is not routed`. The gateway/backend code was correct, but the
   local-dev compose images were stale — `make rebuild` picked up
   the new routes table and the symptom went away. To prevent this
   class of regression from depending on docker-image freshness,
   gateway/internal/backendclient/routes_test.go now asserts that
   every authenticated MessageType constant declared in
   pkg/model/{user,lobby,order,report} is registered, and verifies
   that user.games.order.get specifically resolves to the game
   command client.

2. The inspector kept the un-renamed name after a successful submit.
   ui/frontend/tests/inspector-overlay.test.ts mounts the inspector
   tab against a real OrderDraftStore + a stubbed GameStateStore
   and walks the full happy path (add planetRename → markSubmitting
   → applied → simulate refresh) plus the integration scenario
   driven through the order-tab Submit button. Both cases pass —
   the underlying overlay path is reactive and resilient to a
   refresh that returns the un-renamed snapshot. The original
   in-browser symptom was the rebuilt-image freshness issue from
   point 1; this test pins the reactive contract for future
   refactors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-09 12:40:33 +02:00
parent 57e053764a
commit 0aaa4473a4
2 changed files with 386 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
// Integration test for the Phase 14 optimistic overlay. Mounts the
// inspector tab against a real `OrderDraftStore` + `GameStateStore`
// + the rendered-report context and walks the full happy path:
// add a `planetRename` command → mark it submitting → applied → the
// inspector picks up the new name through the overlay without a
// re-fetch of the report.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { Builder } from "flatbuffers";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import InspectorTab from "../src/lib/sidebar/inspector-tab.svelte";
import OrderTab from "../src/lib/sidebar/order-tab.svelte";
import {
GAME_STATE_CONTEXT_KEY,
GameStateStore,
} from "../src/lib/game-state.svelte";
import {
SELECTION_CONTEXT_KEY,
SelectionStore,
} from "../src/lib/selection.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
createRenderedReportSource,
} from "../src/lib/rendered-report.svelte";
import {
GALAXY_CLIENT_CONTEXT_KEY,
GalaxyClientHolder,
} from "../src/lib/galaxy-client-context.svelte";
import { i18n } from "../src/lib/i18n/index.svelte";
import { uuidToHiLo, type GameReport, type ReportPlanet } from "../src/api/game-state";
import type { GalaxyClient } from "../src/api/galaxy-client";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB } from "../src/platform/store/idb";
import { UUID } from "../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetRename,
UserGamesOrderResponse,
} from "../src/proto/galaxy/fbs/order";
let db: Awaited<ReturnType<typeof openGalaxyDB>>;
let dbName: string;
beforeEach(async () => {
dbName = `galaxy-overlay-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
i18n.resetForTests("en");
});
afterEach(async () => {
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
function makePlanet(overrides: Partial<ReportPlanet>): 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: 1000,
mapHeight: 1000,
planetCount: planets.length,
planets,
};
}
describe("inspector overlay reactivity", () => {
test("applied planetRename swaps the name without a report refresh", async () => {
const cache = new IDBCache(db);
const draft = new OrderDraftStore();
await draft.init({
cache,
gameId: "00000000-0000-0000-0000-000000000abc",
});
const gameState = new GameStateStore();
gameState.report = makeReport([
makePlanet({ number: 7, name: "Earth", kind: "local", size: 100 }),
]);
gameState.status = "ready";
const selection = new SelectionStore();
selection.selectPlanet(7);
const renderedReport = createRenderedReportSource(gameState, draft);
const context = new Map<unknown, unknown>([
[GAME_STATE_CONTEXT_KEY, gameState],
[SELECTION_CONTEXT_KEY, selection],
[ORDER_DRAFT_CONTEXT_KEY, draft],
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
]);
const ui = render(InspectorTab, { context });
await waitFor(() => {
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent("Earth");
});
const cmdId = "00000000-0000-0000-0000-000000000001";
await draft.add({
kind: "planetRename",
id: cmdId,
planetNumber: 7,
name: "New-Earth",
});
// `valid` does not participate in the overlay — the player
// has not submitted yet, the inspector still shows the
// server-side name.
expect(draft.statuses[cmdId]).toBe("valid");
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent("Earth");
draft.markSubmitting([cmdId]);
await waitFor(() => {
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent(
"New-Earth",
);
});
draft.applyResults({
results: new Map([[cmdId, "applied"] as const]),
updatedAt: 99,
});
await waitFor(() => {
expect(draft.statuses[cmdId]).toBe("applied");
});
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent(
"New-Earth",
);
// A simulated server refresh that returns the *un-renamed*
// snapshot must not erase the overlay (turn cutoff has not
// run yet, the engine still reports the old name).
gameState.report = makeReport([
makePlanet({ number: 7, name: "Earth", kind: "local", size: 100 }),
]);
await waitFor(() => {
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent(
"New-Earth",
);
});
draft.dispose();
});
test("submit through the order tab applies the overlay end-to-end", async () => {
const cache = new IDBCache(db);
const draft = new OrderDraftStore();
await draft.init({
cache,
gameId: "11111111-2222-3333-4444-555555555555",
});
const cmdId = "00000000-0000-0000-0000-000000000abc";
await draft.add({
kind: "planetRename",
id: cmdId,
planetNumber: 7,
name: "New-Earth",
});
const gameState = new GameStateStore();
gameState.gameId = "11111111-2222-3333-4444-555555555555";
gameState.report = makeReport([
makePlanet({ number: 7, name: "Earth", kind: "local", size: 100 }),
]);
gameState.status = "ready";
// Stub refresh to return the *un-renamed* server snapshot —
// the engine has not applied the rename yet (turn cutoff
// pending). The overlay must still show the new name.
gameState.refresh = (async () => {
gameState.report = makeReport([
makePlanet({ number: 7, name: "Earth", kind: "local", size: 100 }),
]);
}) as unknown as typeof gameState.refresh;
const selection = new SelectionStore();
selection.selectPlanet(7);
const renderedReport = createRenderedReportSource(gameState, draft);
const responsePayload = (() => {
const builder = new Builder(256);
const cmdIdOffset = builder.createString(cmdId);
const nameOffset = builder.createString("New-Earth");
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(7),
nameOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addCmdApplied(builder, true);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, inner);
const item = CommandItem.endCommandItem(builder);
const commandsVec = UserGamesOrderResponse.createCommandsVector(builder, [
item,
]);
const [hi, lo] = uuidToHiLo("11111111-2222-3333-4444-555555555555");
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(99));
UserGamesOrderResponse.addCommands(builder, commandsVec);
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
builder.finish(offset);
return builder.asUint8Array();
})();
const exec = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: responsePayload,
}));
const clientHolder = new GalaxyClientHolder();
clientHolder.set({ executeCommand: exec } as unknown as GalaxyClient);
const context = new Map<unknown, unknown>([
[GAME_STATE_CONTEXT_KEY, gameState],
[SELECTION_CONTEXT_KEY, selection],
[ORDER_DRAFT_CONTEXT_KEY, draft],
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
[GALAXY_CLIENT_CONTEXT_KEY, clientHolder],
]);
const inspector = render(InspectorTab, { context });
const orderTab = render(OrderTab, { context });
// Pre-submit: the inspector still shows the un-renamed snapshot.
await waitFor(() => {
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
"Earth",
);
});
const submit = orderTab.getByTestId("order-submit");
expect(submit).not.toBeDisabled();
await fireEvent.click(submit);
await waitFor(() => {
expect(draft.statuses[cmdId]).toBe("applied");
});
expect(exec).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
"New-Earth",
);
});
draft.dispose();
});
});