723885e74e
Tests · UI / test (push) Has been cancelled
Tests · Go / test (push) Successful in 2m3s
Tests · Go / test (pull_request) Successful in 2m5s
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · UI / test (pull_request) Failing after 4m28s
Three issues surfaced once the per-command rejection from the previous commit actually reached the UI: 1. Sync banner falsely red. `OrderDraftStore.runSync` flipped `syncStatus = "error"` whenever any command was rejected and advertised a Retry button. A per-command rejection is a player-correctable state — the round trip succeeded, the engine just refused that command — so the retry can't help. Keep `syncStatus = "synced"` on `success`; the red row highlight is the visible cue. 2. Rejection reason missing. Add `cmd_error_message: string` to `CommandItem` in `pkg/schema/fbs/order.fbs` (appended last to preserve existing slot offsets) and regenerate the Go + TS stubs for that one type. Plumb the message through `CommandMeta`, `Controller.applyCommand`'s `m.Result(code, message)` call, the Go transcoder, the UI decoders in `submit.ts` / `order-load.ts`, and the `OrderDraftStore.errorMessages` map. `order-tab.svelte` renders it as an italic danger-coloured line under rejected commands, with new CSS for `.error-reason`. 3. Verdict lost on navigation. `order-load.ts.decodeCommand` never read `cmdApplied`/`cmdErrorCode`, so `hydrateFromServer` fell back to a blanket "applied" status — a previously-rejected command came back green after a lobby → game round trip. Extend the fetch decoder to populate `statuses`/`errorCodes`/ `errorMessages` maps and have `hydrateFromServer` use them. Engine-side persistence already records the verdict on disk — verified against the live `0000/order/<id>.json`. `flatbuffers@25` elides default-int8/int64 fields on write; the Go transcoder force-slots `cmd_applied=false` / `cmd_error_code=0` already, the new test fixtures flip `builder.forceDefaults(true)` to mirror that behaviour so the round trip survives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1036 lines
31 KiB
TypeScript
1036 lines
31 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 { waitFor } from "@testing-library/svelte";
|
|
import { afterEach, beforeEach, describe, expect, test, vi } 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 type { GalaxyClient } from "../src/api/galaxy-client";
|
|
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("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("hydrateFromServer preserves per-command rejected status and error message", async () => {
|
|
const { fakeFetchClient } = await import("./helpers/fake-order-client");
|
|
const { client } = fakeFetchClient(
|
|
GAME_ID,
|
|
[
|
|
{
|
|
kind: "planetRename",
|
|
id: "ok-1",
|
|
planetNumber: 7,
|
|
name: "OkTown",
|
|
},
|
|
{
|
|
kind: "planetRename",
|
|
id: "bad-1",
|
|
planetNumber: 8,
|
|
name: "Doomed",
|
|
},
|
|
],
|
|
42,
|
|
true,
|
|
{
|
|
"ok-1": { applied: true, errorCode: 0 },
|
|
"bad-1": {
|
|
applied: false,
|
|
errorCode: 3003,
|
|
errorMessage: 'Entity does not exists: planet #99',
|
|
},
|
|
},
|
|
);
|
|
|
|
const store = new OrderDraftStore();
|
|
await store.init({ cache, gameId: GAME_ID });
|
|
await store.hydrateFromServer({ client, turn: 5 });
|
|
|
|
expect(store.commands).toHaveLength(2);
|
|
expect(store.statuses["ok-1"]).toBe("applied");
|
|
expect(store.statuses["bad-1"]).toBe("rejected");
|
|
expect(store.errorMessages["ok-1"]).toBeUndefined();
|
|
expect(store.errorMessages["bad-1"]).toBe(
|
|
"Entity does not exists: planet #99",
|
|
);
|
|
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("per-command rejection in a successful response keeps syncStatus 'synced'", async () => {
|
|
// Per-command rejection means the round trip succeeded — only
|
|
// individual commands were rejected by the in-game rules — so
|
|
// the sync bar must stay green. A blanket `syncStatus = "error"`
|
|
// + Retry button only fits genuine transport / engine failures.
|
|
const { Builder } = await import("flatbuffers");
|
|
const fbs = await import("../src/proto/galaxy/fbs/order");
|
|
const common = await import("../src/proto/galaxy/fbs/common");
|
|
const { uuidToHiLo } = await import("../src/api/game-state");
|
|
|
|
const cmd = {
|
|
kind: "planetRename" as const,
|
|
id: "rej-1",
|
|
planetNumber: 9,
|
|
name: "Doomed",
|
|
};
|
|
|
|
const exec = vi.fn(async () => {
|
|
const builder = new Builder(256);
|
|
builder.forceDefaults(true);
|
|
const cmdIdOffset = builder.createString(cmd.id);
|
|
const nameOffset = builder.createString("ignored");
|
|
const errMsg = builder.createString(
|
|
"Entity does not exists: planet #99",
|
|
);
|
|
const inner = fbs.CommandPlanetRename.createCommandPlanetRename(
|
|
builder,
|
|
BigInt(0),
|
|
nameOffset,
|
|
);
|
|
fbs.CommandItem.startCommandItem(builder);
|
|
fbs.CommandItem.addCmdId(builder, cmdIdOffset);
|
|
fbs.CommandItem.addCmdApplied(builder, false);
|
|
fbs.CommandItem.addCmdErrorCode(builder, BigInt(3003));
|
|
fbs.CommandItem.addPayloadType(
|
|
builder,
|
|
fbs.CommandPayload.CommandPlanetRename,
|
|
);
|
|
fbs.CommandItem.addPayload(builder, inner);
|
|
fbs.CommandItem.addCmdErrorMessage(builder, errMsg);
|
|
const item = fbs.CommandItem.endCommandItem(builder);
|
|
const commandsVec = fbs.UserGamesOrderResponse.createCommandsVector(
|
|
builder,
|
|
[item],
|
|
);
|
|
const [hi, lo] = uuidToHiLo(GAME_ID);
|
|
const gameIdOffset = common.UUID.createUUID(builder, hi, lo);
|
|
fbs.UserGamesOrderResponse.startUserGamesOrderResponse(builder);
|
|
fbs.UserGamesOrderResponse.addGameId(builder, gameIdOffset);
|
|
fbs.UserGamesOrderResponse.addUpdatedAt(builder, BigInt(99));
|
|
fbs.UserGamesOrderResponse.addCommands(builder, commandsVec);
|
|
const offset = fbs.UserGamesOrderResponse.endUserGamesOrderResponse(
|
|
builder,
|
|
);
|
|
builder.finish(offset);
|
|
return {
|
|
resultCode: "ok",
|
|
payloadBytes: builder.asUint8Array(),
|
|
};
|
|
});
|
|
const client = { executeCommand: exec } as unknown as GalaxyClient;
|
|
|
|
const store = new OrderDraftStore();
|
|
await store.init({ cache, gameId: GAME_ID });
|
|
store.bindClient(client);
|
|
await store.add(cmd);
|
|
await waitFor(() => {
|
|
expect(exec).toHaveBeenCalled();
|
|
expect(store.statuses[cmd.id]).toBe("rejected");
|
|
});
|
|
|
|
expect(store.syncStatus).toBe("synced");
|
|
expect(store.syncError).toBeNull();
|
|
expect(store.errorMessages[cmd.id]).toBe(
|
|
"Entity does not exists: planet #99",
|
|
);
|
|
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<void>((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<string, Set<() => 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<void>((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();
|
|
});
|
|
});
|
|
|
|
describe("OrderDraftStore Phase 26 history-mode gate", () => {
|
|
test("add is a no-op while getHistoryMode returns true", async () => {
|
|
const store = new OrderDraftStore();
|
|
await store.init({ cache, gameId: GAME_ID });
|
|
await store.add(placeholder("c1", "first"));
|
|
expect(store.commands.map((c) => c.id)).toEqual(["c1"]);
|
|
|
|
let history = false;
|
|
// The store would short-circuit even without bindClient (the
|
|
// gate runs before any sync logic). Binding a fake client
|
|
// here mirrors the real layout where `bindClient` is the path
|
|
// that wires `getHistoryMode` in.
|
|
store.bindClient(
|
|
{ executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
|
|
{ getHistoryMode: () => history },
|
|
);
|
|
|
|
history = true;
|
|
await store.add(placeholder("c2", "second"));
|
|
expect(store.commands.map((c) => c.id)).toEqual(["c1"]);
|
|
|
|
history = false;
|
|
await store.add(placeholder("c3", "third"));
|
|
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c3"]);
|
|
|
|
store.dispose();
|
|
});
|
|
|
|
test("remove is a no-op while getHistoryMode returns true", async () => {
|
|
const store = new OrderDraftStore();
|
|
await store.init({ cache, gameId: GAME_ID });
|
|
await store.add(placeholder("c1", "first"));
|
|
await store.add(placeholder("c2", "second"));
|
|
|
|
let history = true;
|
|
store.bindClient(
|
|
{ executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
|
|
{ getHistoryMode: () => history },
|
|
);
|
|
|
|
await store.remove("c1");
|
|
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
|
|
|
|
history = false;
|
|
await store.remove("c1");
|
|
expect(store.commands.map((c) => c.id)).toEqual(["c2"]);
|
|
|
|
store.dispose();
|
|
});
|
|
|
|
test("move is a no-op while getHistoryMode returns true", 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.add(placeholder("c3", "third"));
|
|
|
|
let history = true;
|
|
store.bindClient(
|
|
{ executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
|
|
{ getHistoryMode: () => history },
|
|
);
|
|
|
|
await store.move(0, 2);
|
|
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2", "c3"]);
|
|
|
|
history = false;
|
|
await store.move(0, 2);
|
|
expect(store.commands.map((c) => c.id)).toEqual(["c2", "c3", "c1"]);
|
|
|
|
store.dispose();
|
|
});
|
|
|
|
test("draft survives entering and leaving history mode untouched", async () => {
|
|
const store = new OrderDraftStore();
|
|
await store.init({ cache, gameId: GAME_ID });
|
|
await store.add(placeholder("c1", "first"));
|
|
await store.add(placeholder("c2", "second"));
|
|
|
|
let history = false;
|
|
store.bindClient(
|
|
{ executeCommand: async () => ({ resultCode: "ok", payloadBytes: new Uint8Array() }) } as never,
|
|
{ getHistoryMode: () => history },
|
|
);
|
|
|
|
history = true;
|
|
// Inspector affordances try to push commands, gate refuses.
|
|
await store.add(placeholder("c3", "history attempt"));
|
|
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
|
|
|
|
history = false;
|
|
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2"]);
|
|
await store.add(placeholder("c4", "back live"));
|
|
expect(store.commands.map((c) => c.id)).toEqual(["c1", "c2", "c4"]);
|
|
|
|
store.dispose();
|
|
});
|
|
});
|