// 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(); }); test("absent cache row flips needsServerHydration flag", async () => { const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); expect(store.needsServerHydration).toBe(true); store.dispose(); }); test("explicitly empty cache row honours the user's empty draft", async () => { const seeded = new OrderDraftStore(); await seeded.init({ cache, gameId: GAME_ID }); await seeded.add({ kind: "planetRename", id: "00000000-0000-0000-0000-000000000001", planetNumber: 7, name: "Earth", }); await seeded.remove("00000000-0000-0000-0000-000000000001"); seeded.dispose(); const reload = new OrderDraftStore(); await reload.init({ cache, gameId: GAME_ID }); expect(reload.needsServerHydration).toBe(false); expect(reload.commands).toEqual([]); 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("markSubmitting / applyResults flip the status map", async () => { const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); await store.add({ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth", }); store.markSubmitting(["id-1"]); expect(store.statuses["id-1"]).toBe("submitting"); store.applyResults({ results: new Map([["id-1", "applied"] as const]), updatedAt: 99, }); expect(store.statuses["id-1"]).toBe("applied"); expect(store.updatedAt).toBe(99); store.dispose(); }); test("markRejected switches submitting entries to rejected", async () => { const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); await store.add({ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth", }); store.markSubmitting(["id-1"]); store.markRejected(["id-1"]); expect(store.statuses["id-1"]).toBe("rejected"); store.dispose(); }); test("revertSubmittingToValid restores status after a thrown submit", async () => { const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); await store.add({ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth", }); store.markSubmitting(["id-1"]); store.revertSubmittingToValid(); expect(store.statuses["id-1"]).toBe("valid"); store.dispose(); }); test("hydrateFromServer seeds the draft on a fresh cache", async () => { const fakeClient = { executeCommand: async () => { const { Builder } = await import("flatbuffers"); const { UUID } = await import("../src/proto/galaxy/fbs/common"); const order = await import("../src/proto/galaxy/fbs/order"); const builder = new Builder(128); const cmdId = builder.createString("hydr-1"); const name = builder.createString("Hydrated"); const inner = order.CommandPlanetRename.createCommandPlanetRename( builder, BigInt(7), name, ); order.CommandItem.startCommandItem(builder); order.CommandItem.addCmdId(builder, cmdId); order.CommandItem.addPayloadType( builder, order.CommandPayload.CommandPlanetRename, ); order.CommandItem.addPayload(builder, inner); const item = order.CommandItem.endCommandItem(builder); const cmds = order.UserGamesOrder.createCommandsVector(builder, [item]); const [hi, lo] = (await import("../src/api/game-state")).uuidToHiLo( GAME_ID, ); const gameIdOffset = UUID.createUUID(builder, hi, lo); order.UserGamesOrder.startUserGamesOrder(builder); order.UserGamesOrder.addGameId(builder, gameIdOffset); order.UserGamesOrder.addUpdatedAt(builder, BigInt(7)); order.UserGamesOrder.addCommands(builder, cmds); const orderOffset = order.UserGamesOrder.endUserGamesOrder(builder); order.UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder); order.UserGamesOrderGetResponse.addFound(builder, true); order.UserGamesOrderGetResponse.addOrder(builder, orderOffset); const offset = order.UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder); builder.finish(offset); return { resultCode: "ok", payloadBytes: builder.asUint8Array(), }; }, }; const store = new OrderDraftStore(); await store.init({ cache, gameId: GAME_ID }); expect(store.needsServerHydration).toBe(true); await store.hydrateFromServer({ client: fakeClient as never, turn: 5, }); expect(store.commands).toHaveLength(1); expect(store.commands[0]!.id).toBe("hydr-1"); expect(store.updatedAt).toBe(7); expect(store.needsServerHydration).toBe(false); store.dispose(); }); });