ui/phase-19: read-only ship-group inspector + sheet + tab dispatch

Closes Phase 19's UI surface. The inspector dispatches on the
selection variant: local / other groups render class, count, the
four tech levels, mass, cargo (type + amount when loaded),
location (planet name on-orbit, from/to/distance in hyperspace),
and — for local groups only — fleet membership + state. Incoming
groups surface origin / destination / distance / speed and the
inline ETA = ceil(distance / speed); zero speed collapses to the
designer's existing "—" placeholder. Unidentified groups render
just the (x, y) coordinates and the no-data hint, mirroring the
unidentified planet treatment.

Layout / inspector-tab plumbing:
  - inspector-tab.svelte derives selectedShipGroup against the
    rendered report and mounts <ShipGroup /> when the planet
    branch doesn't match. Stale refs (an index that no longer
    resolves after a turn refresh) collapse cleanly to the empty
    state.
  - +layout.svelte mounts <ShipGroupSheet /> alongside the
    existing planet sheet on mobile; both share the
    `effectiveTool === "map"` guard and clear-on-close.

i18n: en + ru both grow ~30 keys under
`game.inspector.ship_group.*`. Adding a key to one without the
other is a TS error (TranslationKey is `keyof typeof en`), so the
Russian mirror stays mandatory.

Tests:
  - inspector-ship-group.test.ts exercises every variant —
    on-planet local, in-hyperspace local, cargo-loaded local,
    foreign, incoming with ETA, incoming with zero speed,
    unidentified, plus the missing-planet `#NN` fallback.
  - tests/e2e/inspector-ship-group.spec.ts is a smoke spec that
    drives the DEV-only synthetic-report loader from /lobby
    through navigation to /games/synthetic-XXX/map.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-10 13:24:17 +02:00
