Merge pull request 'feat(ui): F8-11 — battles table under table submenu (#54)' (#69) from feature/issue-54-battles-table into development
This commit was merged in pull request #69.
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
// F8-11 battles table — module-level sort rune.
|
||||||
|
//
|
||||||
|
// Held outside the component so the user's sort choice survives the
|
||||||
|
// component being unmounted (row click → battle viewer → back to the
|
||||||
|
// table). Held in memory only; an F5 reloads the report and the
|
||||||
|
// defaults take over.
|
||||||
|
|
||||||
|
export type BattlesSortColumn = "planet" | "shots";
|
||||||
|
|
||||||
|
export type BattlesSortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface BattlesTableState {
|
||||||
|
sortColumn: BattlesSortColumn;
|
||||||
|
sortDirection: BattlesSortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE: BattlesTableState = {
|
||||||
|
sortColumn: "shots",
|
||||||
|
sortDirection: "desc",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const battlesTableState: BattlesTableState = $state({
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resetBattlesTableState 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 resetBattlesTableState(): void {
|
||||||
|
Object.assign(battlesTableState, DEFAULT_STATE);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<!--
|
||||||
|
F8-11 battles table. Lists every battle the engine recorded for the
|
||||||
|
current turn (`Report.battle[]`) with a sortable planet/shots view.
|
||||||
|
Row click opens the existing Battle Viewer through
|
||||||
|
`activeView.select("battle", { battleId, turn })`, identical to the
|
||||||
|
`section-battles.svelte` and battle-marker entry points so a single
|
||||||
|
viewer wrapper keeps owning the data fetch.
|
||||||
|
|
||||||
|
Intentionally minimal: only the two scalars carried by the report's
|
||||||
|
`BattleSummary` (planet number and shot count). Participants /
|
||||||
|
observers / fleet mass live in the per-battle `BattleReport` and are
|
||||||
|
out of scope here — fetching them per row would mean N round trips
|
||||||
|
on every menu open.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import type { ReportBattle, 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 ViewState from "$lib/ui/view-state.svelte";
|
||||||
|
import { formatInt } from "$lib/util/number-format";
|
||||||
|
import {
|
||||||
|
battlesTableState as persistent,
|
||||||
|
type BattlesSortColumn as SortColumn,
|
||||||
|
} from "./table-battles-state.svelte";
|
||||||
|
|
||||||
|
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
|
||||||
|
planet: "game.table.battles.column.planet",
|
||||||
|
shots: "game.table.battles.column.shots",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLUMNS: readonly SortColumn[] = ["planet", "shots"];
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const reportLoaded = $derived(
|
||||||
|
rendered?.report !== null && rendered?.report !== undefined,
|
||||||
|
);
|
||||||
|
const battles = $derived<ReportBattle[]>(rendered?.report?.battles ?? []);
|
||||||
|
const planets = $derived<ReportPlanet[]>(rendered?.report?.planets ?? []);
|
||||||
|
const turn = $derived<number>(rendered?.report?.turn ?? 0);
|
||||||
|
|
||||||
|
const sorted = $derived.by(() => {
|
||||||
|
const list = [...battles];
|
||||||
|
const dir = persistent.sortDirection === "asc" ? 1 : -1;
|
||||||
|
list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
function compare(
|
||||||
|
a: ReportBattle,
|
||||||
|
b: ReportBattle,
|
||||||
|
column: SortColumn,
|
||||||
|
): number {
|
||||||
|
switch (column) {
|
||||||
|
case "planet":
|
||||||
|
return a.planet - b.planet;
|
||||||
|
case "shots":
|
||||||
|
return a.shots - b.shots;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort(column: SortColumn): void {
|
||||||
|
if (persistent.sortColumn === column) {
|
||||||
|
persistent.sortDirection =
|
||||||
|
persistent.sortDirection === "asc" ? "desc" : "asc";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
persistent.sortColumn = column;
|
||||||
|
// shots is most-interesting-largest-first; planet is naturally
|
||||||
|
// ascending by number.
|
||||||
|
persistent.sortDirection = column === "shots" ? "desc" : "asc";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
|
||||||
|
if (persistent.sortColumn !== column) return "none";
|
||||||
|
return persistent.sortDirection === "asc" ? "ascending" : "descending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBattle(b: ReportBattle): void {
|
||||||
|
activeView.select("battle", { battleId: b.id, turn });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="active-view"
|
||||||
|
data-testid="active-view-table"
|
||||||
|
data-entity="battles"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<h2>{i18n.t("game.table.battles.title")}</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if !reportLoaded}
|
||||||
|
<ViewState
|
||||||
|
kind="loading"
|
||||||
|
testid="battles-loading"
|
||||||
|
message={i18n.t("game.table.battles.loading")}
|
||||||
|
/>
|
||||||
|
{:else if battles.length === 0}
|
||||||
|
<ViewState
|
||||||
|
kind="empty"
|
||||||
|
testid="battles-empty"
|
||||||
|
message={i18n.t("game.table.battles.empty")}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="battles-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{#each COLUMNS as column (column)}
|
||||||
|
<th
|
||||||
|
aria-sort={ariaSort(column)}
|
||||||
|
class:numeric={column === "shots"}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sort"
|
||||||
|
data-testid="battles-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 b (b.id)}
|
||||||
|
<tr
|
||||||
|
data-testid="battles-row"
|
||||||
|
data-id={b.id}
|
||||||
|
onclick={() => openBattle(b)}
|
||||||
|
>
|
||||||
|
<td data-testid="battles-cell-planet">
|
||||||
|
{planetLabel(b.planet, planets)}
|
||||||
|
</td>
|
||||||
|
<td class="numeric" data-testid="battles-cell-shots">
|
||||||
|
{formatInt(b.shots)}
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
.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>
|
||||||
@@ -14,6 +14,7 @@ e2e specs (`game-shell.spec.ts`, `view-menu`) keep matching.
|
|||||||
import TableFleets from "./table-fleets.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";
|
||||||
|
import TableBattles from "./table-battles.svelte";
|
||||||
|
|
||||||
type Props = { entity: string };
|
type Props = { entity: string };
|
||||||
let { entity }: Props = $props();
|
let { entity }: Props = $props();
|
||||||
@@ -36,6 +37,8 @@ e2e specs (`game-shell.spec.ts`, `view-menu`) keep matching.
|
|||||||
<TableSciences />
|
<TableSciences />
|
||||||
{:else if entity === "races"}
|
{:else if entity === "races"}
|
||||||
<TableRaces />
|
<TableRaces />
|
||||||
|
{:else if entity === "battles"}
|
||||||
|
<TableBattles />
|
||||||
{:else}
|
{:else}
|
||||||
<section
|
<section
|
||||||
class="active-view"
|
class="active-view"
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ hamburger (☰) at the 1024 px breakpoint via CSS only; the surface
|
|||||||
itself is identical. The same component is reused for the mobile
|
itself is identical. The same component is reused for the mobile
|
||||||
"More" drawer entry of `bottom-tabs.svelte`.
|
"More" drawer entry of `bottom-tabs.svelte`.
|
||||||
|
|
||||||
Lists the seven IA destinations: map, tables (sub-list of six
|
Lists the IA destinations: map, tables (sub-list of seven entities
|
||||||
entities), report, battle, mail, ship-class designer, science
|
including the battles roll-up that replaces the standalone battle-log
|
||||||
designer. Each entry mutates `activeView` (the single-URL app-shell
|
shortcut), report, mail, science designer. Each entry mutates
|
||||||
has no per-view routes) and closes the menu. Closes on Escape, on
|
`activeView` (the single-URL app-shell has no per-view routes) and
|
||||||
outside click, and after a selection. Phase 26 introduces the
|
closes the menu. Closes on Escape, on outside click, and after a
|
||||||
history-mode entry; microcopy is refined in a later polish pass.
|
selection. Phase 26 introduces the history-mode entry; microcopy is
|
||||||
|
refined in a later polish pass.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
@@ -31,6 +32,7 @@ history-mode entry; microcopy is refined in a later polish pass.
|
|||||||
{ slug: "fleets", key: "game.view.table.fleets" },
|
{ slug: "fleets", key: "game.view.table.fleets" },
|
||||||
{ slug: "sciences", key: "game.view.table.sciences" },
|
{ slug: "sciences", key: "game.view.table.sciences" },
|
||||||
{ slug: "races", key: "game.view.table.races" },
|
{ slug: "races", key: "game.view.table.races" },
|
||||||
|
{ slug: "battles", key: "game.view.table.battles" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function toggleOpen(): void {
|
function toggleOpen(): void {
|
||||||
@@ -120,14 +122,6 @@ history-mode entry; microcopy is refined in a later polish pass.
|
|||||||
>
|
>
|
||||||
{i18n.t("game.view.report")}
|
{i18n.t("game.view.report")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
data-testid="view-menu-item-battle"
|
|
||||||
onclick={() => select("battle")}
|
|
||||||
>
|
|
||||||
{i18n.t("game.view.battle")}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ const en = {
|
|||||||
"game.view.table.fleets": "fleets",
|
"game.view.table.fleets": "fleets",
|
||||||
"game.view.table.sciences": "sciences",
|
"game.view.table.sciences": "sciences",
|
||||||
"game.view.table.races": "races",
|
"game.view.table.races": "races",
|
||||||
|
"game.view.table.battles": "battles",
|
||||||
"game.view.report": "turn report",
|
"game.view.report": "turn report",
|
||||||
"game.view.battle": "battle log",
|
|
||||||
"game.view.mail": "diplomatic mail",
|
"game.view.mail": "diplomatic mail",
|
||||||
"game.view.mail.badge": "{count}",
|
"game.view.mail.badge": "{count}",
|
||||||
"game.events.mail_new.message": "new mail from {from}",
|
"game.events.mail_new.message": "new mail from {from}",
|
||||||
@@ -377,6 +377,11 @@ const en = {
|
|||||||
"game.table.fleets.column.speed": "speed",
|
"game.table.fleets.column.speed": "speed",
|
||||||
"game.table.fleets.filter.planet": "planet:",
|
"game.table.fleets.filter.planet": "planet:",
|
||||||
"game.table.fleets.filter.planet.all": "all planets",
|
"game.table.fleets.filter.planet.all": "all planets",
|
||||||
|
"game.table.battles.title": "battles",
|
||||||
|
"game.table.battles.loading": "loading battles…",
|
||||||
|
"game.table.battles.empty": "no battles this turn",
|
||||||
|
"game.table.battles.column.planet": "planet",
|
||||||
|
"game.table.battles.column.shots": "shots",
|
||||||
"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",
|
||||||
|
|||||||
@@ -184,8 +184,8 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.view.table.fleets": "флоты",
|
"game.view.table.fleets": "флоты",
|
||||||
"game.view.table.sciences": "науки",
|
"game.view.table.sciences": "науки",
|
||||||
"game.view.table.races": "расы",
|
"game.view.table.races": "расы",
|
||||||
|
"game.view.table.battles": "сражения",
|
||||||
"game.view.report": "отчёт хода",
|
"game.view.report": "отчёт хода",
|
||||||
"game.view.battle": "журнал боёв",
|
|
||||||
"game.view.mail": "дипломатическая почта",
|
"game.view.mail": "дипломатическая почта",
|
||||||
"game.view.mail.badge": "{count}",
|
"game.view.mail.badge": "{count}",
|
||||||
"game.events.mail_new.message": "новое письмо от {from}",
|
"game.events.mail_new.message": "новое письмо от {from}",
|
||||||
@@ -378,6 +378,11 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.table.fleets.column.speed": "скорость",
|
"game.table.fleets.column.speed": "скорость",
|
||||||
"game.table.fleets.filter.planet": "планета:",
|
"game.table.fleets.filter.planet": "планета:",
|
||||||
"game.table.fleets.filter.planet.all": "все планеты",
|
"game.table.fleets.filter.planet.all": "все планеты",
|
||||||
|
"game.table.battles.title": "сражения",
|
||||||
|
"game.table.battles.loading": "загрузка сражений…",
|
||||||
|
"game.table.battles.empty": "в этом ходе сражений нет",
|
||||||
|
"game.table.battles.column.planet": "планета",
|
||||||
|
"game.table.battles.column.shots": "выстрелы",
|
||||||
"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": "двигатель",
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ or the header view-menu naturally drops the overlay.
|
|||||||
|
|
||||||
More opens a drawer with the same destination list as the header
|
More opens a drawer with the same destination list as the header
|
||||||
view-menu, each entry mutating `activeView` directly (the single-URL
|
view-menu, each entry mutating `activeView` directly (the single-URL
|
||||||
app-shell has no per-view routes). A later polish pass narrows it to
|
app-shell has no per-view routes). The Tables sub-list includes the
|
||||||
the IA-spec subset (Mail, Battle log, Tables, History, Settings,
|
battles roll-up (F8-11) that replaces the standalone Battle log entry;
|
||||||
Logout) once History exists; until then the convenience of one source
|
the single battle viewer is still reachable by clicking a row. A later
|
||||||
of truth for destinations beats the duplication.
|
polish pass narrows it to the IA-spec subset (Mail, Tables, History,
|
||||||
|
Settings, Logout) once History exists; until then the convenience of
|
||||||
|
one source of truth for destinations beats the duplication.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
@@ -41,6 +43,7 @@ of truth for destinations beats the duplication.
|
|||||||
{ slug: "fleets", key: "game.view.table.fleets" },
|
{ slug: "fleets", key: "game.view.table.fleets" },
|
||||||
{ slug: "sciences", key: "game.view.table.sciences" },
|
{ slug: "sciences", key: "game.view.table.sciences" },
|
||||||
{ slug: "races", key: "game.view.table.races" },
|
{ slug: "races", key: "game.view.table.races" },
|
||||||
|
{ slug: "battles", key: "game.view.table.battles" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function selectTool(tool: MobileTool): void {
|
function selectTool(tool: MobileTool): void {
|
||||||
@@ -170,14 +173,6 @@ of truth for destinations beats the duplication.
|
|||||||
>
|
>
|
||||||
{i18n.t("game.view.report")}
|
{i18n.t("game.view.report")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
data-testid="bottom-tabs-more-battle"
|
|
||||||
onclick={() => go("battle")}
|
|
||||||
>
|
|
||||||
{i18n.t("game.view.battle")}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ test("header view-menu navigates to every active view", async ({ page }) => {
|
|||||||
const destinations: Array<[string, string]> = [
|
const destinations: Array<[string, string]> = [
|
||||||
["view-menu-item-report", "active-view-report"],
|
["view-menu-item-report", "active-view-report"],
|
||||||
["view-menu-item-mail", "active-view-mail"],
|
["view-menu-item-mail", "active-view-mail"],
|
||||||
["view-menu-item-battle", "active-view-battle"],
|
|
||||||
["view-menu-item-designer-science", "active-view-designer-science"],
|
["view-menu-item-designer-science", "active-view-designer-science"],
|
||||||
["view-menu-item-map", "active-view-map"],
|
["view-menu-item-map", "active-view-map"],
|
||||||
];
|
];
|
||||||
@@ -86,6 +85,7 @@ test("header view-menu Tables sub-list navigates to every entity", async ({
|
|||||||
"fleets",
|
"fleets",
|
||||||
"sciences",
|
"sciences",
|
||||||
"races",
|
"races",
|
||||||
|
"battles",
|
||||||
];
|
];
|
||||||
for (const entity of entities) {
|
for (const entity of entities) {
|
||||||
await page.getByTestId("view-menu-trigger").click();
|
await page.getByTestId("view-menu-trigger").click();
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ describe("game-shell header", () => {
|
|||||||
const destinations: Array<[string, string]> = [
|
const destinations: Array<[string, string]> = [
|
||||||
["view-menu-item-map", "map"],
|
["view-menu-item-map", "map"],
|
||||||
["view-menu-item-report", "report"],
|
["view-menu-item-report", "report"],
|
||||||
["view-menu-item-battle", "battle"],
|
|
||||||
["view-menu-item-mail", "mail"],
|
["view-menu-item-mail", "mail"],
|
||||||
["view-menu-item-designer-science", "designer-science"],
|
["view-menu-item-designer-science", "designer-science"],
|
||||||
];
|
];
|
||||||
@@ -170,6 +169,7 @@ describe("game-shell header", () => {
|
|||||||
["view-menu-item-table-fleets", "fleets"],
|
["view-menu-item-table-fleets", "fleets"],
|
||||||
["view-menu-item-table-sciences", "sciences"],
|
["view-menu-item-table-sciences", "sciences"],
|
||||||
["view-menu-item-table-races", "races"],
|
["view-menu-item-table-races", "races"],
|
||||||
|
["view-menu-item-table-battles", "battles"],
|
||||||
];
|
];
|
||||||
for (const [testId, entity] of tableEntities) {
|
for (const [testId, entity] of tableEntities) {
|
||||||
await fireEvent.click(ui.getByTestId("view-menu-trigger"));
|
await fireEvent.click(ui.getByTestId("view-menu-trigger"));
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// Vitest coverage for the F8-11 battles table active view. The
|
||||||
|
// component renders against a synthetic `RenderedReportSource` (no
|
||||||
|
// live `GameStateStore`); no draft / selection plumbing is needed
|
||||||
|
// because the row click only mutates `activeView` (no overlay or
|
||||||
|
// selection state). The module-level rune `battlesTableState` is
|
||||||
|
// reset in `beforeEach` so cases stay independent.
|
||||||
|
|
||||||
|
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,
|
||||||
|
ReportBattle,
|
||||||
|
ReportPlanet,
|
||||||
|
} from "../src/api/game-state";
|
||||||
|
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
|
||||||
|
import { activeView } from "../src/lib/app-nav.svelte";
|
||||||
|
import { resetBattlesTableState } from "../src/lib/active-view/table-battles-state.svelte";
|
||||||
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||||
|
|
||||||
|
const pageMock = vi.hoisted(() => ({
|
||||||
|
url: new URL("http://localhost/games/g1/table/battles"),
|
||||||
|
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 TableBattles from "../src/lib/active-view/table-battles.svelte";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.resetForTests("en");
|
||||||
|
pageMock.params = { id: "g1" };
|
||||||
|
gotoMock.mockClear();
|
||||||
|
activeView.reset();
|
||||||
|
resetBattlesTableState();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
activeView.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function planet(num: number, name = `P${num}`): ReportPlanet {
|
||||||
|
return {
|
||||||
|
number: num,
|
||||||
|
name,
|
||||||
|
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 battle(
|
||||||
|
overrides: Partial<ReportBattle> & Pick<ReportBattle, "id">,
|
||||||
|
): ReportBattle {
|
||||||
|
return {
|
||||||
|
planet: 0,
|
||||||
|
shots: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeReport(opts: {
|
||||||
|
turn?: number;
|
||||||
|
planets?: ReportPlanet[];
|
||||||
|
battles?: ReportBattle[];
|
||||||
|
}): GameReport {
|
||||||
|
const battles = opts.battles ?? [];
|
||||||
|
return {
|
||||||
|
turn: opts.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,
|
||||||
|
battles,
|
||||||
|
battleIds: battles.map((b) => b.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(report: GameReport | null) {
|
||||||
|
const renderedReport = {
|
||||||
|
get report() {
|
||||||
|
return report;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const context = new Map<unknown, unknown>([
|
||||||
|
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
|
||||||
|
]);
|
||||||
|
return render(TableBattles, { context });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("battles table", () => {
|
||||||
|
test("renders a loading placeholder before the report lands", () => {
|
||||||
|
const ui = mount(null);
|
||||||
|
expect(ui.getByTestId("battles-loading")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders an empty placeholder when no battles exist", () => {
|
||||||
|
const ui = mount(makeReport({ planets: [planet(1)] }));
|
||||||
|
expect(ui.getByTestId("battles-empty")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders one row per battle with planet label and shots", () => {
|
||||||
|
const ui = mount(
|
||||||
|
makeReport({
|
||||||
|
planets: [planet(7, "Avalon"), planet(11, "")],
|
||||||
|
battles: [
|
||||||
|
battle({ id: "b1", planet: 7, shots: 3 }),
|
||||||
|
battle({ id: "b2", planet: 11, shots: 25 }),
|
||||||
|
battle({ id: "b3", planet: 99, shots: 1 }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const rows = ui.getAllByTestId("battles-row");
|
||||||
|
expect(rows).toHaveLength(3);
|
||||||
|
const labelById = new Map<string, string>();
|
||||||
|
const shotsById = new Map<string, string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const id = row.getAttribute("data-id")!;
|
||||||
|
labelById.set(
|
||||||
|
id,
|
||||||
|
row.querySelector("[data-testid='battles-cell-planet']")!
|
||||||
|
.textContent!.trim(),
|
||||||
|
);
|
||||||
|
shotsById.set(
|
||||||
|
id,
|
||||||
|
row.querySelector("[data-testid='battles-cell-shots']")!
|
||||||
|
.textContent!.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Known planet with a name → "#N (name)"; known planet with
|
||||||
|
// empty name and unknown planet → "#N".
|
||||||
|
expect(labelById.get("b1")).toBe("#7 (Avalon)");
|
||||||
|
expect(labelById.get("b2")).toBe("#11");
|
||||||
|
expect(labelById.get("b3")).toBe("#99");
|
||||||
|
expect(shotsById.get("b1")).toBe("3");
|
||||||
|
expect(shotsById.get("b2")).toBe("25");
|
||||||
|
expect(shotsById.get("b3")).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default sort is shots descending", () => {
|
||||||
|
const ui = mount(
|
||||||
|
makeReport({
|
||||||
|
planets: [planet(1), planet(2), planet(3)],
|
||||||
|
battles: [
|
||||||
|
battle({ id: "b1", planet: 1, shots: 5 }),
|
||||||
|
battle({ id: "b2", planet: 2, shots: 99 }),
|
||||||
|
battle({ id: "b3", planet: 3, shots: 12 }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const ids = ui
|
||||||
|
.getAllByTestId("battles-row")
|
||||||
|
.map((r) => r.getAttribute("data-id"));
|
||||||
|
expect(ids).toEqual(["b2", "b3", "b1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking the active shots header flips the direction", async () => {
|
||||||
|
const ui = mount(
|
||||||
|
makeReport({
|
||||||
|
planets: [planet(1), planet(2)],
|
||||||
|
battles: [
|
||||||
|
battle({ id: "b1", planet: 1, shots: 5 }),
|
||||||
|
battle({ id: "b2", planet: 2, shots: 99 }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await fireEvent.click(ui.getByTestId("battles-column-shots"));
|
||||||
|
const ids = ui
|
||||||
|
.getAllByTestId("battles-row")
|
||||||
|
.map((r) => r.getAttribute("data-id"));
|
||||||
|
expect(ids).toEqual(["b1", "b2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking the planet header sorts ascending by planet number", async () => {
|
||||||
|
const ui = mount(
|
||||||
|
makeReport({
|
||||||
|
planets: [planet(1), planet(2), planet(3)],
|
||||||
|
battles: [
|
||||||
|
battle({ id: "b1", planet: 3, shots: 5 }),
|
||||||
|
battle({ id: "b2", planet: 1, shots: 99 }),
|
||||||
|
battle({ id: "b3", planet: 2, shots: 12 }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await fireEvent.click(ui.getByTestId("battles-column-planet"));
|
||||||
|
const ids = ui
|
||||||
|
.getAllByTestId("battles-row")
|
||||||
|
.map((r) => r.getAttribute("data-id"));
|
||||||
|
expect(ids).toEqual(["b2", "b3", "b1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking a row opens the battle viewer with the matching id and turn", async () => {
|
||||||
|
const ui = mount(
|
||||||
|
makeReport({
|
||||||
|
turn: 42,
|
||||||
|
planets: [planet(1)],
|
||||||
|
battles: [battle({ id: "battle-uuid", planet: 1, shots: 4 })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await fireEvent.click(ui.getByTestId("battles-row"));
|
||||||
|
expect(activeView.state).toMatchObject({
|
||||||
|
view: "battle",
|
||||||
|
battleId: "battle-uuid",
|
||||||
|
turn: 42,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user