ui: plan 01-27 done #1
@@ -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/<service>` 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)
|
||||||
|
}
|
||||||
@@ -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