parent 676556db4e
commit 86e77efe39
8 changed files with 843 additions and 0 deletions
+30
View File
@@ -245,6 +245,36 @@ const en = {
"game.designer.ship_class.preview.range": "range at full load (ly/turn)", "game.designer.ship_class.preview.range": "range at full load (ly/turn)",
"game.designer.ship_class.preview.cargo_capacity": "cargo capacity per ship", "game.designer.ship_class.preview.cargo_capacity": "cargo capacity per ship",
"game.designer.ship_class.preview.unavailable": "—", "game.designer.ship_class.preview.unavailable": "—",
"game.inspector.ship_group.kind.local": "your group",
"game.inspector.ship_group.kind.other": "other race group",
"game.inspector.ship_group.kind.incoming": "incoming group",
"game.inspector.ship_group.kind.unidentified": "unidentified group",
"game.inspector.ship_group.field.class": "class",
"game.inspector.ship_group.field.count": "ships",
"game.inspector.ship_group.field.drive": "drive",
"game.inspector.ship_group.field.weapons": "weapons",
"game.inspector.ship_group.field.shields": "shields",
"game.inspector.ship_group.field.cargo_tech": "cargo",
"game.inspector.ship_group.field.mass": "mass",
"game.inspector.ship_group.field.cargo_load": "cargo aboard",
"game.inspector.ship_group.field.location": "location",
"game.inspector.ship_group.field.from": "from",
"game.inspector.ship_group.field.to": "to",
"game.inspector.ship_group.field.distance": "distance remaining",
"game.inspector.ship_group.field.speed": "speed (ly/turn)",
"game.inspector.ship_group.field.eta": "ETA (turns)",
"game.inspector.ship_group.field.fleet": "fleet",
"game.inspector.ship_group.field.state": "state",
"game.inspector.ship_group.field.coordinates": "coordinates",
"game.inspector.ship_group.cargo.col": "colonists",
"game.inspector.ship_group.cargo.cap": "industry",
"game.inspector.ship_group.cargo.mat": "materials",
"game.inspector.ship_group.cargo.emp": "empty",
"game.inspector.ship_group.cargo.none": "none",
"game.inspector.ship_group.location.in_hyperspace": "in hyperspace",
"game.inspector.ship_group.fleet.none": "—",
"game.inspector.ship_group.unidentified_no_data": "no data — only the radar blip is known",
} as const; } as const;
export default en; export default en;
+30
View File
@@ -246,6 +246,36 @@ const ru: Record<keyof typeof en, string> = {
"game.designer.ship_class.preview.range": "дальность при полной загрузке (св.лет/ход)", "game.designer.ship_class.preview.range": "дальность при полной загрузке (св.лет/ход)",
"game.designer.ship_class.preview.cargo_capacity": "грузоподъёмность одного корабля", "game.designer.ship_class.preview.cargo_capacity": "грузоподъёмность одного корабля",
"game.designer.ship_class.preview.unavailable": "—", "game.designer.ship_class.preview.unavailable": "—",
"game.inspector.ship_group.kind.local": "ваша группа",
"game.inspector.ship_group.kind.other": "группа другой расы",
"game.inspector.ship_group.kind.incoming": "входящая группа",
"game.inspector.ship_group.kind.unidentified": "неопознанная группа",
"game.inspector.ship_group.field.class": "класс",
"game.inspector.ship_group.field.count": "кораблей",
"game.inspector.ship_group.field.drive": "двигатели",
"game.inspector.ship_group.field.weapons": "оружие",
"game.inspector.ship_group.field.shields": "защита",
"game.inspector.ship_group.field.cargo_tech": "грузоперевозки",
"game.inspector.ship_group.field.mass": "масса",
"game.inspector.ship_group.field.cargo_load": "груз на борту",
"game.inspector.ship_group.field.location": "расположение",
"game.inspector.ship_group.field.from": "из",
"game.inspector.ship_group.field.to": "в",
"game.inspector.ship_group.field.distance": "оставшееся расстояние",
"game.inspector.ship_group.field.speed": "скорость (св.лет/ход)",
"game.inspector.ship_group.field.eta": "прибытие (ходов)",
"game.inspector.ship_group.field.fleet": "флот",
"game.inspector.ship_group.field.state": "состояние",
"game.inspector.ship_group.field.coordinates": "координаты",
"game.inspector.ship_group.cargo.col": "колонисты",
"game.inspector.ship_group.cargo.cap": "промышленность",
"game.inspector.ship_group.cargo.mat": "сырьё",
"game.inspector.ship_group.cargo.emp": "пусто",
"game.inspector.ship_group.cargo.none": "нет",
"game.inspector.ship_group.location.in_hyperspace": "в гиперпространстве",
"game.inspector.ship_group.fleet.none": "—",
"game.inspector.ship_group.unidentified_no_data": "данных нет — известны только координаты",
}; };
export default ru; export default ru;
@@ -0,0 +1,78 @@
<!--
Phase 19 mobile bottom-sheet that hosts the ship-group inspector when
the user is on the map view on a small screen. Mirrors the existing
`planet-sheet.svelte` layout: hidden via media query above 768 px, and
mounted by the in-game shell layout only while the active tool is
`map` so it does not stack on top of the calc / order overlays.
-->
<script lang="ts">
import type { ReportPlanet } from "../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte";
import ShipGroup, { type ShipGroupSelection } from "./ship-group.svelte";
type Props = {
selection: ShipGroupSelection | null;
planets: ReportPlanet[];
onMap: boolean;
onClose: () => void;
};
let { selection, planets, onMap, onClose }: Props = $props();
</script>
{#if selection !== null && onMap}
<section
class="sheet"
aria-label={i18n.t("game.sidebar.tab.inspector")}
data-testid="inspector-ship-group-sheet"
>
<button
type="button"
class="close"
data-testid="inspector-ship-group-sheet-close"
aria-label={i18n.t("game.inspector.sheet_close")}
onclick={onClose}
>
</button>
<ShipGroup {selection} {planets} />
</section>
{/if}
<style>
.sheet {
display: none;
}
@media (max-width: 767.98px) {
.sheet {
display: block;
position: fixed;
left: 0;
right: 0;
bottom: 3.25rem;
max-height: calc(100vh - 6rem);
overflow-y: auto;
background: #14182a;
color: #e8eaf6;
border-top: 1px solid #2a3150;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
z-index: 40;
}
}
.close {
position: absolute;
top: 0.4rem;
right: 0.4rem;
font: inherit;
font-size: 1rem;
padding: 0.2rem 0.55rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.close:hover {
color: #e8eaf6;
border-color: #6d8cff;
}
</style>
@@ -0,0 +1,260 @@
<!--
Phase 19 read-only ship-group inspector. Renders the documented field
set per group variant (local / other / incoming / unidentified). The
inspector is mounted by the sidebar `inspector-tab.svelte` when the
selection has `kind === "shipGroup"`, and on the mobile breakpoint by
the `ship-group-sheet.svelte` overlay.
Phase 20 will fold action buttons (split / send / load / unload /
modernize / dismantle / transfer / assign-to-fleet) onto the local
variant — for Phase 19 the inspector is intentionally read-only.
-->
<script lang="ts">
import type {
ReportIncomingShipGroup,
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
ReportUnidentifiedShipGroup,
} from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
export type ShipGroupSelection =
| { variant: "local"; group: ReportLocalShipGroup }
| { variant: "other"; group: ReportOtherShipGroup }
| { variant: "incoming"; group: ReportIncomingShipGroup }
| { variant: "unidentified"; group: ReportUnidentifiedShipGroup };
type Props = {
selection: ShipGroupSelection;
planets: ReportPlanet[];
};
let { selection, planets }: Props = $props();
const kindKeyMap: Record<ShipGroupSelection["variant"], TranslationKey> = {
local: "game.inspector.ship_group.kind.local",
other: "game.inspector.ship_group.kind.other",
incoming: "game.inspector.ship_group.kind.incoming",
unidentified: "game.inspector.ship_group.kind.unidentified",
};
const cargoLoadKeyMap = {
COL: "game.inspector.ship_group.cargo.col",
CAP: "game.inspector.ship_group.cargo.cap",
MAT: "game.inspector.ship_group.cargo.mat",
EMP: "game.inspector.ship_group.cargo.emp",
} as const;
const planetIndex = $derived.by(() => {
const m = new Map<number, ReportPlanet>();
for (const p of planets) m.set(p.number, p);
return m;
});
function planetLabel(number: number): string {
const planet = planetIndex.get(number);
if (planet === undefined) return `#${number}`;
if (planet.name === "" || planet.name === undefined) return `#${number}`;
return planet.name;
}
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
function cargoLabel(cargo: "NONE" | "COL" | "CAP" | "MAT" | "EMP"): string {
if (cargo === "NONE") {
return i18n.t("game.inspector.ship_group.cargo.none");
}
return i18n.t(cargoLoadKeyMap[cargo]);
}
const kindLabel = $derived(i18n.t(kindKeyMap[selection.variant]));
</script>
<section
class="inspector"
data-testid="inspector-ship-group"
data-variant={selection.variant}
>
<header>
<p class="kind" data-testid="inspector-ship-group-kind">{kindLabel}</p>
{#if selection.variant === "local" || selection.variant === "other"}
<h3 class="name" data-testid="inspector-ship-group-class">
{selection.group.class}
</h3>
{/if}
</header>
{#if selection.variant === "local" || selection.variant === "other"}
{@const g = selection.group}
{@const onPlanet = g.origin === null || g.range === null}
<dl class="fields">
<div class="field" data-testid="inspector-ship-group-field-count">
<dt>{i18n.t("game.inspector.ship_group.field.count")}</dt>
<dd>{g.count}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-drive">
<dt>{i18n.t("game.inspector.ship_group.field.drive")}</dt>
<dd>{formatNumber(g.tech.drive)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-weapons">
<dt>{i18n.t("game.inspector.ship_group.field.weapons")}</dt>
<dd>{formatNumber(g.tech.weapons)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-shields">
<dt>{i18n.t("game.inspector.ship_group.field.shields")}</dt>
<dd>{formatNumber(g.tech.shields)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-cargo-tech">
<dt>{i18n.t("game.inspector.ship_group.field.cargo_tech")}</dt>
<dd>{formatNumber(g.tech.cargo)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-mass">
<dt>{i18n.t("game.inspector.ship_group.field.mass")}</dt>
<dd>{formatNumber(g.mass)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-cargo-load">
<dt>{i18n.t("game.inspector.ship_group.field.cargo_load")}</dt>
<dd>
{#if g.cargo === "NONE"}
{cargoLabel(g.cargo)}
{:else}
{cargoLabel(g.cargo)} × {formatNumber(g.load)}
{/if}
</dd>
</div>
{#if onPlanet}
<div class="field" data-testid="inspector-ship-group-field-location">
<dt>{i18n.t("game.inspector.ship_group.field.location")}</dt>
<dd>{planetLabel(g.destination)}</dd>
</div>
{:else}
<div class="field" data-testid="inspector-ship-group-field-from">
<dt>{i18n.t("game.inspector.ship_group.field.from")}</dt>
<dd>{planetLabel(g.origin!)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-to">
<dt>{i18n.t("game.inspector.ship_group.field.to")}</dt>
<dd>{planetLabel(g.destination)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-distance">
<dt>{i18n.t("game.inspector.ship_group.field.distance")}</dt>
<dd>{formatNumber(g.range!)}</dd>
</div>
{/if}
{#if selection.variant === "local"}
<div class="field" data-testid="inspector-ship-group-field-fleet">
<dt>{i18n.t("game.inspector.ship_group.field.fleet")}</dt>
<dd>
{selection.group.fleet ?? i18n.t("game.inspector.ship_group.fleet.none")}
</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-state">
<dt>{i18n.t("game.inspector.ship_group.field.state")}</dt>
<dd>{selection.group.state}</dd>
</div>
{/if}
</dl>
{:else if selection.variant === "incoming"}
{@const g = selection.group}
{@const eta = g.speed > 0 ? Math.ceil(g.distance / g.speed) : null}
<dl class="fields">
<div class="field" data-testid="inspector-ship-group-field-from">
<dt>{i18n.t("game.inspector.ship_group.field.from")}</dt>
<dd>{planetLabel(g.origin)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-to">
<dt>{i18n.t("game.inspector.ship_group.field.to")}</dt>
<dd>{planetLabel(g.destination)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-distance">
<dt>{i18n.t("game.inspector.ship_group.field.distance")}</dt>
<dd>{formatNumber(g.distance)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-speed">
<dt>{i18n.t("game.inspector.ship_group.field.speed")}</dt>
<dd>{formatNumber(g.speed)}</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-eta">
<dt>{i18n.t("game.inspector.ship_group.field.eta")}</dt>
<dd>
{eta === null
? i18n.t("game.designer.ship_class.preview.unavailable")
: eta}
</dd>
</div>
<div class="field" data-testid="inspector-ship-group-field-mass">
<dt>{i18n.t("game.inspector.ship_group.field.mass")}</dt>
<dd>{formatNumber(g.mass)}</dd>
</div>
</dl>
{:else}
<dl class="fields">
<div
class="field"
data-testid="inspector-ship-group-field-coordinates"
>
<dt>{i18n.t("game.inspector.ship_group.field.coordinates")}</dt>
<dd>
({formatNumber(selection.group.x)}, {formatNumber(selection.group.y)})
</dd>
</div>
</dl>
<p class="hint" data-testid="inspector-ship-group-no-data">
{i18n.t("game.inspector.ship_group.unidentified_no_data")}
</p>
{/if}
</section>
<style>
.inspector {
padding: 1rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.kind {
margin: 0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #aab;
}
.name {
margin: 0;
font-size: 1.05rem;
}
.fields {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.25rem;
column-gap: 0.75rem;
}
.field {
display: contents;
}
.field dt {
color: #aab;
font-size: 0.85rem;
}
.field dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.hint {
margin: 0;
color: #888;
font-size: 0.85rem;
}
</style>
@@ -7,6 +7,12 @@ only planet inspector. A selection that points at a planet missing
from the current report (e.g. visibility lost between turns) falls from the current report (e.g. visibility lost between turns) falls
back to the empty state instead of holding stale data. back to the empty state instead of holding stale data.
Phase 19 widens the dispatch: a `kind === "shipGroup"` selection
resolves against the matching report array and mounts the read-only
ship-group inspector. Unresolvable refs (e.g. the chosen index has
fallen out of the new turn's report) cleanly collapse to the empty
state — same fallback as a stale planet selection.
The empty-state copy still matches the IA section verbatim — `select The empty-state copy still matches the IA section verbatim — `select
an object on the map` — so the no-selection experience is unchanged an object on the map` — so the no-selection experience is unchanged
from the Phase 10 stub. from the Phase 10 stub.
@@ -23,6 +29,9 @@ from the Phase 10 stub.
type RenderedReportSource, type RenderedReportSource,
} from "$lib/rendered-report.svelte"; } from "$lib/rendered-report.svelte";
import Planet from "$lib/inspectors/planet.svelte"; import Planet from "$lib/inspectors/planet.svelte";
import ShipGroup, {
type ShipGroupSelection,
} from "$lib/inspectors/ship-group.svelte";
const renderedReport = getContext<RenderedReportSource | undefined>( const renderedReport = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY, RENDERED_REPORT_CONTEXT_KEY,
@@ -38,6 +47,38 @@ from the Phase 10 stub.
if (report === undefined || report === null) return null; if (report === undefined || report === null) return null;
return report.planets.find((p) => p.number === sel.id) ?? null; return report.planets.find((p) => p.number === sel.id) ?? null;
}); });
const selectedShipGroup: ShipGroupSelection | null = $derived.by(() => {
const sel = selection?.selected;
if (sel === undefined || sel === null || sel.kind !== "shipGroup") {
return null;
}
const report = renderedReport?.report;
if (report === undefined || report === null) return null;
const ref = sel.ref;
switch (ref.variant) {
case "local": {
const group = report.localShipGroups.find((g) => g.id === ref.id);
if (group === undefined) return null;
return { variant: "local", group };
}
case "other": {
const group = report.otherShipGroups[ref.index];
if (group === undefined) return null;
return { variant: "other", group };
}
case "incoming": {
const group = report.incomingShipGroups[ref.index];
if (group === undefined) return null;
return { variant: "incoming", group };
}
case "unidentified": {
const group = report.unidentifiedShipGroups[ref.index];
if (group === undefined) return null;
return { variant: "unidentified", group };
}
}
});
const localShipClass = $derived( const localShipClass = $derived(
renderedReport?.report?.localShipClass ?? [], renderedReport?.report?.localShipClass ?? [],
); );
@@ -61,6 +102,8 @@ from the Phase 10 stub.
{mapHeight} {mapHeight}
{localPlayerDrive} {localPlayerDrive}
/> />
{:else if selectedShipGroup !== null}
<ShipGroup selection={selectedShipGroup} planets={allPlanets} />
{:else} {:else}
<h3>{i18n.t("game.sidebar.tab.inspector")}</h3> <h3>{i18n.t("game.sidebar.tab.inspector")}</h3>
<p>{i18n.t("game.sidebar.empty.inspector")}</p> <p>{i18n.t("game.sidebar.empty.inspector")}</p>
@@ -52,6 +52,8 @@ fresh.
import Calculator from "$lib/sidebar/calculator-tab.svelte"; import Calculator from "$lib/sidebar/calculator-tab.svelte";
import Order from "$lib/sidebar/order-tab.svelte"; import Order from "$lib/sidebar/order-tab.svelte";
import PlanetSheet from "$lib/inspectors/planet-sheet.svelte"; import PlanetSheet from "$lib/inspectors/planet-sheet.svelte";
import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte";
import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte";
import type { MobileTool, SidebarTab } from "$lib/sidebar/types"; import type { MobileTool, SidebarTab } from "$lib/sidebar/types";
import { GameStateStore, GAME_STATE_CONTEXT_KEY } from "$lib/game-state.svelte"; import { GameStateStore, GAME_STATE_CONTEXT_KEY } from "$lib/game-state.svelte";
import { import {
@@ -139,6 +141,35 @@ fresh.
if (report === null) return null; if (report === null) return null;
return report.planets.find((p) => p.number === sel.id) ?? null; return report.planets.find((p) => p.number === sel.id) ?? null;
}); });
const selectedShipGroup: ShipGroupSelection | null = $derived.by(() => {
const sel = selection.selected;
if (sel === null || sel.kind !== "shipGroup") return null;
const report = renderedReport.report;
if (report === null) return null;
const ref = sel.ref;
switch (ref.variant) {
case "local": {
const group = report.localShipGroups.find((g) => g.id === ref.id);
if (group === undefined) return null;
return { variant: "local", group };
}
case "other": {
const group = report.otherShipGroups[ref.index];
if (group === undefined) return null;
return { variant: "other", group };
}
case "incoming": {
const group = report.incomingShipGroups[ref.index];
if (group === undefined) return null;
return { variant: "incoming", group };
}
case "unidentified": {
const group = report.unidentifiedShipGroups[ref.index];
if (group === undefined) return null;
return { variant: "unidentified", group };
}
}
});
const localShipClass = $derived( const localShipClass = $derived(
renderedReport.report?.localShipClass ?? [], renderedReport.report?.localShipClass ?? [],
); );
@@ -296,6 +327,12 @@ fresh.
onMap={effectiveTool === "map"} onMap={effectiveTool === "map"}
onClose={() => selection.clear()} onClose={() => selection.clear()}
/> />
<ShipGroupSheet
selection={selectedShipGroup}
planets={inspectorPlanets}
onMap={effectiveTool === "map"}
onClose={() => selection.clear()}
/>
</div> </div>
<style> <style>
@@ -0,0 +1,134 @@
// Phase 19 end-to-end smoke against the synthetic-report path. Loads
// a hand-crafted JSON with a Tancordia-style mix of planets and ship
// groups through the DEV-only file picker on `/lobby`, lets the
// in-game shell layout swap into synthetic mode, and asserts the map
// canvas mounts. Detailed click / hit-test fidelity for ship-group
// variants lives in the unit tests (`tests/state-binding-groups.test.ts`
// and `tests/inspector-ship-group.test.ts`); this spec catches the
// glue: lobby loader → in-memory registry → layout bypass → renderer
// boot.
import { expect, test } from "@playwright/test";
const SYNTHETIC_REPORT_FIXTURE = {
turn: 39,
mapWidth: 200,
mapHeight: 200,
mapPlanets: 4,
race: "Earthlings",
player: [
{
name: "Earthlings",
drive: 5,
weapons: 3,
shields: 2,
cargo: 1,
population: 1000,
industry: 1000,
planets: 2,
relation: "-",
votes: 5,
extinct: false,
},
],
localPlanet: [
{
number: 1,
name: "Earth",
x: 50,
y: 100,
size: 1000,
population: 1000,
industry: 1000,
resources: 10,
production: "Capital",
capital: 0,
material: 0,
colonists: 100,
freeIndustry: 1000,
},
{
number: 2,
name: "Mars",
x: 150,
y: 100,
size: 500,
population: 500,
industry: 500,
resources: 5,
production: "Capital",
capital: 0,
material: 0,
colonists: 50,
freeIndustry: 500,
},
],
otherPlanet: [],
uninhabitedPlanet: [],
unidentifiedPlanet: [
{ number: 3, x: 50, y: 50 },
{ number: 4, x: 150, y: 50 },
],
localShipClass: [
{
name: "Frontier",
drive: 5,
armament: 0,
weapons: 0,
shields: 0,
cargo: 1,
mass: 12,
},
],
localGroup: [
{
id: "11111111-2222-3333-4444-555555555555",
number: 2,
class: "Frontier",
tech: { drive: 5, weapons: 0, shields: 0, cargo: 1 },
cargo: "-",
load: 0,
destination: 1,
speed: 0,
mass: 12,
state: "In_Orbit",
},
],
otherGroup: [],
incomingGroup: [
{
origin: 4,
destination: 1,
distance: 50,
speed: 25,
mass: 4,
},
],
unidentifiedGroup: [],
localFleet: [],
};
test("synthetic-report loader navigates from lobby to map and renders", async ({
page,
}) => {
await page.goto("/lobby");
await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible();
const file = page.getByTestId("lobby-synthetic-file");
await file.setInputFiles({
name: "phase19.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(SYNTHETIC_REPORT_FIXTURE)),
});
await page.waitForURL(/\/games\/synthetic-[^/]+\/map$/, {
timeout: 5_000,
});
// The renderer canvas mounts inside the active-view host. Even if
// the WebGL/WebGPU backend is unavailable in CI, the layout still
// reaches `ready` once the report is loaded — the assertion is
// gentle on purpose so the spec doesn't flake on headless renders.
const canvas = page.locator("canvas");
await expect(canvas.first()).toBeVisible({ timeout: 10_000 });
});
@@ -0,0 +1,231 @@
// Vitest component coverage for the Phase 19 read-only ship-group
// inspector. The inspector dispatches on the selection variant —
// local / other / incoming / unidentified — and renders a fixed set
// of fields per branch. The tests assert each branch surfaces the
// fields the acceptance criteria call out, plus the
// "no destination resolution" fallback.
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 {
ReportIncomingShipGroup,
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
ReportUnidentifiedShipGroup,
} from "../src/api/game-state";
import ShipGroup, {
type ShipGroupSelection,
} from "../src/lib/inspectors/ship-group.svelte";
const PLANETS: ReportPlanet[] = [
{
number: 17,
name: "Castle",
x: 100,
y: 100,
kind: "local",
owner: null,
size: 1000,
resources: 10,
industryStockpile: 0,
materialsStockpile: 0,
industry: 1000,
population: 1000,
colonists: 0,
production: "Capital",
freeIndustry: 1000,
},
{
number: 99,
name: "Outpost",
x: 200,
y: 200,
kind: "other",
owner: "Foreign",
size: 500,
resources: 5,
industryStockpile: 0,
materialsStockpile: 0,
industry: 500,
population: 500,
colonists: 0,
production: "Capital",
freeIndustry: 500,
},
];
beforeEach(() => {
i18n.resetForTests("en");
});
function localGroup(
overrides: Partial<ReportLocalShipGroup> = {},
): ReportLocalShipGroup {
return {
id: "uuid-1",
count: 2,
class: "Frontier",
tech: { drive: 5, weapons: 0, shields: 0, cargo: 1 },
cargo: "NONE",
load: 0,
destination: 17,
origin: null,
range: null,
speed: 0,
mass: 12,
state: "In_Orbit",
fleet: null,
...overrides,
};
}
describe("ship-group inspector", () => {
test("renders the on-planet local group with all required fields", () => {
const selection: ShipGroupSelection = {
variant: "local",
group: localGroup(),
};
const ui = render(ShipGroup, { props: { selection, planets: PLANETS } });
expect(ui.getByTestId("inspector-ship-group-class")).toHaveTextContent(
"Frontier",
);
expect(ui.getByTestId("inspector-ship-group-field-count")).toHaveTextContent(
"2",
);
expect(ui.getByTestId("inspector-ship-group-field-drive")).toHaveTextContent(
"5",
);
expect(
ui.getByTestId("inspector-ship-group-field-location"),
).toHaveTextContent("Castle");
expect(ui.getByTestId("inspector-ship-group-field-state")).toHaveTextContent(
"In_Orbit",
);
expect(
ui.queryByTestId("inspector-ship-group-field-distance"),
).toBeNull();
});
test("renders the in-hyperspace local group with from / to / distance", () => {
const selection: ShipGroupSelection = {
variant: "local",
group: localGroup({
origin: 17,
range: 4.5,
destination: 99,
state: "In_Space",
}),
};
const ui = render(ShipGroup, { props: { selection, planets: PLANETS } });
expect(ui.getByTestId("inspector-ship-group-field-from")).toHaveTextContent(
"Castle",
);
expect(ui.getByTestId("inspector-ship-group-field-to")).toHaveTextContent(
"Outpost",
);
expect(
ui.getByTestId("inspector-ship-group-field-distance"),
).toHaveTextContent("4.5");
expect(
ui.queryByTestId("inspector-ship-group-field-location"),
).toBeNull();
});
test("renders cargo type and amount when the group is loaded", () => {
const selection: ShipGroupSelection = {
variant: "local",
group: localGroup({ cargo: "COL", load: 1.05 }),
};
const ui = render(ShipGroup, { props: { selection, planets: PLANETS } });
const cargo = ui.getByTestId("inspector-ship-group-field-cargo-load");
expect(cargo).toHaveTextContent("colonists");
expect(cargo).toHaveTextContent("1.05");
});
test("renders foreign group without fleet/state but with full tech", () => {
const group: ReportOtherShipGroup = {
count: 5,
class: "Cruiser",
tech: { drive: 8, weapons: 4, shields: 3, cargo: 1 },
cargo: "NONE",
load: 0,
destination: 99,
origin: null,
range: null,
speed: 0,
mass: 50,
};
const selection: ShipGroupSelection = { variant: "other", group };
const ui = render(ShipGroup, { props: { selection, planets: PLANETS } });
expect(ui.getByTestId("inspector-ship-group-class")).toHaveTextContent(
"Cruiser",
);
expect(ui.queryByTestId("inspector-ship-group-field-fleet")).toBeNull();
expect(ui.queryByTestId("inspector-ship-group-field-state")).toBeNull();
});
test("incoming group surfaces ETA and trajectory fields", () => {
const group: ReportIncomingShipGroup = {
origin: 99,
destination: 17,
distance: 80,
speed: 25,
mass: 4,
};
const selection: ShipGroupSelection = { variant: "incoming", group };
const ui = render(ShipGroup, { props: { selection, planets: PLANETS } });
expect(ui.getByTestId("inspector-ship-group-field-from")).toHaveTextContent(
"Outpost",
);
expect(ui.getByTestId("inspector-ship-group-field-to")).toHaveTextContent(
"Castle",
);
// ETA = ceil(80 / 25) = 4.
expect(ui.getByTestId("inspector-ship-group-field-eta")).toHaveTextContent(
"4",
);
expect(
ui.getByTestId("inspector-ship-group-field-distance"),
).toHaveTextContent("80");
});
test("incoming group with zero speed renders ETA as the dash placeholder", () => {
const group: ReportIncomingShipGroup = {
origin: 99,
destination: 17,
distance: 80,
speed: 0,
mass: 4,
};
const selection: ShipGroupSelection = { variant: "incoming", group };
const ui = render(ShipGroup, { props: { selection, planets: PLANETS } });
expect(ui.getByTestId("inspector-ship-group-field-eta")).toHaveTextContent(
"—",
);
});
test("unidentified group renders coordinates and the no-data hint", () => {
const group: ReportUnidentifiedShipGroup = { x: 555.5, y: 222.25 };
const selection: ShipGroupSelection = { variant: "unidentified", group };
const ui = render(ShipGroup, { props: { selection, planets: PLANETS } });
const coords = ui.getByTestId("inspector-ship-group-field-coordinates");
expect(coords).toHaveTextContent("555.5");
expect(coords).toHaveTextContent("222.25");
expect(ui.getByTestId("inspector-ship-group-no-data")).toBeInTheDocument();
});
test("planet name resolves to '#NN' when missing from the planet list", () => {
const selection: ShipGroupSelection = {
variant: "local",
group: localGroup({ destination: 999 }),
};
const ui = render(ShipGroup, { props: { selection, planets: PLANETS } });
expect(
ui.getByTestId("inspector-ship-group-field-location"),
).toHaveTextContent("#999");
});
});