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
+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();
});
});