Files
galaxy-game/ui/frontend/tests/order-draft.test.ts
T
Ilia Denisov 7c8b5aeb23 ui/phase-16: cargo routes inspector + map pick foundation
Add per-planet cargo routes (COL/CAP/MAT/EMP) to the inspector with
a renderer-driven destination picker (faded out-of-reach planets,
cursor-line anchor, hover-highlight) and per-route arrows on the
map. The pick-mode primitives are exposed via `MapPickService` so
ship-group dispatch in Phase 19/20 can reuse the same surface.

Pass A — generic map foundation:
- hit-test now sizes the click zone to `pointRadiusPx + slopPx` so
  the visible disc is always part of the target.
- `RendererHandle` gains `onPointerMove`, `onHoverChange`,
  `setPickMode`, `getPickState`, `getPrimitiveAlpha`,
  `setExtraPrimitives`, `getPrimitives`. The click dispatcher is
  centralised: pick-mode swallows clicks atomically so the standard
  selection consumers do not race against teardown.
- `MapPickService` (`lib/map-pick.svelte.ts`) wraps the renderer
  contract in a promise-shaped `pick(...)`. The in-game shell
  layout owns the service so sidebar and bottom-sheet inspectors
  see the same instance.
- Debug-surface registry exposes `getMapPrimitives`,
  `getMapPickState`, `getMapCamera` to e2e specs without spawning a
  separate debug page after navigation.

Pass B — cargo-route feature:
- `CargoLoadType`, `setCargoRoute`, `removeCargoRoute` typed
  variants with `(source, loadType)` collapse rule on the order
  draft; round-trip through the FBS encoder/decoder.
- `GameReport` decodes `routes` and the local player's drive tech
  for the inline reach formula (40 × drive). `applyOrderOverlay`
  upserts/drops route entries for valid/submitting/applied
  commands.
- `lib/inspectors/planet/cargo-routes.svelte` renders the
  four-slot section. `Add` / `Edit` call `MapPickService.pick`,
  `Remove` emits `removeCargoRoute`.
- `map/cargo-routes.ts` builds shaft + arrowhead primitives per
  cargo type; the map view pushes them through
  `setExtraPrimitives` so the renderer never re-inits Pixi on
  route mutations (Pixi 8 doesn't support that on a reused
  canvas).

Docs:
- `docs/cargo-routes-ux.md` covers engine semantics + UI map.
- `docs/renderer.md` documents pick mode and the debug surface.
- `docs/calc-bridge.md` records the Phase 16 reach waiver.
- `PLAN.md` rewrites Phase 16 to reflect the foundation + feature
  split and the decisions baked in (map-driven picker, inline
  reach, optimistic overlay via `setExtraPrimitives`).

Tests:
- `tests/map-pick-mode.test.ts` — pure overlay-spec helper.
- `tests/map-cargo-routes.test.ts` — `buildCargoRouteLines`.
- `tests/inspector-planet-cargo-routes.test.ts` — slot rendering,
  picker invocation, collapse, cancel, remove.
- Extensions to `order-draft`, `submit`, `order-load`,
  `order-overlay`, `state-binding`, `inspector-planet`,
  `inspector-overlay`, `game-shell-sidebar`, `game-shell-header`.
- `tests/e2e/cargo-routes.spec.ts` — Playwright happy path: add
  COL, add CAP, remove COL, asserting both the inspector and the
  arrow count via `__galaxyDebug.getMapPrimitives()`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:01:34 +02:00

626 lines
18 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 } 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("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();
});
});