ui/phase-12: order composer skeleton

OrderDraftStore persists per-game command drafts in Cache; the
sidebar Order tab renders the list with a per-row delete control.
The layout passes a `historyMode` prop through Sidebar / BottomTabs
as a constant `false`, so Phase 26 only flips the source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-08 23:26:58 +02:00
parent e5dab2a43a
commit 460591c159
18 changed files with 1022 additions and 53 deletions
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -119,7 +119,8 @@ const ru: Record<keyof typeof en, string> = {
"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": "приказ",
+20 -12
View File
@@ -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.
<span class="icon" aria-hidden="true">🧮</span>
<span class="label">{i18n.t("game.bottom_tabs.calc")}</span>
</button>
<button
type="button"
role="tab"
data-testid="bottom-tab-order"
aria-selected={activeTool === "order"}
class:active={activeTool === "order"}
onclick={() => selectTool("order")}
>
<span class="icon" aria-hidden="true">📝</span>
<span class="label">{i18n.t("game.bottom_tabs.order")}</span>
</button>
{#if !hideOrder}
<button
type="button"
role="tab"
data-testid="bottom-tab-order"
aria-selected={activeTool === "order"}
class:active={activeTool === "order"}
onclick={() => selectTool("order")}
>
<span class="icon" aria-hidden="true">📝</span>
<span class="label">{i18n.t("game.bottom_tabs.order")}</span>
</button>
{/if}
<button
type="button"
data-testid="bottom-tab-more"
+93 -4
View File
@@ -1,14 +1,60 @@
<!--
Phase 10 stub for the Order composer sidebar tool. Phase 12 ships
the composer skeleton; Phase 14 lands the first end-to-end command.
Order composer tool. Resolves the per-game `OrderDraftStore` from
context (set by `routes/games/[id]/+layout.svelte`) and renders the
draft as a vertical, top-to-bottom command list. Empty state shows
the i18n empty-state copy; non-empty state shows an ordered list of
rows, each with a stable `data-testid` plus a per-row delete button.
Phase 12 has no UI for adding commands — Phase 14 lands the first
end-to-end command (`planetRename`) and the inspector affordance
that pushes it into the draft. Tests exercise the skeleton through
`__galaxyDebug.seedOrderDraft` (Playwright) and via direct store
construction (Vitest).
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
function describe(cmd: { kind: string; label?: string }): string {
if (cmd.kind === "placeholder") return cmd.label ?? cmd.kind;
return cmd.kind;
}
</script>
<section class="tool" data-testid="sidebar-tool-order">
<h3>{i18n.t("game.sidebar.tab.order")}</h3>
<p>{i18n.t("game.sidebar.empty.order")}</p>
{#if draft === undefined || draft.commands.length === 0}
<p class="empty" data-testid="order-empty">
{i18n.t("game.sidebar.empty.order")}
</p>
{:else}
<ol class="commands" data-testid="order-list">
{#each draft.commands as cmd, index (cmd.id)}
<li class="command" data-testid="order-command-{index}">
<span class="index" aria-hidden="true">{index + 1}.</span>
<span class="label" data-testid="order-command-label-{index}">
{describe(cmd)}
</span>
<button
type="button"
class="delete"
data-testid="order-command-delete-{index}"
onclick={() => draft?.remove(cmd.id)}
>
{i18n.t("game.sidebar.order.command_delete")}
</button>
</li>
{/each}
</ol>
{/if}
</section>
<style>
@@ -20,8 +66,51 @@ the composer skeleton; Phase 14 lands the first end-to-end command.
margin: 0 0 0.5rem;
font-size: 1rem;
}
.tool p {
.empty {
margin: 0;
color: #888;
}
.commands {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.command {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
background: #14182a;
border: 1px solid #20253a;
border-radius: 4px;
}
.index {
min-width: 1.5rem;
color: #aab;
font-variant-numeric: tabular-nums;
}
.label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.delete {
font: inherit;
font-size: 0.85rem;
padding: 0.2rem 0.55rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.delete:hover {
color: #e8eaf6;
border-color: #6d8cff;
}
</style>
+29 -10
View File
@@ -1,14 +1,19 @@
<!--
Phase 10 sidebar with three tabs (Calculator, Inspector, Order). The
parent layout decides whether the sidebar is rendered at all (mobile
hides it, tablet collapses it behind the header toggle, desktop
keeps it always visible). State preservation across active-view
switches works for free because the layout never remounts when the
user navigates within `/games/:id/*`.
Sidebar with three tabs (Calculator, Inspector, Order). The parent
layout decides whether the sidebar is rendered at all (mobile hides
it, tablet collapses it behind the header toggle, desktop keeps it
always visible). State preservation across active-view switches
works for free because the layout never remounts when the user
navigates within `/games/:id/*`.
The optional `?sidebar=calc|calculator|inspector|order` URL param
seeds the initial tab on first mount — used by the lobby card path
when later phases want to land directly on a particular tool.
The `historyMode` prop hides the Order tab when true: the tab-bar
filters it out and any URL seed targeting `order` falls back to
`inspector`. Phase 12 wires the prop through the layout as a
constant `false`; Phase 26 flips it on for past-turn snapshots.
-->
<script lang="ts">
import { onMount } from "svelte";
@@ -23,8 +28,9 @@ when later phases want to land directly on a particular tool.
type Props = {
open: boolean;
onClose: () => void;
historyMode?: boolean;
};
let { open, onClose }: Props = $props();
let { open, onClose, historyMode = false }: Props = $props();
let activeTab: SidebarTab = $state("inspector");
@@ -36,11 +42,20 @@ when later phases want to land directly on a particular tool.
return null;
}
$effect(() => {
if (historyMode && activeTab === "order") {
activeTab = "inspector";
}
});
onMount(() => {
const seed = readUrlSeed();
if (seed !== null) {
activeTab = seed;
if (seed === null) return;
if (seed === "order" && historyMode) {
activeTab = "inspector";
return;
}
activeTab = seed;
});
</script>
@@ -51,7 +66,11 @@ when later phases want to land directly on a particular tool.
data-open={open}
>
<div class="head">
<TabBar {activeTab} onSelect={(tab) => (activeTab = tab)} />
<TabBar
{activeTab}
onSelect={(tab) => (activeTab = tab)}
hideOrder={historyMode}
/>
<button
type="button"
class="close"
+16 -6
View File
@@ -1,8 +1,14 @@
<!--
Three-button tab switcher for the Phase 10 sidebar. Each button is
labelled and tagged so component tests can target it; the parent
sidebar component owns the selected-tab state and re-renders the
matching tool panel.
Three-button tab switcher for the sidebar. Each button is labelled
and tagged so component tests can target it; the parent sidebar
component owns the selected-tab state and re-renders the matching
tool panel.
Phase 12 introduces the `hideOrder` prop: when true the Order entry
is filtered out of the tab list. The current consumer is the
`historyMode` flag forwarded from the in-game shell layout — the
flag is constant `false` in Phase 12 and Phase 26's history mode
flips it on.
-->
<script lang="ts">
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -11,14 +17,18 @@ matching tool panel.
type Props = {
activeTab: SidebarTab;
onSelect: (tab: SidebarTab) => void;
hideOrder?: boolean;
};
let { activeTab, onSelect }: Props = $props();
let { activeTab, onSelect, hideOrder = false }: Props = $props();
const tabs: ReadonlyArray<{ id: SidebarTab; key: TranslationKey }> = [
const allTabs: ReadonlyArray<{ id: SidebarTab; key: TranslationKey }> = [
{ id: "calculator", key: "game.sidebar.tab.calculator" },
{ id: "inspector", key: "game.sidebar.tab.inspector" },
{ id: "order", key: "game.sidebar.tab.order" },
];
const tabs = $derived(
hideOrder ? allTabs.filter((t) => t.id !== "order") : allTabs,
);
</script>
<div class="tab-bar" role="tablist" data-testid="sidebar-tab-bar">
@@ -6,6 +6,7 @@
setDeviceSessionId,
} from "../../../api/session";
import { loadStore } from "../../../platform/store/index";
import type { OrderCommand } from "../../../sync/order-types";
interface DebugSnapshot {
publicKey: number[];
@@ -22,6 +23,11 @@
message: number[],
signature: number[],
): Promise<boolean>;
seedOrderDraft(
gameId: string,
commands: OrderCommand[],
): Promise<void>;
clearOrderDraft(gameId: string): Promise<void>;
}
type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface };
@@ -96,6 +102,20 @@
throw new Error(`verifyWithStoredPublicKey: ${describe(err)}`);
}
},
async seedOrderDraft(gameId, commands) {
try {
await cache.put("order-drafts", `${gameId}/draft`, commands);
} catch (err) {
throw new Error(`seedOrderDraft: ${describe(err)}`);
}
},
async clearOrderDraft(gameId) {
try {
await cache.delete("order-drafts", `${gameId}/draft`);
} catch (err) {
throw new Error(`clearOrderDraft: ${describe(err)}`);
}
},
};
(window as DebugWindow).__galaxyDebug = surface;
ready = true;
@@ -34,6 +34,10 @@ the next game's snapshot is loaded fresh.
import Order from "$lib/sidebar/order-tab.svelte";
import type { MobileTool } from "$lib/sidebar/types";
import { GameStateStore, GAME_STATE_CONTEXT_KEY } from "$lib/game-state.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../../sync/order-draft.svelte";
import { session } from "$lib/session-store.svelte";
import { loadStore } from "../../../platform/store/index";
import { loadCore } from "../../../platform/core/index";
@@ -45,6 +49,9 @@ the next game's snapshot is loaded fresh.
let sidebarOpen = $state(false);
let mobileTool: MobileTool = $state("map");
// Phase 12 ships the prop wiring; Phase 26 replaces this constant
// with the real history-mode signal from `lib/history-mode.ts`.
const historyMode = false;
const gameId = $derived(page.params.id ?? "");
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname));
@@ -54,6 +61,8 @@ the next game's snapshot is loaded fresh.
const gameState = new GameStateStore();
setContext(GAME_STATE_CONTEXT_KEY, gameState);
const orderDraft = new OrderDraftStore();
setContext(ORDER_DRAFT_CONTEXT_KEY, orderDraft);
function toggleSidebar(): void {
sidebarOpen = !sidebarOpen;
@@ -85,7 +94,10 @@ the next game's snapshot is loaded fresh.
deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
await gameState.init({ client, cache, gameId });
await Promise.all([
gameState.init({ client, cache, gameId }),
orderDraft.init({ cache, gameId }),
]);
} catch (err) {
gameState.failBootstrap(describeBootstrapError(err));
}
@@ -94,6 +106,7 @@ the next game's snapshot is loaded fresh.
onDestroy(() => {
gameState.dispose();
orderDraft.dispose();
});
function describeBootstrapError(err: unknown): string {
@@ -118,12 +131,17 @@ the next game's snapshot is loaded fresh.
{@render children()}
{/if}
</main>
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} />
<Sidebar
open={sidebarOpen}
onClose={() => (sidebarOpen = false)}
{historyMode}
/>
</div>
<BottomTabs
{gameId}
activeTool={effectiveTool}
onSelectTool={(tool) => (mobileTool = tool)}
hideOrder={historyMode}
/>
</div>
+125
View File
@@ -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<void> {
this.cache = opts.cache;
this.gameId = opts.gameId;
try {
const stored = await opts.cache.get<OrderCommand[]>(
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
+59
View File
@@ -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";
@@ -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/<id>/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<void> {
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<void> {
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<void> {
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<void> {
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);
});
@@ -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<DebugSnapshot>;
@@ -23,6 +27,15 @@ interface DebugSurface {
message: number[],
signature: number[],
): Promise<boolean>;
seedOrderDraft(
gameId: string,
commands: ReadonlyArray<{
kind: "placeholder";
id: string;
label: string;
}>,
): Promise<void>;
clearOrderDraft(gameId: string): Promise<void>;
}
declare global {
+178
View File
@@ -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<GalaxyDB>;
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<void>((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();
});
});