f80c623a74
Wires the first end-to-end command through the full pipeline:
inspector rename action → local order draft → user.games.order
submit → optimistic overlay on map / inspector → server hydration
on cache miss via the new user.games.order.get message type.
Backend: GET /api/v1/user/games/{id}/orders forwards to engine
GET /api/v1/order. Gateway parses the engine PUT response into the
extended UserGamesOrderResponse FBS envelope and adds
executeUserGamesOrderGet for the read-back path. Frontend ports
ValidateTypeName to TS, lands the inline rename editor + Submit
button, and exposes a renderedReport context so consumers see the
overlay-applied snapshot.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
// 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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|