diff --git a/game/internal/repo/game.go b/game/internal/repo/game.go index aa595ee..a61811a 100644 --- a/game/internal/repo/game.go +++ b/game/internal/repo/game.go @@ -12,7 +12,6 @@ package repo import ( "encoding/json" - "errors" "fmt" "galaxy/model/order" @@ -234,15 +233,16 @@ func loadOrder(s Storage, t uint, id uuid.UUID) (*order.UserGamesOrder, bool, er if err := s.ReadSafe(path, stored); err != nil { return nil, false, NewStorageError(err) } + // An empty stored batch is a valid state — the player either + // cleared their draft or never added a command yet. We round- + // trip it as `(*UserGamesOrder, true, nil)` with an empty + // `Commands` slice so callers can distinguish "no order yet" + // (ok=false) from "order exists but is empty" (ok=true). result := &order.UserGamesOrder{ GameID: stored.GameID, UpdatedAt: stored.UpdatedAt, Commands: make([]order.DecodableCommand, len(stored.Commands)), } - if len(stored.Commands) == 0 { - return nil, false, errors.New("no commands were stored") - } - for i := range stored.Commands { command, err := ParseOrder(stored.Commands[i], nil) if err != nil { diff --git a/game/internal/repo/repo_test.go b/game/internal/repo/repo_test.go index 738b239..7bca20f 100644 --- a/game/internal/repo/repo_test.go +++ b/game/internal/repo/repo_test.go @@ -104,6 +104,50 @@ func LoadOrderTest(t *testing.T, s repo.Storage, root string, turn uint, id uuid CommandResultTest(t, o) } +func TestSaveOrderEmptyRoundTrip(t *testing.T) { + // An empty order is a legal player intent (the user removed + // every command from the draft). The repo round-trips it as an + // `(*UserGamesOrder, true, nil)` triple with `Commands` empty + // so the front-end can distinguish "no order yet" (ok=false) + // from "order exists but is empty" (ok=true). + root := t.ArtifactDir() + s, err := fs.NewFileStorage(root) + assert.NoError(t, err) + id := uuid.New() + gameID := uuid.New() + now := time.Now().UTC().UnixMilli() + o := &order.UserGamesOrder{ + GameID: gameID, + UpdatedAt: now, + } + var turn uint = 3 + + assert.NoError(t, repo.SaveOrder_T(s, turn, id, o)) + assert.FileExists(t, filepath.Join(root, repo.OrderDir(turn, id))) + + loaded, ok, err := repo.LoadOrder_T(s, turn, id) + assert.NoError(t, err) + assert.True(t, ok, "empty order must surface as ok=true so callers can tell it apart from a missing one") + assert.NotNil(t, loaded) + assert.Equal(t, gameID, loaded.GameID) + assert.Equal(t, now, loaded.UpdatedAt) + assert.Empty(t, loaded.Commands) +} + +func TestLoadOrderMissing(t *testing.T) { + // A turn that has never had a PUT must come back as + // `(nil, false, nil)` — the engine's "no stored order" path. + root := t.ArtifactDir() + s, err := fs.NewFileStorage(root) + assert.NoError(t, err) + id := uuid.New() + + loaded, ok, err := repo.LoadOrder_T(s, 7, id) + assert.NoError(t, err) + assert.False(t, ok) + assert.Nil(t, loaded) +} + func CommandResultTest(t *testing.T, o *order.UserGamesOrder) { assert.NotEmpty(t, o.Commands) for i := range o.Commands { diff --git a/game/internal/router/handler/command.go b/game/internal/router/handler/command.go index 745e599..23c9202 100644 --- a/game/internal/router/handler/command.go +++ b/game/internal/router/handler/command.go @@ -2,7 +2,6 @@ package handler import ( "encoding/json" - "errors" "fmt" "net/http" @@ -33,7 +32,12 @@ func CommandHandler(c *gin.Context, executor CommandExecutor) { commands[i] = command } if len(commands) == 0 { - errorResponse(c, errors.New("no commands given")) + // `PUT /api/v1/command` is the immediate-execution path — + // running an empty batch is a meaningless no-op, so we + // reject it with `400` rather than rely on the validator. + // `PUT /api/v1/order` keeps an empty list (the player + // cleared their draft) — see `OrderHandler`. + c.JSON(http.StatusBadRequest, gin.H{"error": "no commands given"}) return } diff --git a/game/internal/router/handler/order.go b/game/internal/router/handler/order.go index 9a31b3c..0d424b1 100644 --- a/game/internal/router/handler/order.go +++ b/game/internal/router/handler/order.go @@ -1,7 +1,6 @@ package handler import ( - "errors" "net/http" "galaxy/model/order" @@ -18,6 +17,10 @@ func PutOrderHandler(c *gin.Context, executor CommandExecutor) { return } + // An empty `cmd` array is a valid PUT: the client clears its + // local order draft and expects the server to mirror that + // state. The engine stores the empty batch so the next GET + // returns the same empty list with the new `updatedAt`. commands := make([]order.DecodableCommand, len(cmd.Commands)) for i := range cmd.Commands { command, err := repo.ParseOrder(cmd.Commands[i], validateCommand) @@ -26,10 +29,6 @@ func PutOrderHandler(c *gin.Context, executor CommandExecutor) { } commands[i] = command } - if len(commands) == 0 { - errorResponse(c, errors.New("no commands given")) - return - } result, err := executor.ValidateOrder(cmd.Actor, commands...) if errorResponse(c, err) { diff --git a/game/internal/router/order_test.go b/game/internal/router/order_test.go index 4e3acba..5f56d35 100644 --- a/game/internal/router/order_test.go +++ b/game/internal/router/order_test.go @@ -60,16 +60,25 @@ func TestOrderRaceQuit(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code, w.Body) - // error: no commands + // empty cmd[] is a valid PUT — the player cleared their draft; + // the engine stores the empty batch and answers with the + // canonical `UserGamesOrder` envelope. ValidateOrder receives a + // zero-length variadic and the response carries no commands. payload = &rest.Command{ Actor: commandDefaultActor, } + exec := &dummyExecutor{} + emptyRouter := setupRouterExecutor(exec) w = httptest.NewRecorder() req, _ = http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload)) - r.ServeHTTP(w, req) + emptyRouter.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code, w.Body) + assert.Equal(t, commandNoErrorsStatus, w.Code, w.Body) + assert.Equal(t, 0, exec.CommandsExecuted) + var stored order.UserGamesOrder + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &stored)) + assert.Empty(t, stored.Commands) } func TestOrderRaceVote(t *testing.T) { diff --git a/pkg/model/rest/command.go b/pkg/model/rest/command.go index 04f8c8b..bc45ddb 100644 --- a/pkg/model/rest/command.go +++ b/pkg/model/rest/command.go @@ -4,7 +4,14 @@ import "encoding/json" type Command struct { Actor string `json:"actor" binding:"notblank"` - Commands []json.RawMessage `json:"cmd" binding:"min=1"` + // Commands carries the engine-bound payload for either the + // command (`PUT /api/v1/command`, immediate) or the order + // (`PUT /api/v1/order`, validate-and-store) path. The order + // path treats an empty array as "the player has no orders for + // this turn" and stores it. The command handler still rejects + // an empty array by hand because immediate execution of a + // no-op makes no sense. + Commands []json.RawMessage `json:"cmd"` } func (o Command) MarshalBinary() (data []byte, err error) { diff --git a/ui/frontend/src/api/game-state.ts b/ui/frontend/src/api/game-state.ts index 6ffa0e4..1c1aa48 100644 --- a/ui/frontend/src/api/game-state.ts +++ b/ui/frontend/src/api/game-state.ts @@ -67,6 +67,12 @@ export interface GameReport { mapHeight: number; planetCount: number; planets: ReportPlanet[]; + /** + * race is the calling player's race name as resolved by the + * engine from the runtime player mapping. Empty when the engine + * has not produced a report yet (boot state). + */ + race: string; } export async function fetchGameReport( @@ -189,6 +195,7 @@ function decodeReport(report: Report): GameReport { mapHeight: report.height(), planetCount: report.planetCount(), planets, + race: report.race() ?? "", }; } @@ -212,18 +219,20 @@ export function uuidToHiLo(value: string): [bigint, bigint] { } /** - * applyOrderOverlay returns a copy of `report` with every applied or - * still-in-flight (`submitting`) command from `commands` projected on - * top. Phase 14 understands `planetRename` only — every other variant - * passes through. The function is pure: callers re-derive the - * overlay whenever the draft or the report change. + * applyOrderOverlay returns a copy of `report` with every locally- + * valid or still-in-flight or applied command from `commands` + * projected on top. Phase 14 understands `planetRename` only — + * every other variant passes through. The function is pure: + * callers re-derive the overlay whenever the draft or the report + * change. * - * `statuses` maps command id → status. Entries with `applied` or - * `submitting` participate in the overlay; everything else (`draft`, - * `valid`, `invalid`, `rejected`) is treated as "not yet committed - * by the player" and skipped. This matches the order-composer model: - * the player sees their own committed intent, not their unfinished - * edits. + * `statuses` maps command id → status. Entries with `valid`, + * `submitting`, or `applied` participate in the overlay — together + * they describe "the player's committed intent for this turn": + * locally-valid (auto-sync about to fire), in-flight on the wire, + * or acknowledged by the engine. Entries with `draft`, `invalid`, + * or `rejected` skip the overlay so the player keeps the server's + * (un-renamed) view. */ export function applyOrderOverlay( report: GameReport, @@ -234,7 +243,13 @@ export function applyOrderOverlay( let mutatedPlanets: ReportPlanet[] | null = null; for (const cmd of commands) { const status = statuses[cmd.id]; - if (status !== "applied" && status !== "submitting") continue; + if ( + status !== "valid" && + status !== "submitting" && + status !== "applied" + ) { + continue; + } if (cmd.kind !== "planetRename") continue; const idx = report.planets.findIndex((p) => p.number === cmd.planetNumber); if (idx < 0) continue; diff --git a/ui/frontend/src/lib/game-state.svelte.ts b/ui/frontend/src/lib/game-state.svelte.ts index 9aa7b16..053677f 100644 --- a/ui/frontend/src/lib/game-state.svelte.ts +++ b/ui/frontend/src/lib/game-state.svelte.ts @@ -37,6 +37,13 @@ type Status = "idle" | "loading" | "ready" | "error"; export class GameStateStore { gameId: string = $state(""); + /** + * gameName mirrors the lobby's `game_name` for the running game. + * Lifted from the lobby record on `setGame`; empty during boot + * and set once the lobby query resolves. Used by the header to + * compose the ` @ , turn N` display. + */ + gameName: string = $state(""); status: Status = $state("idle"); report: GameReport | null = $state(null); wrapMode: WrapMode = $state("torus"); @@ -95,6 +102,7 @@ export class GameStateStore { this.error = `game ${gameId} is not in your list`; return; } + this.gameName = summary.gameName; this.currentTurn = summary.currentTurn; await this.loadTurn(summary.currentTurn); } catch (err) { diff --git a/ui/frontend/src/lib/header/header.svelte b/ui/frontend/src/lib/header/header.svelte index f85e64f..9aeaf79 100644 --- a/ui/frontend/src/lib/header/header.svelte +++ b/ui/frontend/src/lib/header/header.svelte @@ -1,16 +1,25 @@
- - {i18n.t("game.shell.race_placeholder")} + + {headline} -
- {#if submitError !== null} -

{submitError}

- {/if} + + {#if draft.syncStatus === "syncing"} + {i18n.t("game.sidebar.order.sync.in_flight")} + {:else if draft.syncStatus === "synced"} + {i18n.t("game.sidebar.order.sync.synced")} + {:else if draft.syncStatus === "error"} + {i18n.t("game.sidebar.order.sync.error", { + message: draft.syncError ?? "", + })} + {:else} + {i18n.t("game.sidebar.order.sync.idle")} + {/if} + + {#if draft.syncStatus === "error"} + + {/if} +
{/if} @@ -274,26 +207,35 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft` color: #e8eaf6; border-color: #6d8cff; } - .submit { + .sync { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.8rem; + color: #aab; + } + .sync-error { + color: #d97a7a; + } + .sync-synced { + color: #8be9a3; + } + .sync-syncing { + color: #6d8cff; + } + .sync-retry { font: inherit; - font-size: 0.9rem; - padding: 0.4rem 1rem; - background: #1d2440; - color: #e8eaf6; + font-size: 0.8rem; + padding: 0.15rem 0.5rem; + background: transparent; + color: #aab; border: 1px solid #2a3150; border-radius: 3px; cursor: pointer; } - .submit:not(:disabled):hover { + .sync-retry:hover { + color: #e8eaf6; border-color: #6d8cff; } - .submit:disabled { - cursor: not-allowed; - opacity: 0.6; - } - .error { - margin: 0.5rem 0 0; - color: #d97a7a; - font-size: 0.85rem; - } diff --git a/ui/frontend/src/routes/games/[id]/+layout.svelte b/ui/frontend/src/routes/games/[id]/+layout.svelte index febc438..5ad9429 100644 --- a/ui/frontend/src/routes/games/[id]/+layout.svelte +++ b/ui/frontend/src/routes/games/[id]/+layout.svelte @@ -164,12 +164,17 @@ fresh. orderDraft.init({ cache, gameId }), ]); galaxyClient.set(client); - if (orderDraft.needsServerHydration) { - await orderDraft.hydrateFromServer({ - client, - turn: gameState.currentTurn, - }); - } + orderDraft.bindClient(client); + // The server is always polled at game boot — its + // stored order may be fresher than the local cache + // (e.g. user is on a new device), and an offline + // edit must catch up at re-sync time. The hydration + // is non-fatal: a network error keeps the local + // cache and surfaces through `draft.syncStatus`. + await orderDraft.hydrateFromServer({ + client, + turn: gameState.currentTurn, + }); } catch (err) { gameState.failBootstrap(describeBootstrapError(err)); } diff --git a/ui/frontend/src/sync/order-draft.svelte.ts b/ui/frontend/src/sync/order-draft.svelte.ts index 659bae0..d1b8b26 100644 --- a/ui/frontend/src/sync/order-draft.svelte.ts +++ b/ui/frontend/src/sync/order-draft.svelte.ts @@ -6,11 +6,16 @@ // Draft state is persisted into the platform `Cache` under the // `order-drafts` namespace with a per-game key, so a reload, a // browser restart, or a navigation through the lobby and back into -// the same game restores the previously composed list. Phase 14 -// will add the submit pipeline that drains the draft to the server; -// Phase 26 will hide the order tab in history mode through a flag -// passed by the layout (the store itself remains alive across that -// transition so the draft survives history-mode round-trips). +// the same game restores the previously composed list. +// +// Phase 14 wires the auto-sync pipeline: every successful mutation +// (`add` / `remove` / `move`) coalesces a `submitOrder` call so the +// server always mirrors the local draft. The Submit button is gone — +// the player's intent is the source of truth and the engine is kept +// in lock-step. Phase 26 will hide the order tab in history mode +// through a flag passed by the layout (the store itself remains +// alive across that transition so the draft survives history-mode +// round-trips). // // The store deliberately carries no Svelte component imports so it // can be tested directly with a synthetic `Cache` without rendering @@ -20,6 +25,7 @@ import type { Cache } from "../platform/store/index"; import type { GalaxyClient } from "../api/galaxy-client"; import { fetchOrder } from "./order-load"; import type { CommandStatus, OrderCommand } from "./order-types"; +import { submitOrder } from "./submit"; import { validateEntityName } from "$lib/util/entity-name"; const NAMESPACE = "order-drafts"; @@ -35,6 +41,8 @@ export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft"); type Status = "idle" | "ready" | "error"; +export type SyncStatus = "idle" | "syncing" | "synced" | "error"; + export class OrderDraftStore { commands: OrderCommand[] = $state([]); statuses: Record = $state({}); @@ -43,18 +51,26 @@ export class OrderDraftStore { error: string | null = $state(null); /** - * needsServerHydration is `true` when the cache row for this game - * was absent at `init` time. The layout reads it after both - * `gameState.init` and `orderDraft.init` resolve and, if `true`, - * calls `hydrateFromServer` once the current turn is known. - * An explicitly empty cache row sets it to `false` (the user has - * an empty draft, not a missing one). + * syncStatus reflects the auto-sync pipeline state for the order + * tab status bar: + * - `idle` — no sync attempted yet (e.g. fresh draft after + * hydration or before the first mutation). + * - `syncing` — a `submitOrder` call is in flight. + * - `synced` — the last sync succeeded; statuses match the + * server's view. + * - `error` — the last sync failed (network or non-`ok`); the + * next mutation triggers a retry, or the user can + * force a re-sync via `forceSync`. */ - needsServerHydration = $state(false); + syncStatus: SyncStatus = $state("idle"); + syncError: string | null = $state(null); private cache: Cache | null = null; private gameId = ""; private destroyed = false; + private client: GalaxyClient | null = null; + private syncing: Promise | null = null; + private pending = false; /** * init loads the persisted draft for `opts.gameId` from `opts.cache` @@ -63,11 +79,11 @@ export class OrderDraftStore { * constructs a fresh store per game, so there is no need to support * mid-life game switching here. * - * When the cache row is absent, `needsServerHydration` is set to - * `true`; the layout fans out a `hydrateFromServer` call once the - * current turn is known. An explicitly empty cache row is treated - * as "user has an empty draft" and skipped — local intent always - * wins over server snapshot. + * The cache load is the fast path so the order tab paints + * immediately on reopening the game; `hydrateFromServer` (called + * by the layout once the current turn is known) is the + * authoritative read that always overwrites the local cache when + * the server has a stored order. */ async init(opts: { cache: Cache; gameId: string }): Promise { this.cache = opts.cache; @@ -78,13 +94,7 @@ export class OrderDraftStore { draftKey(opts.gameId), ); if (this.destroyed) return; - if (stored === undefined) { - this.commands = []; - this.needsServerHydration = true; - } else { - this.commands = Array.isArray(stored) ? [...stored] : []; - this.needsServerHydration = false; - } + this.commands = Array.isArray(stored) ? [...stored] : []; this.recomputeStatuses(); this.status = "ready"; } catch (err) { @@ -95,37 +105,64 @@ export class OrderDraftStore { } /** - * hydrateFromServer fetches the player's stored order from the - * gateway when the cache row was absent at boot. The result is - * merged into `commands` and persisted so subsequent reloads - * prefer the cached version. Failures are non-fatal — the draft - * stays empty and the user can keep composing. + * bindClient stores the per-game `GalaxyClient` so subsequent + * mutations can drive the auto-sync pipeline. The layout calls + * this after the boot `Promise.all` resolves and before + * `hydrateFromServer`, so any mutation that lands afterwards goes + * through the network. + */ + bindClient(client: GalaxyClient): void { + this.client = client; + } + + /** + * hydrateFromServer issues `user.games.order.get` for the current + * turn and overwrites the local cache with the server's stored + * order. The server is the source of truth: a player who logged + * in from a fresh device must see their existing orders, and a + * cache that's out-of-sync (e.g. a stale browser tab) is + * superseded by the gateway's view. A `found = false` answer + * empties the local draft. Network failures keep the local cache + * intact and surface as `syncStatus = "error"`. */ async hydrateFromServer(opts: { client: GalaxyClient; turn: number; }): Promise { - if (this.status !== "ready" || !this.needsServerHydration) return; - this.needsServerHydration = false; + if (this.status !== "ready") return; + this.client = opts.client; + this.syncStatus = "syncing"; + this.syncError = null; try { const fetched = await fetchOrder(opts.client, this.gameId, opts.turn); if (this.destroyed) return; this.commands = fetched.commands; this.updatedAt = fetched.updatedAt; this.recomputeStatuses(); + // Server-fetched commands echo cmdApplied=true for entries + // that survived previous turns; keep them as `applied` so + // the overlay continues to project them on the inspector. + const next = { ...this.statuses }; + for (const cmd of this.commands) { + if (next[cmd.id] === "valid") { + next[cmd.id] = "applied"; + } + } + this.statuses = next; await this.persist(); + this.syncStatus = "synced"; } catch (err) { if (this.destroyed) return; - console.warn( - "order-draft: server hydration failed; staying on empty draft", - err, - ); + this.syncStatus = "error"; + this.syncError = err instanceof Error ? err.message : "fetch failed"; + console.warn("order-draft: server hydration failed", err); } } /** * add appends a command to the end of the draft, runs local - * validation for the new entry, and persists the updated list. + * validation for the new entry, persists the updated list, and + * triggers an auto-sync to keep the server in lock-step. * Mutations made before `init` resolves are ignored — the layout * always awaits `init` before exposing the store. */ @@ -134,11 +171,15 @@ export class OrderDraftStore { this.commands = [...this.commands, command]; this.statuses = { ...this.statuses, [command.id]: validateCommand(command) }; await this.persist(); + this.scheduleSync(); } /** - * remove drops the command with the given id from the draft and - * persists the result. A miss is a no-op. + * remove drops the command with the given id from the draft, + * persists the result, and triggers an auto-sync. A miss is a + * no-op. Even removing the last command sends an explicit empty + * order to the server so its stored state matches the local one + * (the engine accepts an empty `cmd[]` per the order handler). */ async remove(id: string): Promise { if (this.status !== "ready") return; @@ -149,13 +190,16 @@ export class OrderDraftStore { delete nextStatuses[id]; this.statuses = nextStatuses; await this.persist(); + this.scheduleSync(); } /** * move relocates the command at `fromIndex` to `toIndex`, shifting * the intermediate commands. Out-of-range indices and identical * positions are no-ops; both indices are clamped against the - * current `commands` length. + * current `commands` length. Triggers an auto-sync — the server + * stores commands in submission order and the engine relies on + * that order at turn cutoff. */ async move(fromIndex: number, toIndex: number): Promise { if (this.status !== "ready") return; @@ -169,49 +213,137 @@ export class OrderDraftStore { next.splice(toIndex, 0, picked); this.commands = next; await this.persist(); + this.scheduleSync(); } /** - * markSubmitting flips the status of every entry in `ids` to - * `submitting` so the order tab can disable per-row controls and - * show a spinner. The state machine runs `valid → submitting → - * applied | rejected` (see ui/docs/order-composer.md). + * forceSync re-runs the auto-sync without requiring a mutation. + * Used by the order tab's retry-on-error affordance. */ - markSubmitting(ids: string[]): void { + forceSync(): void { + this.scheduleSync(); + } + + dispose(): void { + this.destroyed = true; + this.cache = null; + this.client = null; + } + + private scheduleSync(): void { + if (this.client === null) return; + if (this.syncing !== null) { + this.pending = true; + return; + } + this.syncing = this.runSync().finally(() => { + this.syncing = null; + }); + } + + private async runSync(): Promise { + while (true) { + this.pending = false; + const client = this.client; + if (client === null || this.destroyed) return; + + // Capture the snapshot up-front: the in-flight request + // reflects the draft as it was when the mutation landed, + // even if the user adds another command before the + // gateway responds. + const snapshot: OrderCommand[] = $state.snapshot( + this.commands, + ) as OrderCommand[]; + // Auto-sync sends every command the player still has in + // the draft except the locally-invalid ones (we can't + // expect the server to accept a name that fails our own + // validator) and the Phase 12 placeholder. `applied` and + // `rejected` entries are re-sent so the server's stored + // view always mirrors the local one — re-applying an + // already-applied command is idempotent at the engine + // level (the rename ends at the same name). + const submittable = snapshot.filter((cmd) => { + const status = this.statuses[cmd.id]; + return status !== "invalid" && status !== "draft"; + }); + const submittingIds = submittable.map((cmd) => cmd.id); + + this.markSubmittingInternal(submittingIds); + this.syncStatus = "syncing"; + this.syncError = null; + + try { + const result = await submitOrder( + client, + this.gameId, + submittable, + { updatedAt: this.updatedAt }, + ); + if (this.destroyed) return; + if (result.ok) { + this.applyResultsInternal(result.results, result.updatedAt); + // Even with `result.ok === true` an individual + // command may have been rejected by the engine + // (e.g. validation passed transcoders but failed + // the in-game rule). Surface that as an error in + // the sync bar so the player notices and can fix + // or remove the offending command. + const anyRejected = Array.from(result.results.values()).some( + (s) => s === "rejected", + ); + this.syncStatus = anyRejected ? "error" : "synced"; + this.syncError = anyRejected + ? "engine rejected one or more commands" + : null; + } else { + this.markRejectedInternal(submittingIds); + this.syncStatus = "error"; + this.syncError = result.message; + } + } catch (err) { + if (this.destroyed) return; + this.revertSubmittingToValidInternal(); + this.syncStatus = "error"; + this.syncError = err instanceof Error ? err.message : "sync failed"; + } + + if (!this.pending) return; + } + } + + private markSubmittingInternal(ids: string[]): void { const next = { ...this.statuses }; for (const id of ids) { - next[id] = "submitting"; + // `applied` rows stay applied while the wire request is in + // flight — re-sending an already-applied command is a + // no-op idempotent operation, and flipping the badge back + // to `submitting` would flicker the inspector overlay. + if (next[id] === "valid" || next[id] === "rejected") { + next[id] = "submitting"; + } } this.statuses = next; } - /** - * applyResults merges the verdict map returned by `submitOrder` - * into the per-command status map. Entries not present in the - * map keep their current status — useful when only a subset of - * commands round-tripped to the server. The engine-assigned - * `updatedAt` is also stashed for the next submit's stale-order - * detection (kept as plumbing only in Phase 14). - */ - applyResults(opts: { - results: Map; - updatedAt: number; - }): void { + private applyResultsInternal( + results: Map, + updatedAt: number, + ): void { + const liveIds = new Set(this.commands.map((cmd) => cmd.id)); const next = { ...this.statuses }; - for (const [id, status] of opts.results.entries()) { + for (const [id, status] of results.entries()) { + // Drop verdicts for commands the user removed while the + // request was in flight — they are no longer in the + // draft, so re-introducing a stale `applied` row would + // confuse the order tab and the overlay. + if (!liveIds.has(id)) continue; next[id] = status; } this.statuses = next; - this.updatedAt = opts.updatedAt; + this.updatedAt = updatedAt; } - /** - * markRejected switches every supplied id to `rejected`. Used by - * the order tab when `submitOrder` returns `ok: false` — the - * gateway didn't process any command, so the entire batch is - * treated as rejected. - */ - markRejected(ids: string[]): void { + private markRejectedInternal(ids: string[]): void { const next = { ...this.statuses }; for (const id of ids) { next[id] = "rejected"; @@ -219,13 +351,7 @@ export class OrderDraftStore { this.statuses = next; } - /** - * revertSubmittingToValid resets every entry currently in - * `submitting` back to its pre-submit status (typically `valid`). - * Called when the network layer throws an exception so the - * operator can retry without the rows looking stuck mid-flight. - */ - revertSubmittingToValid(): void { + private revertSubmittingToValidInternal(): void { const next = { ...this.statuses }; for (const cmd of this.commands) { if (next[cmd.id] === "submitting") { @@ -235,11 +361,6 @@ export class OrderDraftStore { this.statuses = next; } - dispose(): void { - this.destroyed = true; - this.cache = null; - } - private recomputeStatuses(): void { const next: Record = {}; for (const cmd of this.commands) { diff --git a/ui/frontend/tests/e2e/game-shell-map.spec.ts b/ui/frontend/tests/e2e/game-shell-map.spec.ts index ef7e0b7..afbdc58 100644 --- a/ui/frontend/tests/e2e/game-shell-map.spec.ts +++ b/ui/frontend/tests/e2e/game-shell-map.spec.ts @@ -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", diff --git a/ui/frontend/tests/e2e/game-shell.spec.ts b/ui/frontend/tests/e2e/game-shell.spec.ts index 6b85053..65751d1 100644 --- a/ui/frontend/tests/e2e/game-shell.spec.ts +++ b/ui/frontend/tests/e2e/game-shell.spec.ts @@ -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(); }); diff --git a/ui/frontend/tests/e2e/rename-planet.spec.ts b/ui/frontend/tests/e2e/rename-planet.spec.ts index 5b98ad5..fbc0416 100644 --- a/ui/frontend/tests/e2e/rename-planet.spec.ts +++ b/ui/frontend/tests/e2e/rename-planet.spec.ts @@ -213,7 +213,7 @@ async function clickPlanetCentre(page: Page): Promise { 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. diff --git a/ui/frontend/tests/game-shell-header.test.ts b/ui/frontend/tests/game-shell-header.test.ts index abea334..f27f95f 100644 --- a/ui/frontend/tests/game-shell-header.test.ts +++ b/ui/frontend/tests/game-shell-header.test.ts @@ -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 (` @ , 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 { + 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([[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, { diff --git a/ui/frontend/tests/game-shell-sidebar.test.ts b/ui/frontend/tests/game-shell-sidebar.test.ts index bec3b0f..cdc9406 100644 --- a/ui/frontend/tests/game-shell-sidebar.test.ts +++ b/ui/frontend/tests/game-shell-sidebar.test.ts @@ -72,6 +72,7 @@ function makeReport(planets: ReportPlanet[]): GameReport { mapHeight: 1000, planetCount: planets.length, planets, + race: "", }; } diff --git a/ui/frontend/tests/helpers/fake-order-client.ts b/ui/frontend/tests/helpers/fake-order-client.ts new file mode 100644 index 0000000..b125c54 --- /dev/null +++ b/ui/frontend/tests/helpers/fake-order-client.ts @@ -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; + waitForIdle(): Promise; +} + +/** + * 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((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((resolve) => setTimeout(resolve, 5)); + } + }, + async waitForIdle() { + if (inFlight === 0) return; + await new Promise((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(); +} diff --git a/ui/frontend/tests/inspector-overlay.test.ts b/ui/frontend/tests/inspector-overlay.test.ts index f475907..7006159 100644 --- a/ui/frontend/tests/inspector-overlay.test.ts +++ b/ui/frontend/tests/inspector-overlay.test.ts @@ -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>; 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([ + [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([ - [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"; diff --git a/ui/frontend/tests/order-draft.test.ts b/ui/frontend/tests/order-draft.test.ts index 9d42c09..eb0ff06 100644 --- a/ui/frontend/tests/order-draft.test.ts +++ b/ui/frontend/tests/order-draft.test.ts @@ -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(); }); }); diff --git a/ui/frontend/tests/order-overlay.test.ts b/ui/frontend/tests/order-overlay.test.ts index d233ebf..e624bf5 100644 --- a/ui/frontend/tests/order-overlay.test.ts +++ b/ui/frontend/tests/order-overlay.test.ts @@ -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 = { diff --git a/ui/frontend/tests/order-tab.test.ts b/ui/frontend/tests/order-tab.test.ts index a7c11f6..3867572 100644 --- a/ui/frontend/tests/order-tab.test.ts +++ b/ui/frontend/tests/order-tab.test.ts @@ -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>; 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; - draft: OrderDraftStore; - gameState: GameStateStore; - clientHolder: GalaxyClientHolder; - exec: ReturnType; - refresh: ReturnType; -} - -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 { +async function makeDraft( + commands: OrderCommand[], +): Promise<{ draft: OrderDraftStore; context: Map }> { + 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([ [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(); }); }); diff --git a/ui/frontend/tests/state-binding.test.ts b/ui/frontend/tests/state-binding.test.ts index f59baea..35212b3 100644 --- a/ui/frontend/tests/state-binding.test.ts +++ b/ui/frontend/tests/state-binding.test.ts @@ -19,6 +19,7 @@ function makeReport(overrides: Partial = {}): GameReport { mapHeight: 4000, planetCount: 0, planets: [], + race: "", ...overrides, }; }