ui/phase-23: turn-report view with twenty sections and TOC
Replaces the Phase 10 report stub with a scrollable orchestrator that renders every FBS array as a dedicated section (galaxy summary, votes, player status, my/foreign sciences, my/foreign ship classes, battles, bombings, approaching groups, my/foreign/uninhabited/unknown planets, ships in production, cargo routes, my fleets, my/foreign/unidentified ship groups). A sticky table of contents (a <select> on mobile), "back to map" affordance, IntersectionObserver-driven active-section highlight, and SvelteKit Snapshot-based scroll save/restore round out the view. GameReport gains six new fields (players, otherScience, otherShipClass, battleIds, bombings, shipProductions); decodeReport, the synthetic- report loader, the e2e fixture builder, and EMPTY_SHIP_GROUPS extend in lockstep. ~90 new i18n keys land in en + ru together. The legacy-report parser is extended to populate the new sections from the dg/gplus text formats (Your Sciences, <Race> Sciences, <Race> Ship Types, Bombings, Ships In Production). Ships-in-production prod_used is derived through a new pkg/calc.ShipBuildCost helper; the engine's controller.ProduceShip refactors to call the same helper without any behaviour change (engine tests stay unchanged and green). Battles remain in the parser's Skipped list — the legacy text carries no stable per-battle UUID. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -17,15 +17,20 @@
|
||||
|
||||
import { Builder } from "flatbuffers";
|
||||
|
||||
import { UUID } from "../../../src/proto/galaxy/fbs/common";
|
||||
import {
|
||||
Bombing,
|
||||
LocalPlanet,
|
||||
OtherPlanet,
|
||||
OtherScience,
|
||||
OthersShipClass,
|
||||
Player,
|
||||
Report,
|
||||
Route,
|
||||
RouteEntry,
|
||||
Science,
|
||||
ShipClass,
|
||||
ShipProduction,
|
||||
UnidentifiedPlanet,
|
||||
UninhabitedPlanet,
|
||||
} from "../../../src/proto/galaxy/fbs/report";
|
||||
@@ -94,6 +99,39 @@ export interface RouteFixture {
|
||||
entries: RouteEntryFixture[];
|
||||
}
|
||||
|
||||
export interface OtherScienceFixture extends ScienceFixture {
|
||||
race: string;
|
||||
}
|
||||
|
||||
export interface OtherShipClassFixture extends ShipClassFixture {
|
||||
race: string;
|
||||
mass?: number;
|
||||
}
|
||||
|
||||
export interface BombingFixture {
|
||||
planetNumber: number;
|
||||
planet: string;
|
||||
owner: string;
|
||||
attacker: string;
|
||||
production?: string;
|
||||
industry?: number;
|
||||
population?: number;
|
||||
colonists?: number;
|
||||
capital?: number;
|
||||
material?: number;
|
||||
attackPower?: number;
|
||||
wiped?: boolean;
|
||||
}
|
||||
|
||||
export interface ShipProductionFixture {
|
||||
planet: number;
|
||||
class: string;
|
||||
cost?: number;
|
||||
prodUsed?: number;
|
||||
percent?: number;
|
||||
free?: number;
|
||||
}
|
||||
|
||||
export interface ReportFixture {
|
||||
turn: number;
|
||||
mapWidth?: number;
|
||||
@@ -109,6 +147,11 @@ export interface ReportFixture {
|
||||
routes?: RouteFixture[];
|
||||
myVotes?: number;
|
||||
myVoteFor?: string;
|
||||
otherScience?: OtherScienceFixture[];
|
||||
otherShipClass?: OtherShipClassFixture[];
|
||||
battles?: string[];
|
||||
bombings?: BombingFixture[];
|
||||
shipProductions?: ShipProductionFixture[];
|
||||
}
|
||||
|
||||
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
@@ -245,6 +288,67 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
return Route.endRoute(builder);
|
||||
});
|
||||
|
||||
const otherScienceOffsets = (fixture.otherScience ?? []).map((sci) => {
|
||||
const race = builder.createString(sci.race);
|
||||
const name = builder.createString(sci.name);
|
||||
OtherScience.startOtherScience(builder);
|
||||
OtherScience.addRace(builder, race);
|
||||
OtherScience.addName(builder, name);
|
||||
OtherScience.addDrive(builder, sci.drive ?? 0);
|
||||
OtherScience.addWeapons(builder, sci.weapons ?? 0);
|
||||
OtherScience.addShields(builder, sci.shields ?? 0);
|
||||
OtherScience.addCargo(builder, sci.cargo ?? 0);
|
||||
return OtherScience.endOtherScience(builder);
|
||||
});
|
||||
|
||||
const otherShipClassOffsets = (fixture.otherShipClass ?? []).map((cls) => {
|
||||
const race = builder.createString(cls.race);
|
||||
const name = builder.createString(cls.name);
|
||||
OthersShipClass.startOthersShipClass(builder);
|
||||
OthersShipClass.addRace(builder, race);
|
||||
OthersShipClass.addName(builder, name);
|
||||
OthersShipClass.addDrive(builder, cls.drive ?? 0);
|
||||
OthersShipClass.addArmament(builder, BigInt(cls.armament ?? 0));
|
||||
OthersShipClass.addWeapons(builder, cls.weapons ?? 0);
|
||||
OthersShipClass.addShields(builder, cls.shields ?? 0);
|
||||
OthersShipClass.addCargo(builder, cls.cargo ?? 0);
|
||||
OthersShipClass.addMass(builder, cls.mass ?? 0);
|
||||
return OthersShipClass.endOthersShipClass(builder);
|
||||
});
|
||||
|
||||
const bombingOffsets = (fixture.bombings ?? []).map((b) => {
|
||||
const planet = builder.createString(b.planet);
|
||||
const owner = builder.createString(b.owner);
|
||||
const attacker = builder.createString(b.attacker);
|
||||
const production = builder.createString(b.production ?? "");
|
||||
Bombing.startBombing(builder);
|
||||
Bombing.addNumber(builder, BigInt(b.planetNumber));
|
||||
Bombing.addPlanet(builder, planet);
|
||||
Bombing.addOwner(builder, owner);
|
||||
Bombing.addAttacker(builder, attacker);
|
||||
Bombing.addProduction(builder, production);
|
||||
Bombing.addIndustry(builder, b.industry ?? 0);
|
||||
Bombing.addPopulation(builder, b.population ?? 0);
|
||||
Bombing.addColonists(builder, b.colonists ?? 0);
|
||||
Bombing.addCapital(builder, b.capital ?? 0);
|
||||
Bombing.addMaterial(builder, b.material ?? 0);
|
||||
Bombing.addAttackPower(builder, b.attackPower ?? 0);
|
||||
Bombing.addWiped(builder, b.wiped ?? false);
|
||||
return Bombing.endBombing(builder);
|
||||
});
|
||||
|
||||
const shipProductionOffsets = (fixture.shipProductions ?? []).map((sp) => {
|
||||
const className = builder.createString(sp.class);
|
||||
ShipProduction.startShipProduction(builder);
|
||||
ShipProduction.addPlanet(builder, BigInt(sp.planet));
|
||||
ShipProduction.addClass(builder, className);
|
||||
ShipProduction.addCost(builder, sp.cost ?? 0);
|
||||
ShipProduction.addProdUsed(builder, sp.prodUsed ?? 0);
|
||||
ShipProduction.addPercent(builder, sp.percent ?? 0);
|
||||
ShipProduction.addFree(builder, sp.free ?? 0);
|
||||
return ShipProduction.endShipProduction(builder);
|
||||
});
|
||||
|
||||
const localVec =
|
||||
localOffsets.length === 0
|
||||
? null
|
||||
@@ -277,6 +381,36 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
routeOffsets.length === 0
|
||||
? null
|
||||
: Report.createRouteVector(builder, routeOffsets);
|
||||
const otherScienceVec =
|
||||
otherScienceOffsets.length === 0
|
||||
? null
|
||||
: Report.createOtherScienceVector(builder, otherScienceOffsets);
|
||||
const otherShipClassVec =
|
||||
otherShipClassOffsets.length === 0
|
||||
? null
|
||||
: Report.createOtherShipClassVector(builder, otherShipClassOffsets);
|
||||
const bombingVec =
|
||||
bombingOffsets.length === 0
|
||||
? null
|
||||
: Report.createBombingVector(builder, bombingOffsets);
|
||||
const shipProductionVec =
|
||||
shipProductionOffsets.length === 0
|
||||
? null
|
||||
: Report.createShipProductionVector(builder, shipProductionOffsets);
|
||||
// `battle` is a struct vector (16 bytes per UUID, alignment 8), so
|
||||
// it uses the start/inline-write/end pattern rather than a typical
|
||||
// offset-list helper. Iterating in reverse matches the FlatBuffers
|
||||
// convention that the vector is built end-to-start.
|
||||
const battleVec = (() => {
|
||||
const ids = fixture.battles ?? [];
|
||||
if (ids.length === 0) return null;
|
||||
Report.startBattleVector(builder, ids.length);
|
||||
for (let i = ids.length - 1; i >= 0; i--) {
|
||||
const [hi, lo] = uuidToHiLo(ids[i]!);
|
||||
UUID.createUUID(builder, hi, lo);
|
||||
}
|
||||
return builder.endVector();
|
||||
})();
|
||||
const raceOffset =
|
||||
fixture.race === undefined ? null : builder.createString(fixture.race);
|
||||
const voteForOffset =
|
||||
@@ -308,7 +442,25 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
if (localScienceVec !== null)
|
||||
Report.addLocalScience(builder, localScienceVec);
|
||||
if (routeVec !== null) Report.addRoute(builder, routeVec);
|
||||
if (otherScienceVec !== null)
|
||||
Report.addOtherScience(builder, otherScienceVec);
|
||||
if (otherShipClassVec !== null)
|
||||
Report.addOtherShipClass(builder, otherShipClassVec);
|
||||
if (battleVec !== null) Report.addBattle(builder, battleVec);
|
||||
if (bombingVec !== null) Report.addBombing(builder, bombingVec);
|
||||
if (shipProductionVec !== null)
|
||||
Report.addShipProduction(builder, shipProductionVec);
|
||||
const reportOff = Report.endReport(builder);
|
||||
builder.finish(reportOff);
|
||||
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(`buildReportPayload: invalid battle uuid ${value}`);
|
||||
}
|
||||
const hi = BigInt(`0x${hex.slice(0, 16)}`);
|
||||
const lo = BigInt(`0x${hex.slice(16, 32)}`);
|
||||
return [hi, lo];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
// 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 anchor 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. Snapshot save/restore on the active-view-host scroll
|
||||
// container survives a /map navigation round-trip.
|
||||
// 3. The "back to map" button navigates to the map URL.
|
||||
// 4. The mobile <select> fallback scrolls a section into view on
|
||||
// a narrow viewport.
|
||||
|
||||
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 { 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-<slug>`
|
||||
// and a `report-section-<slug>` testid.
|
||||
const SECTIONS: ReadonlyArray<{ slug: string; expectRow: string | null }> = [
|
||||
{ slug: "galaxy-summary", expectRow: "galaxy-summary-field-turn" },
|
||||
{ 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(
|
||||
"**/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: 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: [BATTLE_ID],
|
||||
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 },
|
||||
],
|
||||
});
|
||||
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(
|
||||
"**/galaxy.gateway.v1.EdgeGateway/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 TOC anchor 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(`/games/${GAME_ID}/report`);
|
||||
|
||||
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();
|
||||
|
||||
for (const entry of SECTIONS) {
|
||||
const anchor = page.getByTestId(`report-toc-${entry.slug}`);
|
||||
await anchor.click();
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("scroll position survives a /map round-trip via Snapshot", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"snapshot mechanism is the same on mobile; one project is enough",
|
||||
);
|
||||
|
||||
await mockGateway(page);
|
||||
await bootSession(page);
|
||||
await page.goto(`/games/${GAME_ID}/report`);
|
||||
|
||||
await expect(page.getByTestId("active-view-report")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("galaxy-summary-field-turn"),
|
||||
).toBeVisible();
|
||||
|
||||
// Scroll the window. The report's host expands to fit
|
||||
// content rather than constraining its own height, so the
|
||||
// document body is the real scroll container. SvelteKit's
|
||||
// default scroll-restoration tracks `window.scrollY` on
|
||||
// history navigation, which is what the acceptance criterion
|
||||
// — "scroll position resets when switching to another view
|
||||
// and is restored on return" — requires.
|
||||
const target = 600;
|
||||
await page.evaluate((value) => {
|
||||
window.scrollTo(0, value);
|
||||
}, target);
|
||||
const savedScrollY = await page.evaluate(() => window.scrollY);
|
||||
expect(savedScrollY).toBeGreaterThan(0);
|
||||
|
||||
// Programmatically click the back-to-map button. Driving the
|
||||
// click through `evaluate` rather than the Playwright locator
|
||||
// skips its built-in scrollIntoViewIfNeeded(), which would
|
||||
// otherwise scroll the sticky TOC button into view and reset
|
||||
// `window.scrollY` to 0 before SvelteKit's Snapshot capture
|
||||
// fires.
|
||||
await page.evaluate(() => {
|
||||
const button = document.querySelector(
|
||||
"[data-testid='report-back-to-map']",
|
||||
) as HTMLButtonElement | null;
|
||||
button?.click();
|
||||
});
|
||||
await page.waitForURL(`**/games/${GAME_ID}/map`);
|
||||
await page.goBack();
|
||||
await page.waitForURL(`**/games/${GAME_ID}/report`);
|
||||
await expect(page.getByTestId("active-view-report")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("galaxy-summary-field-turn"),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => window.scrollY), {
|
||||
timeout: 5_000,
|
||||
intervals: [100, 200, 400],
|
||||
})
|
||||
.toBeGreaterThan(savedScrollY / 2);
|
||||
});
|
||||
|
||||
test("back-to-map button navigates to the map URL", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"navigation is identical on mobile",
|
||||
);
|
||||
|
||||
await mockGateway(page);
|
||||
await bootSession(page);
|
||||
await page.goto(`/games/${GAME_ID}/report`);
|
||||
|
||||
await page.getByTestId("report-back-to-map").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/games/${GAME_ID}/map$`));
|
||||
await expect(page.getByTestId("active-view-map")).toBeVisible();
|
||||
});
|
||||
|
||||
test("mobile select scrolls to 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(`/games/${GAME_ID}/report`);
|
||||
|
||||
const mobileSelect = page.getByTestId("report-toc-mobile");
|
||||
await expect(mobileSelect).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("galaxy-summary-field-turn"),
|
||||
).toBeVisible();
|
||||
await mobileSelect.selectOption("bombings");
|
||||
await expect(
|
||||
page.getByTestId("report-section-bombings"),
|
||||
).toBeInViewport();
|
||||
});
|
||||
});
|
||||
@@ -56,12 +56,20 @@ describe("active-view stubs", () => {
|
||||
expect(node).toHaveTextContent("ship groups");
|
||||
});
|
||||
|
||||
test("report / mail stubs render their localised titles", () => {
|
||||
test("report view mounts with the TOC and the back-to-map link", () => {
|
||||
// Phase 23 replaces the Phase 10 stub with the full report
|
||||
// orchestrator. The orchestrator mounts the table of contents
|
||||
// regardless of report state; the inner sections render
|
||||
// loading copy until a `RenderedReportSource` lands via
|
||||
// context. This test only smokes the orchestrator scaffold —
|
||||
// per-section assertions live in `report-section-*.test.ts`.
|
||||
const r = render(ReportView);
|
||||
expect(r.getByTestId("active-view-report")).toHaveTextContent(
|
||||
"turn report",
|
||||
);
|
||||
expect(r.getByTestId("active-view-report")).toBeInTheDocument();
|
||||
expect(r.getByTestId("report-toc")).toBeInTheDocument();
|
||||
expect(r.getByTestId("report-back-to-map")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("mail stub renders its localised title", () => {
|
||||
const m = render(MailView);
|
||||
expect(m.getByTestId("active-view-mail")).toHaveTextContent(
|
||||
"diplomatic mail",
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
// EMPTY_SHIP_GROUPS supplies empty arrays / zero defaults for the
|
||||
// ancillary report fields added in Phase 19 (ship-groups + fleets),
|
||||
// Phase 21 (sciences), and Phase 22 (races / diplomacy / voting).
|
||||
// Phase 21 (sciences), Phase 22 (races / diplomacy / voting), and
|
||||
// Phase 23 (full player roster, foreign sciences, foreign ship
|
||||
// classes, battle ids, bombings, ships in production).
|
||||
// Test fixtures spread it into their report objects so the fixture
|
||||
// body still focuses on the fields under test, without forcing
|
||||
// every spec to enumerate the full GameReport surface.
|
||||
|
||||
import type {
|
||||
ReportBombing,
|
||||
ReportIncomingShipGroup,
|
||||
ReportLocalFleet,
|
||||
ReportLocalShipGroup,
|
||||
ReportOtherRace,
|
||||
ReportOtherScience,
|
||||
ReportOtherShipClass,
|
||||
ReportOtherShipGroup,
|
||||
ReportPlayer,
|
||||
ReportShipProduction,
|
||||
ReportUnidentifiedShipGroup,
|
||||
ScienceSummary,
|
||||
} from "../../src/api/game-state";
|
||||
@@ -26,6 +33,12 @@ export const EMPTY_SHIP_GROUPS: {
|
||||
races: ReportOtherRace[];
|
||||
myVotes: number;
|
||||
myVoteFor: string;
|
||||
players: ReportPlayer[];
|
||||
otherScience: ReportOtherScience[];
|
||||
otherShipClass: ReportOtherShipClass[];
|
||||
battleIds: string[];
|
||||
bombings: ReportBombing[];
|
||||
shipProductions: ReportShipProduction[];
|
||||
} = {
|
||||
localShipGroups: [],
|
||||
otherShipGroups: [],
|
||||
@@ -37,4 +50,10 @@ export const EMPTY_SHIP_GROUPS: {
|
||||
races: [],
|
||||
myVotes: 0,
|
||||
myVoteFor: "",
|
||||
players: [],
|
||||
otherScience: [],
|
||||
otherShipClass: [],
|
||||
battleIds: [],
|
||||
bombings: [],
|
||||
shipProductions: [],
|
||||
};
|
||||
|
||||
@@ -72,6 +72,12 @@ function makeReport(
|
||||
races: [],
|
||||
myVotes: 0,
|
||||
myVoteFor: "",
|
||||
players: [],
|
||||
otherScience: [],
|
||||
otherShipClass: [],
|
||||
battleIds: [],
|
||||
bombings: [],
|
||||
shipProductions: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Vitest coverage for the Phase 23 Report View's bombings section.
|
||||
// Representative for grid-shape sections (foreign/uninhabited
|
||||
// planets, fleets, ship-groups, ships-in-production). Three
|
||||
// scenarios — empty list, populated row, wiped row with badge —
|
||||
// cover the empty-state copy and the conditional row state.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import type {
|
||||
GameReport,
|
||||
ReportBombing,
|
||||
} from "../src/api/game-state";
|
||||
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
|
||||
import SectionBombings from "../src/lib/active-view/report/section-bombings.svelte";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
function bombing(
|
||||
overrides: Partial<ReportBombing> &
|
||||
Pick<ReportBombing, "planetNumber" | "planet" | "attacker">,
|
||||
): ReportBombing {
|
||||
return {
|
||||
owner: "Owner",
|
||||
production: "Capital",
|
||||
industry: 0,
|
||||
population: 0,
|
||||
colonists: 0,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
attackPower: 0,
|
||||
wiped: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeReport(rows: ReportBombing[]): GameReport {
|
||||
return {
|
||||
turn: 1,
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "Self",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
...EMPTY_SHIP_GROUPS,
|
||||
bombings: rows,
|
||||
};
|
||||
}
|
||||
|
||||
function mountSection(report: GameReport | null) {
|
||||
const context = new Map<unknown, unknown>([
|
||||
[RENDERED_REPORT_CONTEXT_KEY, { get report() {
|
||||
return report;
|
||||
} }],
|
||||
]);
|
||||
return render(SectionBombings, { context });
|
||||
}
|
||||
|
||||
describe("report bombings section", () => {
|
||||
test("renders the loading placeholder before the report lands", () => {
|
||||
const ui = mountSection(null);
|
||||
expect(ui.getByTestId("report-section-bombings")).toHaveTextContent(
|
||||
"loading report",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders the empty-state copy when there are no bombings", () => {
|
||||
const ui = mountSection(makeReport([]));
|
||||
expect(ui.getByTestId("bombings-empty")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders a non-wiped row without the wiped badge", () => {
|
||||
const ui = mountSection(
|
||||
makeReport([
|
||||
bombing({
|
||||
planetNumber: 17,
|
||||
planet: "Castle",
|
||||
attacker: "Ricksha",
|
||||
owner: "Earthlings",
|
||||
production: "Capital",
|
||||
industry: 500.25,
|
||||
population: 200,
|
||||
colonists: 12,
|
||||
industryStockpile: 30,
|
||||
materialsStockpile: 5,
|
||||
attackPower: 250,
|
||||
wiped: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const rows = ui.getAllByTestId("report-bombing-row");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toHaveAttribute("data-planet", "17");
|
||||
expect(rows[0]).toHaveAttribute("data-wiped", "false");
|
||||
expect(rows[0]).not.toHaveClass("wiped");
|
||||
expect(rows[0]).toHaveTextContent("#17 (Castle)");
|
||||
expect(rows[0]).toHaveTextContent("Ricksha");
|
||||
expect(ui.queryByTestId("report-bombing-wiped-badge")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders a wiped row with the wiped badge and the row state", () => {
|
||||
const ui = mountSection(
|
||||
makeReport([
|
||||
bombing({
|
||||
planetNumber: 20,
|
||||
planet: "DW-1207",
|
||||
attacker: "Ricksha",
|
||||
owner: "KnightErrants",
|
||||
production: "Dron",
|
||||
industry: 1.5,
|
||||
attackPower: 7.62,
|
||||
wiped: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const row = ui.getByTestId("report-bombing-row");
|
||||
expect(row).toHaveAttribute("data-wiped", "true");
|
||||
expect(row).toHaveClass("wiped");
|
||||
expect(ui.getByTestId("report-bombing-wiped-badge")).toHaveTextContent(
|
||||
"wiped",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// Vitest coverage for the Phase 23 Report View's foreign sciences
|
||||
// section. Representative for the per-race sub-table shape used by
|
||||
// `section-foreign-ship-classes.svelte` too. Three scenarios — empty
|
||||
// list, single-race table, multi-race grouping — exercise the
|
||||
// decoder's `(race, name)` order and the per-race sub-header.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import type {
|
||||
GameReport,
|
||||
ReportOtherScience,
|
||||
} from "../src/api/game-state";
|
||||
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
|
||||
import SectionForeignSciences from "../src/lib/active-view/report/section-foreign-sciences.svelte";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
function science(
|
||||
overrides: Partial<ReportOtherScience> &
|
||||
Pick<ReportOtherScience, "race" | "name">,
|
||||
): ReportOtherScience {
|
||||
return {
|
||||
drive: 0,
|
||||
weapons: 0,
|
||||
shields: 0,
|
||||
cargo: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeReport(rows: ReportOtherScience[]): GameReport {
|
||||
return {
|
||||
turn: 1,
|
||||
mapWidth: 1000,
|
||||
mapHeight: 1000,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "Self",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
...EMPTY_SHIP_GROUPS,
|
||||
otherScience: rows,
|
||||
};
|
||||
}
|
||||
|
||||
function mountSection(report: GameReport | null) {
|
||||
const context = new Map<unknown, unknown>([
|
||||
[RENDERED_REPORT_CONTEXT_KEY, { get report() {
|
||||
return report;
|
||||
} }],
|
||||
]);
|
||||
return render(SectionForeignSciences, { context });
|
||||
}
|
||||
|
||||
describe("report foreign sciences section", () => {
|
||||
test("renders the empty-state copy when no foreign sciences are observed", () => {
|
||||
const ui = mountSection(makeReport([]));
|
||||
expect(ui.getByTestId("foreign-sciences-empty")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders one sub-table per race with rows sorted by name", () => {
|
||||
const ui = mountSection(
|
||||
makeReport([
|
||||
science({ race: "Andori", name: "AnDrive", drive: 1 }),
|
||||
science({ race: "Andori", name: "AnCargo", cargo: 1 }),
|
||||
science({ race: "Bajori", name: "BjMix", drive: 0.5, cargo: 0.5 }),
|
||||
]),
|
||||
);
|
||||
const headers = ui.getAllByTestId("report-other-science-race");
|
||||
expect(headers).toHaveLength(2);
|
||||
expect(headers[0]).toHaveAttribute("data-race", "Andori");
|
||||
expect(headers[1]).toHaveAttribute("data-race", "Bajori");
|
||||
|
||||
const rows = ui.getAllByTestId("foreign-sciences-row");
|
||||
expect(rows).toHaveLength(3);
|
||||
// Andori sub-table comes first; its rows precede Bajori.
|
||||
expect(rows[0]).toHaveAttribute("data-race", "Andori");
|
||||
expect(rows[0]).toHaveAttribute("data-name", "AnDrive");
|
||||
expect(rows[1]).toHaveAttribute("data-race", "Andori");
|
||||
expect(rows[1]).toHaveAttribute("data-name", "AnCargo");
|
||||
expect(rows[2]).toHaveAttribute("data-race", "Bajori");
|
||||
expect(rows[2]).toHaveAttribute("data-name", "BjMix");
|
||||
});
|
||||
|
||||
test("renders a single race block when only one foreign science is present", () => {
|
||||
const ui = mountSection(
|
||||
makeReport([science({ race: "Solo", name: "Singularity", drive: 1 })]),
|
||||
);
|
||||
const headers = ui.getAllByTestId("report-other-science-race");
|
||||
expect(headers).toHaveLength(1);
|
||||
expect(headers[0]).toHaveTextContent("Solo sciences");
|
||||
const rows = ui.getAllByTestId("foreign-sciences-row");
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// Vitest coverage for the Phase 23 Report View's galaxy summary
|
||||
// section. Representative for kv-list-shape sections (votes,
|
||||
// player-status row markers). Mounts the component against a
|
||||
// synthetic `RenderedReportSource` so the test focuses on shape,
|
||||
// not on the live store wiring.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import type { GameReport } from "../src/api/game-state";
|
||||
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
|
||||
import SectionGalaxySummary from "../src/lib/active-view/report/section-galaxy-summary.svelte";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
});
|
||||
|
||||
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
||||
return {
|
||||
turn: 0,
|
||||
mapWidth: 0,
|
||||
mapHeight: 0,
|
||||
planetCount: 0,
|
||||
planets: [],
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
...EMPTY_SHIP_GROUPS,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mountSection(report: GameReport | null) {
|
||||
const context = new Map<unknown, unknown>([
|
||||
[RENDERED_REPORT_CONTEXT_KEY, { get report() {
|
||||
return report;
|
||||
} }],
|
||||
]);
|
||||
return render(SectionGalaxySummary, { context });
|
||||
}
|
||||
|
||||
describe("report galaxy summary section", () => {
|
||||
test("renders the loading placeholder before the report lands", () => {
|
||||
const ui = mountSection(null);
|
||||
expect(
|
||||
ui.getByTestId("report-section-galaxy-summary"),
|
||||
).toHaveTextContent("loading report");
|
||||
});
|
||||
|
||||
test("renders every kv pair for a populated report", () => {
|
||||
const ui = mountSection(
|
||||
makeReport({
|
||||
turn: 42,
|
||||
mapWidth: 1234,
|
||||
mapHeight: 4321,
|
||||
planetCount: 700,
|
||||
race: "KnightErrants",
|
||||
}),
|
||||
);
|
||||
expect(ui.getByTestId("galaxy-summary-field-turn")).toHaveTextContent("42");
|
||||
expect(ui.getByTestId("galaxy-summary-field-size")).toHaveTextContent(
|
||||
"1234 × 4321",
|
||||
);
|
||||
expect(ui.getByTestId("galaxy-summary-field-planets")).toHaveTextContent(
|
||||
"700",
|
||||
);
|
||||
expect(ui.getByTestId("galaxy-summary-field-race")).toHaveTextContent(
|
||||
"KnightErrants",
|
||||
);
|
||||
});
|
||||
|
||||
test("zero-value boot state still mounts every field", () => {
|
||||
const ui = mountSection(makeReport());
|
||||
// Loading state must be gone — the kv-list takes over.
|
||||
const section = ui.getByTestId("report-section-galaxy-summary");
|
||||
expect(section).not.toHaveTextContent("loading report");
|
||||
expect(ui.getByTestId("galaxy-summary-field-turn")).toHaveTextContent("0");
|
||||
expect(ui.getByTestId("galaxy-summary-field-race")).toHaveTextContent("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
// Vitest coverage for the Phase 23 Report View's table of contents.
|
||||
// Smokes the anchor list, the active-link state, the back-to-map
|
||||
// navigation, and the mobile <select> fallback. The
|
||||
// IntersectionObserver-driven active-section computation lives in
|
||||
// the orchestrator (`report.svelte`); this test only checks the
|
||||
// presentational pieces of the TOC.
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render } from "@testing-library/svelte";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||
import type { TranslationKey } from "../src/lib/i18n/index.svelte";
|
||||
|
||||
const gotoMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("$app/navigation", () => ({
|
||||
goto: gotoMock,
|
||||
}));
|
||||
|
||||
import ReportToc, {
|
||||
type TocEntry,
|
||||
} from "../src/lib/active-view/report/report-toc.svelte";
|
||||
|
||||
const ENTRIES: readonly TocEntry[] = [
|
||||
{ slug: "galaxy-summary", titleKey: "game.report.section.galaxy_summary.title" },
|
||||
{ slug: "votes", titleKey: "game.report.section.votes.title" },
|
||||
{ slug: "bombings", titleKey: "game.report.section.bombings.title" },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.resetForTests("en");
|
||||
gotoMock.mockClear();
|
||||
});
|
||||
|
||||
describe("report TOC", () => {
|
||||
test("renders one anchor per entry and one option in the mobile select", () => {
|
||||
const ui = render(ReportToc, {
|
||||
props: { entries: ENTRIES, activeSlug: "galaxy-summary", gameId: "g-1" },
|
||||
});
|
||||
for (const e of ENTRIES) {
|
||||
expect(ui.getByTestId(`report-toc-${e.slug}`)).toBeInTheDocument();
|
||||
}
|
||||
const mobile = ui.getByTestId("report-toc-mobile") as HTMLSelectElement;
|
||||
expect(mobile.options).toHaveLength(ENTRIES.length);
|
||||
expect(mobile.value).toBe("galaxy-summary");
|
||||
});
|
||||
|
||||
test("marks the active anchor with aria-current=location and a class", () => {
|
||||
const ui = render(ReportToc, {
|
||||
props: { entries: ENTRIES, activeSlug: "bombings", gameId: "g-1" },
|
||||
});
|
||||
const active = ui.getByTestId("report-toc-bombings");
|
||||
expect(active).toHaveAttribute("aria-current", "location");
|
||||
expect(active).toHaveClass("active");
|
||||
|
||||
const inactive = ui.getByTestId("report-toc-votes");
|
||||
expect(inactive).not.toHaveAttribute("aria-current");
|
||||
expect(inactive).not.toHaveClass("active");
|
||||
});
|
||||
|
||||
test("back-to-map button calls goto with the active game's map URL", async () => {
|
||||
const ui = render(ReportToc, {
|
||||
props: {
|
||||
entries: ENTRIES,
|
||||
activeSlug: "galaxy-summary",
|
||||
gameId: "abc",
|
||||
},
|
||||
});
|
||||
const button = ui.getByTestId("report-back-to-map");
|
||||
await fireEvent.click(button);
|
||||
expect(gotoMock).toHaveBeenCalledWith("/games/abc/map");
|
||||
});
|
||||
|
||||
test("anchor click cancels the default jump and calls scrollIntoView on the target", async () => {
|
||||
// Stub `scrollIntoView` on the target — jsdom does not
|
||||
// implement it. The TOC also reads
|
||||
// `prefers-reduced-motion`; the matchMedia stub forces a
|
||||
// stable `behavior: "auto"` so the assertion is reproducible.
|
||||
const scrollSpy = vi.fn();
|
||||
const target = document.createElement("section");
|
||||
target.id = "report-bombings";
|
||||
target.scrollIntoView = scrollSpy;
|
||||
document.body.appendChild(target);
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: query.includes("reduce"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
|
||||
const ui = render(ReportToc, {
|
||||
props: { entries: ENTRIES, activeSlug: "galaxy-summary", gameId: "g" },
|
||||
});
|
||||
await fireEvent.click(ui.getByTestId("report-toc-bombings"));
|
||||
expect(scrollSpy).toHaveBeenCalledWith({
|
||||
behavior: "auto",
|
||||
block: "start",
|
||||
});
|
||||
target.remove();
|
||||
});
|
||||
|
||||
test("mobile select scrolls to the chosen section without navigating", async () => {
|
||||
const scrollSpy = vi.fn();
|
||||
const target = document.createElement("section");
|
||||
target.id = "report-votes";
|
||||
target.scrollIntoView = scrollSpy;
|
||||
document.body.appendChild(target);
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: () => ({
|
||||
matches: false,
|
||||
media: "(prefers-reduced-motion: no-preference)",
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
|
||||
const ui = render(ReportToc, {
|
||||
props: {
|
||||
entries: ENTRIES,
|
||||
activeSlug: "galaxy-summary",
|
||||
gameId: "g",
|
||||
},
|
||||
});
|
||||
const select = ui.getByTestId("report-toc-mobile") as HTMLSelectElement;
|
||||
await fireEvent.change(select, { target: { value: "votes" } });
|
||||
expect(scrollSpy).toHaveBeenCalled();
|
||||
expect(gotoMock).not.toHaveBeenCalled();
|
||||
target.remove();
|
||||
});
|
||||
|
||||
// Tests intentionally validate the *type* of the entries prop is
|
||||
// exposed correctly so future widening of the list does not
|
||||
// silently drop entries. TypeScript already enforces this through
|
||||
// `TocEntry`; the assertion below is a soft check so a stray
|
||||
// `as unknown as ...` cast surfaces fast.
|
||||
test("TocEntry exposes a slug and a TranslationKey", () => {
|
||||
const slug: string = ENTRIES[0]!.slug;
|
||||
const key: TranslationKey = ENTRIES[0]!.titleKey;
|
||||
expect(typeof slug).toBe("string");
|
||||
expect(typeof key).toBe("string");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user