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