ui/phase-14: rename planet end-to-end + order read-back
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>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// FlatBuffers payload builders for the Phase 14 Playwright suite.
|
||||
// Mirrors what `pkg/transcoder/order.go` produces in production for
|
||||
// the `user.games.order` POST response and the
|
||||
// `user.games.order.get` GET response.
|
||||
|
||||
import { Builder } from "flatbuffers";
|
||||
|
||||
import { uuidToHiLo } from "../../../src/api/game-state";
|
||||
import { UUID } from "../../../src/proto/galaxy/fbs/common";
|
||||
import {
|
||||
CommandItem,
|
||||
CommandPayload,
|
||||
CommandPlanetRename,
|
||||
UserGamesOrder,
|
||||
UserGamesOrderGetResponse,
|
||||
UserGamesOrderResponse,
|
||||
} from "../../../src/proto/galaxy/fbs/order";
|
||||
|
||||
export interface CommandResultFixture {
|
||||
cmdId: string;
|
||||
planetNumber: number;
|
||||
name: string;
|
||||
applied: boolean | null;
|
||||
errorCode: number | null;
|
||||
}
|
||||
|
||||
export function buildOrderResponsePayload(
|
||||
gameId: string,
|
||||
commands: CommandResultFixture[],
|
||||
updatedAt: number,
|
||||
): Uint8Array {
|
||||
const builder = new Builder(256);
|
||||
const itemOffsets = commands.map((c) => encodeItem(builder, c));
|
||||
const commandsVec = UserGamesOrderResponse.createCommandsVector(
|
||||
builder,
|
||||
itemOffsets,
|
||||
);
|
||||
const [hi, lo] = uuidToHiLo(gameId);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
|
||||
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
|
||||
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(updatedAt));
|
||||
UserGamesOrderResponse.addCommands(builder, commandsVec);
|
||||
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
|
||||
builder.finish(offset);
|
||||
return builder.asUint8Array();
|
||||
}
|
||||
|
||||
export function buildOrderGetResponsePayload(
|
||||
gameId: string,
|
||||
commands: CommandResultFixture[],
|
||||
updatedAt: number,
|
||||
found = true,
|
||||
): Uint8Array {
|
||||
const builder = new Builder(256);
|
||||
|
||||
let orderOffset = 0;
|
||||
if (found) {
|
||||
const itemOffsets = commands.map((c) => encodeItem(builder, c));
|
||||
const commandsVec = UserGamesOrder.createCommandsVector(
|
||||
builder,
|
||||
itemOffsets,
|
||||
);
|
||||
const [hi, lo] = uuidToHiLo(gameId);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
UserGamesOrder.startUserGamesOrder(builder);
|
||||
UserGamesOrder.addGameId(builder, gameIdOffset);
|
||||
UserGamesOrder.addUpdatedAt(builder, BigInt(updatedAt));
|
||||
UserGamesOrder.addCommands(builder, commandsVec);
|
||||
orderOffset = UserGamesOrder.endUserGamesOrder(builder);
|
||||
}
|
||||
|
||||
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
|
||||
UserGamesOrderGetResponse.addFound(builder, found);
|
||||
if (orderOffset !== 0) {
|
||||
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
|
||||
}
|
||||
const offset =
|
||||
UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
|
||||
builder.finish(offset);
|
||||
return builder.asUint8Array();
|
||||
}
|
||||
|
||||
function encodeItem(builder: Builder, c: CommandResultFixture): number {
|
||||
const cmdIdOffset = builder.createString(c.cmdId);
|
||||
const nameOffset = builder.createString(c.name);
|
||||
const inner = CommandPlanetRename.createCommandPlanetRename(
|
||||
builder,
|
||||
BigInt(c.planetNumber),
|
||||
nameOffset,
|
||||
);
|
||||
CommandItem.startCommandItem(builder);
|
||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||
if (c.applied !== null) CommandItem.addCmdApplied(builder, c.applied);
|
||||
if (c.errorCode !== null) {
|
||||
CommandItem.addCmdErrorCode(builder, BigInt(c.errorCode));
|
||||
}
|
||||
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
||||
CommandItem.addPayload(builder, inner);
|
||||
return CommandItem.endCommandItem(builder);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// Phase 14 end-to-end coverage for the rename-planet flow. Boots an
|
||||
// authenticated session, mocks the lobby + report + order routes,
|
||||
// drives a click into the renderer to select a planet, opens the
|
||||
// Rename action, types a new name, submits, and verifies the
|
||||
// optimistic overlay (inspector + map label). A second test covers
|
||||
// the rejected path: the engine answers `cmdApplied: false` and the
|
||||
// inspector keeps the original name while the order tab row reads
|
||||
// `rejected`.
|
||||
|
||||
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { ByteBuffer } from "flatbuffers";
|
||||
|
||||
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
|
||||
import { UUID } from "../../src/proto/galaxy/fbs/common";
|
||||
import {
|
||||
UserGamesOrder,
|
||||
UserGamesOrderGet,
|
||||
} from "../../src/proto/galaxy/fbs/order";
|
||||
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
|
||||
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
|
||||
import {
|
||||
buildMyGamesListPayload,
|
||||
type GameFixture,
|
||||
} from "./fixtures/lobby-fbs";
|
||||
import { buildReportPayload } from "./fixtures/report-fbs";
|
||||
import {
|
||||
buildOrderGetResponsePayload,
|
||||
buildOrderResponsePayload,
|
||||
type CommandResultFixture,
|
||||
} from "./fixtures/order-fbs";
|
||||
|
||||
const SESSION_ID = "phase-14-rename-session";
|
||||
const GAME_ID = "14141414-1414-1414-1414-141414141414";
|
||||
const WORLD = 4000;
|
||||
const CENTRE = WORLD / 2;
|
||||
const TURN = 4;
|
||||
|
||||
interface MockOpts {
|
||||
storedOrder: CommandResultFixture[];
|
||||
submitOutcome: "applied" | "rejected";
|
||||
}
|
||||
|
||||
interface MockHandle {
|
||||
get submittedRenameName(): string | null;
|
||||
}
|
||||
|
||||
async function mockGateway(page: Page, opts: MockOpts): Promise<MockHandle> {
|
||||
const game: GameFixture = {
|
||||
gameId: GAME_ID,
|
||||
gameName: "Phase 14 Game",
|
||||
gameType: "private",
|
||||
status: "running",
|
||||
ownerUserId: "user-1",
|
||||
minPlayers: 2,
|
||||
maxPlayers: 8,
|
||||
enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
|
||||
createdAtMs: BigInt(Date.now() - 86_400_000),
|
||||
updatedAtMs: BigInt(Date.now()),
|
||||
currentTurn: TURN,
|
||||
};
|
||||
|
||||
let storedOrder = opts.storedOrder.slice();
|
||||
let lastSubmittedName: string | null = null;
|
||||
let lastReportName = "Earth";
|
||||
|
||||
await page.route(
|
||||
"**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
|
||||
async (route) => {
|
||||
const reqText = route.request().postData();
|
||||
if (reqText === null) {
|
||||
await route.fulfill({ status: 400 });
|
||||
return;
|
||||
}
|
||||
const req = fromJson(
|
||||
ExecuteCommandRequestSchema,
|
||||
JSON.parse(reqText) as JsonValue,
|
||||
);
|
||||
|
||||
let resultCode = "ok";
|
||||
let payload: Uint8Array;
|
||||
switch (req.messageType) {
|
||||
case "lobby.my.games.list":
|
||||
payload = buildMyGamesListPayload([game]);
|
||||
break;
|
||||
case "user.games.report": {
|
||||
GameReportRequest.getRootAsGameReportRequest(
|
||||
new ByteBuffer(req.payloadBytes),
|
||||
).gameId(new UUID());
|
||||
payload = buildReportPayload({
|
||||
turn: TURN,
|
||||
mapWidth: WORLD,
|
||||
mapHeight: WORLD,
|
||||
localPlanets: [
|
||||
{
|
||||
number: 17,
|
||||
name: lastReportName,
|
||||
x: CENTRE,
|
||||
y: CENTRE,
|
||||
size: 1000,
|
||||
resources: 10,
|
||||
capital: 0,
|
||||
material: 0,
|
||||
population: 850,
|
||||
colonists: 25,
|
||||
industry: 700,
|
||||
production: "drive",
|
||||
freeIndustry: 175,
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "user.games.order": {
|
||||
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
|
||||
new ByteBuffer(req.payloadBytes),
|
||||
);
|
||||
const length = decoded.commandsLength();
|
||||
const fixtures: CommandResultFixture[] = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
const item = decoded.commands(i);
|
||||
if (item === null) continue;
|
||||
const cmdId = item.cmdId() ?? "";
|
||||
// Decode the embedded planetRename payload to mirror it back
|
||||
// in the response.
|
||||
const inner = new (await import(
|
||||
"../../src/proto/galaxy/fbs/order"
|
||||
)).CommandPlanetRename();
|
||||
item.payload(inner);
|
||||
const submittedName = inner.name() ?? "";
|
||||
lastSubmittedName = submittedName;
|
||||
const applied = opts.submitOutcome === "applied";
|
||||
fixtures.push({
|
||||
cmdId,
|
||||
planetNumber: Number(inner.number()),
|
||||
name: submittedName,
|
||||
applied,
|
||||
errorCode: applied ? null : 1,
|
||||
});
|
||||
}
|
||||
if (opts.submitOutcome === "applied") {
|
||||
storedOrder = fixtures;
|
||||
lastReportName = fixtures[0]?.name ?? lastReportName;
|
||||
}
|
||||
payload = buildOrderResponsePayload(GAME_ID, fixtures, Date.now());
|
||||
break;
|
||||
}
|
||||
case "user.games.order.get": {
|
||||
UserGamesOrderGet.getRootAsUserGamesOrderGet(
|
||||
new ByteBuffer(req.payloadBytes),
|
||||
);
|
||||
payload = buildOrderGetResponsePayload(
|
||||
GAME_ID,
|
||||
storedOrder,
|
||||
Date.now(),
|
||||
storedOrder.length > 0,
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
resultCode = "internal_error";
|
||||
payload = new Uint8Array();
|
||||
}
|
||||
|
||||
const body = await forgeExecuteCommandResponseJson({
|
||||
requestId: req.requestId,
|
||||
timestampMs: BigInt(Date.now()),
|
||||
resultCode,
|
||||
payloadBytes: payload,
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.route(
|
||||
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
|
||||
async () => {
|
||||
await new Promise<void>(() => {});
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
get submittedRenameName(): string | null {
|
||||
return lastSubmittedName;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function bootSession(page: Page): Promise<void> {
|
||||
await page.goto("/__debug/store");
|
||||
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
|
||||
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
|
||||
await page.evaluate(() => window.__galaxyDebug!.clearSession());
|
||||
await page.evaluate(
|
||||
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
|
||||
SESSION_ID,
|
||||
);
|
||||
await page.evaluate(
|
||||
(gameId) => window.__galaxyDebug!.clearOrderDraft(gameId),
|
||||
GAME_ID,
|
||||
);
|
||||
}
|
||||
|
||||
async function clickPlanetCentre(page: Page): Promise<void> {
|
||||
const canvas = page.locator("canvas");
|
||||
const box = await canvas.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (box === null) throw new Error("canvas has no bounding box");
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
}
|
||||
|
||||
test("rename a seeded planet, submit, observe overlay + persist after reload", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"phase 14 spec covers desktop layout; mobile inherits the same store",
|
||||
);
|
||||
|
||||
const handle = await mockGateway(page, {
|
||||
storedOrder: [],
|
||||
submitOutcome: "applied",
|
||||
});
|
||||
await bootSession(page);
|
||||
await page.goto(`/games/${GAME_ID}/map`);
|
||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||
"data-status",
|
||||
"ready",
|
||||
);
|
||||
|
||||
await clickPlanetCentre(page);
|
||||
const sidebar = page.getByTestId("sidebar-tool-inspector");
|
||||
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
|
||||
|
||||
await sidebar.getByTestId("inspector-planet-rename-action").click();
|
||||
const input = sidebar.getByTestId("inspector-planet-rename-input");
|
||||
await input.fill("New-Earth");
|
||||
await sidebar.getByTestId("inspector-planet-rename-confirm").click();
|
||||
|
||||
// Open the order tab and assert the row.
|
||||
await page.getByTestId("sidebar-tab-order").click();
|
||||
const orderTool = page.getByTestId("sidebar-tool-order");
|
||||
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
|
||||
"New-Earth",
|
||||
);
|
||||
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
||||
"valid",
|
||||
);
|
||||
|
||||
await orderTool.getByTestId("order-submit").click();
|
||||
|
||||
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
||||
"applied",
|
||||
);
|
||||
expect(handle.submittedRenameName).toBe("New-Earth");
|
||||
|
||||
// Switch back to the inspector — overlay should reflect the new name.
|
||||
await page.getByTestId("sidebar-tab-inspector").click();
|
||||
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
|
||||
"New-Earth",
|
||||
);
|
||||
|
||||
// Reload: the order draft is persisted; on cache-miss boots the
|
||||
// hydrate-from-server path takes over. Both round-trips re-apply
|
||||
// the overlay so the player still sees the renamed planet.
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||
"data-status",
|
||||
"ready",
|
||||
);
|
||||
await page.getByTestId("sidebar-tab-order").click();
|
||||
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
|
||||
"New-Earth",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejected submit keeps the old name and surfaces the failure", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"phase 14 spec covers desktop layout; mobile inherits the same store",
|
||||
);
|
||||
await mockGateway(page, {
|
||||
storedOrder: [],
|
||||
submitOutcome: "rejected",
|
||||
});
|
||||
await bootSession(page);
|
||||
await page.goto(`/games/${GAME_ID}/map`);
|
||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||
"data-status",
|
||||
"ready",
|
||||
);
|
||||
await clickPlanetCentre(page);
|
||||
const sidebar = page.getByTestId("sidebar-tool-inspector");
|
||||
await sidebar.getByTestId("inspector-planet-rename-action").click();
|
||||
await sidebar.getByTestId("inspector-planet-rename-input").fill("Mars-2");
|
||||
await sidebar.getByTestId("inspector-planet-rename-confirm").click();
|
||||
|
||||
await page.getByTestId("sidebar-tab-order").click();
|
||||
const orderTool = page.getByTestId("sidebar-tool-order");
|
||||
await orderTool.getByTestId("order-submit").click();
|
||||
|
||||
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
||||
"rejected",
|
||||
);
|
||||
|
||||
await page.getByTestId("sidebar-tab-inspector").click();
|
||||
// Overlay does not apply rejected commands — old name persists.
|
||||
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Parity tests for the TS port of `pkg/util/string.go.ValidateTypeName`.
|
||||
// Cases are aligned with `pkg/util/string_test.go.TestValidateString`
|
||||
// so the client-side and server-side validators reject the same set
|
||||
// of inputs — a name that's locally valid is always accepted at the
|
||||
// wire level.
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
validateEntityName,
|
||||
type EntityNameInvalidReason,
|
||||
} from "../src/lib/util/entity-name";
|
||||
|
||||
describe("validateEntityName", () => {
|
||||
const valid: { name: string; input: string; expected: string }[] = [
|
||||
{ name: "latin + digits", input: "Hello_World-123", expected: "Hello_World-123" },
|
||||
{ name: "cyrillic", input: "Привет_мир-42", expected: "Привет_мир-42" },
|
||||
{ name: "greek", input: "Αλφα_Βητα-2024", expected: "Αλφα_Βητα-2024" },
|
||||
{ name: "arabic", input: "مرحبا_العالم-7", expected: "مرحبا_العالم-7" },
|
||||
{ name: "japanese katakana", input: "テスト_ケース-1", expected: "テスト_ケース-1" },
|
||||
{ name: "chinese", input: "你好_世界-123", expected: "你好_世界-123" },
|
||||
{ name: "hindi (combining marks)", input: "नमस्ते_दुनिया-456", expected: "नमस्ते_दुनिया-456" },
|
||||
{ name: "thai (combining marks)", input: "สวัสดี_โลก-789", expected: "สวัสดี_โลก-789" },
|
||||
{ name: "korean", input: "안녕하세요_세계-101", expected: "안녕하세요_세계-101" },
|
||||
{ name: "trim outer whitespace", input: " Earth ", expected: "Earth" },
|
||||
{ name: "valid consecutive specials", input: "Valid_(special)_Chars", expected: "Valid_(special)_Chars" },
|
||||
{ name: "all allowed specials", input: "A@#b$%c^*d-_e=+f~(g)[h]{i}j", expected: "A@#b$%c^*d-_e=+f~(g)[h]{i}j" },
|
||||
];
|
||||
for (const tc of valid) {
|
||||
test(`accepts: ${tc.name}`, () => {
|
||||
const result = validateEntityName(tc.input);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toBe(tc.expected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const invalid: {
|
||||
name: string;
|
||||
input: string;
|
||||
reason: EntityNameInvalidReason;
|
||||
}[] = [
|
||||
{ name: "empty after trim", input: " ", reason: "empty" },
|
||||
{ name: "explicitly empty", input: "", reason: "empty" },
|
||||
{ name: "too long", input: "ValidatedStringHasTooManyCharacters", reason: "too_long" },
|
||||
{ name: "internal space", input: "Test 123", reason: "whitespace" },
|
||||
{ name: "internal tab", input: "Test\tName", reason: "whitespace" },
|
||||
{ name: "internal newline", input: "Test\nName", reason: "whitespace" },
|
||||
{ name: "starts with special after trim", input: " -Test123", reason: "starts_with_special" },
|
||||
{ name: "ends with special after trim", input: "Test123- ", reason: "ends_with_special" },
|
||||
{ name: "emoji", input: "Test🙂Name", reason: "disallowed_character" },
|
||||
{ name: "starts with special $", input: "$pecialString", reason: "starts_with_special" },
|
||||
{ name: "ends with special _", input: "SpecialString_", reason: "ends_with_special" },
|
||||
{ name: "too many consecutive specials", input: "Too_Many_(special[_]Chars", reason: "consecutive_specials" },
|
||||
];
|
||||
for (const tc of invalid) {
|
||||
test(`rejects: ${tc.name}`, () => {
|
||||
const result = validateEntityName(tc.input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toBe(tc.reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -23,6 +23,14 @@ import {
|
||||
SELECTION_CONTEXT_KEY,
|
||||
SelectionStore,
|
||||
} from "../src/lib/selection.svelte";
|
||||
import {
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
createRenderedReportSource,
|
||||
} from "../src/lib/rendered-report.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../src/sync/order-draft.svelte";
|
||||
import type { GameReport, ReportPlanet } from "../src/api/game-state";
|
||||
|
||||
const pageMock = vi.hoisted(() => ({
|
||||
@@ -70,17 +78,22 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
function withStores(report: GameReport | null): {
|
||||
gameState: GameStateStore;
|
||||
selection: SelectionStore;
|
||||
orderDraft: OrderDraftStore;
|
||||
context: Map<unknown, unknown>;
|
||||
} {
|
||||
const gameState = new GameStateStore();
|
||||
gameState.report = report;
|
||||
gameState.status = report === null ? "idle" : "ready";
|
||||
const selection = new SelectionStore();
|
||||
const orderDraft = new OrderDraftStore();
|
||||
const renderedReport = createRenderedReportSource(gameState, orderDraft);
|
||||
const context = new Map<unknown, unknown>([
|
||||
[GAME_STATE_CONTEXT_KEY, gameState],
|
||||
[SELECTION_CONTEXT_KEY, selection],
|
||||
[ORDER_DRAFT_CONTEXT_KEY, orderDraft],
|
||||
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
|
||||
]);
|
||||
return { gameState, selection, context };
|
||||
return { gameState, selection, orderDraft, context };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
// drive it with synthetic `ReportPlanet` literals — no store.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import "fake-indexeddb/auto";
|
||||
import { fireEvent, render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import type { ReportPlanet } from "../src/api/game-state";
|
||||
import Planet from "../src/lib/inspectors/planet.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../src/sync/order-draft.svelte";
|
||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||
import { openGalaxyDB } from "../src/platform/store/idb";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
@@ -192,6 +199,121 @@ describe("planet inspector", () => {
|
||||
expect(ui.queryByTestId("inspector-planet-field-natural_resources")).toBeNull();
|
||||
});
|
||||
|
||||
test("Rename action is hidden for non-local planets", () => {
|
||||
const ui = render(Planet, {
|
||||
props: {
|
||||
planet: makePlanet({
|
||||
number: 9,
|
||||
name: "Far",
|
||||
kind: "other",
|
||||
owner: "Federation",
|
||||
size: 100,
|
||||
resources: 5,
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(ui.queryByTestId("inspector-planet-rename-action")).toBeNull();
|
||||
});
|
||||
|
||||
test("Rename action opens an inline editor and validates locally", async () => {
|
||||
const dbName = `galaxy-rename-${crypto.randomUUID()}`;
|
||||
const db = await openGalaxyDB(dbName);
|
||||
const cache = new IDBCache(db);
|
||||
const draft = new OrderDraftStore();
|
||||
await draft.init({ cache, gameId: "00000000-0000-0000-0000-000000000abc" });
|
||||
const context = new Map<unknown, unknown>([
|
||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||
]);
|
||||
|
||||
const ui = render(Planet, {
|
||||
props: {
|
||||
planet: makePlanet({
|
||||
number: 7,
|
||||
name: "Earth",
|
||||
kind: "local",
|
||||
size: 100,
|
||||
resources: 5,
|
||||
population: 100,
|
||||
colonists: 0,
|
||||
industry: 0,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
production: "drive",
|
||||
freeIndustry: 0,
|
||||
}),
|
||||
},
|
||||
context,
|
||||
});
|
||||
|
||||
const action = ui.getByTestId("inspector-planet-rename-action");
|
||||
await fireEvent.click(action);
|
||||
|
||||
const input = ui.getByTestId("inspector-planet-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Earth");
|
||||
const confirm = ui.getByTestId("inspector-planet-rename-confirm");
|
||||
expect(confirm).not.toBeDisabled();
|
||||
|
||||
await fireEvent.input(input, { target: { value: " " } });
|
||||
expect(ui.getByTestId("inspector-planet-rename-error")).toBeVisible();
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
await fireEvent.input(input, { target: { value: "New Earth!" } });
|
||||
// Whitespace inside disallowed
|
||||
expect(ui.getByTestId("inspector-planet-rename-error")).toBeVisible();
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
await fireEvent.input(input, { target: { value: "Mars-2" } });
|
||||
expect(ui.queryByTestId("inspector-planet-rename-error")).toBeNull();
|
||||
expect(confirm).not.toBeDisabled();
|
||||
|
||||
await fireEvent.click(confirm);
|
||||
expect(draft.commands).toHaveLength(1);
|
||||
const cmd = draft.commands[0]!;
|
||||
expect(cmd.kind).toBe("planetRename");
|
||||
if (cmd.kind !== "planetRename") return;
|
||||
expect(cmd.planetNumber).toBe(7);
|
||||
expect(cmd.name).toBe("Mars-2");
|
||||
|
||||
draft.dispose();
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("Cancel closes the editor without adding to the draft", async () => {
|
||||
const dbName = `galaxy-rename-${crypto.randomUUID()}`;
|
||||
const db = await openGalaxyDB(dbName);
|
||||
const cache = new IDBCache(db);
|
||||
const draft = new OrderDraftStore();
|
||||
await draft.init({ cache, gameId: "00000000-0000-0000-0000-000000000abc" });
|
||||
const context = new Map<unknown, unknown>([
|
||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||
]);
|
||||
const ui = render(Planet, {
|
||||
props: {
|
||||
planet: makePlanet({
|
||||
number: 1,
|
||||
name: "Earth",
|
||||
kind: "local",
|
||||
size: 100,
|
||||
resources: 5,
|
||||
population: 1,
|
||||
colonists: 0,
|
||||
industry: 0,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
production: "drive",
|
||||
freeIndustry: 0,
|
||||
}),
|
||||
},
|
||||
context,
|
||||
});
|
||||
await fireEvent.click(ui.getByTestId("inspector-planet-rename-action"));
|
||||
await fireEvent.click(ui.getByTestId("inspector-planet-rename-cancel"));
|
||||
expect(ui.queryByTestId("inspector-planet-rename")).toBeNull();
|
||||
expect(draft.commands).toEqual([]);
|
||||
draft.dispose();
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("missing production string falls back to the localised placeholder", () => {
|
||||
const ui = render(Planet, {
|
||||
props: {
|
||||
|
||||
@@ -175,4 +175,158 @@ describe("OrderDraftStore", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Vitest unit coverage for `sync/order-load.ts`. Builds FBS
|
||||
// `UserGamesOrderGetResponse` payloads by hand and verifies the
|
||||
// decoder produces the expected `OrderCommand[]`.
|
||||
|
||||
import { Builder } from "flatbuffers";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { GalaxyClient } from "../src/api/galaxy-client";
|
||||
import { uuidToHiLo } from "../src/api/game-state";
|
||||
import { UUID } from "../src/proto/galaxy/fbs/common";
|
||||
import {
|
||||
CommandItem,
|
||||
CommandPayload,
|
||||
CommandPlanetRename,
|
||||
UserGamesOrder,
|
||||
UserGamesOrderGet,
|
||||
UserGamesOrderGetResponse,
|
||||
} from "../src/proto/galaxy/fbs/order";
|
||||
import { fetchOrder, OrderLoadError } from "../src/sync/order-load";
|
||||
|
||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
function mockClient(
|
||||
executeCommand: (
|
||||
messageType: string,
|
||||
payload: Uint8Array,
|
||||
) => Promise<{ resultCode: string; payloadBytes: Uint8Array }>,
|
||||
): GalaxyClient {
|
||||
return { executeCommand } as unknown as GalaxyClient;
|
||||
}
|
||||
|
||||
function buildResponse(
|
||||
commands: { id: string; planetNumber: number; name: string }[],
|
||||
updatedAt: number,
|
||||
found = true,
|
||||
): Uint8Array {
|
||||
const builder = new Builder(256);
|
||||
|
||||
let orderOffset = 0;
|
||||
if (found) {
|
||||
const itemOffsets = commands.map((c) => {
|
||||
const cmdIdOffset = builder.createString(c.id);
|
||||
const nameOffset = builder.createString(c.name);
|
||||
const inner = CommandPlanetRename.createCommandPlanetRename(
|
||||
builder,
|
||||
BigInt(c.planetNumber),
|
||||
nameOffset,
|
||||
);
|
||||
CommandItem.startCommandItem(builder);
|
||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
||||
CommandItem.addPayload(builder, inner);
|
||||
return CommandItem.endCommandItem(builder);
|
||||
});
|
||||
const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets);
|
||||
const [hi, lo] = uuidToHiLo(GAME_ID);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
UserGamesOrder.startUserGamesOrder(builder);
|
||||
UserGamesOrder.addGameId(builder, gameIdOffset);
|
||||
UserGamesOrder.addUpdatedAt(builder, BigInt(updatedAt));
|
||||
UserGamesOrder.addCommands(builder, commandsVec);
|
||||
orderOffset = UserGamesOrder.endUserGamesOrder(builder);
|
||||
}
|
||||
|
||||
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
|
||||
UserGamesOrderGetResponse.addFound(builder, found);
|
||||
if (orderOffset !== 0) {
|
||||
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
|
||||
}
|
||||
const offset = UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
|
||||
builder.finish(offset);
|
||||
return builder.asUint8Array();
|
||||
}
|
||||
|
||||
describe("fetchOrder", () => {
|
||||
test("decodes a found response into typed commands", async () => {
|
||||
const responsePayload = buildResponse(
|
||||
[{ id: "cmd-1", planetNumber: 7, name: "Earth" }],
|
||||
42,
|
||||
);
|
||||
const exec = vi.fn(async (messageType: string) => {
|
||||
expect(messageType).toBe("user.games.order.get");
|
||||
return { resultCode: "ok", payloadBytes: responsePayload };
|
||||
});
|
||||
const result = await fetchOrder(mockClient(exec), GAME_ID, 5);
|
||||
|
||||
expect(result.commands).toHaveLength(1);
|
||||
const cmd = result.commands[0]!;
|
||||
expect(cmd.kind).toBe("planetRename");
|
||||
if (cmd.kind !== "planetRename") return;
|
||||
expect(cmd.id).toBe("cmd-1");
|
||||
expect(cmd.planetNumber).toBe(7);
|
||||
expect(cmd.name).toBe("Earth");
|
||||
expect(result.updatedAt).toBe(42);
|
||||
});
|
||||
|
||||
test("found=false surfaces as an empty draft", async () => {
|
||||
const responsePayload = buildResponse([], 0, false);
|
||||
const exec = vi.fn(async () => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: responsePayload,
|
||||
}));
|
||||
const result = await fetchOrder(mockClient(exec), GAME_ID, 5);
|
||||
expect(result.commands).toEqual([]);
|
||||
expect(result.updatedAt).toBe(0);
|
||||
});
|
||||
|
||||
test("rejects negative turn before issuing a request", async () => {
|
||||
const exec = vi.fn(async () => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: new Uint8Array(),
|
||||
}));
|
||||
await expect(fetchOrder(mockClient(exec), GAME_ID, -1)).rejects.toBeInstanceOf(
|
||||
OrderLoadError,
|
||||
);
|
||||
expect(exec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws OrderLoadError on non-ok resultCode", async () => {
|
||||
const exec = vi.fn(async () => ({
|
||||
resultCode: "internal_error",
|
||||
payloadBytes: new TextEncoder().encode(
|
||||
JSON.stringify({ code: "boom", message: "down" }),
|
||||
),
|
||||
}));
|
||||
await expect(fetchOrder(mockClient(exec), GAME_ID, 5)).rejects.toMatchObject({
|
||||
name: "OrderLoadError",
|
||||
resultCode: "internal_error",
|
||||
code: "boom",
|
||||
});
|
||||
});
|
||||
|
||||
test("posts a well-formed UserGamesOrderGet payload", async () => {
|
||||
let captured: Uint8Array | null = null;
|
||||
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
|
||||
captured = payload;
|
||||
return {
|
||||
resultCode: "ok",
|
||||
payloadBytes: buildResponse([], 0, false),
|
||||
};
|
||||
});
|
||||
await fetchOrder(mockClient(exec), GAME_ID, 9);
|
||||
expect(captured).not.toBeNull();
|
||||
const decoded = UserGamesOrderGet.getRootAsUserGamesOrderGet(
|
||||
new (await import("flatbuffers")).ByteBuffer(captured!),
|
||||
);
|
||||
expect(Number(decoded.turn())).toBe(9);
|
||||
const id = decoded.gameId();
|
||||
expect(id).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// Vitest unit coverage for the pure `applyOrderOverlay` projection.
|
||||
// Phase 14 understands `planetRename` only; future phases (set
|
||||
// production, route updates) will extend the overlay and need
|
||||
// equivalent cases here.
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
applyOrderOverlay,
|
||||
type GameReport,
|
||||
type ReportPlanet,
|
||||
} from "../src/api/game-state";
|
||||
import type { CommandStatus, OrderCommand } from "../src/sync/order-types";
|
||||
|
||||
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
|
||||
return {
|
||||
number: 0,
|
||||
name: "",
|
||||
x: 0,
|
||||
y: 0,
|
||||
kind: "local",
|
||||
owner: null,
|
||||
size: null,
|
||||
resources: null,
|
||||
industryStockpile: null,
|
||||
materialsStockpile: null,
|
||||
industry: null,
|
||||
population: null,
|
||||
colonists: null,
|
||||
production: null,
|
||||
freeIndustry: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
return {
|
||||
turn: 4,
|
||||
mapWidth: 4000,
|
||||
mapHeight: 4000,
|
||||
planetCount: planets.length,
|
||||
planets,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyOrderOverlay", () => {
|
||||
test("returns the same report when no commands match", () => {
|
||||
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
|
||||
const out = applyOrderOverlay(report, [], {});
|
||||
expect(out).toBe(report);
|
||||
});
|
||||
|
||||
test("renames a planet on applied commands", () => {
|
||||
const report = makeReport([
|
||||
makePlanet({ number: 1, name: "Earth" }),
|
||||
makePlanet({ number: 2, name: "Mars" }),
|
||||
]);
|
||||
const cmd: OrderCommand = {
|
||||
kind: "planetRename",
|
||||
id: "cmd-1",
|
||||
planetNumber: 1,
|
||||
name: "New Earth",
|
||||
};
|
||||
const statuses: Record<string, CommandStatus> = { "cmd-1": "applied" };
|
||||
const out = applyOrderOverlay(report, [cmd], statuses);
|
||||
|
||||
expect(out).not.toBe(report);
|
||||
expect(out.planets[0]!.name).toBe("New Earth");
|
||||
expect(out.planets[1]!.name).toBe("Mars");
|
||||
// raw report stays untouched
|
||||
expect(report.planets[0]!.name).toBe("Earth");
|
||||
});
|
||||
|
||||
test("renames on submitting too (in-flight optimistic)", () => {
|
||||
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
|
||||
const cmd: OrderCommand = {
|
||||
kind: "planetRename",
|
||||
id: "cmd-1",
|
||||
planetNumber: 1,
|
||||
name: "Pending",
|
||||
};
|
||||
const out = applyOrderOverlay(report, [cmd], { "cmd-1": "submitting" });
|
||||
expect(out.planets[0]!.name).toBe("Pending");
|
||||
});
|
||||
|
||||
test("skips unsubmitted statuses (draft/valid/invalid/rejected)", () => {
|
||||
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
|
||||
const cmd: OrderCommand = {
|
||||
kind: "planetRename",
|
||||
id: "cmd-1",
|
||||
planetNumber: 1,
|
||||
name: "Tentative",
|
||||
};
|
||||
for (const status of ["draft", "valid", "invalid", "rejected"] as const) {
|
||||
const out = applyOrderOverlay(report, [cmd], { "cmd-1": status });
|
||||
expect(out.planets[0]!.name).toBe("Earth");
|
||||
}
|
||||
});
|
||||
|
||||
test("ignores rename for missing planet (visibility lost)", () => {
|
||||
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
|
||||
const cmd: OrderCommand = {
|
||||
kind: "planetRename",
|
||||
id: "cmd-1",
|
||||
planetNumber: 99,
|
||||
name: "Phantom",
|
||||
};
|
||||
const out = applyOrderOverlay(report, [cmd], { "cmd-1": "applied" });
|
||||
expect(out).toBe(report);
|
||||
});
|
||||
|
||||
test("placeholder commands pass through", () => {
|
||||
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
|
||||
const cmd: OrderCommand = {
|
||||
kind: "placeholder",
|
||||
id: "cmd-1",
|
||||
label: "noop",
|
||||
};
|
||||
const out = applyOrderOverlay(report, [cmd], { "cmd-1": "applied" });
|
||||
expect(out).toBe(report);
|
||||
});
|
||||
|
||||
test("multiple renames apply in command order", () => {
|
||||
const report = makeReport([makePlanet({ number: 1, name: "Old" })]);
|
||||
const first: OrderCommand = {
|
||||
kind: "planetRename",
|
||||
id: "cmd-1",
|
||||
planetNumber: 1,
|
||||
name: "Mid",
|
||||
};
|
||||
const second: OrderCommand = {
|
||||
kind: "planetRename",
|
||||
id: "cmd-2",
|
||||
planetNumber: 1,
|
||||
name: "Final",
|
||||
};
|
||||
const out = applyOrderOverlay(report, [first, second], {
|
||||
"cmd-1": "applied",
|
||||
"cmd-2": "applied",
|
||||
});
|
||||
expect(out.planets[0]!.name).toBe("Final");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
// Component coverage for the Phase 14 order-tab submit flow. Drives
|
||||
// the tab against an in-memory `OrderDraftStore`, a synthetic
|
||||
// `GalaxyClient`, and a stubbed `GameStateStore.refresh`. Every
|
||||
// case asserts both the rendered DOM (status badges, button state)
|
||||
// and the side effect on the draft store (per-command status flips).
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||
import { Builder } from "flatbuffers";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import OrderTab from "../src/lib/sidebar/order-tab.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../src/sync/order-draft.svelte";
|
||||
import {
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
GameStateStore,
|
||||
} from "../src/lib/game-state.svelte";
|
||||
import {
|
||||
GALAXY_CLIENT_CONTEXT_KEY,
|
||||
GalaxyClientHolder,
|
||||
} from "../src/lib/galaxy-client-context.svelte";
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import { uuidToHiLo } from "../src/api/game-state";
|
||||
import type { GalaxyClient } from "../src/api/galaxy-client";
|
||||
import type { OrderCommand } from "../src/sync/order-types";
|
||||
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 { UUID } from "../src/proto/galaxy/fbs/common";
|
||||
import {
|
||||
CommandItem,
|
||||
CommandPayload,
|
||||
CommandPlanetRename,
|
||||
UserGamesOrderResponse,
|
||||
} from "../src/proto/galaxy/fbs/order";
|
||||
|
||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
let db: Awaited<ReturnType<typeof openGalaxyDB>>;
|
||||
let dbName: string;
|
||||
let cache: Cache;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `galaxy-order-tab-${crypto.randomUUID()}`;
|
||||
db = await openGalaxyDB(dbName);
|
||||
cache = new IDBCache(db);
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(dbName);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => resolve();
|
||||
});
|
||||
});
|
||||
|
||||
interface Setup {
|
||||
context: Map<unknown, unknown>;
|
||||
draft: OrderDraftStore;
|
||||
gameState: GameStateStore;
|
||||
clientHolder: GalaxyClientHolder;
|
||||
exec: ReturnType<typeof vi.fn>;
|
||||
refresh: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function buildResponse(
|
||||
commands: { id: string; applied: boolean | null; errorCode: number | null }[],
|
||||
updatedAt: number,
|
||||
): Uint8Array {
|
||||
const builder = new Builder(256);
|
||||
const itemOffsets = commands.map((c) => {
|
||||
const cmdIdOffset = builder.createString(c.id);
|
||||
const nameOffset = builder.createString("ignored");
|
||||
const inner = CommandPlanetRename.createCommandPlanetRename(
|
||||
builder,
|
||||
BigInt(0),
|
||||
nameOffset,
|
||||
);
|
||||
CommandItem.startCommandItem(builder);
|
||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||
if (c.applied !== null) CommandItem.addCmdApplied(builder, c.applied);
|
||||
if (c.errorCode !== null) {
|
||||
CommandItem.addCmdErrorCode(builder, BigInt(c.errorCode));
|
||||
}
|
||||
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
||||
CommandItem.addPayload(builder, inner);
|
||||
return CommandItem.endCommandItem(builder);
|
||||
});
|
||||
const commandsVec = UserGamesOrderResponse.createCommandsVector(
|
||||
builder,
|
||||
itemOffsets,
|
||||
);
|
||||
const [hi, lo] = uuidToHiLo(GAME_ID);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
|
||||
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
|
||||
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(updatedAt));
|
||||
UserGamesOrderResponse.addCommands(builder, commandsVec);
|
||||
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
|
||||
builder.finish(offset);
|
||||
return builder.asUint8Array();
|
||||
}
|
||||
|
||||
async function makeSetup(commands: OrderCommand[]): Promise<Setup> {
|
||||
const draft = new OrderDraftStore();
|
||||
await draft.init({ cache, gameId: GAME_ID });
|
||||
for (const cmd of commands) {
|
||||
await draft.add(cmd);
|
||||
}
|
||||
const gameState = new GameStateStore();
|
||||
gameState.gameId = GAME_ID;
|
||||
gameState.status = "ready";
|
||||
const refresh = vi.fn(async () => {});
|
||||
gameState.refresh = refresh as unknown as typeof gameState.refresh;
|
||||
const clientHolder = new GalaxyClientHolder();
|
||||
const exec = vi.fn(async (_messageType: string, _payload: Uint8Array) => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: buildResponse(
|
||||
commands.map((cmd) => ({
|
||||
id: cmd.id,
|
||||
applied: true,
|
||||
errorCode: null,
|
||||
})),
|
||||
17,
|
||||
),
|
||||
}));
|
||||
clientHolder.set({ executeCommand: exec } as unknown as GalaxyClient);
|
||||
const context = new Map<unknown, unknown>([
|
||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||
[GAME_STATE_CONTEXT_KEY, gameState],
|
||||
[GALAXY_CLIENT_CONTEXT_KEY, clientHolder],
|
||||
]);
|
||||
return { context, draft, gameState, clientHolder, exec, refresh };
|
||||
}
|
||||
|
||||
describe("order-tab", () => {
|
||||
test("renders the empty state when the draft has no commands", async () => {
|
||||
const { context } = await makeSetup([]);
|
||||
const ui = render(OrderTab, { context });
|
||||
expect(ui.getByTestId("order-empty")).toBeVisible();
|
||||
expect(ui.queryByTestId("order-submit")).toBeNull();
|
||||
});
|
||||
|
||||
test("Submit is disabled when every entry is invalid", async () => {
|
||||
const { context } = await makeSetup([
|
||||
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "" },
|
||||
]);
|
||||
const ui = render(OrderTab, { context });
|
||||
const submit = ui.getByTestId("order-submit");
|
||||
expect(submit).toBeDisabled();
|
||||
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
|
||||
"invalid",
|
||||
);
|
||||
});
|
||||
|
||||
test("Submit posts every valid command and applies returned statuses", async () => {
|
||||
const { context, draft, exec, refresh } = await makeSetup([
|
||||
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
|
||||
]);
|
||||
const ui = render(OrderTab, { context });
|
||||
const submit = ui.getByTestId("order-submit");
|
||||
expect(submit).not.toBeDisabled();
|
||||
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent("valid");
|
||||
|
||||
await fireEvent.click(submit);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(draft.statuses["id-1"]).toBe("applied");
|
||||
});
|
||||
expect(exec).toHaveBeenCalledTimes(1);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
|
||||
"applied",
|
||||
);
|
||||
});
|
||||
|
||||
test("Non-ok response marks every submitting entry as rejected", async () => {
|
||||
const { context, draft, refresh } = await makeSetup([
|
||||
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
|
||||
]);
|
||||
const exec = vi.fn(async () => ({
|
||||
resultCode: "invalid_request",
|
||||
payloadBytes: new TextEncoder().encode(
|
||||
JSON.stringify({ code: "boom", message: "down" }),
|
||||
),
|
||||
}));
|
||||
const holder = context.get(GALAXY_CLIENT_CONTEXT_KEY) as GalaxyClientHolder;
|
||||
holder.set({ executeCommand: exec } as unknown as GalaxyClient);
|
||||
|
||||
const ui = render(OrderTab, { context });
|
||||
await fireEvent.click(ui.getByTestId("order-submit"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(draft.statuses["id-1"]).toBe("rejected");
|
||||
});
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(ui.getByTestId("order-submit-error")).toHaveTextContent("down");
|
||||
});
|
||||
|
||||
test("Already-applied entries do not get re-submitted", async () => {
|
||||
const { context, draft, exec } = await makeSetup([
|
||||
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
|
||||
]);
|
||||
draft.markSubmitting(["id-1"]);
|
||||
draft.applyResults({
|
||||
results: new Map([["id-1", "applied"] as const]),
|
||||
updatedAt: 1,
|
||||
});
|
||||
|
||||
const ui = render(OrderTab, { context });
|
||||
const submit = ui.getByTestId("order-submit");
|
||||
expect(submit).toBeDisabled();
|
||||
expect(exec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// Vitest unit coverage for `sync/submit.ts`. Drives the submit
|
||||
// pipeline against a stub `GalaxyClient` whose `executeCommand`
|
||||
// hand-builds FBS responses, so the parser is exercised against
|
||||
// payloads identical to what the real gateway returns.
|
||||
|
||||
import { Builder } from "flatbuffers";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { GalaxyClient } from "../src/api/galaxy-client";
|
||||
import { uuidToHiLo } from "../src/api/game-state";
|
||||
import { UUID } from "../src/proto/galaxy/fbs/common";
|
||||
import {
|
||||
CommandItem,
|
||||
CommandPlanetRename,
|
||||
CommandPayload,
|
||||
UserGamesOrder,
|
||||
UserGamesOrderResponse,
|
||||
} from "../src/proto/galaxy/fbs/order";
|
||||
import { submitOrder } from "../src/sync/submit";
|
||||
import type { OrderCommand } from "../src/sync/order-types";
|
||||
|
||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
function mockClient(
|
||||
executeCommand: (
|
||||
messageType: string,
|
||||
payload: Uint8Array,
|
||||
) => Promise<{ resultCode: string; payloadBytes: Uint8Array }>,
|
||||
): GalaxyClient {
|
||||
return { executeCommand } as unknown as GalaxyClient;
|
||||
}
|
||||
|
||||
function buildResponse(
|
||||
commands: { id: string; applied: boolean | null; errorCode: number | null }[],
|
||||
updatedAt: number,
|
||||
): Uint8Array {
|
||||
const builder = new Builder(256);
|
||||
const itemOffsets = commands.map((c) => {
|
||||
const cmdIdOffset = builder.createString(c.id);
|
||||
const nameOffset = builder.createString("ignored");
|
||||
const payloadOffset = CommandPlanetRename.createCommandPlanetRename(
|
||||
builder,
|
||||
BigInt(0),
|
||||
nameOffset,
|
||||
);
|
||||
CommandItem.startCommandItem(builder);
|
||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||
if (c.applied !== null) CommandItem.addCmdApplied(builder, c.applied);
|
||||
if (c.errorCode !== null) {
|
||||
CommandItem.addCmdErrorCode(builder, BigInt(c.errorCode));
|
||||
}
|
||||
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
||||
CommandItem.addPayload(builder, payloadOffset);
|
||||
return CommandItem.endCommandItem(builder);
|
||||
});
|
||||
const commandsVec = UserGamesOrderResponse.createCommandsVector(builder, itemOffsets);
|
||||
const [hi, lo] = uuidToHiLo(GAME_ID);
|
||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
|
||||
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
|
||||
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(updatedAt));
|
||||
UserGamesOrderResponse.addCommands(builder, commandsVec);
|
||||
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
|
||||
builder.finish(offset);
|
||||
return builder.asUint8Array();
|
||||
}
|
||||
|
||||
const sampleRename: OrderCommand = {
|
||||
kind: "planetRename",
|
||||
id: "00000000-0000-0000-0000-00000000aaaa",
|
||||
planetNumber: 7,
|
||||
name: "Earth",
|
||||
};
|
||||
|
||||
describe("submitOrder", () => {
|
||||
test("decodes per-command results from a populated response", async () => {
|
||||
const responsePayload = buildResponse(
|
||||
[{ id: sampleRename.id, applied: true, errorCode: null }],
|
||||
99,
|
||||
);
|
||||
const exec = vi.fn(async () => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: responsePayload,
|
||||
}));
|
||||
const result = await submitOrder(mockClient(exec), GAME_ID, [sampleRename]);
|
||||
|
||||
expect(exec).toHaveBeenCalledOnce();
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.results.get(sampleRename.id)).toBe("applied");
|
||||
expect(result.errorCodes.get(sampleRename.id)).toBeNull();
|
||||
expect(result.updatedAt).toBe(99);
|
||||
});
|
||||
|
||||
test("falls back to batch-level applied when commands array is empty", async () => {
|
||||
// Hand-craft an envelope without `commands` to mimic the legacy
|
||||
// gateway behaviour (or a 204 wrapped via the fallback path).
|
||||
const builder = new Builder(64);
|
||||
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
|
||||
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
|
||||
builder.finish(offset);
|
||||
const exec = vi.fn(async () => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: builder.asUint8Array(),
|
||||
}));
|
||||
|
||||
const result = await submitOrder(mockClient(exec), GAME_ID, [sampleRename]);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.results.get(sampleRename.id)).toBe("applied");
|
||||
expect(result.errorCodes.get(sampleRename.id)).toBeNull();
|
||||
});
|
||||
|
||||
test("surfaces mixed applied / rejected entries by cmd id", async () => {
|
||||
const second: OrderCommand = {
|
||||
kind: "planetRename",
|
||||
id: "00000000-0000-0000-0000-00000000bbbb",
|
||||
planetNumber: 8,
|
||||
name: "Mars",
|
||||
};
|
||||
const responsePayload = buildResponse(
|
||||
[
|
||||
{ id: sampleRename.id, applied: true, errorCode: null },
|
||||
{ id: second.id, applied: false, errorCode: 42 },
|
||||
],
|
||||
120,
|
||||
);
|
||||
const exec = vi.fn(async () => ({
|
||||
resultCode: "ok",
|
||||
payloadBytes: responsePayload,
|
||||
}));
|
||||
|
||||
const result = await submitOrder(mockClient(exec), GAME_ID, [sampleRename, second]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.results.get(sampleRename.id)).toBe("applied");
|
||||
expect(result.errorCodes.get(sampleRename.id)).toBeNull();
|
||||
expect(result.results.get(second.id)).toBe("rejected");
|
||||
expect(result.errorCodes.get(second.id)).toBe(42);
|
||||
});
|
||||
|
||||
test("returns SubmitFailure on non-ok resultCode without throwing", async () => {
|
||||
const exec = vi.fn(async () => ({
|
||||
resultCode: "invalid_request",
|
||||
payloadBytes: new TextEncoder().encode(
|
||||
JSON.stringify({ code: "validation_failed", message: "bad name" }),
|
||||
),
|
||||
}));
|
||||
|
||||
const result = await submitOrder(mockClient(exec), GAME_ID, [sampleRename]);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.resultCode).toBe("invalid_request");
|
||||
expect(result.code).toBe("validation_failed");
|
||||
expect(result.message).toBe("bad name");
|
||||
});
|
||||
|
||||
test("posts a well-formed UserGamesOrder payload", async () => {
|
||||
let captured: Uint8Array | null = null;
|
||||
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
|
||||
captured = payload;
|
||||
return { resultCode: "ok", payloadBytes: new Uint8Array() };
|
||||
});
|
||||
await submitOrder(mockClient(exec), GAME_ID, [sampleRename]);
|
||||
expect(captured).not.toBeNull();
|
||||
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
|
||||
new (await import("flatbuffers")).ByteBuffer(captured!),
|
||||
);
|
||||
expect(decoded.commandsLength()).toBe(1);
|
||||
const item = decoded.commands(0);
|
||||
expect(item).not.toBeNull();
|
||||
expect(item!.cmdId()).toBe(sampleRename.id);
|
||||
expect(item!.payloadType()).toBe(CommandPayload.CommandPlanetRename);
|
||||
const inner = new CommandPlanetRename();
|
||||
item!.payload(inner);
|
||||
expect(Number(inner.number())).toBe(7);
|
||||
expect(inner.name()).toBe("Earth");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user