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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user