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