From 80ed11e3b6a992d6cecac434675dd65cbfadc766 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 27 May 2026 20:35:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20F8-10=20=E2=80=94=20tables=20planet?= =?UTF-8?q?s=20/=20ship-groups=20/=20fleets,=20ship-classes=20delete=20gua?= =?UTF-8?q?rd=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lights up three previously-stubbed table active views and tightens the existing one: - table-planets: 4 kind checkboxes (own / foreign / uninhabited / unknown) + race dropdown that filters the foreign slice; row click selects + centres the planet on the map. - table-ship-groups: local + foreign groups in one grid, owner checkboxes, planet dropdown (destination OR origin), class dropdown; on-planet click focuses the destination planet, in-space click focuses the ship group itself (camera follows interpolated position). - table-fleets: own fleets only with the shared planet dropdown; on-planet click focuses the planet, in-space click centres the camera on the interpolated fleet position without altering the selection (no fleet variant in Selected). - table-ship-classes: per-row Delete is disabled with a count tooltip while at least one local ship group references the class. The engine refuses the removal anyway; the UI pre-empts the surface. Wires the click → map flow through a transient `SelectionStore.focus` / `focusPoint` channel that `map.svelte` consumes once on mount — in-memory only, so an F5 does not re-centre. Co-Authored-By: Claude Opus 4.7 --- ui/frontend/src/lib/active-view/map.svelte | 63 ++- .../src/lib/active-view/table-fleets.svelte | 358 +++++++++++++ .../src/lib/active-view/table-planets.svelte | 403 +++++++++++++++ .../lib/active-view/table-ship-classes.svelte | 26 + .../lib/active-view/table-ship-groups.svelte | 485 ++++++++++++++++++ ui/frontend/src/lib/active-view/table.svelte | 19 +- ui/frontend/src/lib/i18n/locales/en.ts | 43 ++ ui/frontend/src/lib/i18n/locales/ru.ts | 43 ++ ui/frontend/src/lib/selection.svelte.ts | 65 ++- ui/frontend/src/map/ship-groups.ts | 6 +- ui/frontend/tests/e2e/tables.spec.ts | 143 ++++++ ui/frontend/tests/game-shell-stubs.test.ts | 17 +- ui/frontend/tests/selection-store.test.ts | 85 +++ ui/frontend/tests/table-fleets.test.ts | 219 ++++++++ ui/frontend/tests/table-planets.test.ts | 210 ++++++++ ui/frontend/tests/table-ship-classes.test.ts | 62 ++- ui/frontend/tests/table-ship-groups.test.ts | 312 +++++++++++ 17 files changed, 2537 insertions(+), 22 deletions(-) create mode 100644 ui/frontend/src/lib/active-view/table-fleets.svelte create mode 100644 ui/frontend/src/lib/active-view/table-planets.svelte create mode 100644 ui/frontend/src/lib/active-view/table-ship-groups.svelte create mode 100644 ui/frontend/tests/e2e/tables.spec.ts create mode 100644 ui/frontend/tests/table-fleets.test.ts create mode 100644 ui/frontend/tests/table-planets.test.ts create mode 100644 ui/frontend/tests/table-ship-groups.test.ts diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index 1cd1524..431c007 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -63,8 +63,10 @@ preference the store already manages. } from "$lib/game-state.svelte"; import { SELECTION_CONTEXT_KEY, + type Selected, type SelectionStore, } from "$lib/selection.svelte"; + import { computeInSpacePosition } from "../../map/ship-groups"; import { RENDERED_REPORT_CONTEXT_KEY, type RenderedReportSource, @@ -515,7 +517,27 @@ preference the store already manages. }, world, ); - if (previousCamera !== null) { + // Consume an F8-10 table click that asked the next map mount + // to centre on a particular target. The store self-clears on + // read, so any later remount inside the same session sees + // null and falls through to the default centring path. The + // coord-only `pendingCenter` is the fleet-row fallback: a + // fleet has no `Selected` variant, but its xy still feeds + // the camera. `pendingFocus` wins when both are queued. + const focusTarget = selection?.consumePendingFocus() ?? null; + const focusPoint = + resolveFocusPoint(focusTarget, report, world.width, world.height) + ?? selection?.consumePendingCenter() + ?? null; + if (focusPoint !== null) { + handle.viewport.moveCenter(focusPoint.x, focusPoint.y); + handle.viewport.setZoom( + previousCamera === null + ? minScale * 1.05 + : Math.max(previousCamera.scale, minScale), + true, + ); + } else if (previousCamera !== null) { // Same-game remount — preserve pan/zoom. Clamp zoom // to `minScale` so a remount that re-derives the // minimum (e.g. a viewport resize between renderers) @@ -642,6 +664,45 @@ preference the store already manages. } } + // resolveFocusPoint maps an F8-10 table click target to world (x, y) + // for camera centring. Planets resolve via the report; in-space + // ship groups via the shared interpolation helper; on-planet ship + // groups fall back to the destination planet's xy (so a click on a + // group stationed at #5 centres on #5). Returns null when the + // target cannot be resolved — a stale ref after a fresh report, + // or a planet that is no longer in the visible set; the caller + // then falls through to the default centring path. + function resolveFocusPoint( + target: Selected | null, + report: NonNullable, + worldWidth: number, + worldHeight: number, + ): { x: number; y: number } | null { + if (target === null) return null; + if (target.kind === "planet") { + const planet = report.planets.find((p) => p.number === target.id); + return planet === undefined ? null : { x: planet.x, y: planet.y }; + } + const ref = target.ref; + const group = + ref.variant === "local" + ? report.localShipGroups.find((g) => g.id === ref.id) + : ref.variant === "other" + ? report.otherShipGroups[ref.index] + : undefined; + if (group === undefined) return null; + const planetIndex = new Map(report.planets.map((p) => [p.number, p])); + const inSpace = computeInSpacePosition( + group, + planetIndex, + worldWidth, + worldHeight, + ); + if (inSpace !== null) return inSpace; + const dest = planetIndex.get(group.destination); + return dest === undefined ? null : { x: dest.x, y: dest.y }; + } + // handleMapClick translates a renderer click into a selection // update. A click that misses every primitive (empty space) is a // deliberate no-op: the selection rule from Phase 13 is that only diff --git a/ui/frontend/src/lib/active-view/table-fleets.svelte b/ui/frontend/src/lib/active-view/table-fleets.svelte new file mode 100644 index 0000000..9c2ec1d --- /dev/null +++ b/ui/frontend/src/lib/active-view/table-fleets.svelte @@ -0,0 +1,358 @@ + + + +
+
+

{i18n.t("game.table.fleets.title")}

+
+ {#if planets.length > 0} + + {/if} +
+
+ + {#if !reportLoaded} + + {:else if fleets.length === 0} + + {:else} + + + + {#each COLUMNS as column (column)} + + {/each} + + + + {#each sorted as f (f.name)} + openOnMap(f)} + > + + + + + + + {/each} + +
+ +
{f.name || "—"} + {formatInt(f.groupCount)} + {f.state || "—"}{locationLabel(f)} + {formatFloat(f.speed)} +
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/table-planets.svelte b/ui/frontend/src/lib/active-view/table-planets.svelte new file mode 100644 index 0000000..a5924df --- /dev/null +++ b/ui/frontend/src/lib/active-view/table-planets.svelte @@ -0,0 +1,403 @@ + + + +
+
+

{i18n.t("game.table.planets.title")}

+
+ + + + + {#if owners.length > 0} + + {/if} +
+
+ + {#if !reportLoaded} + + {:else if planets.length === 0} + + {:else} + + + + {#each COLUMNS as column (column)} + + {/each} + + + + + {#each sorted as p (p.number)} + openOnMap(p)} + > + + + + + + + + + {/each} + +
+ + + {i18n.t("game.table.planets.column.coordinates")} +
+ {p.number} + {p.name || "—"} + {i18n.t(KIND_LABELS[p.kind])} + {ownerDisplay(p)} + {p.size === null ? "—" : formatFloat(p.size)} + + {p.resources === null ? "—" : formatFloat(p.resources)} + + {formatInt(p.x)},{formatInt(p.y)} +
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/table-ship-classes.svelte b/ui/frontend/src/lib/active-view/table-ship-classes.svelte index f2295db..c40dc23 100644 --- a/ui/frontend/src/lib/active-view/table-ship-classes.svelte +++ b/ui/frontend/src/lib/active-view/table-ship-classes.svelte @@ -73,6 +73,21 @@ data fetching is performed here — the layout is responsible. ); const reportLoaded = $derived(rendered?.report !== null && rendered?.report !== undefined); + // inUseCounts is a derived index from class name → number of player + // ship groups currently referencing it. The engine refuses + // `removeShipClass` while any such group exists, so the table + // pre-emptively disables the per-row Delete affordance instead of + // surfacing a server-side rejection. The map is rebuilt whenever + // the rendered report changes; an empty report yields an empty map + // and every Delete stays enabled. + const inUseCounts = $derived.by(() => { + const map = new Map(); + for (const group of rendered?.report?.localShipGroups ?? []) { + map.set(group.class, (map.get(group.class) ?? 0) + 1); + } + return map; + }); + const filtered = $derived.by(() => { const needle = filter.trim().toLowerCase(); if (needle === "") return localShipClass; @@ -123,6 +138,13 @@ data fetching is performed here — the layout is responsible. name, }); } + + function deleteTooltip(count: number): string { + if (count === 0) return ""; + return i18n.t("game.table.ship_classes.action.delete.in_use", { + count: String(count), + }); + }
{#each sorted as cls (cls.name)} + {@const inUse = inUseCounts.get(cls.name) ?? 0} 0 || draft === undefined} + title={deleteTooltip(inUse)} onclick={() => void deleteShipClass(cls.name)} > {i18n.t("game.table.ship_classes.action.delete")} diff --git a/ui/frontend/src/lib/active-view/table-ship-groups.svelte b/ui/frontend/src/lib/active-view/table-ship-groups.svelte new file mode 100644 index 0000000..4e40d2d --- /dev/null +++ b/ui/frontend/src/lib/active-view/table-ship-groups.svelte @@ -0,0 +1,485 @@ + + + +
+
+

{i18n.t("game.table.ship_groups.title")}

+
+ + + {#if planets.length > 0} + + {/if} + {#if classes.length > 0} + + {/if} +
+
+ + {#if !reportLoaded} + + {:else if rows.length === 0} + + {:else} + + + + {#each COLUMNS as column (column)} + + {/each} + + + + {#each sorted as row (row.key)} + openOnMap(row)} + > + + + + + + + + + {/each} + +
+ +
{ownerLabel(row)}{row.class || "—"} + {formatInt(row.count)} + {row.race || "—"} + {locationLabel(row)} + + {formatFloat(row.mass)} + + {formatFloat(row.speed)} +
+ {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/table.svelte b/ui/frontend/src/lib/active-view/table.svelte index 3cc9975..fe8a1ba 100644 --- a/ui/frontend/src/lib/active-view/table.svelte +++ b/ui/frontend/src/lib/active-view/table.svelte @@ -1,16 +1,17 @@ -{#if entity === "ship-classes"} +{#if entity === "planets"} + +{:else if entity === "ship-classes"} +{:else if entity === "ship-groups"} + +{:else if entity === "fleets"} + {:else if entity === "sciences"} {:else if entity === "races"} diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index 233dbd9..8e406b2 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -335,6 +335,48 @@ const en = { "game.sidebar.order.label.ship_group_join_fleet": "assign group {group} → fleet {fleet}", "game.sidebar.order.label.race_relation": "declare {relation} on {acceptor}", "game.sidebar.order.label.race_vote": "give my votes to {acceptor}", + "game.table.planets.title": "planets", + "game.table.planets.loading": "loading planets…", + "game.table.planets.empty": "no planets in the report", + "game.table.planets.column.number": "#", + "game.table.planets.column.name": "name", + "game.table.planets.column.kind": "kind", + "game.table.planets.column.owner": "owner", + "game.table.planets.column.size": "size", + "game.table.planets.column.resources": "resources", + "game.table.planets.column.coordinates": "x,y", + "game.table.planets.kind.own": "own", + "game.table.planets.kind.foreign": "foreign", + "game.table.planets.kind.uninhabited": "uninhabited", + "game.table.planets.kind.unknown": "unknown", + "game.table.planets.filter.owner": "owner:", + "game.table.planets.filter.owner.all": "all races", + "game.table.ship_groups.title": "ship groups", + "game.table.ship_groups.loading": "loading ship groups…", + "game.table.ship_groups.empty": "no ship groups in the report", + "game.table.ship_groups.column.owner": "owner", + "game.table.ship_groups.column.class": "class", + "game.table.ship_groups.column.count": "count", + "game.table.ship_groups.column.race": "race", + "game.table.ship_groups.column.location": "location", + "game.table.ship_groups.column.mass": "mass", + "game.table.ship_groups.column.speed": "speed", + "game.table.ship_groups.owner.own": "own", + "game.table.ship_groups.owner.foreign": "foreign", + "game.table.ship_groups.filter.planet": "planet:", + "game.table.ship_groups.filter.planet.all": "all planets", + "game.table.ship_groups.filter.class": "class:", + "game.table.ship_groups.filter.class.all": "all classes", + "game.table.fleets.title": "fleets", + "game.table.fleets.loading": "loading fleets…", + "game.table.fleets.empty": "no fleets in the report", + "game.table.fleets.column.name": "name", + "game.table.fleets.column.groups": "groups", + "game.table.fleets.column.state": "state", + "game.table.fleets.column.location": "location", + "game.table.fleets.column.speed": "speed", + "game.table.fleets.filter.planet": "planet:", + "game.table.fleets.filter.planet.all": "all planets", "game.table.ship_classes.title": "ship classes", "game.table.ship_classes.column.name": "name", "game.table.ship_classes.column.drive": "drive", @@ -347,6 +389,7 @@ const en = { "game.table.ship_classes.filter.placeholder": "filter by name", "game.table.ship_classes.action.new": "+ new ship class", "game.table.ship_classes.action.delete": "delete", + "game.table.ship_classes.action.delete.in_use": "in use by {count} ship group(s)", "game.table.ship_classes.loading": "loading ship classes…", "game.designer.ship_class.title.new": "design new ship class", "game.designer.ship_class.title.view": "ship class {name}", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index 729a534..23110cf 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -336,6 +336,48 @@ const ru: Record = { "game.sidebar.order.label.ship_group_join_fleet": "включить группу {group} → флот {fleet}", "game.sidebar.order.label.race_relation": "объявить {relation} расе {acceptor}", "game.sidebar.order.label.race_vote": "отдать голоса расе {acceptor}", + "game.table.planets.title": "планеты", + "game.table.planets.loading": "загрузка планет…", + "game.table.planets.empty": "в отчёте нет планет", + "game.table.planets.column.number": "#", + "game.table.planets.column.name": "название", + "game.table.planets.column.kind": "тип", + "game.table.planets.column.owner": "владелец", + "game.table.planets.column.size": "размер", + "game.table.planets.column.resources": "ресурсы", + "game.table.planets.column.coordinates": "x,y", + "game.table.planets.kind.own": "свои", + "game.table.planets.kind.foreign": "чужие", + "game.table.planets.kind.uninhabited": "ничейные", + "game.table.planets.kind.unknown": "неизвестные", + "game.table.planets.filter.owner": "владелец:", + "game.table.planets.filter.owner.all": "все расы", + "game.table.ship_groups.title": "группы кораблей", + "game.table.ship_groups.loading": "загрузка групп кораблей…", + "game.table.ship_groups.empty": "в отчёте нет групп кораблей", + "game.table.ship_groups.column.owner": "владелец", + "game.table.ship_groups.column.class": "класс", + "game.table.ship_groups.column.count": "количество", + "game.table.ship_groups.column.race": "раса", + "game.table.ship_groups.column.location": "положение", + "game.table.ship_groups.column.mass": "масса", + "game.table.ship_groups.column.speed": "скорость", + "game.table.ship_groups.owner.own": "свои", + "game.table.ship_groups.owner.foreign": "чужие", + "game.table.ship_groups.filter.planet": "планета:", + "game.table.ship_groups.filter.planet.all": "все планеты", + "game.table.ship_groups.filter.class": "класс:", + "game.table.ship_groups.filter.class.all": "все классы", + "game.table.fleets.title": "флоты", + "game.table.fleets.loading": "загрузка флотов…", + "game.table.fleets.empty": "в отчёте нет флотов", + "game.table.fleets.column.name": "название", + "game.table.fleets.column.groups": "групп", + "game.table.fleets.column.state": "состояние", + "game.table.fleets.column.location": "положение", + "game.table.fleets.column.speed": "скорость", + "game.table.fleets.filter.planet": "планета:", + "game.table.fleets.filter.planet.all": "все планеты", "game.table.ship_classes.title": "классы кораблей", "game.table.ship_classes.column.name": "название", "game.table.ship_classes.column.drive": "двигатель", @@ -348,6 +390,7 @@ const ru: Record = { "game.table.ship_classes.filter.placeholder": "фильтр по названию", "game.table.ship_classes.action.new": "+ новый класс корабля", "game.table.ship_classes.action.delete": "удалить", + "game.table.ship_classes.action.delete.in_use": "используется в {count} группе(-ах) кораблей", "game.table.ship_classes.loading": "загрузка классов кораблей…", "game.designer.ship_class.title.new": "конструктор нового класса корабля", "game.designer.ship_class.title.view": "класс корабля {name}", diff --git a/ui/frontend/src/lib/selection.svelte.ts b/ui/frontend/src/lib/selection.svelte.ts index 9aa0e85..7776f9a 100644 --- a/ui/frontend/src/lib/selection.svelte.ts +++ b/ui/frontend/src/lib/selection.svelte.ts @@ -56,6 +56,20 @@ export const SELECTION_CONTEXT_KEY = Symbol("selection"); export class SelectionStore { selected: Selected | null = $state(null); + // pendingFocus is a transient one-shot request to centre the map + // camera on a selection. F8-10 tables raise it together with the + // view switch to map; map.svelte consumes it once on mount (see + // `consumePendingFocus`) and clears it. Held in memory only — not + // persisted, so an F5 after a click-through does not re-centre. + #pendingFocus: Selected | null = $state(null); + + // pendingCenter is the coord-only sibling of pendingFocus: it + // asks map.svelte to centre on a world point without touching + // the selection. F8-10 uses it for in-space fleet rows (the + // `Selected` union has no "fleet" variant, but the user still + // expects the camera to find them). Also one-shot and transient. + #pendingCenter: { x: number; y: number } | null = $state(null); + private destroyed = false; /** @@ -77,10 +91,57 @@ export class SelectionStore { this.selected = { kind: "shipGroup", ref }; } + /** + * focus sets the active selection to `target` and queues a one-shot + * camera-centre request for whichever next mount of the map view + * picks it up via `consumePendingFocus`. Used by the F8-10 tables to + * navigate from a row click to the map. A no-op once the store has + * been disposed. + */ + focus(target: Selected): void { + if (this.destroyed) return; + this.selected = target; + this.#pendingFocus = target; + } + + /** + * consumePendingFocus returns the queued focus target (if any) and + * clears it in the same call. Designed for `map.svelte` to invoke + * once after the renderer mounts. + */ + consumePendingFocus(): Selected | null { + const target = this.#pendingFocus; + this.#pendingFocus = null; + return target; + } + + /** + * focusPoint queues a one-shot camera-centre on a free-form world + * coordinate, without altering selection. Used by the fleets table + * when the user clicks an in-space fleet (there is no `"fleet"` + * variant in `Selected`, but the camera should still find it). + * A no-op once the store has been disposed. + */ + focusPoint(x: number, y: number): void { + if (this.destroyed) return; + this.#pendingCenter = { x, y }; + } + + /** + * consumePendingCenter returns the queued centre point (if any) + * and clears it. + */ + consumePendingCenter(): { x: number; y: number } | null { + const point = this.#pendingCenter; + this.#pendingCenter = null; + return point; + } + /** * clear drops the current selection. The mobile sheet's close * button calls this; otherwise selection persists across active- - * view switches. + * view switches. Does not affect any queued pending focus — that + * is a transient delivery channel, not selection state. */ clear(): void { if (this.destroyed) return; @@ -90,5 +151,7 @@ export class SelectionStore { dispose(): void { this.destroyed = true; this.selected = null; + this.#pendingFocus = null; + this.#pendingCenter = null; } } diff --git a/ui/frontend/src/map/ship-groups.ts b/ui/frontend/src/map/ship-groups.ts index 3692a4d..5979ab8 100644 --- a/ui/frontend/src/map/ship-groups.ts +++ b/ui/frontend/src/map/ship-groups.ts @@ -293,8 +293,12 @@ export function shipGroupsToPrimitives( * — the planet inspector lists them instead. Returns null when either * the group is on-planet, or the origin / destination planets are * not visible to the local player. + * + * Exported so the active-view map can centre the camera on an + * in-space group when the F8-10 tables raise a `selection.focus` + * request for one. */ -function computeInSpacePosition( +export function computeInSpacePosition( group: ReportLocalShipGroup | ReportOtherShipGroup, planetIndex: Map, mapWidth: number, diff --git a/ui/frontend/tests/e2e/tables.spec.ts b/ui/frontend/tests/e2e/tables.spec.ts new file mode 100644 index 0000000..84f5c36 --- /dev/null +++ b/ui/frontend/tests/e2e/tables.spec.ts @@ -0,0 +1,143 @@ +// F8-10 end-to-end coverage for the planets table → map navigation. +// Boots an authenticated session, mocks the gateway with a small +// planets-only report, navigates to `table → planets`, clicks the +// first row, and asserts the active view switches to the map (which +// also implicitly proves that the `SelectionStore.focus` → map mount +// hand-off lands inside the live shell). Ship-groups and fleets are +// covered by their vitest specs — one e2e is enough to smoke the +// composed flow. + +import { fromJson, type JsonValue } from "@bufbuild/protobuf"; +import { expect, test, type Page } from "@playwright/test"; + +import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb"; +import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response"; +import { + buildMyGamesListPayload, + type GameFixture, +} from "./fixtures/lobby-fbs"; +import { buildReportPayload } from "./fixtures/report-fbs"; + +const SESSION_ID = "f8-10-tables-session"; +const GAME_ID = "f8101010-0000-4000-8000-101010101010"; + +async function mockGateway(page: Page): Promise { + const game: GameFixture = { + gameId: GAME_ID, + gameName: "F8-10 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, + }; + + 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 payload: Uint8Array; + switch (req.messageType) { + case "lobby.my.games.list": + payload = buildMyGamesListPayload([game]); + break; + case "user.games.report": + payload = buildReportPayload({ + turn: 1, + mapWidth: 4000, + mapHeight: 4000, + localPlanets: [ + { number: 1, name: "Home", x: 1000, y: 1000 }, + ], + otherPlanets: [ + { + number: 2, + name: "Frontier", + x: 2000, + y: 1500, + owner: "Federation", + }, + ], + uninhabitedPlanets: [ + { number: 3, name: "Rock", x: 1500, y: 2200 }, + ], + }); + break; + default: + payload = new Uint8Array(); + } + + const body = await forgeExecuteCommandResponseJson({ + requestId: req.requestId, + timestampMs: BigInt(Date.now()), + resultCode: "ok", + payloadBytes: payload, + }); + await route.fulfill({ + status: 200, + contentType: "application/json", + body, + }); + }, + ); + + // Hold SubscribeEvents open — mirrors the pattern in other e2e + // specs to avoid the revocation watcher signing the session out. + await page.route( + "**/edge.v1.Gateway/SubscribeEvents", + async () => { + await new Promise(() => {}); + }, + ); +} + +async function bootSession(page: Page): Promise { + 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, + ); +} + +test("clicking a row in the planets table opens the map", async ({ page }) => { + await mockGateway(page); + await bootSession(page); + await page.goto("/"); + await page.waitForFunction(() => window.__galaxyNav !== undefined); + await page.evaluate( + (id) => + window.__galaxyNav!.enterGame(id, "table", { + tableEntity: "planets", + }), + GAME_ID, + ); + + const table = page.getByTestId("planets-table"); + await expect(table).toBeVisible(); + const rows = page.getByTestId("planets-row"); + await expect(rows).toHaveCount(3); + + // Click the foreign planet — the data-* stamps let the spec assert + // against a deterministic row regardless of default sort. + await page + .locator('[data-testid="planets-row"][data-number="2"]') + .click(); + + await expect(page.getByTestId("active-view-map")).toBeVisible(); +}); diff --git a/ui/frontend/tests/game-shell-stubs.test.ts b/ui/frontend/tests/game-shell-stubs.test.ts index 40bbd31..8033234 100644 --- a/ui/frontend/tests/game-shell-stubs.test.ts +++ b/ui/frontend/tests/game-shell-stubs.test.ts @@ -48,21 +48,16 @@ describe("active-view stubs", () => { expect(ui.getByTestId("map-canvas-wrap")).toBeInTheDocument(); }); - test("table stub falls back for not-yet-implemented entities", () => { - const ui = render(TableView, { props: { entity: "planets" } }); + test("table stub falls back for unknown entities", () => { + // Every menu-known slug is wired to a real component by F8-10; + // the fallback branch still exists for defensive routing (e.g. + // a restored snapshot referencing a removed entity). + const ui = render(TableView, { props: { entity: "unknown-slug" } }); const node = ui.getByTestId("active-view-table"); - expect(node).toHaveAttribute("data-entity", "planets"); - expect(node).toHaveTextContent("planets"); + expect(node).toHaveAttribute("data-entity", "unknown-slug"); expect(node).toHaveTextContent("coming soon"); }); - test("table stub also handles multi-word entities", () => { - const ui = render(TableView, { props: { entity: "ship-groups" } }); - const node = ui.getByTestId("active-view-table"); - expect(node).toHaveAttribute("data-entity", "ship-groups"); - expect(node).toHaveTextContent("ship groups"); - }); - test("report view mounts with the icon-popup TOC", () => { // Phase 23 replaces the Phase 10 stub with the full report // orchestrator. The orchestrator mounts the table of contents diff --git a/ui/frontend/tests/selection-store.test.ts b/ui/frontend/tests/selection-store.test.ts index 9f26c47..6408713 100644 --- a/ui/frontend/tests/selection-store.test.ts +++ b/ui/frontend/tests/selection-store.test.ts @@ -44,4 +44,89 @@ describe("SelectionStore", () => { store.clear(); expect(store.selected).toBeNull(); }); + + test("focus sets the selection and queues the pending focus", () => { + const store = new SelectionStore(); + store.focus({ kind: "planet", id: 11 }); + expect(store.selected).toEqual({ kind: "planet", id: 11 }); + expect(store.consumePendingFocus()).toEqual({ kind: "planet", id: 11 }); + }); + + test("focus also works for a ship group target", () => { + const store = new SelectionStore(); + store.focus({ kind: "shipGroup", ref: { variant: "local", id: "abc" } }); + expect(store.selected).toEqual({ + kind: "shipGroup", + ref: { variant: "local", id: "abc" }, + }); + expect(store.consumePendingFocus()).toEqual({ + kind: "shipGroup", + ref: { variant: "local", id: "abc" }, + }); + }); + + test("consumePendingFocus clears the queued request", () => { + const store = new SelectionStore(); + store.focus({ kind: "planet", id: 1 }); + expect(store.consumePendingFocus()).not.toBeNull(); + expect(store.consumePendingFocus()).toBeNull(); + }); + + test("consumePendingFocus returns null when no focus was queued", () => { + const store = new SelectionStore(); + store.selectPlanet(5); + expect(store.consumePendingFocus()).toBeNull(); + }); + + test("clear leaves any queued pending focus untouched", () => { + const store = new SelectionStore(); + store.focus({ kind: "planet", id: 9 }); + store.clear(); + expect(store.selected).toBeNull(); + expect(store.consumePendingFocus()).toEqual({ kind: "planet", id: 9 }); + }); + + test("dispose drops a queued pending focus", () => { + const store = new SelectionStore(); + store.focus({ kind: "planet", id: 2 }); + store.dispose(); + expect(store.consumePendingFocus()).toBeNull(); + }); + + test("focus is a no-op after dispose", () => { + const store = new SelectionStore(); + store.dispose(); + store.focus({ kind: "planet", id: 7 }); + expect(store.selected).toBeNull(); + expect(store.consumePendingFocus()).toBeNull(); + }); + + test("focusPoint queues a coord without touching selection", () => { + const store = new SelectionStore(); + store.selectPlanet(1); + store.focusPoint(12, 34); + expect(store.selected).toEqual({ kind: "planet", id: 1 }); + expect(store.consumePendingCenter()).toEqual({ x: 12, y: 34 }); + }); + + test("consumePendingCenter clears the queued point", () => { + const store = new SelectionStore(); + store.focusPoint(5, 7); + expect(store.consumePendingCenter()).toEqual({ x: 5, y: 7 }); + expect(store.consumePendingCenter()).toBeNull(); + }); + + test("dispose drops a queued pending centre", () => { + const store = new SelectionStore(); + store.focusPoint(1, 2); + store.dispose(); + expect(store.consumePendingCenter()).toBeNull(); + }); + + test("focusPoint is a no-op after dispose", () => { + const store = new SelectionStore(); + store.dispose(); + store.focusPoint(1, 2); + expect(store.consumePendingCenter()).toBeNull(); + }); }); diff --git a/ui/frontend/tests/table-fleets.test.ts b/ui/frontend/tests/table-fleets.test.ts new file mode 100644 index 0000000..b79beb5 --- /dev/null +++ b/ui/frontend/tests/table-fleets.test.ts @@ -0,0 +1,219 @@ +// Vitest coverage for the F8-10 fleets table active view. +// Mounts the component against a synthetic `RenderedReportSource` +// and a real `SelectionStore`; verifies the click semantics for both +// on-planet (focus the destination planet) and in-space (centre the +// camera on the interpolated point, leave selection untouched). + +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { i18n } from "../src/lib/i18n/index.svelte"; +import type { + GameReport, + ReportLocalFleet, + ReportPlanet, +} from "../src/api/game-state"; +import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte"; +import { + SELECTION_CONTEXT_KEY, + SelectionStore, +} from "../src/lib/selection.svelte"; +import { activeView } from "../src/lib/app-nav.svelte"; +import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups"; + +const pageMock = vi.hoisted(() => ({ + url: new URL("http://localhost/games/g1/table/fleets"), + params: { id: "g1" } as Record, +})); + +const gotoMock = vi.hoisted(() => vi.fn()); + +vi.mock("$app/state", () => ({ + page: pageMock, +})); + +vi.mock("$app/navigation", () => ({ + goto: gotoMock, + pushState: vi.fn(), + replaceState: vi.fn(), +})); + +import TableFleets from "../src/lib/active-view/table-fleets.svelte"; + +let selection: SelectionStore; + +beforeEach(() => { + selection = new SelectionStore(); + i18n.resetForTests("en"); + pageMock.params = { id: "g1" }; + gotoMock.mockClear(); + activeView.reset(); +}); + +afterEach(() => { + selection.dispose(); +}); + +function planet(num: number, x = 0, y = 0): ReportPlanet { + return { + number: num, + name: `P${num}`, + x, + y, + kind: "local", + owner: null, + size: null, + resources: null, + industryStockpile: null, + materialsStockpile: null, + industry: null, + population: null, + colonists: null, + production: null, + freeIndustry: null, + }; +} + +function fleet( + overrides: Partial & Pick, +): ReportLocalFleet { + return { + groupCount: 1, + origin: null, + range: null, + speed: 0, + state: "In_Orbit", + ...overrides, + }; +} + +function makeReport(opts: { + planets?: ReportPlanet[]; + fleets?: ReportLocalFleet[]; +}): GameReport { + return { + turn: 1, + mapWidth: 1000, + mapHeight: 1000, + planetCount: opts.planets?.length ?? 0, + planets: opts.planets ?? [], + race: "Me", + localShipClass: [], + routes: [], + localPlayerDrive: 0, + localPlayerWeapons: 0, + localPlayerShields: 0, + localPlayerCargo: 0, + ...EMPTY_SHIP_GROUPS, + localFleets: opts.fleets ?? [], + }; +} + +function mount(report: GameReport | null) { + const renderedReport = { + get report() { + return report; + }, + }; + const context = new Map([ + [RENDERED_REPORT_CONTEXT_KEY, renderedReport], + [SELECTION_CONTEXT_KEY, selection], + ]); + return render(TableFleets, { context }); +} + +describe("fleets table", () => { + test("renders a loading placeholder before the report lands", () => { + const ui = mount(null); + expect(ui.getByTestId("fleets-loading")).toBeInTheDocument(); + }); + + test("renders an empty placeholder when no fleets exist", () => { + const ui = mount(makeReport({ planets: [planet(1)] })); + expect(ui.getByTestId("fleets-empty")).toBeInTheDocument(); + }); + + test("renders one row per fleet", () => { + const ui = mount( + makeReport({ + planets: [planet(1), planet(2)], + fleets: [ + fleet({ name: "Alpha", destination: 1 }), + fleet({ + name: "Bravo", + destination: 2, + origin: 1, + range: 4, + state: "In_Space", + }), + ], + }), + ); + expect(ui.getAllByTestId("fleets-row")).toHaveLength(2); + }); + + test("planet dropdown filters by destination OR origin", async () => { + const ui = mount( + makeReport({ + planets: [planet(1), planet(2), planet(3)], + fleets: [ + fleet({ name: "Alpha", destination: 1 }), + fleet({ + name: "Bravo", + destination: 3, + origin: 2, + range: 4, + state: "In_Space", + }), + ], + }), + ); + const sel = ui.getByTestId("fleets-filter-planet") as HTMLSelectElement; + await fireEvent.change(sel, { target: { value: "2" } }); + const names = ui + .getAllByTestId("fleets-row") + .map((r) => r.getAttribute("data-name")); + expect(names).toEqual(["Bravo"]); + }); + + test("click on on-planet fleet focuses the destination planet", async () => { + const ui = mount( + makeReport({ + planets: [planet(7)], + fleets: [fleet({ name: "Alpha", destination: 7 })], + }), + ); + await fireEvent.click(ui.getByTestId("fleets-row")); + expect(selection.selected).toEqual({ kind: "planet", id: 7 }); + expect(selection.consumePendingFocus()).toEqual({ + kind: "planet", + id: 7, + }); + expect(activeView.view).toBe("map"); + }); + + test("click on in-space fleet centres camera point and leaves selection alone", async () => { + const ui = mount( + makeReport({ + planets: [planet(1, 0, 0), planet(2, 100, 0)], + fleets: [ + fleet({ + name: "Bravo", + destination: 2, + origin: 1, + range: 25, + state: "In_Space", + }), + ], + }), + ); + expect(selection.selected).toBeNull(); + await fireEvent.click(ui.getByTestId("fleets-row")); + expect(selection.selected).toBeNull(); + // 25 world units away from dest (2 at x=100) toward origin (1 at x=0): + // dest + (range/total)*(origin-dest) = 100 + 0.25 * -100 = 75 + expect(selection.consumePendingCenter()).toEqual({ x: 75, y: 0 }); + expect(activeView.view).toBe("map"); + }); +}); diff --git a/ui/frontend/tests/table-planets.test.ts b/ui/frontend/tests/table-planets.test.ts new file mode 100644 index 0000000..c6f5e64 --- /dev/null +++ b/ui/frontend/tests/table-planets.test.ts @@ -0,0 +1,210 @@ +// Vitest coverage for the F8-10 planets table active view. +// The component is mounted against a synthetic `RenderedReportSource` +// (kind discriminants set per case) and a real `SelectionStore`, so +// the click → focus contract is exercised end-to-end without needing +// the live map renderer. + +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { i18n } from "../src/lib/i18n/index.svelte"; +import type { GameReport, ReportPlanet } from "../src/api/game-state"; +import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte"; +import { + SELECTION_CONTEXT_KEY, + SelectionStore, +} from "../src/lib/selection.svelte"; +import { activeView } from "../src/lib/app-nav.svelte"; +import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups"; + +const pageMock = vi.hoisted(() => ({ + url: new URL("http://localhost/games/g1/table/planets"), + params: { id: "g1" } as Record, +})); + +const gotoMock = vi.hoisted(() => vi.fn()); + +vi.mock("$app/state", () => ({ + page: pageMock, +})); + +vi.mock("$app/navigation", () => ({ + goto: gotoMock, + pushState: vi.fn(), + replaceState: vi.fn(), +})); + +import TablePlanets from "../src/lib/active-view/table-planets.svelte"; + +let selection: SelectionStore; + +beforeEach(() => { + selection = new SelectionStore(); + i18n.resetForTests("en"); + pageMock.params = { id: "g1" }; + gotoMock.mockClear(); + activeView.reset(); +}); + +afterEach(() => { + selection.dispose(); +}); + +function planet(overrides: Partial & { number: number }): ReportPlanet { + return { + name: `P${overrides.number}`, + x: 0, + y: 0, + kind: "uninhabited", + 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: 1, + mapWidth: 1000, + mapHeight: 1000, + planetCount: planets.length, + planets, + race: "Me", + localShipClass: [], + routes: [], + localPlayerDrive: 0, + localPlayerWeapons: 0, + localPlayerShields: 0, + localPlayerCargo: 0, + ...EMPTY_SHIP_GROUPS, + }; +} + +function mount(report: GameReport | null) { + const renderedReport = { + get report() { + return report; + }, + }; + const context = new Map([ + [RENDERED_REPORT_CONTEXT_KEY, renderedReport], + [SELECTION_CONTEXT_KEY, selection], + ]); + return render(TablePlanets, { context }); +} + +describe("planets table", () => { + test("renders a loading placeholder before the report lands", () => { + const ui = mount(null); + expect(ui.getByTestId("planets-loading")).toBeInTheDocument(); + }); + + test("renders an empty placeholder when the report has no planets", () => { + const ui = mount(makeReport([])); + expect(ui.getByTestId("planets-empty")).toBeInTheDocument(); + }); + + test("renders one row per planet with kind classification", () => { + const ui = mount( + makeReport([ + planet({ number: 1, name: "Earth", kind: "local" }), + planet({ + number: 2, + name: "Vega", + kind: "other", + owner: "Klingon", + }), + planet({ number: 3, name: "Rock", kind: "uninhabited" }), + planet({ number: 4, name: "", kind: "unidentified" }), + ]), + ); + const rows = ui.getAllByTestId("planets-row"); + expect(rows).toHaveLength(4); + expect(rows[0]).toHaveAttribute("data-kind", "local"); + expect(rows[1]).toHaveAttribute("data-kind", "other"); + }); + + test("kind checkboxes filter the visible rows independently", async () => { + const ui = mount( + makeReport([ + planet({ number: 1, kind: "local" }), + planet({ number: 2, kind: "other", owner: "A" }), + planet({ number: 3, kind: "uninhabited" }), + planet({ number: 4, kind: "unidentified" }), + ]), + ); + await fireEvent.click(ui.getByTestId("planets-filter-own")); + let kinds = ui + .getAllByTestId("planets-row") + .map((r) => r.getAttribute("data-kind")); + expect(kinds).toEqual(["other", "uninhabited", "unidentified"]); + await fireEvent.click(ui.getByTestId("planets-filter-uninhabited")); + kinds = ui + .getAllByTestId("planets-row") + .map((r) => r.getAttribute("data-kind")); + expect(kinds).toEqual(["other", "unidentified"]); + }); + + test("owner dropdown narrows the foreign slice only", async () => { + const ui = mount( + makeReport([ + planet({ number: 1, kind: "local" }), + planet({ number: 2, kind: "other", owner: "Klingon" }), + planet({ number: 3, kind: "other", owner: "Romulan" }), + planet({ number: 4, kind: "uninhabited" }), + ]), + ); + const select = ui.getByTestId( + "planets-filter-owner", + ) as HTMLSelectElement; + await fireEvent.change(select, { target: { value: "Klingon" } }); + const numbers = ui + .getAllByTestId("planets-row") + .map((r) => r.getAttribute("data-number")); + // own (1) + foreign Klingon (2) + uninhabited (4); Romulan dropped + expect(numbers).toEqual(["1", "2", "4"]); + }); + + test("clicking a row focuses the planet and switches the active view", async () => { + const ui = mount( + makeReport([ + planet({ number: 7, kind: "local", name: "Earth", x: 12, y: 34 }), + ]), + ); + await fireEvent.click(ui.getByTestId("planets-row")); + expect(selection.selected).toEqual({ kind: "planet", id: 7 }); + expect(selection.consumePendingFocus()).toEqual({ + kind: "planet", + id: 7, + }); + expect(activeView.view).toBe("map"); + }); + + test("number column sorts ascending then descending", async () => { + const ui = mount( + makeReport([ + planet({ number: 3, kind: "local" }), + planet({ number: 1, kind: "local" }), + planet({ number: 2, kind: "local" }), + ]), + ); + const numbers = ui + .getAllByTestId("planets-row") + .map((r) => r.getAttribute("data-number")); + expect(numbers).toEqual(["1", "2", "3"]); + await fireEvent.click(ui.getByTestId("planets-column-number")); + const reversed = ui + .getAllByTestId("planets-row") + .map((r) => r.getAttribute("data-number")); + expect(reversed).toEqual(["3", "2", "1"]); + }); +}); diff --git a/ui/frontend/tests/table-ship-classes.test.ts b/ui/frontend/tests/table-ship-classes.test.ts index 3a59ac2..090bf4b 100644 --- a/ui/frontend/tests/table-ship-classes.test.ts +++ b/ui/frontend/tests/table-ship-classes.test.ts @@ -17,7 +17,11 @@ import { } from "vitest"; import { i18n } from "../src/lib/i18n/index.svelte"; -import type { GameReport, ShipClassSummary } from "../src/api/game-state"; +import type { + GameReport, + ReportLocalShipGroup, + ShipClassSummary, +} from "../src/api/game-state"; import { ORDER_DRAFT_CONTEXT_KEY, OrderDraftStore, @@ -89,7 +93,10 @@ function shipClass( }; } -function makeReport(localShipClass: ShipClassSummary[]): GameReport { +function makeReport( + localShipClass: ShipClassSummary[], + localShipGroups: ReportLocalShipGroup[] = [], +): GameReport { return { turn: 1, mapWidth: 1000, @@ -104,6 +111,29 @@ function makeReport(localShipClass: ShipClassSummary[]): GameReport { localPlayerShields: 0, localPlayerCargo: 0, ...EMPTY_SHIP_GROUPS, + localShipGroups, + }; +} + +function shipGroup( + overrides: Partial & + Pick, +): ReportLocalShipGroup { + return { + id: crypto.randomUUID(), + state: "In_Orbit", + fleet: null, + count: 1, + tech: { drive: 0, weapons: 0, shields: 0, cargo: 0 }, + cargo: "NONE", + load: 0, + destination: 1, + origin: null, + range: null, + speed: 0, + mass: 0, + race: "Foo", + ...overrides, }; } @@ -210,6 +240,34 @@ describe("ship-classes table", () => { expect(cmd.name).toBe("Drone"); }); + test("delete is disabled when a local ship group uses the class", async () => { + const ui = mountTable( + makeReport( + [shipClass({ name: "Cruiser" }), shipClass({ name: "Drone" })], + [ + shipGroup({ class: "Cruiser" }), + shipGroup({ class: "Cruiser", count: 4 }), + ], + ), + ); + const buttons = ui.getAllByTestId("ship-classes-delete"); + const map = new Map( + buttons.map((b) => { + const row = b.closest('[data-testid="ship-classes-row"]'); + return [row?.getAttribute("data-name") ?? "", b]; + }), + ); + const cruiserBtn = map.get("Cruiser") as HTMLButtonElement; + const droneBtn = map.get("Drone") as HTMLButtonElement; + expect(cruiserBtn).toBeDisabled(); + expect(cruiserBtn).toHaveAttribute( + "title", + expect.stringContaining("2"), + ); + expect(droneBtn).not.toBeDisabled(); + expect(droneBtn).toHaveAttribute("title", ""); + }); + test("new button requests a fresh calculator design", async () => { const ui = mountTable(makeReport([])); const before = calculatorLoadRequest.token; diff --git a/ui/frontend/tests/table-ship-groups.test.ts b/ui/frontend/tests/table-ship-groups.test.ts new file mode 100644 index 0000000..a8208d8 --- /dev/null +++ b/ui/frontend/tests/table-ship-groups.test.ts @@ -0,0 +1,312 @@ +// Vitest coverage for the F8-10 ship-groups table active view. +// Mounts the component against a synthetic `RenderedReportSource` +// and a real `SelectionStore`; the click → focus contract (planet +// for on-orbit groups, ship-group ref for in-space) is exercised +// directly through the store. + +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { i18n } from "../src/lib/i18n/index.svelte"; +import type { + GameReport, + ReportLocalShipGroup, + ReportOtherShipGroup, + ReportPlanet, +} from "../src/api/game-state"; +import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte"; +import { + SELECTION_CONTEXT_KEY, + SelectionStore, +} from "../src/lib/selection.svelte"; +import { activeView } from "../src/lib/app-nav.svelte"; +import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups"; + +const pageMock = vi.hoisted(() => ({ + url: new URL("http://localhost/games/g1/table/ship-groups"), + params: { id: "g1" } as Record, +})); + +const gotoMock = vi.hoisted(() => vi.fn()); + +vi.mock("$app/state", () => ({ + page: pageMock, +})); + +vi.mock("$app/navigation", () => ({ + goto: gotoMock, + pushState: vi.fn(), + replaceState: vi.fn(), +})); + +import TableShipGroups from "../src/lib/active-view/table-ship-groups.svelte"; + +let selection: SelectionStore; + +beforeEach(() => { + selection = new SelectionStore(); + i18n.resetForTests("en"); + pageMock.params = { id: "g1" }; + gotoMock.mockClear(); + activeView.reset(); +}); + +afterEach(() => { + selection.dispose(); +}); + +function planet(num: number, name?: string): ReportPlanet { + return { + number: num, + name: name ?? `P${num}`, + 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, + }; +} + +function localGroup( + overrides: Partial & + Pick, +): ReportLocalShipGroup { + return { + state: "In_Orbit", + fleet: null, + count: 1, + tech: { drive: 0, weapons: 0, shields: 0, cargo: 0 }, + cargo: "NONE", + load: 0, + origin: null, + range: null, + speed: 0, + mass: 0, + race: "Me", + ...overrides, + }; +} + +function otherGroup( + overrides: Partial & + Pick, +): ReportOtherShipGroup { + return { + count: 1, + tech: { drive: 0, weapons: 0, shields: 0, cargo: 0 }, + cargo: "NONE", + load: 0, + origin: null, + range: null, + speed: 0, + mass: 0, + ...overrides, + }; +} + +function makeReport(opts: { + planets?: ReportPlanet[]; + local?: ReportLocalShipGroup[]; + other?: ReportOtherShipGroup[]; +}): GameReport { + return { + turn: 1, + mapWidth: 1000, + mapHeight: 1000, + planetCount: opts.planets?.length ?? 0, + planets: opts.planets ?? [], + race: "Me", + localShipClass: [], + routes: [], + localPlayerDrive: 0, + localPlayerWeapons: 0, + localPlayerShields: 0, + localPlayerCargo: 0, + ...EMPTY_SHIP_GROUPS, + localShipGroups: opts.local ?? [], + otherShipGroups: opts.other ?? [], + }; +} + +function mount(report: GameReport | null) { + const renderedReport = { + get report() { + return report; + }, + }; + const context = new Map([ + [RENDERED_REPORT_CONTEXT_KEY, renderedReport], + [SELECTION_CONTEXT_KEY, selection], + ]); + return render(TableShipGroups, { context }); +} + +describe("ship-groups table", () => { + test("renders a loading placeholder before the report lands", () => { + const ui = mount(null); + expect(ui.getByTestId("ship-groups-loading")).toBeInTheDocument(); + }); + + test("renders an empty placeholder when no groups are present", () => { + const ui = mount(makeReport({ planets: [planet(1)] })); + expect(ui.getByTestId("ship-groups-empty")).toBeInTheDocument(); + }); + + test("renders local and foreign rows under one table", () => { + const ui = mount( + makeReport({ + planets: [planet(1), planet(2)], + local: [localGroup({ id: "L1", class: "Cruiser", destination: 1 })], + other: [ + otherGroup({ class: "Hunter", destination: 2, race: "Klingon" }), + ], + }), + ); + const rows = ui.getAllByTestId("ship-groups-row"); + expect(rows).toHaveLength(2); + const owners = rows.map((r) => r.getAttribute("data-owner")); + expect(owners.sort()).toEqual(["foreign", "own"]); + }); + + test("owner checkboxes filter independently", async () => { + const ui = mount( + makeReport({ + planets: [planet(1), planet(2)], + local: [localGroup({ id: "L1", class: "Cruiser", destination: 1 })], + other: [ + otherGroup({ class: "Hunter", destination: 2, race: "Klingon" }), + ], + }), + ); + await fireEvent.click(ui.getByTestId("ship-groups-filter-foreign")); + const owners = ui + .getAllByTestId("ship-groups-row") + .map((r) => r.getAttribute("data-owner")); + expect(owners).toEqual(["own"]); + }); + + test("planet dropdown filters by destination OR origin", async () => { + const ui = mount( + makeReport({ + planets: [planet(1), planet(2), planet(3)], + local: [ + localGroup({ id: "L1", class: "C", destination: 1 }), + localGroup({ + id: "L2", + class: "C", + destination: 3, + origin: 2, + range: 4, + state: "In_Space", + }), + localGroup({ id: "L3", class: "C", destination: 3 }), + ], + }), + ); + const sel = ui.getByTestId( + "ship-groups-filter-planet", + ) as HTMLSelectElement; + await fireEvent.change(sel, { target: { value: "2" } }); + // only L2 touches planet 2 (origin === 2) + const keys = ui + .getAllByTestId("ship-groups-row") + .map((r) => r.getAttribute("data-key")); + expect(keys).toEqual(["local:L2"]); + }); + + test("class dropdown filters by class name", async () => { + const ui = mount( + makeReport({ + planets: [planet(1)], + local: [ + localGroup({ id: "L1", class: "Cruiser", destination: 1 }), + localGroup({ id: "L2", class: "Drone", destination: 1 }), + ], + }), + ); + const sel = ui.getByTestId( + "ship-groups-filter-class", + ) as HTMLSelectElement; + await fireEvent.change(sel, { target: { value: "Drone" } }); + const keys = ui + .getAllByTestId("ship-groups-row") + .map((r) => r.getAttribute("data-key")); + expect(keys).toEqual(["local:L2"]); + }); + + test("click on on-planet group focuses the destination planet", async () => { + const ui = mount( + makeReport({ + planets: [planet(7)], + local: [localGroup({ id: "L1", class: "Cruiser", destination: 7 })], + }), + ); + await fireEvent.click(ui.getByTestId("ship-groups-row")); + expect(selection.selected).toEqual({ kind: "planet", id: 7 }); + expect(selection.consumePendingFocus()).toEqual({ + kind: "planet", + id: 7, + }); + expect(activeView.view).toBe("map"); + }); + + test("click on in-space local group focuses the ship-group ref", async () => { + const ui = mount( + makeReport({ + planets: [planet(1), planet(2)], + local: [ + localGroup({ + id: "L1", + class: "Cruiser", + destination: 2, + origin: 1, + range: 3, + state: "In_Space", + }), + ], + }), + ); + await fireEvent.click(ui.getByTestId("ship-groups-row")); + expect(selection.selected).toEqual({ + kind: "shipGroup", + ref: { variant: "local", id: "L1" }, + }); + expect(selection.consumePendingFocus()).toEqual({ + kind: "shipGroup", + ref: { variant: "local", id: "L1" }, + }); + expect(activeView.view).toBe("map"); + }); + + test("click on in-space foreign group focuses other variant by index", async () => { + const ui = mount( + makeReport({ + planets: [planet(1), planet(2)], + other: [ + otherGroup({ + class: "Hunter", + destination: 2, + origin: 1, + range: 5, + race: "Klingon", + }), + ], + }), + ); + await fireEvent.click(ui.getByTestId("ship-groups-row")); + expect(selection.selected).toEqual({ + kind: "shipGroup", + ref: { variant: "other", index: 0 }, + }); + expect(activeView.view).toBe("map"); + }); +});