Merge pull request 'feat(ui): F8-10 — tables planets / ship-groups / fleets, ship-classes delete guard (#53)' (#68) from feature/issue-53-table-planets-groups-fleets into development
This commit was merged in pull request #68.
This commit is contained in:
@@ -63,8 +63,10 @@ preference the store already manages.
|
|||||||
} from "$lib/game-state.svelte";
|
} from "$lib/game-state.svelte";
|
||||||
import {
|
import {
|
||||||
SELECTION_CONTEXT_KEY,
|
SELECTION_CONTEXT_KEY,
|
||||||
|
type Selected,
|
||||||
type SelectionStore,
|
type SelectionStore,
|
||||||
} from "$lib/selection.svelte";
|
} from "$lib/selection.svelte";
|
||||||
|
import { computeInSpacePosition } from "../../map/ship-groups";
|
||||||
import {
|
import {
|
||||||
RENDERED_REPORT_CONTEXT_KEY,
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
type RenderedReportSource,
|
type RenderedReportSource,
|
||||||
@@ -465,15 +467,23 @@ preference the store already manages.
|
|||||||
if (canvasEl === null || containerEl === null) return;
|
if (canvasEl === null || containerEl === null) return;
|
||||||
// Capture camera state before disposing so a remount inside
|
// Capture camera state before disposing so a remount inside
|
||||||
// the same game (e.g. cargo-route overlay change) keeps the
|
// the same game (e.g. cargo-route overlay change) keeps the
|
||||||
// user's pan/zoom. A new game / first mount has no prior
|
// user's pan / zoom. On a cold mount with no live `handle` we
|
||||||
// camera, so `previousCamera` stays null and the default
|
// fall back to the per-game `store.lastCamera` snapshot, so
|
||||||
// centring path runs.
|
// leaving the map for a table / report and coming back also
|
||||||
|
// restores the prior view. A new game / first mount has no
|
||||||
|
// prior camera in either source, so `previousCamera` stays
|
||||||
|
// null and the default centring path runs.
|
||||||
const previousGameId = mountedGameId;
|
const previousGameId = mountedGameId;
|
||||||
const targetGameId = store?.gameId ?? "";
|
const targetGameId = store?.gameId ?? "";
|
||||||
const previousCamera =
|
let previousCamera: ReturnType<RendererHandle["getCamera"]> | null = null;
|
||||||
handle !== null && previousGameId === targetGameId
|
if (handle !== null && previousGameId === targetGameId) {
|
||||||
? handle.getCamera()
|
previousCamera = handle.getCamera();
|
||||||
: null;
|
} else if (handle === null && store?.lastCamera) {
|
||||||
|
previousCamera = store.lastCamera;
|
||||||
|
}
|
||||||
|
if (handle !== null && store !== undefined) {
|
||||||
|
store.lastCamera = handle.getCamera();
|
||||||
|
}
|
||||||
if (detachClick !== null) {
|
if (detachClick !== null) {
|
||||||
detachClick();
|
detachClick();
|
||||||
detachClick = null;
|
detachClick = null;
|
||||||
@@ -515,7 +525,27 @@ preference the store already manages.
|
|||||||
},
|
},
|
||||||
world,
|
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
|
// Same-game remount — preserve pan/zoom. Clamp zoom
|
||||||
// to `minScale` so a remount that re-derives the
|
// to `minScale` so a remount that re-derives the
|
||||||
// minimum (e.g. a viewport resize between renderers)
|
// minimum (e.g. a viewport resize between renderers)
|
||||||
@@ -642,6 +672,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<GameStateStore["report"]>,
|
||||||
|
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
|
// handleMapClick translates a renderer click into a selection
|
||||||
// update. A click that misses every primitive (empty space) is a
|
// update. A click that misses every primitive (empty space) is a
|
||||||
// deliberate no-op: the selection rule from Phase 13 is that only
|
// deliberate no-op: the selection rule from Phase 13 is that only
|
||||||
@@ -748,6 +817,9 @@ preference the store already manages.
|
|||||||
detachDebugSurface = null;
|
detachDebugSurface = null;
|
||||||
}
|
}
|
||||||
if (handle !== null) {
|
if (handle !== null) {
|
||||||
|
// Persist the camera snapshot to the per-game store so the
|
||||||
|
// next mount (active-view switch back to map) restores it.
|
||||||
|
if (store !== undefined) store.lastCamera = handle.getCamera();
|
||||||
handle.dispose();
|
handle.dispose();
|
||||||
handle = null;
|
handle = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// F8-10 fleets table — module-level filter / sort rune.
|
||||||
|
// Mirrors `table-planets-state.svelte.ts`. See that file for the
|
||||||
|
// rationale.
|
||||||
|
|
||||||
|
export type FleetsSortColumn = "name" | "groupCount" | "state" | "location" | "speed";
|
||||||
|
export type FleetsSortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface FleetsTableState {
|
||||||
|
sortColumn: FleetsSortColumn;
|
||||||
|
sortDirection: FleetsSortDirection;
|
||||||
|
planetFilter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE: FleetsTableState = {
|
||||||
|
sortColumn: "name",
|
||||||
|
sortDirection: "asc",
|
||||||
|
planetFilter: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fleetsTableState: FleetsTableState = $state({ ...DEFAULT_STATE });
|
||||||
|
|
||||||
|
export function resetFleetsTableState(): void {
|
||||||
|
Object.assign(fleetsTableState, DEFAULT_STATE);
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
<!--
|
||||||
|
F8-10 fleets table. The report only carries the player's own fleets
|
||||||
|
(`localFleets`) — there is no foreign analogue — so the table is a
|
||||||
|
single block without owner toggles. Filters are reduced to the
|
||||||
|
shared planet dropdown (matching destination OR origin).
|
||||||
|
|
||||||
|
Click semantics:
|
||||||
|
- on-planet fleet (origin === null && range === null) focuses the
|
||||||
|
destination planet via `SelectionStore.focus`, identical to the
|
||||||
|
ship-groups behaviour;
|
||||||
|
- in-space fleet centres the camera on the interpolated fleet
|
||||||
|
position via `SelectionStore.focusPoint`, leaving the selection
|
||||||
|
unchanged (the `Selected` union has no "fleet" variant and
|
||||||
|
extending it for one table would be needless surface area).
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import type { ReportLocalFleet, ReportPlanet } from "../../api/game-state";
|
||||||
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
|
import { planetLabel } from "./report/format";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import {
|
||||||
|
SELECTION_CONTEXT_KEY,
|
||||||
|
type SelectionStore,
|
||||||
|
} from "$lib/selection.svelte";
|
||||||
|
import ViewState from "$lib/ui/view-state.svelte";
|
||||||
|
import { formatFloat, formatInt } from "$lib/util/number-format";
|
||||||
|
import {
|
||||||
|
fleetsTableState as persistent,
|
||||||
|
type FleetsSortColumn as SortColumn,
|
||||||
|
} from "./table-fleets-state.svelte";
|
||||||
|
|
||||||
|
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
|
||||||
|
name: "game.table.fleets.column.name",
|
||||||
|
groupCount: "game.table.fleets.column.groups",
|
||||||
|
state: "game.table.fleets.column.state",
|
||||||
|
location: "game.table.fleets.column.location",
|
||||||
|
speed: "game.table.fleets.column.speed",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLUMNS: readonly SortColumn[] = [
|
||||||
|
"name",
|
||||||
|
"groupCount",
|
||||||
|
"state",
|
||||||
|
"location",
|
||||||
|
"speed",
|
||||||
|
];
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
||||||
|
|
||||||
|
// `persistent` (module-level rune above) drives the dropdown
|
||||||
|
// selection so the user's planet filter survives navigation.
|
||||||
|
|
||||||
|
const reportLoaded = $derived(
|
||||||
|
rendered?.report !== null && rendered?.report !== undefined,
|
||||||
|
);
|
||||||
|
const fleets = $derived<ReportLocalFleet[]>(
|
||||||
|
rendered?.report?.localFleets ?? [],
|
||||||
|
);
|
||||||
|
const allPlanets = $derived<ReportPlanet[]>(rendered?.report?.planets ?? []);
|
||||||
|
const planetIndex = $derived(
|
||||||
|
new Map(allPlanets.map((p) => [p.number, p])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const planets = $derived.by(() => {
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const f of fleets) {
|
||||||
|
set.add(f.destination);
|
||||||
|
if (f.origin !== null) set.add(f.origin);
|
||||||
|
}
|
||||||
|
return Array.from(set).sort((a, b) => a - b);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const planet =
|
||||||
|
persistent.planetFilter === "" ? null : Number(persistent.planetFilter);
|
||||||
|
return fleets.filter((f) => {
|
||||||
|
if (planet === null) return true;
|
||||||
|
return f.destination === planet || f.origin === planet;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorted = $derived.by(() => {
|
||||||
|
const list = [...filtered];
|
||||||
|
const dir = persistent.sortDirection === "asc" ? 1 : -1;
|
||||||
|
list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
function compare(
|
||||||
|
a: ReportLocalFleet,
|
||||||
|
b: ReportLocalFleet,
|
||||||
|
column: SortColumn,
|
||||||
|
): number {
|
||||||
|
switch (column) {
|
||||||
|
case "name":
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
case "groupCount":
|
||||||
|
return a.groupCount - b.groupCount;
|
||||||
|
case "state":
|
||||||
|
return a.state.localeCompare(b.state);
|
||||||
|
case "location": {
|
||||||
|
if (a.destination !== b.destination) return a.destination - b.destination;
|
||||||
|
const ao = a.origin ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const bo = b.origin ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
return ao - bo;
|
||||||
|
}
|
||||||
|
case "speed":
|
||||||
|
return a.speed - b.speed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort(column: SortColumn): void {
|
||||||
|
if (persistent.sortColumn === column) {
|
||||||
|
persistent.sortDirection = persistent.sortDirection === "asc" ? "desc" : "asc";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
persistent.sortColumn = column;
|
||||||
|
persistent.sortDirection = "asc";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
|
||||||
|
if (persistent.sortColumn !== column) return "none";
|
||||||
|
return persistent.sortDirection === "asc" ? "ascending" : "descending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInSpace(f: ReportLocalFleet): boolean {
|
||||||
|
return f.origin !== null && f.range !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationLabel(f: ReportLocalFleet): string {
|
||||||
|
if (!isInSpace(f)) {
|
||||||
|
return `@ ${planetLabel(f.destination, allPlanets)}`;
|
||||||
|
}
|
||||||
|
const from = planetLabel(f.origin!, allPlanets);
|
||||||
|
const to = planetLabel(f.destination, allPlanets);
|
||||||
|
return `${from} → ${to} (${formatFloat(f.range!)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOnMap(f: ReportLocalFleet): void {
|
||||||
|
if (selection === undefined) {
|
||||||
|
activeView.select("map");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isInSpace(f)) {
|
||||||
|
selection.focus({ kind: "planet", id: f.destination });
|
||||||
|
activeView.select("map");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dest = planetIndex.get(f.destination);
|
||||||
|
const origin = planetIndex.get(f.origin!);
|
||||||
|
if (dest !== undefined && origin !== undefined) {
|
||||||
|
const dx = dest.x - origin.x;
|
||||||
|
const dy = dest.y - origin.y;
|
||||||
|
const total = Math.hypot(dx, dy);
|
||||||
|
if (total === 0 || f.range! <= 0) {
|
||||||
|
selection.focusPoint(dest.x, dest.y);
|
||||||
|
} else {
|
||||||
|
const t = Math.min(1, f.range! / total);
|
||||||
|
selection.focusPoint(dest.x + t * (origin.x - dest.x), dest.y + t * (origin.y - dest.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeView.select("map");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="active-view"
|
||||||
|
data-testid="active-view-table"
|
||||||
|
data-entity="fleets"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<h2>{i18n.t("game.table.fleets.title")}</h2>
|
||||||
|
<div class="controls">
|
||||||
|
{#if planets.length > 0}
|
||||||
|
<label class="dropdown">
|
||||||
|
<span>{i18n.t("game.table.fleets.filter.planet")}</span>
|
||||||
|
<select
|
||||||
|
data-testid="fleets-filter-planet"
|
||||||
|
bind:value={persistent.planetFilter}
|
||||||
|
>
|
||||||
|
<option value=""
|
||||||
|
>{i18n.t("game.table.fleets.filter.planet.all")}</option
|
||||||
|
>
|
||||||
|
{#each planets as n (n)}
|
||||||
|
<option value={String(n)}
|
||||||
|
>{planetLabel(n, allPlanets)}</option
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if !reportLoaded}
|
||||||
|
<ViewState
|
||||||
|
kind="loading"
|
||||||
|
testid="fleets-loading"
|
||||||
|
message={i18n.t("game.table.fleets.loading")}
|
||||||
|
/>
|
||||||
|
{:else if fleets.length === 0}
|
||||||
|
<ViewState
|
||||||
|
kind="empty"
|
||||||
|
testid="fleets-empty"
|
||||||
|
message={i18n.t("game.table.fleets.empty")}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="fleets-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{#each COLUMNS as column (column)}
|
||||||
|
<th
|
||||||
|
aria-sort={ariaSort(column)}
|
||||||
|
class:numeric={column === "groupCount" || column === "speed"}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sort"
|
||||||
|
data-testid="fleets-column-{column}"
|
||||||
|
onclick={() => toggleSort(column)}
|
||||||
|
>
|
||||||
|
{i18n.t(COLUMN_LABELS[column])}
|
||||||
|
{#if persistent.sortColumn === column}
|
||||||
|
<span class="sort-indicator" aria-hidden="true">
|
||||||
|
{persistent.sortDirection === "asc" ? "▲" : "▼"}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each sorted as f (f.name)}
|
||||||
|
<tr
|
||||||
|
data-testid="fleets-row"
|
||||||
|
data-name={f.name}
|
||||||
|
data-in-space={isInSpace(f)}
|
||||||
|
onclick={() => openOnMap(f)}
|
||||||
|
>
|
||||||
|
<td data-testid="fleets-cell-name">{f.name || "—"}</td>
|
||||||
|
<td class="numeric" data-testid="fleets-cell-groups">
|
||||||
|
{formatInt(f.groupCount)}
|
||||||
|
</td>
|
||||||
|
<td data-testid="fleets-cell-state">{f.state || "—"}</td>
|
||||||
|
<td data-testid="fleets-cell-location">{locationLabel(f)}</td>
|
||||||
|
<td class="numeric" data-testid="fleets-cell-speed">
|
||||||
|
{formatFloat(f.speed)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.active-view {
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.dropdown {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.dropdown select {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid th.numeric,
|
||||||
|
.grid td.numeric {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.grid th.numeric .sort {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.grid tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
.sort {
|
||||||
|
font: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.sort-indicator {
|
||||||
|
font-size: 0.7em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// F8-10 planets table — module-level filter / sort rune.
|
||||||
|
//
|
||||||
|
// Held outside the component so the user's filter selections survive
|
||||||
|
// the component being unmounted (e.g. row click → map → back to the
|
||||||
|
// table). Held in memory only; an F5 reloads the report and the
|
||||||
|
// defaults take over.
|
||||||
|
|
||||||
|
export type PlanetSortColumn =
|
||||||
|
| "number"
|
||||||
|
| "name"
|
||||||
|
| "kind"
|
||||||
|
| "owner"
|
||||||
|
| "size"
|
||||||
|
| "resources";
|
||||||
|
|
||||||
|
export type PlanetSortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface PlanetsTableState {
|
||||||
|
sortColumn: PlanetSortColumn;
|
||||||
|
sortDirection: PlanetSortDirection;
|
||||||
|
showLocal: boolean;
|
||||||
|
showOther: boolean;
|
||||||
|
showUninhabited: boolean;
|
||||||
|
showUnknown: boolean;
|
||||||
|
ownerFilter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE: PlanetsTableState = {
|
||||||
|
sortColumn: "number",
|
||||||
|
sortDirection: "asc",
|
||||||
|
showLocal: true,
|
||||||
|
showOther: true,
|
||||||
|
showUninhabited: true,
|
||||||
|
showUnknown: true,
|
||||||
|
ownerFilter: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const planetsTableState: PlanetsTableState = $state({ ...DEFAULT_STATE });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resetPlanetsTableState restores every field to its default value.
|
||||||
|
* Production code never calls it; the Vitest harness uses it from
|
||||||
|
* `beforeEach` to keep cases independent (the rune is a module-level
|
||||||
|
* singleton that otherwise carries state across test boundaries).
|
||||||
|
*/
|
||||||
|
export function resetPlanetsTableState(): void {
|
||||||
|
Object.assign(planetsTableState, DEFAULT_STATE);
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
<!--
|
||||||
|
F8-10 planets table. Lists every planet from the rendered report with
|
||||||
|
4 kind checkboxes (own / foreign / uninhabited / unknown) and a race
|
||||||
|
dropdown that filters foreign planets by owner. A row click focuses
|
||||||
|
the planet on the map (selection + camera centre) via the transient
|
||||||
|
`SelectionStore.focus` channel — `map.svelte` consumes it on mount.
|
||||||
|
|
||||||
|
Lives inside the active-view slot; reads the report and the selection
|
||||||
|
store from context, matching the surrounding tables. No data
|
||||||
|
fetching here.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import type { ReportPlanet } from "../../api/game-state";
|
||||||
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import {
|
||||||
|
SELECTION_CONTEXT_KEY,
|
||||||
|
type SelectionStore,
|
||||||
|
} from "$lib/selection.svelte";
|
||||||
|
import ViewState from "$lib/ui/view-state.svelte";
|
||||||
|
import { formatFloat, formatInt } from "$lib/util/number-format";
|
||||||
|
import {
|
||||||
|
planetsTableState as persistent,
|
||||||
|
type PlanetSortColumn as SortColumn,
|
||||||
|
} from "./table-planets-state.svelte";
|
||||||
|
|
||||||
|
type PlanetKind = ReportPlanet["kind"];
|
||||||
|
|
||||||
|
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
|
||||||
|
number: "game.table.planets.column.number",
|
||||||
|
name: "game.table.planets.column.name",
|
||||||
|
kind: "game.table.planets.column.kind",
|
||||||
|
owner: "game.table.planets.column.owner",
|
||||||
|
size: "game.table.planets.column.size",
|
||||||
|
resources: "game.table.planets.column.resources",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLUMNS: readonly SortColumn[] = [
|
||||||
|
"number",
|
||||||
|
"name",
|
||||||
|
"kind",
|
||||||
|
"owner",
|
||||||
|
"size",
|
||||||
|
"resources",
|
||||||
|
];
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<PlanetKind, TranslationKey> = {
|
||||||
|
local: "game.table.planets.kind.own",
|
||||||
|
other: "game.table.planets.kind.foreign",
|
||||||
|
uninhabited: "game.table.planets.kind.uninhabited",
|
||||||
|
unidentified: "game.table.planets.kind.unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_ORDER: Record<PlanetKind, number> = {
|
||||||
|
local: 0,
|
||||||
|
other: 1,
|
||||||
|
uninhabited: 2,
|
||||||
|
unidentified: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
||||||
|
|
||||||
|
// `persistent` (declared in the module script above) is the live
|
||||||
|
// source for every filter / sort selection so leaving the table
|
||||||
|
// and coming back restores the prior state. Kind toggles default
|
||||||
|
// to all-on; owner narrows the `other` slice only — "" means
|
||||||
|
// "all owners". The owner dropdown is conditional on
|
||||||
|
// `persistent.showOther`, so toggling foreign off hides the
|
||||||
|
// chooser; the stored owner value is preserved across that flip.
|
||||||
|
|
||||||
|
const planets = $derived<ReportPlanet[]>(rendered?.report?.planets ?? []);
|
||||||
|
const reportLoaded = $derived(
|
||||||
|
rendered?.report !== null && rendered?.report !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const owners = $derived.by(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const p of planets) {
|
||||||
|
if (p.kind === "other" && p.owner !== null && p.owner !== "") {
|
||||||
|
set.add(p.owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
return planets.filter((p) => {
|
||||||
|
if (p.kind === "local" && !persistent.showLocal) return false;
|
||||||
|
if (p.kind === "uninhabited" && !persistent.showUninhabited) return false;
|
||||||
|
if (p.kind === "unidentified" && !persistent.showUnknown) return false;
|
||||||
|
if (p.kind === "other") {
|
||||||
|
if (!persistent.showOther) return false;
|
||||||
|
if (persistent.ownerFilter !== "" && p.owner !== persistent.ownerFilter) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorted = $derived.by(() => {
|
||||||
|
const list = [...filtered];
|
||||||
|
const dir = persistent.sortDirection === "asc" ? 1 : -1;
|
||||||
|
list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
function compare(a: ReportPlanet, b: ReportPlanet, column: SortColumn): number {
|
||||||
|
switch (column) {
|
||||||
|
case "number":
|
||||||
|
return a.number - b.number;
|
||||||
|
case "name":
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
case "kind":
|
||||||
|
return KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
|
||||||
|
case "owner": {
|
||||||
|
const ao = a.owner ?? "";
|
||||||
|
const bo = b.owner ?? "";
|
||||||
|
return ao.localeCompare(bo);
|
||||||
|
}
|
||||||
|
case "size":
|
||||||
|
return (a.size ?? -1) - (b.size ?? -1);
|
||||||
|
case "resources":
|
||||||
|
return (a.resources ?? -1) - (b.resources ?? -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort(column: SortColumn): void {
|
||||||
|
if (persistent.sortColumn === column) {
|
||||||
|
persistent.sortDirection = persistent.sortDirection === "asc" ? "desc" : "asc";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
persistent.sortColumn = column;
|
||||||
|
persistent.sortDirection = "asc";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
|
||||||
|
if (persistent.sortColumn !== column) return "none";
|
||||||
|
return persistent.sortDirection === "asc" ? "ascending" : "descending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOnMap(planet: ReportPlanet): void {
|
||||||
|
selection?.focus({ kind: "planet", id: planet.number });
|
||||||
|
activeView.select("map");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerDisplay(p: ReportPlanet): string {
|
||||||
|
if (p.kind === "other") return p.owner ?? "";
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="active-view"
|
||||||
|
data-testid="active-view-table"
|
||||||
|
data-entity="planets"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<h2>{i18n.t("game.table.planets.title")}</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<label class="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="planets-filter-own"
|
||||||
|
bind:checked={persistent.showLocal}
|
||||||
|
/>
|
||||||
|
<span>{i18n.t("game.table.planets.kind.own")}</span>
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="planets-filter-uninhabited"
|
||||||
|
bind:checked={persistent.showUninhabited}
|
||||||
|
/>
|
||||||
|
<span>{i18n.t("game.table.planets.kind.uninhabited")}</span>
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="planets-filter-unknown"
|
||||||
|
bind:checked={persistent.showUnknown}
|
||||||
|
/>
|
||||||
|
<span>{i18n.t("game.table.planets.kind.unknown")}</span>
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="planets-filter-foreign"
|
||||||
|
bind:checked={persistent.showOther}
|
||||||
|
/>
|
||||||
|
<span>{i18n.t("game.table.planets.kind.foreign")}</span>
|
||||||
|
</label>
|
||||||
|
{#if persistent.showOther && owners.length > 0}
|
||||||
|
<label class="owner">
|
||||||
|
<span>{i18n.t("game.table.planets.filter.owner")}</span>
|
||||||
|
<select
|
||||||
|
data-testid="planets-filter-owner"
|
||||||
|
bind:value={persistent.ownerFilter}
|
||||||
|
>
|
||||||
|
<option value=""
|
||||||
|
>{i18n.t("game.table.planets.filter.owner.all")}</option
|
||||||
|
>
|
||||||
|
{#each owners as owner (owner)}
|
||||||
|
<option value={owner}>{owner}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if !reportLoaded}
|
||||||
|
<ViewState
|
||||||
|
kind="loading"
|
||||||
|
testid="planets-loading"
|
||||||
|
message={i18n.t("game.table.planets.loading")}
|
||||||
|
/>
|
||||||
|
{:else if planets.length === 0}
|
||||||
|
<ViewState
|
||||||
|
kind="empty"
|
||||||
|
testid="planets-empty"
|
||||||
|
message={i18n.t("game.table.planets.empty")}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="planets-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{#each COLUMNS as column (column)}
|
||||||
|
<th
|
||||||
|
aria-sort={ariaSort(column)}
|
||||||
|
class:numeric={column === "number" ||
|
||||||
|
column === "size" ||
|
||||||
|
column === "resources"}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sort"
|
||||||
|
data-testid="planets-column-{column}"
|
||||||
|
onclick={() => toggleSort(column)}
|
||||||
|
>
|
||||||
|
{i18n.t(COLUMN_LABELS[column])}
|
||||||
|
{#if persistent.sortColumn === column}
|
||||||
|
<span class="sort-indicator" aria-hidden="true">
|
||||||
|
{persistent.sortDirection === "asc" ? "▲" : "▼"}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
<th class="numeric">
|
||||||
|
{i18n.t("game.table.planets.column.coordinates")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each sorted as p (p.number)}
|
||||||
|
<tr
|
||||||
|
data-testid="planets-row"
|
||||||
|
data-number={p.number}
|
||||||
|
data-kind={p.kind}
|
||||||
|
onclick={() => openOnMap(p)}
|
||||||
|
>
|
||||||
|
<td class="numeric" data-testid="planets-cell-number">
|
||||||
|
{p.number}
|
||||||
|
</td>
|
||||||
|
<td data-testid="planets-cell-name">{p.name || "—"}</td>
|
||||||
|
<td data-testid="planets-cell-kind">
|
||||||
|
{i18n.t(KIND_LABELS[p.kind])}
|
||||||
|
</td>
|
||||||
|
<td data-testid="planets-cell-owner">{ownerDisplay(p)}</td>
|
||||||
|
<td class="numeric" data-testid="planets-cell-size">
|
||||||
|
{p.size === null ? "—" : formatFloat(p.size)}
|
||||||
|
</td>
|
||||||
|
<td class="numeric" data-testid="planets-cell-resources">
|
||||||
|
{p.resources === null ? "—" : formatFloat(p.resources)}
|
||||||
|
</td>
|
||||||
|
<td class="numeric" data-testid="planets-cell-coordinates">
|
||||||
|
{formatInt(p.x)},{formatInt(p.y)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.active-view {
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.check {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.check input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.owner {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.owner select {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid th.numeric,
|
||||||
|
.grid td.numeric {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.grid th.numeric .sort {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.grid tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
.sort {
|
||||||
|
font: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.sort-indicator {
|
||||||
|
font-size: 0.7em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -73,6 +73,21 @@ data fetching is performed here — the layout is responsible.
|
|||||||
);
|
);
|
||||||
const reportLoaded = $derived(rendered?.report !== null && rendered?.report !== undefined);
|
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<string, number>();
|
||||||
|
for (const group of rendered?.report?.localShipGroups ?? []) {
|
||||||
|
map.set(group.class, (map.get(group.class) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
const filtered = $derived.by(() => {
|
const filtered = $derived.by(() => {
|
||||||
const needle = filter.trim().toLowerCase();
|
const needle = filter.trim().toLowerCase();
|
||||||
if (needle === "") return localShipClass;
|
if (needle === "") return localShipClass;
|
||||||
@@ -123,6 +138,13 @@ data fetching is performed here — the layout is responsible.
|
|||||||
name,
|
name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteTooltip(count: number): string {
|
||||||
|
if (count === 0) return "";
|
||||||
|
return i18n.t("game.table.ship_classes.action.delete.in_use", {
|
||||||
|
count: String(count),
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -189,6 +211,7 @@ data fetching is performed here — the layout is responsible.
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each sorted as cls (cls.name)}
|
{#each sorted as cls (cls.name)}
|
||||||
|
{@const inUse = inUseCounts.get(cls.name) ?? 0}
|
||||||
<tr
|
<tr
|
||||||
data-testid="ship-classes-row"
|
data-testid="ship-classes-row"
|
||||||
data-name={cls.name}
|
data-name={cls.name}
|
||||||
@@ -215,6 +238,9 @@ data fetching is performed here — the layout is responsible.
|
|||||||
type="button"
|
type="button"
|
||||||
class="delete"
|
class="delete"
|
||||||
data-testid="ship-classes-delete"
|
data-testid="ship-classes-delete"
|
||||||
|
data-in-use={inUse}
|
||||||
|
disabled={inUse > 0 || draft === undefined}
|
||||||
|
title={deleteTooltip(inUse)}
|
||||||
onclick={() => void deleteShipClass(cls.name)}
|
onclick={() => void deleteShipClass(cls.name)}
|
||||||
>
|
>
|
||||||
{i18n.t("game.table.ship_classes.action.delete")}
|
{i18n.t("game.table.ship_classes.action.delete")}
|
||||||
@@ -333,7 +359,12 @@ data fetching is performed here — the layout is responsible.
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.delete:hover {
|
.delete:not(:disabled):hover {
|
||||||
border-color: var(--color-danger);
|
border-color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
.delete:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// F8-10 ship-groups table — module-level filter / sort rune.
|
||||||
|
// Mirrors `table-planets-state.svelte.ts`. See that file for the
|
||||||
|
// rationale.
|
||||||
|
|
||||||
|
export type ShipGroupsSortColumn =
|
||||||
|
| "owner"
|
||||||
|
| "class"
|
||||||
|
| "count"
|
||||||
|
| "race"
|
||||||
|
| "location"
|
||||||
|
| "mass"
|
||||||
|
| "speed";
|
||||||
|
|
||||||
|
export type ShipGroupsSortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface ShipGroupsTableState {
|
||||||
|
sortColumn: ShipGroupsSortColumn;
|
||||||
|
sortDirection: ShipGroupsSortDirection;
|
||||||
|
showOwn: boolean;
|
||||||
|
showForeign: boolean;
|
||||||
|
planetFilter: string;
|
||||||
|
classFilter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE: ShipGroupsTableState = {
|
||||||
|
sortColumn: "owner",
|
||||||
|
sortDirection: "asc",
|
||||||
|
showOwn: true,
|
||||||
|
showForeign: true,
|
||||||
|
planetFilter: "",
|
||||||
|
classFilter: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shipGroupsTableState: ShipGroupsTableState = $state({
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function resetShipGroupsTableState(): void {
|
||||||
|
Object.assign(shipGroupsTableState, DEFAULT_STATE);
|
||||||
|
}
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
<!--
|
||||||
|
F8-10 ship-groups table. Lists own (`localShipGroups`) and foreign
|
||||||
|
(`otherShipGroups`) ship groups under a single grid, with checkboxes
|
||||||
|
for the two owner buckets, a planet dropdown that narrows to groups
|
||||||
|
touching the chosen planet (destination OR origin), and a ship-class
|
||||||
|
dropdown. A row click focuses the on-map target through the
|
||||||
|
transient `SelectionStore.focus` channel:
|
||||||
|
|
||||||
|
- on-planet groups (origin === null && range === null) focus the
|
||||||
|
destination planet so the map centres on it and the inspector
|
||||||
|
opens its planet card;
|
||||||
|
- in-space groups focus the group itself, so the map centres on
|
||||||
|
its interpolated position and the inspector shows the group.
|
||||||
|
|
||||||
|
`incomingShipGroups` and `unidentifiedShipGroups` are intentionally
|
||||||
|
out of scope here — the map and inspector already surface those
|
||||||
|
categories.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ReportLocalShipGroup,
|
||||||
|
ReportOtherShipGroup,
|
||||||
|
} from "../../api/game-state";
|
||||||
|
import { activeView } from "$lib/app-nav.svelte";
|
||||||
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
|
import { planetLabel } from "./report/format";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import {
|
||||||
|
SELECTION_CONTEXT_KEY,
|
||||||
|
type Selected,
|
||||||
|
type SelectionStore,
|
||||||
|
type ShipGroupRef,
|
||||||
|
} from "$lib/selection.svelte";
|
||||||
|
import ViewState from "$lib/ui/view-state.svelte";
|
||||||
|
import { formatFloat, formatInt } from "$lib/util/number-format";
|
||||||
|
import {
|
||||||
|
shipGroupsTableState as persistent,
|
||||||
|
type ShipGroupsSortColumn as SortColumn,
|
||||||
|
} from "./table-ship-groups-state.svelte";
|
||||||
|
|
||||||
|
type OwnerKind = "own" | "foreign";
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
key: string;
|
||||||
|
owner: OwnerKind;
|
||||||
|
ref: ShipGroupRef;
|
||||||
|
class: string;
|
||||||
|
count: number;
|
||||||
|
race: string;
|
||||||
|
mass: number;
|
||||||
|
speed: number;
|
||||||
|
destination: number;
|
||||||
|
origin: number | null;
|
||||||
|
range: number | null;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
|
||||||
|
owner: "game.table.ship_groups.column.owner",
|
||||||
|
class: "game.table.ship_groups.column.class",
|
||||||
|
count: "game.table.ship_groups.column.count",
|
||||||
|
race: "game.table.ship_groups.column.race",
|
||||||
|
location: "game.table.ship_groups.column.location",
|
||||||
|
mass: "game.table.ship_groups.column.mass",
|
||||||
|
speed: "game.table.ship_groups.column.speed",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLUMNS: readonly SortColumn[] = [
|
||||||
|
"owner",
|
||||||
|
"class",
|
||||||
|
"count",
|
||||||
|
"race",
|
||||||
|
"location",
|
||||||
|
"mass",
|
||||||
|
"speed",
|
||||||
|
];
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
||||||
|
|
||||||
|
// `persistent` (module-level rune above) drives every filter /
|
||||||
|
// sort selection so the user's choices survive table↔map round
|
||||||
|
// trips.
|
||||||
|
|
||||||
|
const reportLoaded = $derived(
|
||||||
|
rendered?.report !== null && rendered?.report !== undefined,
|
||||||
|
);
|
||||||
|
const local = $derived<ReportLocalShipGroup[]>(
|
||||||
|
rendered?.report?.localShipGroups ?? [],
|
||||||
|
);
|
||||||
|
const other = $derived<ReportOtherShipGroup[]>(
|
||||||
|
rendered?.report?.otherShipGroups ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = $derived.by<Row[]>(() => {
|
||||||
|
const acc: Row[] = [];
|
||||||
|
for (const g of local) {
|
||||||
|
acc.push({
|
||||||
|
key: `local:${g.id}`,
|
||||||
|
owner: "own",
|
||||||
|
ref: { variant: "local", id: g.id },
|
||||||
|
class: g.class,
|
||||||
|
count: g.count,
|
||||||
|
race: g.race,
|
||||||
|
mass: g.mass,
|
||||||
|
speed: g.speed,
|
||||||
|
destination: g.destination,
|
||||||
|
origin: g.origin,
|
||||||
|
range: g.range,
|
||||||
|
state: g.state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < other.length; i++) {
|
||||||
|
const g = other[i]!;
|
||||||
|
acc.push({
|
||||||
|
key: `other:${i}`,
|
||||||
|
owner: "foreign",
|
||||||
|
ref: { variant: "other", index: i },
|
||||||
|
class: g.class,
|
||||||
|
count: g.count,
|
||||||
|
race: g.race,
|
||||||
|
mass: g.mass,
|
||||||
|
speed: g.speed,
|
||||||
|
destination: g.destination,
|
||||||
|
origin: g.origin,
|
||||||
|
range: g.range,
|
||||||
|
state: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
});
|
||||||
|
|
||||||
|
// rowsByOwner is the slice surviving just the owner checkboxes,
|
||||||
|
// reused for the planet / class dropdown options so toggling
|
||||||
|
// "foreign" off prunes the choices to only own-group planets and
|
||||||
|
// classes (and vice versa).
|
||||||
|
const rowsByOwner = $derived.by(() => {
|
||||||
|
return rows.filter((r) => {
|
||||||
|
if (r.owner === "own" && !persistent.showOwn) return false;
|
||||||
|
if (r.owner === "foreign" && !persistent.showForeign) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const classes = $derived.by(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const r of rowsByOwner) if (r.class !== "") set.add(r.class);
|
||||||
|
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
const planets = $derived.by(() => {
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const r of rowsByOwner) {
|
||||||
|
set.add(r.destination);
|
||||||
|
if (r.origin !== null) set.add(r.origin);
|
||||||
|
}
|
||||||
|
return Array.from(set).sort((a, b) => a - b);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const planet =
|
||||||
|
persistent.planetFilter === "" ? null : Number(persistent.planetFilter);
|
||||||
|
return rowsByOwner.filter((r) => {
|
||||||
|
if (planet !== null && r.destination !== planet && r.origin !== planet) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (persistent.classFilter !== "" && r.class !== persistent.classFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorted = $derived.by(() => {
|
||||||
|
const list = [...filtered];
|
||||||
|
const dir = persistent.sortDirection === "asc" ? 1 : -1;
|
||||||
|
list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
function compare(a: Row, b: Row, column: SortColumn): number {
|
||||||
|
switch (column) {
|
||||||
|
case "owner":
|
||||||
|
return a.owner.localeCompare(b.owner);
|
||||||
|
case "class":
|
||||||
|
return a.class.localeCompare(b.class);
|
||||||
|
case "count":
|
||||||
|
return a.count - b.count;
|
||||||
|
case "race":
|
||||||
|
return a.race.localeCompare(b.race);
|
||||||
|
case "location": {
|
||||||
|
if (a.destination !== b.destination) return a.destination - b.destination;
|
||||||
|
const ao = a.origin ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const bo = b.origin ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
return ao - bo;
|
||||||
|
}
|
||||||
|
case "mass":
|
||||||
|
return a.mass - b.mass;
|
||||||
|
case "speed":
|
||||||
|
return a.speed - b.speed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort(column: SortColumn): void {
|
||||||
|
if (persistent.sortColumn === column) {
|
||||||
|
persistent.sortDirection = persistent.sortDirection === "asc" ? "desc" : "asc";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
persistent.sortColumn = column;
|
||||||
|
persistent.sortDirection = "asc";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
|
||||||
|
if (persistent.sortColumn !== column) return "none";
|
||||||
|
return persistent.sortDirection === "asc" ? "ascending" : "descending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInSpace(row: Row): boolean {
|
||||||
|
return row.origin !== null && row.range !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOnMap(row: Row): void {
|
||||||
|
let target: Selected;
|
||||||
|
if (isInSpace(row)) {
|
||||||
|
target = { kind: "shipGroup", ref: row.ref };
|
||||||
|
} else {
|
||||||
|
target = { kind: "planet", id: row.destination };
|
||||||
|
}
|
||||||
|
selection?.focus(target);
|
||||||
|
activeView.select("map");
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationLabel(row: Row): string {
|
||||||
|
const all = rendered?.report?.planets ?? [];
|
||||||
|
if (!isInSpace(row)) {
|
||||||
|
return `@ ${planetLabel(row.destination, all)}`;
|
||||||
|
}
|
||||||
|
const from = planetLabel(row.origin!, all);
|
||||||
|
const to = planetLabel(row.destination, all);
|
||||||
|
return `${from} → ${to} (${formatFloat(row.range!)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerLabel(row: Row): string {
|
||||||
|
return i18n.t(
|
||||||
|
row.owner === "own"
|
||||||
|
? "game.table.ship_groups.owner.own"
|
||||||
|
: "game.table.ship_groups.owner.foreign",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="active-view"
|
||||||
|
data-testid="active-view-table"
|
||||||
|
data-entity="ship-groups"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<h2>{i18n.t("game.table.ship_groups.title")}</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<label class="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="ship-groups-filter-own"
|
||||||
|
bind:checked={persistent.showOwn}
|
||||||
|
/>
|
||||||
|
<span>{i18n.t("game.table.ship_groups.owner.own")}</span>
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="ship-groups-filter-foreign"
|
||||||
|
bind:checked={persistent.showForeign}
|
||||||
|
/>
|
||||||
|
<span>{i18n.t("game.table.ship_groups.owner.foreign")}</span>
|
||||||
|
</label>
|
||||||
|
{#if planets.length > 0}
|
||||||
|
<label class="dropdown">
|
||||||
|
<span>{i18n.t("game.table.ship_groups.filter.planet")}</span>
|
||||||
|
<select
|
||||||
|
data-testid="ship-groups-filter-planet"
|
||||||
|
bind:value={persistent.planetFilter}
|
||||||
|
>
|
||||||
|
<option value=""
|
||||||
|
>{i18n.t("game.table.ship_groups.filter.planet.all")}</option
|
||||||
|
>
|
||||||
|
{#each planets as n (n)}
|
||||||
|
<option value={String(n)}
|
||||||
|
>{planetLabel(n, rendered?.report?.planets ?? [])}</option
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{#if classes.length > 0}
|
||||||
|
<label class="dropdown">
|
||||||
|
<span>{i18n.t("game.table.ship_groups.filter.class")}</span>
|
||||||
|
<select
|
||||||
|
data-testid="ship-groups-filter-class"
|
||||||
|
bind:value={persistent.classFilter}
|
||||||
|
>
|
||||||
|
<option value=""
|
||||||
|
>{i18n.t("game.table.ship_groups.filter.class.all")}</option
|
||||||
|
>
|
||||||
|
{#each classes as c (c)}
|
||||||
|
<option value={c}>{c}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if !reportLoaded}
|
||||||
|
<ViewState
|
||||||
|
kind="loading"
|
||||||
|
testid="ship-groups-loading"
|
||||||
|
message={i18n.t("game.table.ship_groups.loading")}
|
||||||
|
/>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<ViewState
|
||||||
|
kind="empty"
|
||||||
|
testid="ship-groups-empty"
|
||||||
|
message={i18n.t("game.table.ship_groups.empty")}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="ship-groups-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{#each COLUMNS as column (column)}
|
||||||
|
<th
|
||||||
|
aria-sort={ariaSort(column)}
|
||||||
|
class:numeric={column === "count" ||
|
||||||
|
column === "mass" ||
|
||||||
|
column === "speed"}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sort"
|
||||||
|
data-testid="ship-groups-column-{column}"
|
||||||
|
onclick={() => toggleSort(column)}
|
||||||
|
>
|
||||||
|
{i18n.t(COLUMN_LABELS[column])}
|
||||||
|
{#if persistent.sortColumn === column}
|
||||||
|
<span class="sort-indicator" aria-hidden="true">
|
||||||
|
{persistent.sortDirection === "asc" ? "▲" : "▼"}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each sorted as row (row.key)}
|
||||||
|
<tr
|
||||||
|
data-testid="ship-groups-row"
|
||||||
|
data-key={row.key}
|
||||||
|
data-owner={row.owner}
|
||||||
|
data-in-space={isInSpace(row)}
|
||||||
|
onclick={() => openOnMap(row)}
|
||||||
|
>
|
||||||
|
<td data-testid="ship-groups-cell-owner">{ownerLabel(row)}</td>
|
||||||
|
<td data-testid="ship-groups-cell-class">{row.class || "—"}</td>
|
||||||
|
<td class="numeric" data-testid="ship-groups-cell-count">
|
||||||
|
{formatInt(row.count)}
|
||||||
|
</td>
|
||||||
|
<td data-testid="ship-groups-cell-race">{row.race || "—"}</td>
|
||||||
|
<td data-testid="ship-groups-cell-location">
|
||||||
|
{locationLabel(row)}
|
||||||
|
</td>
|
||||||
|
<td class="numeric" data-testid="ship-groups-cell-mass">
|
||||||
|
{formatFloat(row.mass)}
|
||||||
|
</td>
|
||||||
|
<td class="numeric" data-testid="ship-groups-cell-speed">
|
||||||
|
{formatFloat(row.speed)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.active-view {
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.check {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.check input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.dropdown {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.dropdown select {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid th.numeric,
|
||||||
|
.grid td.numeric {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.grid th.numeric .sort {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.grid tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
.sort {
|
||||||
|
font: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.sort-indicator {
|
||||||
|
font-size: 0.7em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
<!--
|
<!--
|
||||||
Active-view router for the per-entity tables. Phase 17 lights up
|
Active-view router for the per-entity tables. Phase 17 lit up
|
||||||
the ship-classes table; Phase 21 lights up the sciences table;
|
ship-classes; Phase 21 sciences; Phase 22 races; F8-10 lights up
|
||||||
Phase 22 lights up the races table; the remaining slugs (planets,
|
planets, ship-groups, and fleets. The wrapper preserves
|
||||||
ship-groups, fleets) keep the Phase 10 stub copy until their
|
|
||||||
respective phases land. The wrapper preserves
|
|
||||||
`data-testid="active-view-table"` and `data-entity={entity}` for
|
`data-testid="active-view-table"` and `data-entity={entity}` for
|
||||||
every branch (each leaf component mirrors them) so the navigation
|
every branch (each leaf component mirrors them) so the navigation
|
||||||
e2e specs (`game-shell.spec.ts`, `view-menu`) keep matching.
|
e2e specs (`game-shell.spec.ts`, `view-menu`) keep matching.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
|
import TablePlanets from "./table-planets.svelte";
|
||||||
import TableShipClasses from "./table-ship-classes.svelte";
|
import TableShipClasses from "./table-ship-classes.svelte";
|
||||||
|
import TableShipGroups from "./table-ship-groups.svelte";
|
||||||
|
import TableFleets from "./table-fleets.svelte";
|
||||||
import TableSciences from "./table-sciences.svelte";
|
import TableSciences from "./table-sciences.svelte";
|
||||||
import TableRaces from "./table-races.svelte";
|
import TableRaces from "./table-races.svelte";
|
||||||
|
|
||||||
@@ -23,8 +24,14 @@ e2e specs (`game-shell.spec.ts`, `view-menu`) keep matching.
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if entity === "ship-classes"}
|
{#if entity === "planets"}
|
||||||
|
<TablePlanets />
|
||||||
|
{:else if entity === "ship-classes"}
|
||||||
<TableShipClasses />
|
<TableShipClasses />
|
||||||
|
{:else if entity === "ship-groups"}
|
||||||
|
<TableShipGroups />
|
||||||
|
{:else if entity === "fleets"}
|
||||||
|
<TableFleets />
|
||||||
{:else if entity === "sciences"}
|
{:else if entity === "sciences"}
|
||||||
<TableSciences />
|
<TableSciences />
|
||||||
{:else if entity === "races"}
|
{:else if entity === "races"}
|
||||||
|
|||||||
@@ -117,6 +117,17 @@ export class GameStateStore {
|
|||||||
* `RendererHandle.setHiddenPrimitiveIds`.
|
* `RendererHandle.setHiddenPrimitiveIds`.
|
||||||
*/
|
*/
|
||||||
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
|
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
|
||||||
|
/**
|
||||||
|
* lastCamera is the most recent map camera snapshot (centre + zoom)
|
||||||
|
* captured before the renderer was disposed — either because the
|
||||||
|
* map view unmounted (active-view switch) or because it remounted
|
||||||
|
* inside the same session. `map.svelte` reads it on cold mount so
|
||||||
|
* leaving the map for a table / report and coming back keeps the
|
||||||
|
* user's prior pan / zoom. Resetting to null falls back to the
|
||||||
|
* default world-centre + minScale fit. Held in memory only; an F5
|
||||||
|
* re-loads the report and the default fit takes over.
|
||||||
|
*/
|
||||||
|
lastCamera: { centerX: number; centerY: number; scale: number } | null = $state(null);
|
||||||
error: string | null = $state(null);
|
error: string | null = $state(null);
|
||||||
/**
|
/**
|
||||||
* notFound is the distinct "this game is not in the player's list"
|
* notFound is the distinct "this game is not in the player's list"
|
||||||
|
|||||||
@@ -335,6 +335,48 @@ const en = {
|
|||||||
"game.sidebar.order.label.ship_group_join_fleet": "assign group {group} → fleet {fleet}",
|
"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_relation": "declare {relation} on {acceptor}",
|
||||||
"game.sidebar.order.label.race_vote": "give my votes to {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.title": "ship classes",
|
||||||
"game.table.ship_classes.column.name": "name",
|
"game.table.ship_classes.column.name": "name",
|
||||||
"game.table.ship_classes.column.drive": "drive",
|
"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.filter.placeholder": "filter by name",
|
||||||
"game.table.ship_classes.action.new": "+ new ship class",
|
"game.table.ship_classes.action.new": "+ new ship class",
|
||||||
"game.table.ship_classes.action.delete": "delete",
|
"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.table.ship_classes.loading": "loading ship classes…",
|
||||||
"game.designer.ship_class.title.new": "design new ship class",
|
"game.designer.ship_class.title.new": "design new ship class",
|
||||||
"game.designer.ship_class.title.view": "ship class {name}",
|
"game.designer.ship_class.title.view": "ship class {name}",
|
||||||
|
|||||||
@@ -336,6 +336,48 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.sidebar.order.label.ship_group_join_fleet": "включить группу {group} → флот {fleet}",
|
"game.sidebar.order.label.ship_group_join_fleet": "включить группу {group} → флот {fleet}",
|
||||||
"game.sidebar.order.label.race_relation": "объявить {relation} расе {acceptor}",
|
"game.sidebar.order.label.race_relation": "объявить {relation} расе {acceptor}",
|
||||||
"game.sidebar.order.label.race_vote": "отдать голоса расе {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.title": "классы кораблей",
|
||||||
"game.table.ship_classes.column.name": "название",
|
"game.table.ship_classes.column.name": "название",
|
||||||
"game.table.ship_classes.column.drive": "двигатель",
|
"game.table.ship_classes.column.drive": "двигатель",
|
||||||
@@ -348,6 +390,7 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.table.ship_classes.filter.placeholder": "фильтр по названию",
|
"game.table.ship_classes.filter.placeholder": "фильтр по названию",
|
||||||
"game.table.ship_classes.action.new": "+ новый класс корабля",
|
"game.table.ship_classes.action.new": "+ новый класс корабля",
|
||||||
"game.table.ship_classes.action.delete": "удалить",
|
"game.table.ship_classes.action.delete": "удалить",
|
||||||
|
"game.table.ship_classes.action.delete.in_use": "используется в {count} группе(-ах) кораблей",
|
||||||
"game.table.ship_classes.loading": "загрузка классов кораблей…",
|
"game.table.ship_classes.loading": "загрузка классов кораблей…",
|
||||||
"game.designer.ship_class.title.new": "конструктор нового класса корабля",
|
"game.designer.ship_class.title.new": "конструктор нового класса корабля",
|
||||||
"game.designer.ship_class.title.view": "класс корабля {name}",
|
"game.designer.ship_class.title.view": "класс корабля {name}",
|
||||||
|
|||||||
@@ -56,6 +56,20 @@ export const SELECTION_CONTEXT_KEY = Symbol("selection");
|
|||||||
export class SelectionStore {
|
export class SelectionStore {
|
||||||
selected: Selected | null = $state(null);
|
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;
|
private destroyed = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,10 +91,57 @@ export class SelectionStore {
|
|||||||
this.selected = { kind: "shipGroup", ref };
|
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
|
* clear drops the current selection. The mobile sheet's close
|
||||||
* button calls this; otherwise selection persists across active-
|
* 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 {
|
clear(): void {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
@@ -90,5 +151,7 @@ export class SelectionStore {
|
|||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
this.selected = null;
|
this.selected = null;
|
||||||
|
this.#pendingFocus = null;
|
||||||
|
this.#pendingCenter = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,8 +293,12 @@ export function shipGroupsToPrimitives(
|
|||||||
* — the planet inspector lists them instead. Returns null when either
|
* — the planet inspector lists them instead. Returns null when either
|
||||||
* the group is on-planet, or the origin / destination planets are
|
* the group is on-planet, or the origin / destination planets are
|
||||||
* not visible to the local player.
|
* 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,
|
group: ReportLocalShipGroup | ReportOtherShipGroup,
|
||||||
planetIndex: Map<number, ReportPlanet>,
|
planetIndex: Map<number, ReportPlanet>,
|
||||||
mapWidth: number,
|
mapWidth: number,
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void>(() => {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -48,21 +48,16 @@ describe("active-view stubs", () => {
|
|||||||
expect(ui.getByTestId("map-canvas-wrap")).toBeInTheDocument();
|
expect(ui.getByTestId("map-canvas-wrap")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("table stub falls back for not-yet-implemented entities", () => {
|
test("table stub falls back for unknown entities", () => {
|
||||||
const ui = render(TableView, { props: { entity: "planets" } });
|
// 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");
|
const node = ui.getByTestId("active-view-table");
|
||||||
expect(node).toHaveAttribute("data-entity", "planets");
|
expect(node).toHaveAttribute("data-entity", "unknown-slug");
|
||||||
expect(node).toHaveTextContent("planets");
|
|
||||||
expect(node).toHaveTextContent("coming soon");
|
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", () => {
|
test("report view mounts with the icon-popup TOC", () => {
|
||||||
// Phase 23 replaces the Phase 10 stub with the full report
|
// Phase 23 replaces the Phase 10 stub with the full report
|
||||||
// orchestrator. The orchestrator mounts the table of contents
|
// orchestrator. The orchestrator mounts the table of contents
|
||||||
|
|||||||
@@ -44,4 +44,89 @@ describe("SelectionStore", () => {
|
|||||||
store.clear();
|
store.clear();
|
||||||
expect(store.selected).toBeNull();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
// 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 { resetFleetsTableState } from "../src/lib/active-view/table-fleets-state.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<string, string>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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();
|
||||||
|
resetFleetsTableState();
|
||||||
|
});
|
||||||
|
|
||||||
|
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<ReportLocalFleet> & Pick<ReportLocalFleet, "name" | "destination">,
|
||||||
|
): 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<unknown, unknown>([
|
||||||
|
[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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// 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 { resetPlanetsTableState } from "../src/lib/active-view/table-planets-state.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<string, string>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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();
|
||||||
|
resetPlanetsTableState();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
selection.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
function planet(overrides: Partial<ReportPlanet> & { 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<unknown, unknown>([
|
||||||
|
[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("filter checkboxes survive an unmount/remount cycle", async () => {
|
||||||
|
const report = makeReport([
|
||||||
|
planet({ number: 1, kind: "local" }),
|
||||||
|
planet({ number: 2, kind: "other", owner: "Klingon" }),
|
||||||
|
]);
|
||||||
|
const first = mount(report);
|
||||||
|
await fireEvent.click(first.getByTestId("planets-filter-own"));
|
||||||
|
first.unmount();
|
||||||
|
const second = mount(report);
|
||||||
|
const ownCheckbox = second.getByTestId(
|
||||||
|
"planets-filter-own",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(ownCheckbox.checked).toBe(false);
|
||||||
|
const kinds = second
|
||||||
|
.getAllByTestId("planets-row")
|
||||||
|
.map((r) => r.getAttribute("data-kind"));
|
||||||
|
expect(kinds).toEqual(["other"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,7 +17,11 @@ import {
|
|||||||
} from "vitest";
|
} from "vitest";
|
||||||
|
|
||||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
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 {
|
import {
|
||||||
ORDER_DRAFT_CONTEXT_KEY,
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
OrderDraftStore,
|
OrderDraftStore,
|
||||||
@@ -89,7 +93,10 @@ function shipClass(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeReport(localShipClass: ShipClassSummary[]): GameReport {
|
function makeReport(
|
||||||
|
localShipClass: ShipClassSummary[],
|
||||||
|
localShipGroups: ReportLocalShipGroup[] = [],
|
||||||
|
): GameReport {
|
||||||
return {
|
return {
|
||||||
turn: 1,
|
turn: 1,
|
||||||
mapWidth: 1000,
|
mapWidth: 1000,
|
||||||
@@ -104,6 +111,29 @@ function makeReport(localShipClass: ShipClassSummary[]): GameReport {
|
|||||||
localPlayerShields: 0,
|
localPlayerShields: 0,
|
||||||
localPlayerCargo: 0,
|
localPlayerCargo: 0,
|
||||||
...EMPTY_SHIP_GROUPS,
|
...EMPTY_SHIP_GROUPS,
|
||||||
|
localShipGroups,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shipGroup(
|
||||||
|
overrides: Partial<ReportLocalShipGroup> &
|
||||||
|
Pick<ReportLocalShipGroup, "class">,
|
||||||
|
): 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");
|
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 () => {
|
test("new button requests a fresh calculator design", async () => {
|
||||||
const ui = mountTable(makeReport([]));
|
const ui = mountTable(makeReport([]));
|
||||||
const before = calculatorLoadRequest.token;
|
const before = calculatorLoadRequest.token;
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
// 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 { resetShipGroupsTableState } from "../src/lib/active-view/table-ship-groups-state.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<string, string>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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();
|
||||||
|
resetShipGroupsTableState();
|
||||||
|
});
|
||||||
|
|
||||||
|
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<ReportLocalShipGroup> &
|
||||||
|
Pick<ReportLocalShipGroup, "id" | "class" | "destination">,
|
||||||
|
): 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<ReportOtherShipGroup> &
|
||||||
|
Pick<ReportOtherShipGroup, "class" | "destination" | "race">,
|
||||||
|
): 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<unknown, unknown>([
|
||||||
|
[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("planet/class dropdowns narrow to the owner-checkbox cut", async () => {
|
||||||
|
const ui = mount(
|
||||||
|
makeReport({
|
||||||
|
planets: [planet(1), planet(2), planet(3)],
|
||||||
|
local: [localGroup({ id: "L1", class: "Cruiser", destination: 1 })],
|
||||||
|
other: [
|
||||||
|
otherGroup({ class: "Hunter", destination: 3, race: "Klingon" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// All four planet options available when both owner kinds are on
|
||||||
|
const planetSelect = ui.getByTestId(
|
||||||
|
"ship-groups-filter-planet",
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const valuesFull = Array.from(planetSelect.options).map((o) => o.value);
|
||||||
|
expect(valuesFull).toContain("1");
|
||||||
|
expect(valuesFull).toContain("3");
|
||||||
|
// Hide foreign rows; planet 3 (only touched by Klingon) disappears
|
||||||
|
await fireEvent.click(ui.getByTestId("ship-groups-filter-foreign"));
|
||||||
|
const valuesNarrowed = Array.from(planetSelect.options).map((o) => o.value);
|
||||||
|
expect(valuesNarrowed).toContain("1");
|
||||||
|
expect(valuesNarrowed).not.toContain("3");
|
||||||
|
// Class dropdown narrows too: Hunter disappears
|
||||||
|
const classSelect = ui.getByTestId(
|
||||||
|
"ship-groups-filter-class",
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const classValues = Array.from(classSelect.options).map((o) => o.value);
|
||||||
|
expect(classValues).not.toContain("Hunter");
|
||||||
|
expect(classValues).toContain("Cruiser");
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user