battle-viewer e2e: mock user.games.battle ConnectRPC command
Phase 28 moved the battle fetch off the REST passthrough onto the signed envelope, so the Playwright spec's `page.route(...)` against the old REST path no longer intercepts anything and the viewer times out waiting for data. Update the spec to: - Build a FlatBuffers `BattleReport` payload in `fixtures/battle-fbs.ts` (mirrors `report-fbs.ts`'s pattern). - Add a `user.games.battle` case to the ExecuteCommand mock that decodes the FBS `GameBattleRequest`, returns the encoded report when the battle_id matches the seeded one, and surfaces a canonical `not_found` resultCode otherwise. - Drop the obsolete REST route stubs. - Drive the negative-path test with a real UUID that does not match the seeded one, so the gateway-side switch is the source of the 404 (the old `missing-uuid` literal was no longer a valid wire shape for the UUID decoder). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
// Phase 27 — Playwright coverage for the Battle Viewer.
|
// Phase 27 — Playwright coverage for the Battle Viewer.
|
||||||
//
|
//
|
||||||
// Mocks both the Connect-RPC `user.games.report` (so the report
|
// Mocks the Connect-RPC `user.games.report` (report with battles +
|
||||||
// renders battles + bombings) and the REST forwarder
|
// bombings) and `user.games.battle` (Phase 28 migration; the viewer
|
||||||
// `/api/v1/user/games/{game_id}/battles/{turn}/{battle_id}` (so the
|
// fetches the full BattleReport through the signed envelope instead
|
||||||
// viewer page loads its `BattleReport` without an engine).
|
// of the old REST passthrough).
|
||||||
// Drives three flows:
|
// Drives three flows:
|
||||||
// 1. Reports view → click battle UUID → viewer renders.
|
// 1. Reports view → click battle UUID → viewer renders.
|
||||||
// 2. Playback controls: play / step back.
|
// 2. Playback controls: play / step back.
|
||||||
@@ -18,6 +18,7 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
|
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
|
||||||
import { UUID } from "../../src/proto/galaxy/fbs/common";
|
import { UUID } from "../../src/proto/galaxy/fbs/common";
|
||||||
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
|
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
|
||||||
|
import { GameBattleRequest } from "../../src/proto/galaxy/fbs/battle";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildOrderResponsePayload,
|
buildOrderResponsePayload,
|
||||||
@@ -25,6 +26,10 @@ import {
|
|||||||
} from "./fixtures/order-fbs";
|
} from "./fixtures/order-fbs";
|
||||||
import { buildMyGamesListPayload, type GameFixture } from "./fixtures/lobby-fbs";
|
import { buildMyGamesListPayload, type GameFixture } from "./fixtures/lobby-fbs";
|
||||||
import { buildReportPayload } from "./fixtures/report-fbs";
|
import { buildReportPayload } from "./fixtures/report-fbs";
|
||||||
|
import {
|
||||||
|
buildBattlePayload,
|
||||||
|
type BattleFixture,
|
||||||
|
} from "./fixtures/battle-fbs";
|
||||||
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
|
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
|
||||||
|
|
||||||
const GAME_ID = "00000000-0000-0000-0000-000000000010";
|
const GAME_ID = "00000000-0000-0000-0000-000000000010";
|
||||||
@@ -33,13 +38,14 @@ const SESSION_ID = "device-session-battle";
|
|||||||
const RACE_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
const RACE_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||||
const RACE_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
|
const RACE_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
|
||||||
|
|
||||||
const SAMPLE_BATTLE = {
|
const SAMPLE_BATTLE: BattleFixture = {
|
||||||
id: BATTLE_ID,
|
id: BATTLE_ID,
|
||||||
planet: 1,
|
planet: 1,
|
||||||
planetName: "Earth",
|
planetName: "Earth",
|
||||||
races: { "0": RACE_A, "1": RACE_B },
|
races: { "0": RACE_A, "1": RACE_B },
|
||||||
ships: {
|
ships: [
|
||||||
"10": {
|
{
|
||||||
|
key: 10,
|
||||||
race: "Earthlings",
|
race: "Earthlings",
|
||||||
className: "Cruiser",
|
className: "Cruiser",
|
||||||
tech: { WEAPONS: 1 },
|
tech: { WEAPONS: 1 },
|
||||||
@@ -49,7 +55,8 @@ const SAMPLE_BATTLE = {
|
|||||||
loadQuantity: 0,
|
loadQuantity: 0,
|
||||||
inBattle: true,
|
inBattle: true,
|
||||||
},
|
},
|
||||||
"20": {
|
{
|
||||||
|
key: 20,
|
||||||
race: "Bajori",
|
race: "Bajori",
|
||||||
className: "Hawk",
|
className: "Hawk",
|
||||||
tech: { SHIELDS: 1 },
|
tech: { SHIELDS: 1 },
|
||||||
@@ -59,7 +66,7 @@ const SAMPLE_BATTLE = {
|
|||||||
loadQuantity: 0,
|
loadQuantity: 0,
|
||||||
inBattle: true,
|
inBattle: true,
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
protocol: [
|
protocol: [
|
||||||
{ a: 0, sa: 10, d: 1, sd: 20, x: false },
|
{ a: 0, sa: 10, d: 1, sd: 20, x: false },
|
||||||
{ a: 0, sa: 10, d: 1, sd: 20, x: true },
|
{ a: 0, sa: 10, d: 1, sd: 20, x: true },
|
||||||
@@ -160,6 +167,23 @@ async function mockGatewayAndBattle(page: Page): Promise<void> {
|
|||||||
case "user.games.order.get":
|
case "user.games.order.get":
|
||||||
payload = buildOrderGetResponsePayload(GAME_ID, [], Date.now(), false);
|
payload = buildOrderGetResponsePayload(GAME_ID, [], Date.now(), false);
|
||||||
break;
|
break;
|
||||||
|
case "user.games.battle": {
|
||||||
|
const fb = GameBattleRequest.getRootAsGameBattleRequest(
|
||||||
|
new ByteBuffer(req.payloadBytes),
|
||||||
|
);
|
||||||
|
const id = fb.battleId();
|
||||||
|
if (
|
||||||
|
id !== null &&
|
||||||
|
id.hi() === BigInt("0x1111111111111111") &&
|
||||||
|
id.lo() === BigInt("0x1111111111111111")
|
||||||
|
) {
|
||||||
|
payload = buildBattlePayload(SAMPLE_BATTLE);
|
||||||
|
} else {
|
||||||
|
resultCode = "not_found";
|
||||||
|
payload = new Uint8Array();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
resultCode = "internal_error";
|
resultCode = "internal_error";
|
||||||
payload = new Uint8Array();
|
payload = new Uint8Array();
|
||||||
@@ -179,23 +203,6 @@ async function mockGatewayAndBattle(page: Page): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.route(
|
|
||||||
`**/api/v1/user/games/${GAME_ID}/battles/1/${BATTLE_ID}`,
|
|
||||||
async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(SAMPLE_BATTLE),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.route(
|
|
||||||
`**/api/v1/user/games/${GAME_ID}/battles/1/missing-uuid`,
|
|
||||||
async (route) => {
|
|
||||||
await route.fulfill({ status: 404 });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootSession(page: Page): Promise<void> {
|
async function bootSession(page: Page): Promise<void> {
|
||||||
@@ -267,7 +274,9 @@ test.describe("Phase 27 battle viewer", () => {
|
|||||||
|
|
||||||
await mockGatewayAndBattle(page);
|
await mockGatewayAndBattle(page);
|
||||||
await bootSession(page);
|
await bootSession(page);
|
||||||
await page.goto(`/games/${GAME_ID}/battle/missing-uuid?turn=1`);
|
await page.goto(
|
||||||
|
`/games/${GAME_ID}/battle/22222222-2222-2222-2222-222222222222?turn=1`,
|
||||||
|
);
|
||||||
|
|
||||||
await expect(page.getByTestId("battle-not-found")).toBeVisible();
|
await expect(page.getByTestId("battle-not-found")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Phase 28 — FBS BattleReport payload builder used by Battle Viewer
|
||||||
|
// Playwright specs. Mirrors the `user.games.battle` ConnectRPC
|
||||||
|
// response that the gateway re-encodes from the engine's JSON.
|
||||||
|
|
||||||
|
import { Builder } from "flatbuffers";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BattleActionReport,
|
||||||
|
BattleReport,
|
||||||
|
BattleReportGroup,
|
||||||
|
RaceEntry,
|
||||||
|
ShipEntry,
|
||||||
|
TechEntry,
|
||||||
|
UUID,
|
||||||
|
} from "../../../src/proto/galaxy/fbs/battle";
|
||||||
|
|
||||||
|
export interface BattleGroupFixture {
|
||||||
|
key: number;
|
||||||
|
race: string;
|
||||||
|
className: string;
|
||||||
|
tech?: Record<string, number>;
|
||||||
|
num: number;
|
||||||
|
numLeft: number;
|
||||||
|
loadType?: string;
|
||||||
|
loadQuantity?: number;
|
||||||
|
inBattle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BattleActionFixture {
|
||||||
|
a: number;
|
||||||
|
sa: number;
|
||||||
|
d: number;
|
||||||
|
sd: number;
|
||||||
|
x: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BattleFixture {
|
||||||
|
id: string;
|
||||||
|
planet: number;
|
||||||
|
planetName: string;
|
||||||
|
races: Record<string, string>;
|
||||||
|
ships: BattleGroupFixture[];
|
||||||
|
protocol: BattleActionFixture[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBattlePayload(fixture: BattleFixture): Uint8Array {
|
||||||
|
const builder = new Builder(512);
|
||||||
|
|
||||||
|
const planetNameOff = builder.createString(fixture.planetName);
|
||||||
|
|
||||||
|
const raceOffsets: number[] = [];
|
||||||
|
for (const [keyStr, value] of Object.entries(fixture.races)) {
|
||||||
|
const key = Number(keyStr);
|
||||||
|
const [hi, lo] = uuidToHiLo(value);
|
||||||
|
RaceEntry.startRaceEntry(builder);
|
||||||
|
RaceEntry.addKey(builder, BigInt(key));
|
||||||
|
RaceEntry.addValue(builder, UUID.createUUID(builder, hi, lo));
|
||||||
|
raceOffsets.push(RaceEntry.endRaceEntry(builder));
|
||||||
|
}
|
||||||
|
const racesVec = BattleReport.createRacesVector(builder, raceOffsets);
|
||||||
|
|
||||||
|
const shipOffsets: number[] = [];
|
||||||
|
for (const ship of fixture.ships) {
|
||||||
|
const techOffsets: number[] = [];
|
||||||
|
for (const [tk, tv] of Object.entries(ship.tech ?? {})) {
|
||||||
|
const tkOff = builder.createString(tk);
|
||||||
|
TechEntry.startTechEntry(builder);
|
||||||
|
TechEntry.addKey(builder, tkOff);
|
||||||
|
TechEntry.addValue(builder, tv);
|
||||||
|
techOffsets.push(TechEntry.endTechEntry(builder));
|
||||||
|
}
|
||||||
|
const techVec = BattleReportGroup.createTechVector(builder, techOffsets);
|
||||||
|
const raceStrOff = builder.createString(ship.race);
|
||||||
|
const classNameOff = builder.createString(ship.className);
|
||||||
|
const loadTypeOff = builder.createString(ship.loadType ?? "");
|
||||||
|
BattleReportGroup.startBattleReportGroup(builder);
|
||||||
|
BattleReportGroup.addInBattle(builder, ship.inBattle ?? true);
|
||||||
|
BattleReportGroup.addNumber(builder, BigInt(ship.num));
|
||||||
|
BattleReportGroup.addNumberLeft(builder, BigInt(ship.numLeft));
|
||||||
|
BattleReportGroup.addLoadQuantity(builder, ship.loadQuantity ?? 0);
|
||||||
|
BattleReportGroup.addTech(builder, techVec);
|
||||||
|
BattleReportGroup.addRace(builder, raceStrOff);
|
||||||
|
BattleReportGroup.addClassName(builder, classNameOff);
|
||||||
|
BattleReportGroup.addLoadType(builder, loadTypeOff);
|
||||||
|
const groupOff = BattleReportGroup.endBattleReportGroup(builder);
|
||||||
|
ShipEntry.startShipEntry(builder);
|
||||||
|
ShipEntry.addKey(builder, BigInt(ship.key));
|
||||||
|
ShipEntry.addValue(builder, groupOff);
|
||||||
|
shipOffsets.push(ShipEntry.endShipEntry(builder));
|
||||||
|
}
|
||||||
|
const shipsVec = BattleReport.createShipsVector(builder, shipOffsets);
|
||||||
|
|
||||||
|
const protocolOffsets: number[] = [];
|
||||||
|
for (const action of fixture.protocol) {
|
||||||
|
BattleActionReport.startBattleActionReport(builder);
|
||||||
|
BattleActionReport.addAttacker(builder, BigInt(action.a));
|
||||||
|
BattleActionReport.addAttackerShipClass(builder, BigInt(action.sa));
|
||||||
|
BattleActionReport.addDefender(builder, BigInt(action.d));
|
||||||
|
BattleActionReport.addDefenderShipClass(builder, BigInt(action.sd));
|
||||||
|
BattleActionReport.addDestroyed(builder, action.x);
|
||||||
|
protocolOffsets.push(BattleActionReport.endBattleActionReport(builder));
|
||||||
|
}
|
||||||
|
const protocolVec = BattleReport.createProtocolVector(
|
||||||
|
builder,
|
||||||
|
protocolOffsets,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [idHi, idLo] = uuidToHiLo(fixture.id);
|
||||||
|
BattleReport.startBattleReport(builder);
|
||||||
|
BattleReport.addId(builder, UUID.createUUID(builder, idHi, idLo));
|
||||||
|
BattleReport.addPlanet(builder, BigInt(fixture.planet));
|
||||||
|
BattleReport.addPlanetName(builder, planetNameOff);
|
||||||
|
BattleReport.addRaces(builder, racesVec);
|
||||||
|
BattleReport.addShips(builder, shipsVec);
|
||||||
|
BattleReport.addProtocol(builder, protocolVec);
|
||||||
|
builder.finish(BattleReport.endBattleReport(builder));
|
||||||
|
return builder.asUint8Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuidToHiLo(value: string): [bigint, bigint] {
|
||||||
|
const hex = value.replace(/-/g, "").toLowerCase();
|
||||||
|
if (hex.length !== 32 || /[^0-9a-f]/.test(hex)) {
|
||||||
|
throw new Error(`buildBattlePayload: invalid uuid ${value}`);
|
||||||
|
}
|
||||||
|
const hi = BigInt(`0x${hex.slice(0, 16)}`);
|
||||||
|
const lo = BigInt(`0x${hex.slice(16, 32)}`);
|
||||||
|
return [hi, lo];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user