diff --git a/ui/PLAN.md b/ui/PLAN.md index 3d11d22..adba24f 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -1332,17 +1332,97 @@ Goal: implement the empty order composer as a persistent vertical list that survives navigation and reloads, ready to receive commands in later phases. -Artifacts: +Decisions taken with the project owner during implementation: -- `ui/frontend/src/lib/sidebar/order-tab.svelte` vertical command list - with empty state copy -- `ui/frontend/src/sync/order-draft.ts` draft order store backed by - `Cache`, persisting across reloads -- `ui/frontend/src/sync/order-types.ts` typed command shape - (`OrderCommand` discriminated union) -- topic doc `ui/docs/order-composer.md` describing the - draft-replaces-server-order model, the local-validation invariant, - and command status semantics +1. **Store filename uses the runes extension.** PLAN.md originally + listed `ui/frontend/src/sync/order-draft.ts`. Svelte 5 runes only + compile inside `*.svelte` / `*.svelte.ts` files; the draft state + has to be reactive so `order-tab.svelte` re-renders on + add/remove/move. The artifact ships as + `ui/frontend/src/sync/order-draft.svelte.ts`, mirroring the + Phase 11 `lib/game-state.svelte.ts` pattern. +2. **Single `placeholder` variant in the discriminated union.** The + project compactness rule rejects defining surface for the next + phase. Phase 14 owns `planetRename` end-to-end (inspector UI, + command type, submit pipeline, server-result merging) and is the + right place to add the first real variant. Phase 12 ships exactly + one variant — `{ kind: "placeholder"; id: string; label: string }` + — sufficient for the add/remove/reorder/persist tests. +3. **Reorder API is `move(fromIndex, toIndex)`.** One canonical + operation; up/down at the call site is a one-line index + arithmetic. No `moveUp`/`moveDown` aliases. +4. **Write-on-every-mutation persistence.** `add`/`remove`/`move` + each call `Cache.put` with the full draft snapshot. Phase 25 may + profile the submit pipeline and batch writes if needed; until + then deterministic writes are easier to test. +5. **Per-game scoping via Svelte context.** One `OrderDraftStore` + instance per game is created in `routes/games/[id]/+layout.svelte` + alongside `GameStateStore`, exposed through + `ORDER_DRAFT_CONTEXT_KEY`, disposed on layout destroy. +6. **`historyMode` as a prop, not a module.** Layout passes + `historyMode={false}` (a constant in Phase 12) to `Sidebar` and + `BottomTabs`; both forward to their tab-bar children which omit + the order entry when the flag is true. Phase 26 introduces the + real `lib/history-mode.ts` module and replaces the constant in + one place. +7. **Empty-state copy is `order is empty` / `приказ пуст`.** The + `coming soon` placeholder text is replaced; per-row delete + button reads `delete` / `удалить`. +8. **e2e seeding via `__galaxyDebug.seedOrderDraft`.** The existing + debug surface in `routes/__debug/store/+page.svelte` is extended + with `seedOrderDraft(gameId, commands)` and + `clearOrderDraft(gameId)` helpers that write directly to the + `order-drafts` cache namespace. The store loads the seeded draft + on the next layout mount the same way it would after a real + reload. +9. **Race / disposal hygiene mirrors `GameStateStore`.** Mutations + are gated on `status === "ready"` so calls before `init` + resolves are no-ops, and `persist` checks a `destroyed` flag so + in-flight writes after `dispose` resolve into nothing. + +Artifacts (delivered): + +- `ui/frontend/src/sync/order-types.ts` — `OrderCommand` + discriminated union (single `placeholder` variant) and + `CommandStatus` lifecycle type. +- `ui/frontend/src/sync/order-draft.svelte.ts` — + `OrderDraftStore` runes class with + `init` / `add` / `remove` / `move` / `dispose`, plus + `ORDER_DRAFT_CONTEXT_KEY`. Persists the full draft on every + mutation under namespace `order-drafts`, key `{gameId}/draft`. +- `ui/frontend/src/lib/sidebar/order-tab.svelte` — replaces the + Phase 10 stub. Empty state from `game.sidebar.empty.order`; + ordered list with stable `data-testid="order-command-{i}"` + rows and a per-row delete button. +- `ui/frontend/src/lib/sidebar/sidebar.svelte`, + `tab-bar.svelte`, `bottom-tabs.svelte` — `historyMode` prop on + the sidebar forwards to `hideOrder` on tab-bar / bottom-tabs; + active-tab `order` is reset to `inspector` if the flag flips + on, and the `?sidebar=order` URL seed falls back to + `inspector` while the flag is true. +- `ui/frontend/src/routes/games/[id]/+layout.svelte` — + instantiates `OrderDraftStore`, sets context, runs + `init({ cache, gameId })` next to `gameState.init` through + one `Promise.all`, disposes on destroy, passes + `historyMode={false}` down. +- `ui/frontend/src/routes/__debug/store/+page.svelte` — extended + `DebugSurface` with `seedOrderDraft` / `clearOrderDraft`. +- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new + `game.sidebar.order.command_delete` key plus updated + `game.sidebar.empty.order` copy. +- `ui/docs/order-composer.md` — topic doc describing the + draft-replaces-server-order model, local-validation invariant, + command status state machine, persistence, history-mode wiring, + and test layout. Cross-references `storage.md` and + `navigation.md`. +- `ui/docs/storage.md` — namespace registry row for + `order-drafts`. +- `ui/docs/navigation.md` — describes the historyMode prop wiring + through Sidebar / BottomTabs. +- `ui/README.md` — new entry under topic docs for + `order-composer.md`. +- Vitest: `ui/frontend/tests/order-draft.test.ts`. +- Playwright: `ui/frontend/tests/e2e/order-composer.spec.ts`. Dependencies: Phases 6, 10. diff --git a/ui/README.md b/ui/README.md index 80c51ca..7d649a2 100644 --- a/ui/README.md +++ b/ui/README.md @@ -65,6 +65,7 @@ ui/ ├── docs/ topic-based design notes │ ├── auth-flow.md email-code login, session store, revocation │ ├── i18n.md translation primitive, native-name picker, extensibility +│ ├── order-composer.md order draft model, persistence, history-mode wiring │ ├── storage.md web KeyStore/Cache, IDB schema, baseline │ ├── testing.md per-PR / release test tiers │ └── wasm-toolchain.md TinyGo build, JSDOM loading, bundle budget @@ -90,6 +91,8 @@ Linked topic docs: language picker, recipe for adding a new locale. - [`docs/storage.md`](docs/storage.md) — web KeyStore/Cache, IndexedDB schema, browser baseline. +- [`docs/order-composer.md`](docs/order-composer.md) — local + order draft store, persistence, history-mode wiring. - [`docs/wasm-toolchain.md`](docs/wasm-toolchain.md) — TinyGo build, loading recipe, bundle size budget. - [`docs/testing.md`](docs/testing.md) — Tier 1 per-PR + Tier 2 diff --git a/ui/docs/navigation.md b/ui/docs/navigation.md index fede5f1..492e067 100644 --- a/ui/docs/navigation.md +++ b/ui/docs/navigation.md @@ -61,6 +61,20 @@ on mount and seeds the initial tab. Later phases that want to land the user on a particular tool (for example, Phase 14's first end-to-end command flow) can set it on navigation. +The Order entry is hidden when the layout's `historyMode` flag is +true. Phase 12 plumbs the flag end-to-end as a prop — +`+layout.svelte` passes a constant `false` to `Sidebar`, which +forwards `hideOrder` to its `TabBar`; the same flag goes to +`BottomTabs` so the mobile `Order` button is also suppressed. A +`?sidebar=order` URL seed that arrives while the flag is true falls +back to `inspector`, and an `$effect` on the sidebar resets +`activeTab` away from `order` if the flag flips on mid-session. +Phase 26 introduces `lib/history-mode.ts` and replaces the constant +with the live signal; the order draft survives the toggle because +`OrderDraftStore` lives one level above the sidebar in the layout +hierarchy. See [`order-composer.md`](order-composer.md) for the +draft-store side of the flow. + ## Layout breakpoints Three discrete CSS modes matched to the IA section diagrams: diff --git a/ui/docs/order-composer.md b/ui/docs/order-composer.md new file mode 100644 index 0000000..d3ff815 --- /dev/null +++ b/ui/docs/order-composer.md @@ -0,0 +1,189 @@ +# Order composer + +This doc covers the local order draft: the per-game in-memory list +of commands the player has composed but not yet submitted, the +`Cache`-backed persistence that survives reloads, and the +`historyMode` flag that hides the composer when the user is browsing +past turns. The user-facing spec — the IA section's sidebar +description, the `Order` tab placement, the per-command status +display — lives in [`../PLAN.md`](../PLAN.md), section +`Information Architecture and Navigation`. This doc is the source of +truth for how those rules are implemented. + +## Draft replaces server order + +The client's view of "the player's intent for the next turn" lives +entirely in the local draft. The gateway exposes a turn-scoped +`user.games.order` endpoint that the submit pipeline drains the +draft into; until the player explicitly submits, the server has no +view of pending commands. The composer never reads back the +server's idea of an order — it reads its own draft and renders it. + +The motivation is operational: a draft can be edited, reordered, +or partially removed without round-trips, and a half-composed order +during a connectivity hiccup keeps every line the player typed. A +remote-first composer that reflects the gateway's pending-orders +queue would force a sync on every keystroke. + +When the submit pipeline lands (Phase 25), it iterates the draft +in order, sending one `command` per line. The gateway's per-line +result rejoins the draft entry through `cmdId`, and the entry's +`CommandStatus` flips to `applied` or `rejected`. Successfully +applied entries stay visible until the next turn cutoff so the +player can see what was committed; rejected entries stay until the +player edits or removes them. + +## Local-validation invariant + +Every command in the draft is *locally valid as of submission time*. +A command may live in the draft as `draft` (not yet validated) or +`valid`; `invalid` is allowed during composition but the submit +pipeline refuses to drain a draft that contains any `invalid` +entries. The validation step is per-command and pure — it consults +the current `GameStateStore` snapshot only, never the network. + +Phase 12 ships the skeleton without any concrete validators: the +single `placeholder` variant is content-free and stays at `draft` +forever. Phase 14's `planetRename` is the first variant that +exercises the `draft → valid | invalid` transition. + +## Command status state machine + +```text +draft ──validate──▶ valid ──submit──▶ submitting ──ack──▶ applied + ╲ │ ╲ + ╲──validate──▶ invalid ╲──nack──▶ rejected +``` + +Transitions: + +- **`draft → valid` / `draft → invalid`**: local validation. May + re-run when the underlying `GameStateStore` snapshot changes + (Phase 14+). +- **`valid → submitting`**: the submit pipeline picks the entry off + the draft and sends it to the gateway. +- **`submitting → applied` / `submitting → rejected`**: the gateway + responded; the entry is no longer in flight. + +Phase 12 stores the type but does not implement any transitions. +Every entry remains at `draft` until later phases land the +validators (Phase 14) and the submit pipeline (Phase 25). + +## Discriminated union shape + +`OrderCommand` is a discriminated union on the `kind` field. Phase +12 ships a single variant: + +```ts +interface PlaceholderCommand { + readonly kind: "placeholder"; + readonly id: string; + readonly label: string; +} +type OrderCommand = PlaceholderCommand; +``` + +The `id` field is the canonical identifier the store uses for +remove and reorder; later variants must keep `id: string` so the +store API stays uniform. The whole draft round-trips through +IndexedDB structured clone, so every variant must use only +JSON-friendly value types. Phase 14 adds the first real variant +(`planetRename`) and updates this list. + +## Store + +`OrderDraftStore` lives in +[`../frontend/src/sync/order-draft.svelte.ts`](../frontend/src/sync/order-draft.svelte.ts). +The class is a Svelte 5 runes store, so the file extension is +`.svelte.ts` (the original PLAN.md artifact line listed `.ts` — +the deviation is documented inline in `PLAN.md`'s Phase 12 +"Decisions" subsection). + +Lifecycle: + +| Method | Effect | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| `init({ cache, gameId })` | Reads the persisted draft from `cache`; sets `commands` and flips `status` to `ready`. Errors flow into `status = "error"`. | +| `add(cmd)` | Appends to the end and persists. | +| `remove(id)` | Drops the matching entry and persists. A miss is a no-op. | +| `move(fromIndex, toIndex)` | Reorders within the list and persists. Out-of-range and identical indices are no-ops. | +| `dispose()` | Marks the store destroyed; subsequent `persist()` calls are no-ops so a fast game-switch does not write stale state into the next id. | + +Mutations made before `init` resolves are silently ignored — the +layout always awaits `init` through `Promise.all([...])` next to +`gameState.init` before exposing the store. + +Layout integration mirrors `GameStateStore`: + +- One instance per game, created in + [`../frontend/src/routes/games/[id]/+layout.svelte`](../frontend/src/routes/games/[id]/+layout.svelte). +- Exposed through the `ORDER_DRAFT_CONTEXT_KEY` Svelte context. +- Disposed in the layout's `onDestroy`. + +The order tab consumes the store via +`getContext(ORDER_DRAFT_CONTEXT_KEY)`; Phase 14's planet inspector +will use the same key to push a new command. + +## Persistence + +Cache row layout: + +| Namespace | Key | Value type | +| -------------- | ------------------ | ---------------- | +| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | + +The store writes the full draft on every mutation. Phase 25 may +profile the submit pipeline and batch into a microtask if write +amplification becomes a problem; until then the deterministic +write-on-every-mutation model is what tests assert and what the +layout relies on for crash safety. + +The namespace is registered in [`storage.md`](storage.md). Cache +implementation details live there; this doc only describes how the +order composer uses the namespace. + +## History mode wiring + +Phase 26 introduces a global history-mode flag. The IA section +specifies that the Order tab is hidden when history mode is active — +the player is browsing a past turn snapshot, and composing commands +against an immutable snapshot would be confusing. + +Phase 12 wires the flag end-to-end as a prop. The layout owns the +flag (a constant `false` until Phase 26) and passes it to: + +- `Sidebar` as `historyMode`. The sidebar forwards it to its + `TabBar` as `hideOrder`. The Order entry is filtered out of the + tab list when true. If a `?sidebar=order` URL seed lands while + the flag is true, the sidebar falls back to `inspector`. If the + active tab is `order` when the flag flips on, an effect resets + it to `inspector`. +- `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order` + button is suppressed when true. + +The store itself stays alive across history-mode round-trips so +the draft survives. Phase 26 will replace the constant with the +real signal from `lib/history-mode.ts` and exercise the toggle in +its own test suite. + +## Testing + +Two test artifacts cover the skeleton: + +- [`../frontend/tests/order-draft.test.ts`](../frontend/tests/order-draft.test.ts) + — Vitest unit tests for the store. Drives `OrderDraftStore` + directly with `IDBCache` over `fake-indexeddb`. Covers init, + add, remove, move, per-game isolation, mutations-before-init, + and dispose hygiene. +- [`../frontend/tests/e2e/order-composer.spec.ts`](../frontend/tests/e2e/order-composer.spec.ts) + — Playwright spec. Seeds three commands through + `__galaxyDebug.seedOrderDraft`, navigates into + `/games//map`, opens the Order tool (sidebar tab on + desktop, bottom tab on mobile), asserts the rows, reloads, and + asserts the rows again. + +The `__galaxyDebug.seedOrderDraft(gameId, commands)` and +`__galaxyDebug.clearOrderDraft(gameId)` helpers in +[`../frontend/src/routes/__debug/store/+page.svelte`](../frontend/src/routes/__debug/store/+page.svelte) +write directly to the cache namespace, so seeding is independent +of any mounted store. diff --git a/ui/docs/storage.md b/ui/docs/storage.md index 71614fb..4ef406e 100644 --- a/ui/docs/storage.md +++ b/ui/docs/storage.md @@ -112,14 +112,16 @@ wipes every namespace. Namespaces in current use: -| Namespace | Key | Value type | Owner | -|-----------|----------------------|------------|-------------| -| `session` | `device-session-id` | `string` | Phase 7+ | +| Namespace | Key | Value type | Owner | +|-----------------|---------------------|------------------|-----------------------------| +| `session` | `device-session-id` | `string` | Phase 7+ | +| `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) | +| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) | -Phase 8 onwards will add per-feature namespaces (lobby snapshot, -game state, fixtures, etc.). The contract is namespace-strings -stay scoped to one feature; cross-feature reads through the cache -are by convention disallowed. +Later phases will add more per-feature namespaces (fixtures, lobby +snapshot, etc.). The contract is namespace-strings stay scoped to +one feature; cross-feature reads through the cache are by convention +disallowed. ## Keystore lifecycle diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index 48207f7..683e0b5 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -118,7 +118,8 @@ const en = { "game.sidebar.tab.order": "order", "game.sidebar.empty.calculator": "coming soon", "game.sidebar.empty.inspector": "select an object on the map", - "game.sidebar.empty.order": "coming soon", + "game.sidebar.empty.order": "order is empty", + "game.sidebar.order.command_delete": "delete", "game.bottom_tabs.map": "map", "game.bottom_tabs.calc": "calc", "game.bottom_tabs.order": "order", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index 5199364..4cabef9 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -119,7 +119,8 @@ const ru: Record = { "game.sidebar.tab.order": "приказ", "game.sidebar.empty.calculator": "скоро будет", "game.sidebar.empty.inspector": "выберите объект на карте", - "game.sidebar.empty.order": "скоро будет", + "game.sidebar.empty.order": "приказ пуст", + "game.sidebar.order.command_delete": "удалить", "game.bottom_tabs.map": "карта", "game.bottom_tabs.calc": "калк", "game.bottom_tabs.order": "приказ", diff --git a/ui/frontend/src/lib/sidebar/bottom-tabs.svelte b/ui/frontend/src/lib/sidebar/bottom-tabs.svelte index 2b4febb..955d4c2 100644 --- a/ui/frontend/src/lib/sidebar/bottom-tabs.svelte +++ b/ui/frontend/src/lib/sidebar/bottom-tabs.svelte @@ -22,8 +22,14 @@ destinations beats the duplication. gameId: string; activeTool: MobileTool; onSelectTool: (tool: MobileTool) => void; + hideOrder?: boolean; }; - let { gameId, activeTool, onSelectTool }: Props = $props(); + let { + gameId, + activeTool, + onSelectTool, + hideOrder = false, + }: Props = $props(); let moreOpen = $state(false); let rootEl: HTMLDivElement | null = $state(null); @@ -99,17 +105,19 @@ destinations beats the duplication. {i18n.t("game.bottom_tabs.calc")} - + {#if !hideOrder} + + {/if} + + {/each} + + {/if} diff --git a/ui/frontend/src/lib/sidebar/sidebar.svelte b/ui/frontend/src/lib/sidebar/sidebar.svelte index a8796e6..344ad32 100644 --- a/ui/frontend/src/lib/sidebar/sidebar.svelte +++ b/ui/frontend/src/lib/sidebar/sidebar.svelte @@ -1,14 +1,19 @@ @@ -51,7 +66,11 @@ when later phases want to land directly on a particular tool. data-open={open} >
- (activeTab = tab)} /> + (activeTab = tab)} + hideOrder={historyMode} + />
diff --git a/ui/frontend/src/sync/order-draft.svelte.ts b/ui/frontend/src/sync/order-draft.svelte.ts new file mode 100644 index 0000000..7cc2b39 --- /dev/null +++ b/ui/frontend/src/sync/order-draft.svelte.ts @@ -0,0 +1,125 @@ +// Per-game runes store that owns the local order draft. Mirrors the +// Phase 11 `GameStateStore` lifecycle: one instance per game, created +// in `routes/games/[id]/+layout.svelte`, exposed to descendants via +// Svelte context, disposed when the layout unmounts. +// +// 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 store deliberately carries no Svelte component imports so it +// can be tested directly with a synthetic `Cache` without rendering +// any UI. + +import type { Cache } from "../platform/store/index"; +import type { OrderCommand } from "./order-types"; + +const NAMESPACE = "order-drafts"; +const draftKey = (gameId: string): string => `${gameId}/draft`; + +/** + * ORDER_DRAFT_CONTEXT_KEY is the Svelte context key the in-game shell + * layout uses to expose its `OrderDraftStore` instance to descendants. + * The order tab and any later command-builder UI resolve the store via + * `getContext(ORDER_DRAFT_CONTEXT_KEY)`. + */ +export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft"); + +type Status = "idle" | "ready" | "error"; + +export class OrderDraftStore { + commands: OrderCommand[] = $state([]); + status: Status = $state("idle"); + error: string | null = $state(null); + + private cache: Cache | null = null; + private gameId = ""; + private destroyed = false; + + /** + * init loads the persisted draft for `opts.gameId` from `opts.cache` + * into `commands` and flips `status` to `ready`. The call is + * idempotent on the same store instance — the layout always + * constructs a fresh store per game, so there is no need to support + * mid-life game switching here. + */ + async init(opts: { cache: Cache; gameId: string }): Promise { + this.cache = opts.cache; + this.gameId = opts.gameId; + try { + const stored = await opts.cache.get( + NAMESPACE, + draftKey(opts.gameId), + ); + if (this.destroyed) return; + this.commands = Array.isArray(stored) ? [...stored] : []; + this.status = "ready"; + } catch (err) { + if (this.destroyed) return; + this.status = "error"; + this.error = err instanceof Error ? err.message : "load failed"; + } + } + + /** + * add appends a command to the end of the draft and persists the + * updated list. Mutations made before `init` resolves are ignored — + * the layout always awaits `init` before exposing the store. + */ + async add(command: OrderCommand): Promise { + if (this.status !== "ready") return; + this.commands = [...this.commands, command]; + await this.persist(); + } + + /** + * remove drops the command with the given id from the draft and + * persists the result. A miss is a no-op. + */ + async remove(id: string): Promise { + if (this.status !== "ready") return; + const next = this.commands.filter((cmd) => cmd.id !== id); + if (next.length === this.commands.length) return; + this.commands = next; + await this.persist(); + } + + /** + * 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. + */ + async move(fromIndex: number, toIndex: number): Promise { + if (this.status !== "ready") return; + const length = this.commands.length; + if (fromIndex < 0 || fromIndex >= length) return; + if (toIndex < 0 || toIndex >= length) return; + if (fromIndex === toIndex) return; + const next = [...this.commands]; + const [picked] = next.splice(fromIndex, 1); + if (picked === undefined) return; + next.splice(toIndex, 0, picked); + this.commands = next; + await this.persist(); + } + + dispose(): void { + this.destroyed = true; + this.cache = null; + } + + private async persist(): Promise { + if (this.cache === null || this.destroyed) return; + // `commands` is `$state`, so individual entries are proxies. + // IndexedDB's structured clone refuses to clone proxies, so the + // snapshot must be taken before the put. + const snapshot = $state.snapshot(this.commands) as OrderCommand[]; + await this.cache.put(NAMESPACE, draftKey(this.gameId), snapshot); + } +} diff --git a/ui/frontend/src/sync/order-types.ts b/ui/frontend/src/sync/order-types.ts new file mode 100644 index 0000000..b3519c5 --- /dev/null +++ b/ui/frontend/src/sync/order-types.ts @@ -0,0 +1,59 @@ +// Typed shape of a single command entry inside the local order +// draft. Phase 12 intentionally ships exactly one variant +// (`placeholder`) — Phase 14 lands the first real command +// (`planetRename`) together with the inspector UI that constructs +// it and the submit pipeline that drains the draft to the server. +// +// `OrderCommand` is a discriminated union on the `kind` field so +// later variants can extend the union without changing the array +// shape persisted in `Cache`. The whole draft round-trips through +// IndexedDB structured clone, so every variant must use only +// JSON-friendly value types (`string`, `number`, `boolean`, +// nested plain objects, and `Uint8Array`). + +/** + * PlaceholderCommand is the single variant shipped with the Phase 12 + * skeleton. It carries a stable `id` (used by remove and as a + * `data-testid` suffix) and a human-readable `label` rendered in the + * order tab's vertical list. The variant is deliberately content-free + * so test fixtures and the empty composer skeleton do not pre-bias + * Phase 14's first real command shape. + */ +export interface PlaceholderCommand { + readonly kind: "placeholder"; + readonly id: string; + readonly label: string; +} + +/** + * OrderCommand is the discriminated union of every command shape the + * local order draft can hold. The `kind` field is the discriminator; + * narrowing on it enables exhaustive `switch` statements at every + * call site. Phase 14 will widen the union with `planetRename`. + */ +export type OrderCommand = PlaceholderCommand; + +/** + * CommandStatus is the lifecycle of a single command from the moment + * it lands in the draft to the moment the server resolves it. The + * skeleton stores only the type description; Phase 14 adds the + * `valid` / `invalid` transitions driven by local validation, and + * Phase 25 introduces `submitting` / `applied` / `rejected` driven + * by the submit pipeline. + * + * The state machine is: + * + * draft → valid → submitting → applied + * ↘ invalid ↘ rejected + * + * A command is `draft` until local validation has run, then `valid` + * or `invalid`. On submit the entry transitions to `submitting`, + * then to `applied` or `rejected` once the gateway responds. + */ +export type CommandStatus = + | "draft" + | "valid" + | "invalid" + | "submitting" + | "applied" + | "rejected"; diff --git a/ui/frontend/tests/e2e/order-composer.spec.ts b/ui/frontend/tests/e2e/order-composer.spec.ts new file mode 100644 index 0000000..94025a7 --- /dev/null +++ b/ui/frontend/tests/e2e/order-composer.spec.ts @@ -0,0 +1,140 @@ +// Phase 12 end-to-end coverage for the order composer skeleton. The +// shell makes no gateway calls in this spec — the boot flow seeds an +// authenticated session and a draft directly through `/__debug/store`, +// then navigates into `/games//map` and exercises the order tab. +// +// Persistence is covered by reloading the page mid-spec: the +// `OrderDraftStore` re-reads the same cache row on the next mount, +// so the rendered list survives the round-trip. + +import { expect, test, type Page } from "@playwright/test"; + +// `window.__galaxyDebug` is owned by `routes/__debug/store/+page.svelte` +// and typed by `tests/e2e/storage-keypair-persistence.spec.ts`. The +// merged global declaration covers every helper this spec calls. + +const SESSION_ID = "phase-12-order-session"; +const GAME_ID = "test-order"; + +const SEED = [ + { kind: "placeholder" as const, id: "cmd-a", label: "first command" }, + { kind: "placeholder" as const, id: "cmd-b", label: "second command" }, + { kind: "placeholder" as const, id: "cmd-c", label: "third command" }, +]; + +async function bootDebug(page: Page): Promise { + await page.goto("/__debug/store"); + await expect(page.getByTestId("debug-store-ready")).toBeVisible(); + await page.waitForFunction(() => window.__galaxyDebug?.ready === true); +} + +async function seedShell(page: Page): Promise { + await bootDebug(page); + await page.evaluate(() => window.__galaxyDebug!.clearSession()); + await page.evaluate( + (id) => window.__galaxyDebug!.setDeviceSessionId(id), + SESSION_ID, + ); + await page.evaluate( + ({ gameId, commands }) => + window.__galaxyDebug!.clearOrderDraft(gameId).then(() => + window.__galaxyDebug!.seedOrderDraft(gameId, commands), + ), + { gameId: GAME_ID, commands: SEED }, + ); +} + +async function openOrderTool(page: Page, isMobile: boolean): Promise { + if (isMobile) { + await page.getByTestId("bottom-tab-order").click(); + } else { + await page.getByTestId("sidebar-tab-order").click(); + } + await expect(page.getByTestId("sidebar-tool-order")).toBeVisible(); +} + +async function expectSeededRows(page: Page): Promise { + const list = page.getByTestId("order-list"); + await expect(list).toBeVisible(); + for (let i = 0; i < SEED.length; i++) { + const row = page.getByTestId(`order-command-${i}`); + await expect(row).toBeVisible(); + await expect(row.getByTestId(`order-command-label-${i}`)).toHaveText( + SEED[i]!.label, + ); + } + await expect(page.getByTestId("order-empty")).toHaveCount(0); +} + +test("seeded draft renders on the order tab and survives a reload", async ({ + page, +}, testInfo) => { + const isMobile = testInfo.project.name.startsWith("chromium-mobile"); + await seedShell(page); + await page.goto(`/games/${GAME_ID}/map`); + await expect(page.getByTestId("game-shell")).toBeVisible(); + await expect(page.getByTestId("active-view-map")).toBeVisible(); + + await openOrderTool(page, isMobile); + await expectSeededRows(page); + + await page.reload(); + await expect(page.getByTestId("game-shell")).toBeVisible(); + await openOrderTool(page, isMobile); + await expectSeededRows(page); +}); + +test("removing a command from the order tab persists the removal", async ({ + page, +}, testInfo) => { + const isMobile = testInfo.project.name.startsWith("chromium-mobile"); + await seedShell(page); + await page.goto(`/games/${GAME_ID}/map`); + await expect(page.getByTestId("game-shell")).toBeVisible(); + await openOrderTool(page, isMobile); + + await expect(page.getByTestId("order-command-1")).toBeVisible(); + await page.getByTestId("order-command-delete-1").click(); + // The remaining two commands shift up by one slot. + await expect(page.getByTestId("order-command-label-0")).toHaveText( + SEED[0]!.label, + ); + await expect(page.getByTestId("order-command-label-1")).toHaveText( + SEED[2]!.label, + ); + await expect(page.getByTestId("order-command-2")).toHaveCount(0); + + await page.reload(); + await expect(page.getByTestId("game-shell")).toBeVisible(); + await openOrderTool(page, isMobile); + await expect(page.getByTestId("order-command-label-0")).toHaveText( + SEED[0]!.label, + ); + await expect(page.getByTestId("order-command-label-1")).toHaveText( + SEED[2]!.label, + ); + await expect(page.getByTestId("order-command-2")).toHaveCount(0); +}); + +test("empty draft renders the empty-state copy", async ({ + page, +}, testInfo) => { + const isMobile = testInfo.project.name.startsWith("chromium-mobile"); + await bootDebug(page); + await page.evaluate(() => window.__galaxyDebug!.clearSession()); + await page.evaluate( + (id) => window.__galaxyDebug!.setDeviceSessionId(id), + SESSION_ID, + ); + await page.evaluate( + (gameId) => window.__galaxyDebug!.clearOrderDraft(gameId), + GAME_ID, + ); + + await page.goto(`/games/${GAME_ID}/map`); + await expect(page.getByTestId("game-shell")).toBeVisible(); + await openOrderTool(page, isMobile); + + await expect(page.getByTestId("order-empty")).toBeVisible(); + await expect(page.getByTestId("order-list")).toHaveCount(0); +}); diff --git a/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts b/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts index 5784888..a16ee3b 100644 --- a/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts +++ b/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts @@ -13,6 +13,10 @@ interface DebugSnapshot { deviceSessionId: string | null; } +// Mirrors the surface mounted by `routes/__debug/store/+page.svelte`. +// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`) +// reuse the global declaration below, so this interface lists every +// helper any spec calls — not only those exercised by this file. interface DebugSurface { ready: true; loadSession(): Promise; @@ -23,6 +27,15 @@ interface DebugSurface { message: number[], signature: number[], ): Promise; + seedOrderDraft( + gameId: string, + commands: ReadonlyArray<{ + kind: "placeholder"; + id: string; + label: string; + }>, + ): Promise; + clearOrderDraft(gameId: string): Promise; } declare global { diff --git a/ui/frontend/tests/order-draft.test.ts b/ui/frontend/tests/order-draft.test.ts new file mode 100644 index 0000000..e6ed9b8 --- /dev/null +++ b/ui/frontend/tests/order-draft.test.ts @@ -0,0 +1,178 @@ +// OrderDraftStore unit tests under JSDOM with `fake-indexeddb` +// standing in for the browser's IndexedDB factory. The store is +// driven directly with a real `IDBCache` so persistence is exercised +// the same way it would be inside the in-game shell layout. +// +// Each case opens a freshly named database so state cannot leak +// across tests; per-game isolation is verified explicitly by mixing +// drafts under different `gameId`s through one shared cache. + +import "@testing-library/jest-dom/vitest"; +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import type { IDBPDatabase } from "idb"; + +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 { OrderDraftStore } from "../src/sync/order-draft.svelte"; +import type { OrderCommand } from "../src/sync/order-types"; + +let db: IDBPDatabase; +let dbName: string; +let cache: Cache; + +beforeEach(async () => { + dbName = `galaxy-order-draft-test-${crypto.randomUUID()}`; + db = await openGalaxyDB(dbName); + cache = new IDBCache(db); +}); + +afterEach(async () => { + db.close(); + await new Promise((resolve) => { + const req = indexedDB.deleteDatabase(dbName); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +}); + +const GAME_ID = "11111111-2222-3333-4444-555555555555"; + +function placeholder(id: string, label: string): OrderCommand { + return { kind: "placeholder", id, label }; +} + +describe("OrderDraftStore", () => { + test("init on empty cache yields ready status with no commands", async () => { + const store = new OrderDraftStore(); + expect(store.status).toBe("idle"); + await store.init({ cache, gameId: GAME_ID }); + expect(store.status).toBe("ready"); + expect(store.commands).toEqual([]); + store.dispose(); + }); + + test("add appends commands and persists across instances", async () => { + const a = new OrderDraftStore(); + await a.init({ cache, gameId: GAME_ID }); + await a.add(placeholder("c1", "first")); + await a.add(placeholder("c2", "second")); + expect(a.commands.map((c) => c.id)).toEqual(["c1", "c2"]); + a.dispose(); + + const b = new OrderDraftStore(); + await b.init({ cache, gameId: GAME_ID }); + expect(b.commands.map((c) => c.id)).toEqual(["c1", "c2"]); + expect(b.commands[1]).toEqual(placeholder("c2", "second")); + b.dispose(); + }); + + test("remove drops the matching command and persists the removal", async () => { + const a = new OrderDraftStore(); + await a.init({ cache, gameId: GAME_ID }); + await a.add(placeholder("c1", "first")); + await a.add(placeholder("c2", "second")); + await a.add(placeholder("c3", "third")); + await a.remove("c2"); + expect(a.commands.map((c) => c.id)).toEqual(["c1", "c3"]); + a.dispose(); + + const b = new OrderDraftStore(); + await b.init({ cache, gameId: GAME_ID }); + expect(b.commands.map((c) => c.id)).toEqual(["c1", "c3"]); + b.dispose(); + }); + + test("remove on a missing id is a silent no-op", async () => { + const store = new OrderDraftStore(); + await store.init({ cache, gameId: GAME_ID }); + await store.add(placeholder("c1", "first")); + await store.remove("absent"); + expect(store.commands.map((c) => c.id)).toEqual(["c1"]); + store.dispose(); + }); + + test("move reorders the commands and persists the new order", async () => { + const a = new OrderDraftStore(); + await a.init({ cache, gameId: GAME_ID }); + await a.add(placeholder("c1", "first")); + await a.add(placeholder("c2", "second")); + await a.add(placeholder("c3", "third")); + await a.move(0, 2); + expect(a.commands.map((c) => c.id)).toEqual(["c2", "c3", "c1"]); + a.dispose(); + + const b = new OrderDraftStore(); + await b.init({ cache, gameId: GAME_ID }); + expect(b.commands.map((c) => c.id)).toEqual(["c2", "c3", "c1"]); + b.dispose(); + }); + + test("move with out-of-range or identical indices is a no-op", async () => { + const store = new OrderDraftStore(); + await store.init({ cache, gameId: GAME_ID }); + await store.add(placeholder("c1", "first")); + await store.add(placeholder("c2", "second")); + await store.move(1, 1); + await store.move(-1, 0); + await store.move(0, 5); + expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2"]); + store.dispose(); + }); + + test("drafts under different game ids do not bleed through one cache", async () => { + const otherGame = "99999999-9999-9999-9999-999999999999"; + + const a = new OrderDraftStore(); + await a.init({ cache, gameId: GAME_ID }); + await a.add(placeholder("a1", "from-a")); + a.dispose(); + + const b = new OrderDraftStore(); + await b.init({ cache, gameId: otherGame }); + expect(b.commands).toEqual([]); + await b.add(placeholder("b1", "from-b")); + b.dispose(); + + const reloadA = new OrderDraftStore(); + await reloadA.init({ cache, gameId: GAME_ID }); + expect(reloadA.commands.map((c) => c.id)).toEqual(["a1"]); + reloadA.dispose(); + + const reloadB = new OrderDraftStore(); + await reloadB.init({ cache, gameId: otherGame }); + expect(reloadB.commands.map((c) => c.id)).toEqual(["b1"]); + reloadB.dispose(); + }); + + test("mutations made before init resolves are ignored", async () => { + const store = new OrderDraftStore(); + await store.add(placeholder("c1", "first")); + await store.remove("c1"); + await store.move(0, 1); + expect(store.status).toBe("idle"); + expect(store.commands).toEqual([]); + + await store.init({ cache, gameId: GAME_ID }); + expect(store.commands).toEqual([]); + store.dispose(); + }); + + test("dispose suppresses persistence side effects of in-flight mutations", async () => { + const store = new OrderDraftStore(); + await store.init({ cache, gameId: GAME_ID }); + await store.add(placeholder("c1", "first")); + store.dispose(); + // Adding after dispose is a no-op because status remains + // `ready` but the cache pointer is null and the destroyed flag + // blocks the persist path. + await store.add(placeholder("c2", "second")); + + const reload = new OrderDraftStore(); + await reload.init({ cache, gameId: GAME_ID }); + expect(reload.commands.map((c) => c.id)).toEqual(["c1"]); + reload.dispose(); + }); +});