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