ui/phase-14: auto-sync order draft + always GET on boot + header headline
Replaces the manual Submit button with an auto-sync pipeline driven by `OrderDraftStore`: every successful add / remove / move coalesces a `submitOrder` call so the engine always mirrors the local draft. Removing the last command sends an empty cmd[] PUT — the engine, repo, and rest model now accept that as a valid "player cleared their draft" state. `hydrateFromServer` is now invoked unconditionally on game boot so a fresh device picks up the player's stored order, and the local cache is overwritten by the server's view (server is the source of truth). Header replaces the static "race ?" + turn counter with a single headline string `<race> @ <game>, turn <n>`, sourced from the engine's Report.race + the lobby's GameSummary.gameName + the live turn number, with a `?` fallback while any piece is loading. Tests: - engine: empty PUT round-trips, repo round-trips empty Commands - order-draft: auto-sync sends full draft on every mutation, rejected response surfaces error sync status, rapid mutations coalesce, server hydration overwrites cache - order-tab: per-row status flips through the auto-sync lifecycle, remove → empty cmd[] PUT, rejected → retry button - inspector overlay: applied + valid + submitting all participate in the optimistic projection - header: live race / game / turn rendering with fall-back Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -177,7 +177,7 @@ test("map view renders the reported turn and planet count from a live report", a
|
||||
"data-status",
|
||||
"ready",
|
||||
);
|
||||
await expect(page.getByTestId("turn-counter")).toContainText("turn 4");
|
||||
await expect(page.getByTestId("game-shell-headline")).toContainText("turn 4");
|
||||
await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
|
||||
"data-planet-count",
|
||||
"4",
|
||||
@@ -207,7 +207,7 @@ test("zero-planet game renders the empty world without errors", async ({
|
||||
"data-status",
|
||||
"ready",
|
||||
);
|
||||
await expect(page.getByTestId("turn-counter")).toContainText("turn 0");
|
||||
await expect(page.getByTestId("game-shell-headline")).toContainText("turn 0");
|
||||
await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
|
||||
"data-planet-count",
|
||||
"0",
|
||||
|
||||
@@ -35,8 +35,9 @@ test("shell mounts with header / sidebar / active-view chrome", async ({
|
||||
}) => {
|
||||
await bootShell(page);
|
||||
await expect(page.getByTestId("game-shell-header")).toBeVisible();
|
||||
await expect(page.getByTestId("race-name")).toContainText("race ?");
|
||||
await expect(page.getByTestId("turn-counter")).toContainText("turn");
|
||||
await expect(page.getByTestId("game-shell-headline")).toContainText(
|
||||
"turn",
|
||||
);
|
||||
await expect(page.getByTestId("view-menu-trigger")).toBeVisible();
|
||||
await expect(page.getByTestId("account-menu-trigger")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -213,7 +213,7 @@ async function clickPlanetCentre(page: Page): Promise<void> {
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
}
|
||||
|
||||
test("rename a seeded planet, submit, observe overlay + persist after reload", async ({
|
||||
test("rename a seeded planet auto-syncs and the overlay survives reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
@@ -241,32 +241,30 @@ test("rename a seeded planet, submit, observe overlay + persist after reload", a
|
||||
await input.fill("New-Earth");
|
||||
await sidebar.getByTestId("inspector-planet-rename-confirm").click();
|
||||
|
||||
// Open the order tab and assert the row.
|
||||
// Overlay applies immediately on `valid` — no Submit click is
|
||||
// required because the auto-sync pipeline drives the network.
|
||||
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
|
||||
"New-Earth",
|
||||
);
|
||||
|
||||
// Open the order tab and assert the row plus the synced status bar.
|
||||
await page.getByTestId("sidebar-tab-order").click();
|
||||
const orderTool = page.getByTestId("sidebar-tool-order");
|
||||
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
|
||||
"New-Earth",
|
||||
);
|
||||
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
||||
"valid",
|
||||
);
|
||||
|
||||
await orderTool.getByTestId("order-submit").click();
|
||||
|
||||
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
||||
"applied",
|
||||
);
|
||||
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
|
||||
"data-sync-status",
|
||||
"synced",
|
||||
);
|
||||
expect(handle.submittedRenameName).toBe("New-Earth");
|
||||
|
||||
// Switch back to the inspector — overlay should reflect the new name.
|
||||
await page.getByTestId("sidebar-tab-inspector").click();
|
||||
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
|
||||
"New-Earth",
|
||||
);
|
||||
|
||||
// Reload: the order draft is persisted; on cache-miss boots the
|
||||
// hydrate-from-server path takes over. Both round-trips re-apply
|
||||
// the overlay so the player still sees the renamed planet.
|
||||
// Reload: the layout always polls user.games.order.get on boot,
|
||||
// so the overlay is rebuilt from the server's stored order even
|
||||
// when the local cache was wiped.
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||
"data-status",
|
||||
@@ -303,11 +301,17 @@ test("rejected submit keeps the old name and surfaces the failure", async ({
|
||||
|
||||
await page.getByTestId("sidebar-tab-order").click();
|
||||
const orderTool = page.getByTestId("sidebar-tool-order");
|
||||
await orderTool.getByTestId("order-submit").click();
|
||||
|
||||
// The auto-sync pipeline reaches the server immediately after
|
||||
// the inline confirm; the rejected verdict surfaces through the
|
||||
// per-row status badge and the sync bar.
|
||||
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
||||
"rejected",
|
||||
);
|
||||
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
|
||||
"data-sync-status",
|
||||
"error",
|
||||
);
|
||||
|
||||
await page.getByTestId("sidebar-tab-inspector").click();
|
||||
// Overlay does not apply rejected commands — old name persists.
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// Component tests for the Phase 10 in-game shell header. The header
|
||||
// composes the static `race ?` placeholder, the placeholder
|
||||
// turn-counter (Phase 11 wires the live source), the view-menu, and
|
||||
// the account-menu. The tests assert the placeholder copy, that
|
||||
// every view-menu entry dispatches `goto` with the right URL, and
|
||||
// that the Logout entry of the account-menu calls
|
||||
// `session.signOut("user")`.
|
||||
// Component tests for the in-game shell header. The header composes
|
||||
// the headline strip (`<race> @ <game>, turn N`, falling back to `?`
|
||||
// while the lobby / report calls are in flight), the view-menu, and
|
||||
// the account-menu. The tests assert the headline copy, that every
|
||||
// view-menu entry dispatches `goto` with the right URL, and that the
|
||||
// Logout entry of the account-menu calls `session.signOut("user")`.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render } from "@testing-library/svelte";
|
||||
@@ -20,6 +19,31 @@ import {
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import { session } from "../src/lib/session-store.svelte";
|
||||
import Header from "../src/lib/header/header.svelte";
|
||||
import {
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
GameStateStore,
|
||||
} from "../src/lib/game-state.svelte";
|
||||
|
||||
function withGameState(opts: {
|
||||
gameName?: string;
|
||||
race?: string;
|
||||
turn?: number;
|
||||
} = {}): Map<unknown, unknown> {
|
||||
const store = new GameStateStore();
|
||||
store.gameName = opts.gameName ?? "";
|
||||
if (opts.race !== undefined || opts.turn !== undefined) {
|
||||
store.report = {
|
||||
turn: opts.turn ?? 0,
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: opts.race ?? "",
|
||||
};
|
||||
store.status = "ready";
|
||||
}
|
||||
return new Map<unknown, unknown>([[GAME_STATE_CONTEXT_KEY, store]]);
|
||||
}
|
||||
|
||||
const gotoSpy = vi.fn(async (..._args: unknown[]) => {});
|
||||
vi.mock("$app/navigation", () => ({
|
||||
@@ -37,19 +61,43 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("game-shell header", () => {
|
||||
test("renders the static race / turn placeholders and toggles", () => {
|
||||
test("renders fall-back placeholders before the lobby / report data lands", () => {
|
||||
const onToggleSidebar = vi.fn();
|
||||
const ui = render(Header, {
|
||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
|
||||
context: withGameState(),
|
||||
});
|
||||
expect(ui.getByTestId("race-name")).toHaveTextContent("race ?");
|
||||
expect(ui.getByTestId("turn-counter").textContent ?? "").toMatch(
|
||||
/turn\s+\?/,
|
||||
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
|
||||
"? @ ?, turn ?",
|
||||
);
|
||||
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
|
||||
expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the live race / game / turn from GameStateStore", () => {
|
||||
const ui = render(Header, {
|
||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
||||
context: withGameState({
|
||||
gameName: "Phase 14",
|
||||
race: "Federation",
|
||||
turn: 7,
|
||||
}),
|
||||
});
|
||||
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
|
||||
"Federation @ Phase 14, turn 7",
|
||||
);
|
||||
});
|
||||
|
||||
test("partial data still falls back gracefully (race known, game unknown)", () => {
|
||||
const ui = render(Header, {
|
||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
||||
context: withGameState({ race: "Federation", turn: 3 }),
|
||||
});
|
||||
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
|
||||
"Federation @ ?, turn 3",
|
||||
);
|
||||
});
|
||||
|
||||
test("clicking the sidebar toggle invokes the prop callback", async () => {
|
||||
const onToggleSidebar = vi.fn();
|
||||
const ui = render(Header, {
|
||||
|
||||
@@ -72,6 +72,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
mapHeight: 1000,
|
||||
planetCount: planets.length,
|
||||
planets,
|
||||
race: "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// Test helpers that fabricate `GalaxyClient` stand-ins for the
|
||||
// auto-sync pipeline. Two flavours:
|
||||
//
|
||||
// - `recordingClient` — captures every `submitOrder` call and lets
|
||||
// the test assert on the order of in-flight payloads. The
|
||||
// outcome (`ok` / `rejected`) is settable per call so tests can
|
||||
// simulate retry loops.
|
||||
// - `fakeFetchClient` — wires a synthetic `user.games.order.get`
|
||||
// response so `OrderDraftStore.hydrateFromServer` exercises the
|
||||
// decoder against a populated FBS envelope.
|
||||
//
|
||||
// Both helpers live under `tests/helpers/` so they can be reused
|
||||
// across `order-draft.test.ts`, `inspector-overlay.test.ts`, and
|
||||
// future Phase 14+ specs.
|
||||
|
||||
import { Builder } from "flatbuffers";
|
||||
|
||||
import type { GalaxyClient } from "../../src/api/galaxy-client";
|
||||
import { uuidToHiLo } from "../../src/api/game-state";
|
||||
import { UUID } from "../../src/proto/galaxy/fbs/common";
|
||||
import {
|
||||
CommandItem,
|
||||
CommandPayload,
|
||||
CommandPlanetRename,
|
||||
UserGamesOrder,
|
||||
UserGamesOrderGetResponse,
|
||||
UserGamesOrderResponse,
|
||||
} from "../../src/proto/galaxy/fbs/order";
|
||||
import type { OrderCommand } from "../../src/sync/order-types";
|
||||
|
||||
interface RecordedCall {
|
||||
messageType: string;
|
||||
commandIds: string[];
|
||||
}
|
||||
|
||||
interface RecordingHandle {
|
||||
client: GalaxyClient;
|
||||
calls: RecordedCall[];
|
||||
setOutcome(outcome: "ok" | "rejected"): void;
|
||||
waitForCalls(n: number): Promise<void>;
|
||||
waitForIdle(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* recordingClient returns a fake GalaxyClient whose `executeCommand`
|
||||
* decodes the in-flight UserGamesOrder, records the cmd_ids, and
|
||||
* answers with a synthesised UserGamesOrderResponse where every
|
||||
* cmdApplied is true (when outcome="ok") or false (when outcome=
|
||||
* "rejected"). An optional `delayMs` simulates network latency so
|
||||
* tests can exercise the coalescing path.
|
||||
*/
|
||||
export function recordingClient(
|
||||
gameId: string,
|
||||
initialOutcome: "ok" | "rejected",
|
||||
options: { delayMs?: number } = {},
|
||||
): RecordingHandle {
|
||||
const calls: RecordedCall[] = [];
|
||||
let outcome: "ok" | "rejected" = initialOutcome;
|
||||
let inFlight = 0;
|
||||
const waiters: (() => void)[] = [];
|
||||
|
||||
const client: GalaxyClient = {
|
||||
async executeCommand(messageType: string, payload: Uint8Array) {
|
||||
inFlight += 1;
|
||||
try {
|
||||
if (options.delayMs !== undefined) {
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, options.delayMs),
|
||||
);
|
||||
}
|
||||
if (messageType === "user.games.order") {
|
||||
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
|
||||
new (await import("flatbuffers")).ByteBuffer(payload),
|
||||
);
|
||||
const length = decoded.commandsLength();
|
||||
const commandIds: string[] = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
const item = decoded.commands(i);
|
||||
if (item === null) continue;
|
||||
const id = item.cmdId();
|
||||
if (id !== null) commandIds.push(id);
|
||||
}
|
||||
calls.push({ messageType, commandIds });
|
||||
if (outcome === "ok") {
|
||||
return {
|
||||
resultCode: "ok",
|
||||
payloadBytes: encodeApplied(gameId, commandIds, true),
|
||||
};
|
||||
}
|
||||
return {
|
||||
resultCode: "invalid_request",
|
||||
payloadBytes: new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
code: "validation_failed",
|
||||
message: "rejected by fixture",
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected messageType ${messageType}`);
|
||||
} finally {
|
||||
inFlight -= 1;
|
||||
if (inFlight === 0) {
|
||||
while (waiters.length > 0) {
|
||||
const wake = waiters.shift();
|
||||
wake?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
} as unknown as GalaxyClient;
|
||||
|
||||
return {
|
||||
client,
|
||||
calls,
|
||||
setOutcome(next: "ok" | "rejected") {
|
||||
outcome = next;
|
||||
},
|
||||
async waitForCalls(n: number) {
|
||||
while (calls.length < n) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
},
|
||||
async waitForIdle() {
|
||||
if (inFlight === 0) return;
|
||||
await new Promise<void>((resolve) => waiters.push(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* fakeFetchClient returns a GalaxyClient stand-in whose
|
||||
* `executeCommand` answers a single hard-coded
|
||||
* UserGamesOrderGetResponse — enough for `hydrateFromServer` to
|
||||
* decode a realistic payload without standing up a full mock
|
||||
* gateway.
|
||||
*/
|
||||
export function fakeFetchClient(
|
||||
gameId: string,
|
||||
commands: OrderCommand[],
|
||||
updatedAt: number,
|
||||
found = true,
|
||||
): { client: GalaxyClient } {
|
||||
const client: GalaxyClient = {
|
||||
async executeCommand(messageType: string) {
|
||||
if (messageType !== "user.games.order.get") {
|
||||
throw new Error(`unexpected messageType ${messageType}`);
|
||||
}
|
||||
return {
|
||||
resultCode: "ok",
|
||||
payloadBytes: encodeOrderGet(gameId, commands, updatedAt, found),
|
||||
};
|
||||
},
|
||||
} as unknown as GalaxyClient;
|
||||
return { client };
|
||||
}
|
||||
|
||||
function encodeApplied(
|
||||
gameId: string,
|
||||
cmdIds: string[],
|
||||
applied: boolean,
|
||||
): Uint8Array {
|
||||
const builder = new Builder(256);
|
||||
const itemOffsets = cmdIds.map((id) => {
|
||||
const cmdIdOffset = builder.createString(id);
|
||||
const nameOffset = builder.createString("ignored");
|
||||
const inner = CommandPlanetRename.createCommandPlanetRename(
|
||||
builder,
|
||||
BigInt(0),
|
||||
nameOffset,
|
||||
);
|
||||
CommandItem.startCommandItem(builder);
|
||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||
CommandItem.addCmdApplied(builder, applied);
|
||||
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
||||
CommandItem.addPayload(builder, inner);
|
||||
return CommandItem.endCommandItem(builder);
|
||||
});
|
||||
const commandsVec = UserGamesOrderResponse.createCommandsVector(
|
||||
builder,
|
||||
itemOffsets,
|
||||
);
|
||||
const [hi, lo] = uuidToHiLo(gameId);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
|
||||
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
|
||||
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(Date.now()));
|
||||
UserGamesOrderResponse.addCommands(builder, commandsVec);
|
||||
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
|
||||
builder.finish(offset);
|
||||
return builder.asUint8Array();
|
||||
}
|
||||
|
||||
function encodeOrderGet(
|
||||
gameId: string,
|
||||
commands: OrderCommand[],
|
||||
updatedAt: number,
|
||||
found: boolean,
|
||||
): Uint8Array {
|
||||
const builder = new Builder(256);
|
||||
|
||||
let orderOffset = 0;
|
||||
if (found) {
|
||||
const itemOffsets = commands.map((cmd) => {
|
||||
if (cmd.kind !== "planetRename") {
|
||||
throw new Error(`unsupported command kind ${cmd.kind}`);
|
||||
}
|
||||
const cmdIdOffset = builder.createString(cmd.id);
|
||||
const nameOffset = builder.createString(cmd.name);
|
||||
const inner = CommandPlanetRename.createCommandPlanetRename(
|
||||
builder,
|
||||
BigInt(cmd.planetNumber),
|
||||
nameOffset,
|
||||
);
|
||||
CommandItem.startCommandItem(builder);
|
||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
||||
CommandItem.addPayload(builder, inner);
|
||||
return CommandItem.endCommandItem(builder);
|
||||
});
|
||||
const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets);
|
||||
const [hi, lo] = uuidToHiLo(gameId);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
UserGamesOrder.startUserGamesOrder(builder);
|
||||
UserGamesOrder.addGameId(builder, gameIdOffset);
|
||||
UserGamesOrder.addUpdatedAt(builder, BigInt(updatedAt));
|
||||
UserGamesOrder.addCommands(builder, commandsVec);
|
||||
orderOffset = UserGamesOrder.endUserGamesOrder(builder);
|
||||
}
|
||||
|
||||
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
|
||||
UserGamesOrderGetResponse.addFound(builder, found);
|
||||
if (orderOffset !== 0) {
|
||||
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
|
||||
}
|
||||
const offset =
|
||||
UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
|
||||
builder.finish(offset);
|
||||
return builder.asUint8Array();
|
||||
}
|
||||
@@ -7,12 +7,10 @@
|
||||
|
||||
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 { render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, describe, expect, test } 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,
|
||||
@@ -29,22 +27,10 @@ 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 type { GameReport, ReportPlanet } from "../src/api/game-state";
|
||||
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;
|
||||
@@ -93,6 +79,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
mapHeight: 1000,
|
||||
planetCount: planets.length,
|
||||
planets,
|
||||
race: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,30 +121,15 @@ describe("inspector overlay reactivity", () => {
|
||||
name: "New-Earth",
|
||||
});
|
||||
|
||||
// `valid` does not participate in the overlay — the player
|
||||
// has not submitted yet, the inspector still shows the
|
||||
// server-side name.
|
||||
// `valid` already participates in the overlay (auto-sync may
|
||||
// not have fired yet, but the player's intent is committed).
|
||||
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).
|
||||
@@ -173,13 +145,39 @@ describe("inspector overlay reactivity", () => {
|
||||
draft.dispose();
|
||||
});
|
||||
|
||||
test("submit through the order tab applies the overlay end-to-end", async () => {
|
||||
test("auto-sync after add applies the overlay end-to-end", async () => {
|
||||
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||
const handle = recordingClient(GAME_ID, "ok");
|
||||
const cache = new IDBCache(db);
|
||||
const draft = new OrderDraftStore();
|
||||
await draft.init({
|
||||
cache,
|
||||
gameId: "11111111-2222-3333-4444-555555555555",
|
||||
await draft.init({ cache, gameId: GAME_ID });
|
||||
draft.bindClient(handle.client);
|
||||
|
||||
const gameState = new GameStateStore();
|
||||
gameState.gameId = GAME_ID;
|
||||
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 inspector = render(InspectorTab, { context });
|
||||
await waitFor(() => {
|
||||
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
|
||||
"Earth",
|
||||
);
|
||||
});
|
||||
|
||||
const cmdId = "00000000-0000-0000-0000-000000000abc";
|
||||
await draft.add({
|
||||
kind: "planetRename",
|
||||
@@ -187,94 +185,28 @@ describe("inspector overlay reactivity", () => {
|
||||
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);
|
||||
|
||||
// Overlay applies on `valid` immediately — auto-sync hasn't
|
||||
// landed yet but the player's intent is committed.
|
||||
await waitFor(() => {
|
||||
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
|
||||
"New-Earth",
|
||||
);
|
||||
});
|
||||
|
||||
await handle.waitForCalls(1);
|
||||
await waitFor(() => {
|
||||
expect(draft.statuses[cmdId]).toBe("applied");
|
||||
});
|
||||
expect(handle.calls).toHaveLength(1);
|
||||
expect(handle.calls[0]!.commandIds).toEqual([cmdId]);
|
||||
|
||||
// Inspector still shows the new name after auto-sync.
|
||||
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
|
||||
"New-Earth",
|
||||
);
|
||||
|
||||
draft.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import type { IDBPDatabase } from "idb";
|
||||
|
||||
@@ -176,32 +177,6 @@ describe("OrderDraftStore", () => {
|
||||
reload.dispose();
|
||||
});
|
||||
|
||||
test("absent cache row flips needsServerHydration flag", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
expect(store.needsServerHydration).toBe(true);
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("explicitly empty cache row honours the user's empty draft", async () => {
|
||||
const seeded = new OrderDraftStore();
|
||||
await seeded.init({ cache, gameId: GAME_ID });
|
||||
await seeded.add({
|
||||
kind: "planetRename",
|
||||
id: "00000000-0000-0000-0000-000000000001",
|
||||
planetNumber: 7,
|
||||
name: "Earth",
|
||||
});
|
||||
await seeded.remove("00000000-0000-0000-0000-000000000001");
|
||||
seeded.dispose();
|
||||
|
||||
const reload = new OrderDraftStore();
|
||||
await reload.init({ cache, gameId: GAME_ID });
|
||||
expect(reload.needsServerHydration).toBe(false);
|
||||
expect(reload.commands).toEqual([]);
|
||||
reload.dispose();
|
||||
});
|
||||
|
||||
test("planetRename validates locally and statuses reflect valid/invalid", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
@@ -222,111 +197,200 @@ describe("OrderDraftStore", () => {
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("markSubmitting / applyResults flip the status map", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
store.markSubmitting(["id-1"]);
|
||||
expect(store.statuses["id-1"]).toBe("submitting");
|
||||
store.applyResults({
|
||||
results: new Map([["id-1", "applied"] as const]),
|
||||
updatedAt: 99,
|
||||
});
|
||||
expect(store.statuses["id-1"]).toBe("applied");
|
||||
expect(store.updatedAt).toBe(99);
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("markRejected switches submitting entries to rejected", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
store.markSubmitting(["id-1"]);
|
||||
store.markRejected(["id-1"]);
|
||||
expect(store.statuses["id-1"]).toBe("rejected");
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("revertSubmittingToValid restores status after a thrown submit", async () => {
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
await store.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
store.markSubmitting(["id-1"]);
|
||||
store.revertSubmittingToValid();
|
||||
expect(store.statuses["id-1"]).toBe("valid");
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("hydrateFromServer seeds the draft on a fresh cache", async () => {
|
||||
const fakeClient = {
|
||||
executeCommand: async () => {
|
||||
const { Builder } = await import("flatbuffers");
|
||||
const { UUID } = await import("../src/proto/galaxy/fbs/common");
|
||||
const order = await import("../src/proto/galaxy/fbs/order");
|
||||
const builder = new Builder(128);
|
||||
const cmdId = builder.createString("hydr-1");
|
||||
const name = builder.createString("Hydrated");
|
||||
const inner = order.CommandPlanetRename.createCommandPlanetRename(
|
||||
builder,
|
||||
BigInt(7),
|
||||
name,
|
||||
);
|
||||
order.CommandItem.startCommandItem(builder);
|
||||
order.CommandItem.addCmdId(builder, cmdId);
|
||||
order.CommandItem.addPayloadType(
|
||||
builder,
|
||||
order.CommandPayload.CommandPlanetRename,
|
||||
);
|
||||
order.CommandItem.addPayload(builder, inner);
|
||||
const item = order.CommandItem.endCommandItem(builder);
|
||||
const cmds = order.UserGamesOrder.createCommandsVector(builder, [item]);
|
||||
const [hi, lo] = (await import("../src/api/game-state")).uuidToHiLo(
|
||||
GAME_ID,
|
||||
);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
order.UserGamesOrder.startUserGamesOrder(builder);
|
||||
order.UserGamesOrder.addGameId(builder, gameIdOffset);
|
||||
order.UserGamesOrder.addUpdatedAt(builder, BigInt(7));
|
||||
order.UserGamesOrder.addCommands(builder, cmds);
|
||||
const orderOffset = order.UserGamesOrder.endUserGamesOrder(builder);
|
||||
order.UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
|
||||
order.UserGamesOrderGetResponse.addFound(builder, true);
|
||||
order.UserGamesOrderGetResponse.addOrder(builder, orderOffset);
|
||||
const offset =
|
||||
order.UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
|
||||
builder.finish(offset);
|
||||
return {
|
||||
resultCode: "ok",
|
||||
payloadBytes: builder.asUint8Array(),
|
||||
};
|
||||
test("hydrateFromServer overwrites the local cache with the server snapshot", async () => {
|
||||
const { fakeFetchClient } = await import("./helpers/fake-order-client");
|
||||
const { client } = fakeFetchClient(GAME_ID, [
|
||||
{
|
||||
kind: "planetRename",
|
||||
id: "hydr-1",
|
||||
planetNumber: 7,
|
||||
name: "Hydrated",
|
||||
},
|
||||
};
|
||||
], 7);
|
||||
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
expect(store.needsServerHydration).toBe(true);
|
||||
await store.hydrateFromServer({
|
||||
client: fakeClient as never,
|
||||
turn: 5,
|
||||
});
|
||||
await store.hydrateFromServer({ client, turn: 5 });
|
||||
expect(store.commands).toHaveLength(1);
|
||||
expect(store.commands[0]!.id).toBe("hydr-1");
|
||||
expect(store.updatedAt).toBe(7);
|
||||
expect(store.needsServerHydration).toBe(false);
|
||||
expect(store.statuses["hydr-1"]).toBe("applied");
|
||||
expect(store.syncStatus).toBe("synced");
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("hydrate empties the local cache when server returns found=false", async () => {
|
||||
// First seed a local draft.
|
||||
const seeded = new OrderDraftStore();
|
||||
await seeded.init({ cache, gameId: GAME_ID });
|
||||
await seeded.add({
|
||||
kind: "planetRename",
|
||||
id: "stale",
|
||||
planetNumber: 1,
|
||||
name: "Stale",
|
||||
});
|
||||
seeded.dispose();
|
||||
|
||||
const { fakeFetchClient } = await import("./helpers/fake-order-client");
|
||||
const { client } = fakeFetchClient(GAME_ID, [], 0, false);
|
||||
|
||||
const reload = new OrderDraftStore();
|
||||
await reload.init({ cache, gameId: GAME_ID });
|
||||
// Local cache shows the stale entry until the server speaks up.
|
||||
expect(reload.commands).toHaveLength(1);
|
||||
await reload.hydrateFromServer({ client, turn: 5 });
|
||||
expect(reload.commands).toEqual([]);
|
||||
expect(reload.syncStatus).toBe("synced");
|
||||
reload.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrderDraftStore auto-sync", () => {
|
||||
test("add triggers submitOrder with the full draft", async () => {
|
||||
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||
const handle = recordingClient(GAME_ID, "ok");
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
store.bindClient(handle.client);
|
||||
|
||||
await store.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
|
||||
await handle.waitForCalls(1);
|
||||
expect(handle.calls).toHaveLength(1);
|
||||
expect(handle.calls[0]!.commandIds).toEqual(["id-1"]);
|
||||
expect(store.statuses["id-1"]).toBe("applied");
|
||||
expect(store.syncStatus).toBe("synced");
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("remove of last command sends an empty cmd[] to the server", async () => {
|
||||
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||
const handle = recordingClient(GAME_ID, "ok");
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
store.bindClient(handle.client);
|
||||
|
||||
await store.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
await handle.waitForCalls(1);
|
||||
|
||||
await store.remove("id-1");
|
||||
await handle.waitForCalls(2);
|
||||
expect(handle.calls[1]!.commandIds).toEqual([]);
|
||||
expect(store.commands).toEqual([]);
|
||||
expect(store.syncStatus).toBe("synced");
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("rapid mutations coalesce into the latest draft", async () => {
|
||||
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||
const handle = recordingClient(GAME_ID, "ok", { delayMs: 10 });
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
store.bindClient(handle.client);
|
||||
|
||||
await store.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
await store.add({
|
||||
kind: "planetRename",
|
||||
id: "id-2",
|
||||
planetNumber: 2,
|
||||
name: "Mars",
|
||||
});
|
||||
await store.remove("id-1");
|
||||
|
||||
// Wait until the store reaches a steady "synced" state. The
|
||||
// in-flight first call carries [id-1], the coalesced retry
|
||||
// reflects the post-remove draft.
|
||||
await waitFor(() => {
|
||||
expect(store.syncStatus).toBe("synced");
|
||||
expect(store.statuses["id-2"]).toBe("applied");
|
||||
});
|
||||
expect(handle.calls.length).toBeGreaterThanOrEqual(2);
|
||||
const last = handle.calls[handle.calls.length - 1]!;
|
||||
expect(last.commandIds).toEqual(["id-2"]);
|
||||
expect(store.statuses["id-1"]).toBeUndefined();
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("non-ok response marks every in-flight command as rejected", async () => {
|
||||
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||
const handle = recordingClient(GAME_ID, "rejected");
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
store.bindClient(handle.client);
|
||||
|
||||
await store.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
await handle.waitForCalls(1);
|
||||
|
||||
expect(store.statuses["id-1"]).toBe("rejected");
|
||||
expect(store.syncStatus).toBe("error");
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("forceSync re-runs the pipeline after a previous failure", async () => {
|
||||
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||
const handle = recordingClient(GAME_ID, "rejected");
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
store.bindClient(handle.client);
|
||||
|
||||
await store.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
await handle.waitForCalls(1);
|
||||
expect(store.syncStatus).toBe("error");
|
||||
|
||||
handle.setOutcome("ok");
|
||||
store.forceSync();
|
||||
await handle.waitForCalls(2);
|
||||
expect(store.statuses["id-1"]).toBe("applied");
|
||||
expect(store.syncStatus).toBe("synced");
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
test("mutations made before bindClient still sync once client is bound", async () => {
|
||||
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||
const handle = recordingClient(GAME_ID, "ok");
|
||||
const store = new OrderDraftStore();
|
||||
await store.init({ cache, gameId: GAME_ID });
|
||||
|
||||
// Mutation lands before the client is wired — the layout
|
||||
// can't always sequence init → bindClient → mutate, e.g.
|
||||
// when bind happens after a slow `Promise.all`.
|
||||
await store.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
expect(handle.calls).toHaveLength(0);
|
||||
|
||||
store.bindClient(handle.client);
|
||||
store.forceSync();
|
||||
await handle.waitForCalls(1);
|
||||
expect(store.statuses["id-1"]).toBe("applied");
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
mapHeight: 4000,
|
||||
planetCount: planets.length,
|
||||
planets,
|
||||
race: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ describe("applyOrderOverlay", () => {
|
||||
expect(out.planets[0]!.name).toBe("Pending");
|
||||
});
|
||||
|
||||
test("skips unsubmitted statuses (draft/valid/invalid/rejected)", () => {
|
||||
test("skips draft / invalid / rejected statuses", () => {
|
||||
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
|
||||
const cmd: OrderCommand = {
|
||||
kind: "planetRename",
|
||||
@@ -91,12 +92,24 @@ describe("applyOrderOverlay", () => {
|
||||
planetNumber: 1,
|
||||
name: "Tentative",
|
||||
};
|
||||
for (const status of ["draft", "valid", "invalid", "rejected"] as const) {
|
||||
for (const status of ["draft", "invalid", "rejected"] as const) {
|
||||
const out = applyOrderOverlay(report, [cmd], { "cmd-1": status });
|
||||
expect(out.planets[0]!.name).toBe("Earth");
|
||||
}
|
||||
});
|
||||
|
||||
test("applies on `valid` so the player sees their committed intent immediately", () => {
|
||||
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
|
||||
const cmd: OrderCommand = {
|
||||
kind: "planetRename",
|
||||
id: "cmd-1",
|
||||
planetNumber: 1,
|
||||
name: "Pending-Sync",
|
||||
};
|
||||
const out = applyOrderOverlay(report, [cmd], { "cmd-1": "valid" });
|
||||
expect(out.planets[0]!.name).toBe("Pending-Sync");
|
||||
});
|
||||
|
||||
test("ignores rename for missing planet (visibility lost)", () => {
|
||||
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
|
||||
const cmd: OrderCommand = {
|
||||
|
||||
+109
-153
@@ -1,53 +1,33 @@
|
||||
// Component coverage for the Phase 14 order-tab submit flow. Drives
|
||||
// the tab against an in-memory `OrderDraftStore`, a synthetic
|
||||
// `GalaxyClient`, and a stubbed `GameStateStore.refresh`. Every
|
||||
// case asserts both the rendered DOM (status badges, button state)
|
||||
// and the side effect on the draft store (per-command status flips).
|
||||
// Component coverage for the Phase 14 order tab. The Submit button
|
||||
// has been retired — every successful `add` / `remove` triggers
|
||||
// `OrderDraftStore.scheduleSync`, so the tab is mostly a status
|
||||
// surface. Tests assert the per-row status badge transitions and
|
||||
// the bottom-bar sync state.
|
||||
|
||||
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 { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import OrderTab from "../src/lib/sidebar/order-tab.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../src/sync/order-draft.svelte";
|
||||
import {
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
GameStateStore,
|
||||
} from "../src/lib/game-state.svelte";
|
||||
import {
|
||||
GALAXY_CLIENT_CONTEXT_KEY,
|
||||
GalaxyClientHolder,
|
||||
} from "../src/lib/galaxy-client-context.svelte";
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import { uuidToHiLo } from "../src/api/game-state";
|
||||
import type { GalaxyClient } from "../src/api/galaxy-client";
|
||||
import type { OrderCommand } from "../src/sync/order-types";
|
||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||
import type { Cache } from "../src/platform/store/index";
|
||||
import { UUID } from "../src/proto/galaxy/fbs/common";
|
||||
import {
|
||||
CommandItem,
|
||||
CommandPayload,
|
||||
CommandPlanetRename,
|
||||
UserGamesOrderResponse,
|
||||
} from "../src/proto/galaxy/fbs/order";
|
||||
import { openGalaxyDB } from "../src/platform/store/idb";
|
||||
import { recordingClient } from "./helpers/fake-order-client";
|
||||
|
||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
let db: Awaited<ReturnType<typeof openGalaxyDB>>;
|
||||
let dbName: string;
|
||||
let cache: Cache;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `galaxy-order-tab-${crypto.randomUUID()}`;
|
||||
db = await openGalaxyDB(dbName);
|
||||
cache = new IDBCache(db);
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
@@ -61,162 +41,138 @@ afterEach(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
interface Setup {
|
||||
context: Map<unknown, unknown>;
|
||||
draft: OrderDraftStore;
|
||||
gameState: GameStateStore;
|
||||
clientHolder: GalaxyClientHolder;
|
||||
exec: ReturnType<typeof vi.fn>;
|
||||
refresh: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function buildResponse(
|
||||
commands: { id: string; applied: boolean | null; errorCode: number | null }[],
|
||||
updatedAt: number,
|
||||
): Uint8Array {
|
||||
const builder = new Builder(256);
|
||||
const itemOffsets = commands.map((c) => {
|
||||
const cmdIdOffset = builder.createString(c.id);
|
||||
const nameOffset = builder.createString("ignored");
|
||||
const inner = CommandPlanetRename.createCommandPlanetRename(
|
||||
builder,
|
||||
BigInt(0),
|
||||
nameOffset,
|
||||
);
|
||||
CommandItem.startCommandItem(builder);
|
||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||
if (c.applied !== null) CommandItem.addCmdApplied(builder, c.applied);
|
||||
if (c.errorCode !== null) {
|
||||
CommandItem.addCmdErrorCode(builder, BigInt(c.errorCode));
|
||||
}
|
||||
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
||||
CommandItem.addPayload(builder, inner);
|
||||
return CommandItem.endCommandItem(builder);
|
||||
});
|
||||
const commandsVec = UserGamesOrderResponse.createCommandsVector(
|
||||
builder,
|
||||
itemOffsets,
|
||||
);
|
||||
const [hi, lo] = uuidToHiLo(GAME_ID);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
|
||||
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
|
||||
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(updatedAt));
|
||||
UserGamesOrderResponse.addCommands(builder, commandsVec);
|
||||
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
|
||||
builder.finish(offset);
|
||||
return builder.asUint8Array();
|
||||
}
|
||||
|
||||
async function makeSetup(commands: OrderCommand[]): Promise<Setup> {
|
||||
async function makeDraft(
|
||||
commands: OrderCommand[],
|
||||
): Promise<{ draft: OrderDraftStore; context: Map<unknown, unknown> }> {
|
||||
const cache = new IDBCache(db);
|
||||
const draft = new OrderDraftStore();
|
||||
await draft.init({ cache, gameId: GAME_ID });
|
||||
for (const cmd of commands) {
|
||||
await draft.add(cmd);
|
||||
}
|
||||
const gameState = new GameStateStore();
|
||||
gameState.gameId = GAME_ID;
|
||||
gameState.status = "ready";
|
||||
const refresh = vi.fn(async () => {});
|
||||
gameState.refresh = refresh as unknown as typeof gameState.refresh;
|
||||
const clientHolder = new GalaxyClientHolder();
|
||||
const exec = vi.fn(async (_messageType: string, _payload: Uint8Array) => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: buildResponse(
|
||||
commands.map((cmd) => ({
|
||||
id: cmd.id,
|
||||
applied: true,
|
||||
errorCode: null,
|
||||
})),
|
||||
17,
|
||||
),
|
||||
}));
|
||||
clientHolder.set({ executeCommand: exec } as unknown as GalaxyClient);
|
||||
const context = new Map<unknown, unknown>([
|
||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||
[GAME_STATE_CONTEXT_KEY, gameState],
|
||||
[GALAXY_CLIENT_CONTEXT_KEY, clientHolder],
|
||||
]);
|
||||
return { context, draft, gameState, clientHolder, exec, refresh };
|
||||
return { draft, context };
|
||||
}
|
||||
|
||||
describe("order-tab", () => {
|
||||
test("renders the empty state when the draft has no commands", async () => {
|
||||
const { context } = await makeSetup([]);
|
||||
const { draft, context } = await makeDraft([]);
|
||||
const ui = render(OrderTab, { context });
|
||||
expect(ui.getByTestId("order-empty")).toBeVisible();
|
||||
expect(ui.queryByTestId("order-submit")).toBeNull();
|
||||
expect(ui.queryByTestId("order-list")).toBeNull();
|
||||
// The sync bar still renders so the user can see the
|
||||
// idle / synced / error transitions.
|
||||
expect(ui.getByTestId("order-sync")).toBeVisible();
|
||||
draft.dispose();
|
||||
});
|
||||
|
||||
test("Submit is disabled when every entry is invalid", async () => {
|
||||
const { context } = await makeSetup([
|
||||
test("invalid command shows the invalid status badge", async () => {
|
||||
const { draft, context } = await makeDraft([
|
||||
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "" },
|
||||
]);
|
||||
const ui = render(OrderTab, { context });
|
||||
const submit = ui.getByTestId("order-submit");
|
||||
expect(submit).toBeDisabled();
|
||||
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
|
||||
"invalid",
|
||||
);
|
||||
draft.dispose();
|
||||
});
|
||||
|
||||
test("Submit posts every valid command and applies returned statuses", async () => {
|
||||
const { context, draft, exec, refresh } = await makeSetup([
|
||||
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
|
||||
]);
|
||||
test("auto-sync flips the row to applied and the sync bar to synced", async () => {
|
||||
const handle = recordingClient(GAME_ID, "ok");
|
||||
const { draft, context } = await makeDraft([]);
|
||||
draft.bindClient(handle.client);
|
||||
|
||||
const ui = render(OrderTab, { context });
|
||||
const submit = ui.getByTestId("order-submit");
|
||||
expect(submit).not.toBeDisabled();
|
||||
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent("valid");
|
||||
|
||||
await fireEvent.click(submit);
|
||||
await draft.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
|
||||
await handle.waitForCalls(1);
|
||||
await waitFor(() => {
|
||||
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
|
||||
"applied",
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(ui.getByTestId("order-sync")).toHaveAttribute(
|
||||
"data-sync-status",
|
||||
"synced",
|
||||
);
|
||||
});
|
||||
expect(handle.calls).toHaveLength(1);
|
||||
expect(handle.calls[0]!.commandIds).toEqual(["id-1"]);
|
||||
draft.dispose();
|
||||
});
|
||||
|
||||
test("removing the last command sends an empty cmd[] PUT", async () => {
|
||||
const handle = recordingClient(GAME_ID, "ok");
|
||||
const { draft, context } = await makeDraft([]);
|
||||
draft.bindClient(handle.client);
|
||||
|
||||
await draft.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
await handle.waitForCalls(1);
|
||||
|
||||
const ui = render(OrderTab, { context });
|
||||
await fireEvent.click(ui.getByTestId("order-command-delete-0"));
|
||||
|
||||
await handle.waitForCalls(2);
|
||||
expect(handle.calls[1]!.commandIds).toEqual([]);
|
||||
expect(draft.commands).toEqual([]);
|
||||
await waitFor(() => {
|
||||
expect(ui.getByTestId("order-empty")).toBeVisible();
|
||||
});
|
||||
draft.dispose();
|
||||
});
|
||||
|
||||
test("non-ok response surfaces the sync error and a retry button", async () => {
|
||||
const handle = recordingClient(GAME_ID, "rejected");
|
||||
const { draft, context } = await makeDraft([]);
|
||||
draft.bindClient(handle.client);
|
||||
|
||||
const ui = render(OrderTab, { context });
|
||||
await draft.add({
|
||||
kind: "planetRename",
|
||||
id: "id-1",
|
||||
planetNumber: 1,
|
||||
name: "Earth",
|
||||
});
|
||||
|
||||
await handle.waitForCalls(1);
|
||||
await waitFor(() => {
|
||||
expect(ui.getByTestId("order-sync")).toHaveAttribute(
|
||||
"data-sync-status",
|
||||
"error",
|
||||
);
|
||||
});
|
||||
expect(ui.getByTestId("order-sync-retry")).toBeVisible();
|
||||
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
|
||||
"rejected",
|
||||
);
|
||||
|
||||
// Retry the call now that the fixture answers ok.
|
||||
handle.setOutcome("ok");
|
||||
await fireEvent.click(ui.getByTestId("order-sync-retry"));
|
||||
await handle.waitForCalls(2);
|
||||
await waitFor(() => {
|
||||
expect(draft.statuses["id-1"]).toBe("applied");
|
||||
});
|
||||
expect(exec).toHaveBeenCalledTimes(1);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
|
||||
"applied",
|
||||
);
|
||||
});
|
||||
|
||||
test("Non-ok response marks every submitting entry as rejected", async () => {
|
||||
const { context, draft, refresh } = await makeSetup([
|
||||
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
|
||||
]);
|
||||
const exec = vi.fn(async () => ({
|
||||
resultCode: "invalid_request",
|
||||
payloadBytes: new TextEncoder().encode(
|
||||
JSON.stringify({ code: "boom", message: "down" }),
|
||||
),
|
||||
}));
|
||||
const holder = context.get(GALAXY_CLIENT_CONTEXT_KEY) as GalaxyClientHolder;
|
||||
holder.set({ executeCommand: exec } as unknown as GalaxyClient);
|
||||
|
||||
const ui = render(OrderTab, { context });
|
||||
await fireEvent.click(ui.getByTestId("order-submit"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(draft.statuses["id-1"]).toBe("rejected");
|
||||
expect(ui.getByTestId("order-sync")).toHaveAttribute(
|
||||
"data-sync-status",
|
||||
"synced",
|
||||
);
|
||||
});
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(ui.getByTestId("order-submit-error")).toHaveTextContent("down");
|
||||
});
|
||||
|
||||
test("Already-applied entries do not get re-submitted", async () => {
|
||||
const { context, draft, exec } = await makeSetup([
|
||||
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
|
||||
]);
|
||||
draft.markSubmitting(["id-1"]);
|
||||
draft.applyResults({
|
||||
results: new Map([["id-1", "applied"] as const]),
|
||||
updatedAt: 1,
|
||||
});
|
||||
|
||||
const ui = render(OrderTab, { context });
|
||||
const submit = ui.getByTestId("order-submit");
|
||||
expect(submit).toBeDisabled();
|
||||
expect(exec).not.toHaveBeenCalled();
|
||||
draft.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
||||
mapHeight: 4000,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user