// 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 { waitFor } from "@testing-library/svelte"; 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(); }); test("planetRename validates locally and statuses reflect valid/invalid", async () => { const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); await store.add({ kind: "planetRename", id: "id-valid", planetNumber: 1, name: "Earth", }); await store.add({ kind: "planetRename", id: "id-invalid", planetNumber: 2, name: "$bad", }); expect(store.statuses["id-valid"]).toBe("valid"); expect(store.statuses["id-invalid"]).toBe("invalid"); store.dispose(); }); test("setProductionType validates locally per the engine's subject rule", async () => { const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); await store.add({ kind: "setProductionType", id: "cap", planetNumber: 1, productionType: "CAP", subject: "", }); await store.add({ kind: "setProductionType", id: "drive", planetNumber: 2, productionType: "DRIVE", subject: "", }); await store.add({ kind: "setProductionType", id: "ship-ok", planetNumber: 3, productionType: "SHIP", subject: "Scout", }); await store.add({ kind: "setProductionType", id: "ship-empty", planetNumber: 4, productionType: "SHIP", subject: "", }); await store.add({ kind: "setProductionType", id: "science-bad", planetNumber: 5, productionType: "SCIENCE", subject: "Bad Name", }); expect(store.statuses["cap"]).toBe("valid"); expect(store.statuses["drive"]).toBe("valid"); expect(store.statuses["ship-ok"]).toBe("valid"); expect(store.statuses["ship-empty"]).toBe("invalid"); expect(store.statuses["science-bad"]).toBe("invalid"); store.dispose(); }); test("setProductionType collapses to the latest entry per planet", async () => { const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); await store.add({ kind: "setProductionType", id: "first", planetNumber: 7, productionType: "CAP", subject: "", }); await store.add({ kind: "setProductionType", id: "second", planetNumber: 7, productionType: "MAT", subject: "", }); await store.add({ kind: "setProductionType", id: "third", planetNumber: 7, productionType: "DRIVE", subject: "", }); expect(store.commands).toHaveLength(1); const only = store.commands[0]!; expect(only.id).toBe("third"); if (only.kind !== "setProductionType") { throw new Error("expected setProductionType"); } expect(only.productionType).toBe("DRIVE"); // Old ids are scrubbed from statuses so the order tab does not // keep ghost rows. expect(store.statuses["first"]).toBeUndefined(); expect(store.statuses["second"]).toBeUndefined(); expect(store.statuses["third"]).toBe("valid"); store.dispose(); }); test("setProductionType for different planets stay independent", async () => { const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); await store.add({ kind: "setProductionType", id: "p7-cap", planetNumber: 7, productionType: "CAP", subject: "", }); await store.add({ kind: "setProductionType", id: "p9-mat", planetNumber: 9, productionType: "MAT", subject: "", }); expect(store.commands.map((c) => c.id)).toEqual([ "p7-cap", "p9-mat", ]); store.dispose(); }); test("planetRename and setProductionType on the same planet keep both", async () => { const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); await store.add({ kind: "planetRename", id: "ren", planetNumber: 7, name: "Earth", }); await store.add({ kind: "setProductionType", id: "prod", planetNumber: 7, productionType: "CAP", subject: "", }); expect(store.commands.map((c) => c.id)).toEqual(["ren", "prod"]); expect(store.statuses["ren"]).toBe("valid"); expect(store.statuses["prod"]).toBe("valid"); store.dispose(); }); test("setCargoRoute collapses by (source, loadType) — newer wins", async () => { const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); await store.add({ kind: "setCargoRoute", id: "first", sourcePlanetNumber: 1, destinationPlanetNumber: 2, loadType: "COL", }); await store.add({ kind: "setCargoRoute", id: "second", sourcePlanetNumber: 1, destinationPlanetNumber: 3, loadType: "COL", }); expect(store.commands.map((c) => c.id)).toEqual(["second"]); expect(store.statuses["first"]).toBeUndefined(); expect(store.statuses["second"]).toBe("valid"); store.dispose(); }); test("setCargoRoute and removeCargoRoute share a collapse key", async () => { const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); await store.add({ kind: "setCargoRoute", id: "set", sourcePlanetNumber: 1, destinationPlanetNumber: 2, loadType: "MAT", }); await store.add({ kind: "removeCargoRoute", id: "remove", sourcePlanetNumber: 1, loadType: "MAT", }); expect(store.commands.map((c) => c.id)).toEqual(["remove"]); // And remove → set on the same slot collapses again. await store.add({ kind: "setCargoRoute", id: "set2", sourcePlanetNumber: 1, destinationPlanetNumber: 4, loadType: "MAT", }); expect(store.commands.map((c) => c.id)).toEqual(["set2"]); store.dispose(); }); test("cargo routes for different load-types or sources stay independent", async () => { const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); await store.add({ kind: "setCargoRoute", id: "p1-col", sourcePlanetNumber: 1, destinationPlanetNumber: 2, loadType: "COL", }); await store.add({ kind: "setCargoRoute", id: "p1-cap", sourcePlanetNumber: 1, destinationPlanetNumber: 3, loadType: "CAP", }); await store.add({ kind: "setCargoRoute", id: "p9-col", sourcePlanetNumber: 9, destinationPlanetNumber: 2, loadType: "COL", }); expect(store.commands.map((c) => c.id)).toEqual([ "p1-col", "p1-cap", "p9-col", ]); store.dispose(); }); test("setCargoRoute is invalid when source equals destination", async () => { const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); await store.add({ kind: "setCargoRoute", id: "self", sourcePlanetNumber: 1, destinationPlanetNumber: 1, loadType: "EMP", }); expect(store.statuses["self"]).toBe("invalid"); store.dispose(); }); 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 }); 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.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(); }); }); describe("OrderDraftStore Phase 25 conflict / paused / offline", () => { test("turn_already_closed marks the in-flight commands as conflict", async () => { const { recordingClient } = await import("./helpers/fake-order-client"); const handle = recordingClient(GAME_ID, "turn_already_closed"); const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); store.bindClient(handle.client, { getCurrentTurn: () => 7 }); await store.add({ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth", }); await handle.waitForCalls(1); expect(store.syncStatus).toBe("conflict"); expect(store.statuses["id-1"]).toBe("conflict"); expect(store.conflictBanner).not.toBeNull(); expect(store.conflictBanner?.turn).toBe(7); expect(store.conflictBanner?.code).toBe("turn_already_closed"); store.dispose(); }); test("mutating after a conflict clears the banner and revalidates", async () => { const { recordingClient } = await import("./helpers/fake-order-client"); const handle = recordingClient(GAME_ID, "turn_already_closed"); const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); store.bindClient(handle.client, { getCurrentTurn: () => 3 }); await store.add({ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth", }); await handle.waitForCalls(1); expect(store.syncStatus).toBe("conflict"); // Adding a second command must wipe the conflict banner and // re-validate the prior conflict-marked entry. Auto-sync // re-fires (still seeing turn_already_closed) and the // store ends up back in conflict for the new attempt. handle.setOutcome("ok"); await store.add({ kind: "planetRename", id: "id-2", planetNumber: 2, name: "Mars", }); await handle.waitForCalls(2); expect(store.statuses["id-1"]).toBe("applied"); expect(store.statuses["id-2"]).toBe("applied"); expect(store.syncStatus).toBe("synced"); expect(store.conflictBanner).toBeNull(); store.dispose(); }); test("game_paused outcome surfaces the pause banner and locks sync", async () => { const { recordingClient } = await import("./helpers/fake-order-client"); const handle = recordingClient(GAME_ID, "game_paused"); 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("paused"); expect(store.pausedBanner).not.toBeNull(); expect(store.statuses["id-1"]).toBe("valid"); // reverted, not in flight // While paused, additional mutations should not trigger another // submit — the queue would just hit the same wall. const before = handle.calls.length; store.forceSync(); await new Promise((resolve) => setTimeout(resolve, 20)); expect(handle.calls.length).toBe(before); store.dispose(); }); test("markPaused projects a push event into the store", async () => { const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); store.markPaused({ reason: "generation_failed" }); expect(store.syncStatus).toBe("paused"); expect(store.pausedBanner?.reason).toBe("generation_failed"); store.dispose(); }); test("resetForNewTurn clears the conflict banner and rehydrates", async () => { const { fakeFetchClient, recordingClient } = await import( "./helpers/fake-order-client" ); const recHandle = recordingClient(GAME_ID, "turn_already_closed"); const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); store.bindClient(recHandle.client, { getCurrentTurn: () => 5 }); await store.add({ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth", }); await recHandle.waitForCalls(1); expect(store.syncStatus).toBe("conflict"); const { client: fetchClient } = fakeFetchClient(GAME_ID, [ { kind: "planetRename", id: "fresh-1", planetNumber: 9, name: "Hydrated", }, ], 11); await store.resetForNewTurn({ client: fetchClient, turn: 6 }); expect(store.conflictBanner).toBeNull(); expect(store.syncStatus).toBe("synced"); expect(store.commands.map((c) => c.id)).toEqual(["fresh-1"]); expect(store.updatedAt).toBe(11); store.dispose(); }); test("offline outcome holds the submit until online flips", async () => { const { recordingClient } = await import("./helpers/fake-order-client"); const handle = recordingClient(GAME_ID, "ok"); const store = new OrderDraftStore(); // Stub the browser event surface so we can flip online/offline // deterministically and assert the queue's reaction. const listeners = new Map void>>(); let online = false; await store.init({ cache, gameId: GAME_ID, queue: { onlineProbe: () => online, addEventListener: (event, handler) => { let bucket = listeners.get(event); if (bucket === undefined) { bucket = new Set(); listeners.set(event, bucket); } bucket.add(handler); }, removeEventListener: (event, handler) => { listeners.get(event)?.delete(handler); }, onOnline: () => undefined, }, }); store.bindClient(handle.client); await store.add({ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth", }); // Submission must NOT have left the queue while offline. await new Promise((resolve) => setTimeout(resolve, 20)); expect(handle.calls).toHaveLength(0); expect(store.syncStatus).toBe("offline"); expect(store.statuses["id-1"]).toBe("valid"); // Flip online and fire the browser `online` event; the queue's // onOnline callback (set inside OrderDraftStore) schedules a // fresh sync. online = true; const onlineBucket = listeners.get("online"); onlineBucket?.forEach((h) => h()); await handle.waitForCalls(1); expect(store.statuses["id-1"]).toBe("applied"); expect(store.syncStatus).toBe("synced"); store.dispose(); }); });