9e9977d5f1
Surface the inactivity-removal countdown the rules promise but the engine never reported. A race within five turns of being auto-removed for inactivity gets a personal warning in its own report; every race within three turns is listed publicly to all participants. - model: Report.PersonalExitWarning + RacesLeavingSoon ([]RaceExitNotice) - fbs: RaceExitNotice table + Report.personal_exit_warning / races_leaving_soon (regenerated Go + TS bindings) - transcoder: encode/decode both fields - engine: ReportExitWarnings fills the recipient's TTL (1..5) and lists other non-extinct races with TTL 1..3, excluding the recipient itself - ui: danger-styled personal banner + "races leaving soon" section (hidden when empty), wired into the report view, EN/RU i18n - docs: rules.txt report-section list, FUNCTIONAL.md 6.4 + RU mirror Voluntary quit and idle timeout share the TTL countdown and are not distinguished, per the agreed scope.
345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
// Phase 23 end-to-end coverage for the Report View. Mocks the
|
|
// gateway with a single seeded report that fills every wire field
|
|
// the orchestrator's sections render, then drives the page through
|
|
// the targeted-test contract:
|
|
//
|
|
// 1. Every TOC menuitem click scrolls the matching section into
|
|
// view and the section is present in the DOM with at least one
|
|
// row (or its empty-state copy when it is intentionally empty).
|
|
// 2. On a narrow viewport the same trigger surfaces a bottom-sheet
|
|
// popover and the same menuitem flow lands the chosen section.
|
|
//
|
|
// F8-09 collapsed the desktop sidebar and mobile `<select>` into a
|
|
// single sticky icon-popup trigger; the in-report "Back to map"
|
|
// button was removed (the affordance lives in the app-shell view
|
|
// menu, exercised by `game-shell.spec.ts`).
|
|
|
|
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
import { ByteBuffer } from "flatbuffers";
|
|
|
|
import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb";
|
|
import { UUID } from "../../src/proto/galaxy/fbs/common";
|
|
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-23-report-session";
|
|
const GAME_ID = "23232323-2323-2323-2323-232323232323";
|
|
const BATTLE_ID = "00000000-0000-0000-0000-000000000001";
|
|
|
|
// SECTIONS lists every TOC slug paired with a row-presence hook.
|
|
// `expectRow` is null for sections that the seeded report
|
|
// intentionally leaves empty so the empty-state copy is asserted
|
|
// instead. The orchestrator's section order must match this list —
|
|
// the spec relies on each slug having a `report-toc-item-<slug>` in
|
|
// the popover and a `report-section-<slug>` testid in the body.
|
|
const SECTIONS: ReadonlyArray<{ slug: string; expectRow: string | null }> = [
|
|
{ slug: "galaxy-summary", expectRow: "galaxy-summary-field-turn" },
|
|
{ slug: "race-exit-warnings", expectRow: "race-exit-warnings-row" },
|
|
{ slug: "votes", expectRow: "votes-mine" },
|
|
{ slug: "player-status", expectRow: "player-status-row" },
|
|
{ slug: "my-sciences", expectRow: "my-sciences-row" },
|
|
{ slug: "foreign-sciences", expectRow: "foreign-sciences-row" },
|
|
{ slug: "my-ship-classes", expectRow: "my-ship-classes-row" },
|
|
{ slug: "foreign-ship-classes", expectRow: "foreign-ship-classes-row" },
|
|
{ slug: "battles", expectRow: "report-battle-row" },
|
|
{ slug: "bombings", expectRow: "report-bombing-row" },
|
|
// `incomingShipGroups` cannot be seeded through the current
|
|
// e2e fixture (no builder); the orchestrator surfaces the
|
|
// empty-state copy and that is sufficient coverage here.
|
|
{ slug: "approaching-groups", expectRow: null },
|
|
{ slug: "my-planets", expectRow: "my-planets-row" },
|
|
{ slug: "ships-in-production", expectRow: "ships-in-production-row" },
|
|
// `cargo-routes` is empty in the seeded report — no route fixtures.
|
|
// The orchestrator surfaces the empty-state copy instead.
|
|
{ slug: "cargo-routes", expectRow: null },
|
|
{ slug: "foreign-planets", expectRow: "foreign-planets-row" },
|
|
{ slug: "uninhabited-planets", expectRow: "uninhabited-planets-row" },
|
|
{ slug: "unknown-planets", expectRow: "unknown-planets-row" },
|
|
// `my-fleets`, `my-ship-groups`, `foreign-ship-groups`,
|
|
// `unidentified-groups` are also empty — seeding them would
|
|
// require a parallel name-resolution pipeline for the fixture
|
|
// builder; the empty-state coverage is sufficient for the
|
|
// acceptance criterion.
|
|
{ slug: "my-fleets", expectRow: null },
|
|
{ slug: "my-ship-groups", expectRow: null },
|
|
{ slug: "foreign-ship-groups", expectRow: null },
|
|
{ slug: "unidentified-groups", expectRow: null },
|
|
];
|
|
|
|
async function mockGateway(page: Page): Promise<void> {
|
|
const game: GameFixture = {
|
|
gameId: GAME_ID,
|
|
gameName: "Phase 23 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: 1,
|
|
};
|
|
|
|
const storedOrder: CommandResultFixture[] = [];
|
|
|
|
await page.route(
|
|
"**/edge.v1.Gateway/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: 1,
|
|
mapWidth: 4000,
|
|
mapHeight: 4000,
|
|
race: "Earthlings",
|
|
myVotes: 4,
|
|
myVoteFor: "Andori",
|
|
players: [
|
|
{ name: "Earthlings", drive: 1, weapons: 1, shields: 1, cargo: 1, population: 4000, industry: 3000, planets: 2, relation: "-", votes: 4 },
|
|
{ name: "Andori", drive: 0.8, weapons: 0.6, shields: 0.5, cargo: 0.5, population: 3000, industry: 2500, planets: 2, relation: "PEACE", votes: 3 },
|
|
{ name: "Bajori", drive: 0.3, weapons: 0.2, shields: 0.2, cargo: 0.3, population: 2000, industry: 1500, planets: 1, relation: "WAR", votes: 2 },
|
|
{ name: "Cardassian", drive: 0, weapons: 0, shields: 0, cargo: 0, population: 0, industry: 0, planets: 0, relation: "PEACE", votes: 0, extinct: true },
|
|
],
|
|
localPlanets: [
|
|
{ number: 1, name: "Earth", x: 2000, y: 2000, size: 1000, resources: 5, population: 4000, industry: 3000, capital: 0, material: 0, colonists: 100, freeIndustry: 800, production: "Cruiser" },
|
|
],
|
|
otherPlanets: [
|
|
{ number: 2, name: "Andoria", owner: "Andori", x: 2500, y: 2000, size: 800, resources: 4, population: 3000, industry: 2500, capital: 12, material: 7, colonists: 80, freeIndustry: 600, production: "Capital" },
|
|
],
|
|
uninhabitedPlanets: [
|
|
{ number: 3, name: "Rock-1", x: 1800, y: 2300, size: 200, resources: 3, capital: 0, material: 25 },
|
|
],
|
|
unidentifiedPlanets: [{ number: 4, x: 2900, y: 1800 }],
|
|
localShipClass: [
|
|
{ name: "Cruiser", drive: 10, armament: 2, weapons: 5, shields: 5, cargo: 2 },
|
|
],
|
|
localScience: [
|
|
{ name: "DriveResearch", drive: 1, weapons: 0, shields: 0, cargo: 0 },
|
|
],
|
|
otherScience: [
|
|
{ race: "Andori", name: "AnDrive", drive: 1 },
|
|
{ race: "Bajori", name: "BjMix", drive: 0.5, cargo: 0.5 },
|
|
],
|
|
otherShipClass: [
|
|
{ race: "Andori", name: "Spear", drive: 8, armament: 4, weapons: 6, shields: 3, cargo: 1, mass: 90 },
|
|
{ race: "Bajori", name: "Hawk", drive: 12, armament: 1, weapons: 4, shields: 2, cargo: 0, mass: 75 },
|
|
],
|
|
battles: [{ id: BATTLE_ID, planet: 1, shots: 12 }],
|
|
bombings: [
|
|
{ planetNumber: 1, planet: "Earth", owner: "Earthlings", attacker: "Bajori", production: "Cruiser", industry: 500, population: 200, colonists: 12, capital: 30, material: 5, attackPower: 250, wiped: false },
|
|
{ planetNumber: 99, planet: "DW-99", owner: "Earthlings", attacker: "Bajori", production: "Dron", industry: 0, population: 0, colonists: 0, capital: 0, material: 0, attackPower: 800, wiped: true },
|
|
],
|
|
shipProductions: [
|
|
{ planet: 1, class: "Cruiser", cost: 100, prodUsed: 25, percent: 0.25, free: 800 },
|
|
],
|
|
racesLeavingSoon: [
|
|
{ race: "Bajori", turnsLeft: 2 },
|
|
{ race: "Cardassian", turnsLeft: 3 },
|
|
],
|
|
});
|
|
break;
|
|
}
|
|
case "user.games.order": {
|
|
payload = buildOrderResponsePayload(GAME_ID, storedOrder, Date.now());
|
|
break;
|
|
}
|
|
case "user.games.order.get": {
|
|
payload = buildOrderGetResponsePayload(
|
|
GAME_ID,
|
|
storedOrder,
|
|
Date.now(),
|
|
false,
|
|
);
|
|
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(
|
|
"**/edge.v1.Gateway/SubscribeEvents",
|
|
async () => {
|
|
await new Promise<void>(() => {});
|
|
},
|
|
);
|
|
|
|
// Approaching groups: the wire decoder filters incoming-group
|
|
// rows whose origin/destination names don't resolve against the
|
|
// planet tables — but the FBS builder takes raw numbers, so we
|
|
// inject one row directly through the buildReportPayload helper
|
|
// extension by re-routing the call in `mockGateway`. The fixture
|
|
// currently lacks an `incoming` builder; the seed above already
|
|
// fills bombings/ship-production, so approaching-groups stays
|
|
// empty here. The orchestrator surfaces the empty-state copy and
|
|
// the spec records that explicitly via `SECTIONS[].expectRow`.
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
test.describe("Phase 23 report view", () => {
|
|
test("every popover menuitem lands its section in view", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.skip(
|
|
testInfo.project.name.startsWith("chromium-mobile"),
|
|
"mobile coverage is the dedicated test below",
|
|
);
|
|
|
|
await mockGateway(page);
|
|
await bootSession(page);
|
|
await page.goto("/");
|
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
|
await page.evaluate(
|
|
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
|
|
GAME_ID,
|
|
);
|
|
|
|
await expect(page.getByTestId("active-view-report")).toBeVisible();
|
|
await expect(page.getByTestId("report-toc")).toBeVisible();
|
|
// Wait for the report to land. `galaxy-summary-field-turn`
|
|
// only mounts once `RenderedReportSource.report !== null`, so
|
|
// observing it confirms the gateway round-trip completed.
|
|
await expect(
|
|
page.getByTestId("galaxy-summary-field-turn"),
|
|
).toBeVisible();
|
|
|
|
const trigger = page.getByTestId("report-toc-trigger");
|
|
const surface = page.getByTestId("report-toc-surface");
|
|
|
|
for (const entry of SECTIONS) {
|
|
await trigger.click();
|
|
await expect(surface).toBeVisible();
|
|
await page.getByTestId(`report-toc-item-${entry.slug}`).click();
|
|
// The popover closes on selection and the section
|
|
// scrolls into view.
|
|
await expect(surface).toHaveCount(0);
|
|
const section = page.getByTestId(`report-section-${entry.slug}`);
|
|
await expect(section).toBeInViewport();
|
|
if (entry.expectRow !== null) {
|
|
const row = section.getByTestId(entry.expectRow).first();
|
|
await expect(row).toBeVisible();
|
|
} else {
|
|
// Empty-state copy is rendered as a single status
|
|
// paragraph; the section still has visible content.
|
|
await expect(section).toBeVisible();
|
|
}
|
|
}
|
|
});
|
|
|
|
// NOTE: the old "scroll position survives a /map round-trip via
|
|
// Snapshot" spec was dropped here. It exercised the per-route
|
|
// SvelteKit `Snapshot` exported by the deleted
|
|
// `routes/games/[id]/report/+page.svelte`, which captured and
|
|
// restored `window.scrollY` across a browser history navigation to
|
|
// `/map` and back. The single-URL app-shell switches the active view
|
|
// in memory (`activeView.select`) without changing the URL or pushing
|
|
// a history entry, and it remounts the report component on return —
|
|
// so neither the URL round-trip, the `page.goBack()`, nor the
|
|
// scroll-restoration the test asserted exist any more. Re-adding that
|
|
// behaviour would be a production change outside this test migration.
|
|
|
|
// F8-09 removed the in-report "Back to map" button — the same
|
|
// affordance lives in the app-shell view menu, exercised by
|
|
// `game-shell.spec.ts` ("header view-menu navigates to every
|
|
// active view").
|
|
|
|
test("mobile bottom-sheet popover lands the chosen section", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.skip(
|
|
!testInfo.project.name.startsWith("chromium-mobile"),
|
|
"desktop branches are covered by the other tests above",
|
|
);
|
|
|
|
await mockGateway(page);
|
|
await bootSession(page);
|
|
await page.goto("/");
|
|
await page.waitForFunction(() => window.__galaxyNav !== undefined);
|
|
await page.evaluate(
|
|
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
|
|
GAME_ID,
|
|
);
|
|
|
|
await expect(
|
|
page.getByTestId("galaxy-summary-field-turn"),
|
|
).toBeVisible();
|
|
|
|
const trigger = page.getByTestId("report-toc-trigger");
|
|
await expect(trigger).toBeVisible();
|
|
await trigger.click();
|
|
|
|
const surface = page.getByTestId("report-toc-surface");
|
|
await expect(surface).toBeVisible();
|
|
// Below 768 px the surface re-styles into a fixed
|
|
// bottom-sheet anchored above the bottom-tabs bar.
|
|
const position = await surface.evaluate(
|
|
(el) => getComputedStyle(el).position,
|
|
);
|
|
expect(position).toBe("fixed");
|
|
|
|
await page.getByTestId("report-toc-item-bombings").click();
|
|
await expect(surface).toHaveCount(0);
|
|
await expect(
|
|
page.getByTestId("report-section-bombings"),
|
|
).toBeInViewport();
|
|
});
|
|
});
|