From 0aaa4473a41ca0073500410d3d29a847c4e5987b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 9 May 2026 12:40:33 +0200 Subject: [PATCH] ui/phase-14: regression tests for routes registry + overlay reactivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- gateway/internal/backendclient/routes_test.go | 106 +++++++ ui/frontend/tests/inspector-overlay.test.ts | 280 ++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 gateway/internal/backendclient/routes_test.go create mode 100644 ui/frontend/tests/inspector-overlay.test.ts diff --git a/gateway/internal/backendclient/routes_test.go b/gateway/internal/backendclient/routes_test.go new file mode 100644 index 0000000..2c4df89 --- /dev/null +++ b/gateway/internal/backendclient/routes_test.go @@ -0,0 +1,106 @@ +package backendclient_test + +import ( + "context" + "testing" + + "galaxy/gateway/internal/backendclient" + "galaxy/gateway/internal/downstream" + lobbymodel "galaxy/model/lobby" + ordermodel "galaxy/model/order" + reportmodel "galaxy/model/report" + usermodel "galaxy/model/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Phase 14 follow-up: every authenticated message-type constant +// declared in `pkg/model/` must be wired into the matching +// route table. Without this regression test, adding a new constant +// without registering it surfaces only at runtime as +// `unimplemented: message_type is not routed` — exactly what the +// owner saw when an outdated gateway image missed +// `user.games.order.get`. +func TestRoutesCoverAllAuthenticatedMessageTypes(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + expected []string + actual map[string]downstream.Client + }{ + "user": { + expected: []string{ + usermodel.MessageTypeGetMyAccount, + usermodel.MessageTypeUpdateMyProfile, + usermodel.MessageTypeUpdateMySettings, + usermodel.MessageTypeListMySessions, + usermodel.MessageTypeRevokeMySession, + usermodel.MessageTypeRevokeAllMySessions, + }, + actual: backendclient.UserRoutes(nil), + }, + "lobby": { + expected: []string{ + lobbymodel.MessageTypeMyGamesList, + lobbymodel.MessageTypePublicGamesList, + lobbymodel.MessageTypeMyApplicationsList, + lobbymodel.MessageTypeMyInvitesList, + lobbymodel.MessageTypeOpenEnrollment, + lobbymodel.MessageTypeGameCreate, + lobbymodel.MessageTypeApplicationSubmit, + lobbymodel.MessageTypeInviteRedeem, + lobbymodel.MessageTypeInviteDecline, + }, + actual: backendclient.LobbyRoutes(nil), + }, + "game": { + expected: []string{ + ordermodel.MessageTypeUserGamesCommand, + ordermodel.MessageTypeUserGamesOrder, + ordermodel.MessageTypeUserGamesOrderGet, + reportmodel.MessageTypeUserGamesReport, + }, + actual: backendclient.GameRoutes(nil), + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.Len(t, tc.actual, len(tc.expected), + "%s routes table size diverges from the expected message-type list", name) + for _, mt := range tc.expected { + client, ok := tc.actual[mt] + assert.Truef(t, ok, "%s routes are missing %q", name, mt) + assert.NotNilf(t, client, "%s routes resolve %q to a nil client", name, mt) + } + }) + } +} + +// Sanity-check that the order-get route really points at the game +// command client (and not, say, the lobby one if a future refactor +// reshuffles the helpers): the route table must dispatch through +// `gameCommandClient.ExecuteCommand`, which in turn calls +// `RESTClient.ExecuteGameCommand`. We exercise this through the +// public Router contract. +func TestUserGamesOrderGetRoutedToGameClient(t *testing.T) { + t.Parallel() + + routes := backendclient.GameRoutes(nil) + router := downstream.NewStaticRouter(routes) + + client, err := router.Route(ordermodel.MessageTypeUserGamesOrderGet) + require.NoError(t, err) + require.NotNil(t, client) + + // Without a live RESTClient the client is the unavailable stub — + // calling ExecuteCommand surfaces the canonical "downstream + // service is unavailable" sentinel rather than the "not routed" + // error we want to keep regression-tested. + _, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{ + MessageType: ordermodel.MessageTypeUserGamesOrderGet, + }) + assert.ErrorIs(t, err, downstream.ErrDownstreamUnavailable) +} diff --git a/ui/frontend/tests/inspector-overlay.test.ts b/ui/frontend/tests/inspector-overlay.test.ts new file mode 100644 index 0000000..f475907 --- /dev/null +++ b/ui/frontend/tests/inspector-overlay.test.ts @@ -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>; +let dbName: string; + +beforeEach(async () => { + dbName = `galaxy-overlay-${crypto.randomUUID()}`; + db = await openGalaxyDB(dbName); + i18n.resetForTests("en"); +}); + +afterEach(async () => { + db.close(); + await new Promise((resolve) => { + const req = indexedDB.deleteDatabase(dbName); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +}); + +function makePlanet(overrides: Partial): 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([ + [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([ + [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(); + }); +});