9ae7b88b89
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet. pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm. Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store. Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
6.8 KiB
TypeScript
168 lines
6.8 KiB
TypeScript
// Component tests for the remaining Phase 10 active-view stubs. Each
|
|
// stub renders the localised view title plus the `coming soon` body
|
|
// copy and exposes a stable `data-testid` so later phases can replace
|
|
// the content without renaming the test hook. Phase 17 lit up the
|
|
// ship-classes table (Phase 30 folded the designer into the sidebar
|
|
// calculator); Phase 21 lit up the sciences table and the science
|
|
// designer. Their assertions moved to dedicated suites
|
|
// (`table-ship-classes.test.ts`, `calculator-tab.test.ts`,
|
|
// `table-sciences.test.ts`, `designer-science.test.ts`); the
|
|
// `table.svelte` router still falls
|
|
// back to the stub for the remaining entities (planets, ship-groups,
|
|
// fleets, races) and that fallback is exercised here.
|
|
|
|
import "@testing-library/jest-dom/vitest";
|
|
import { render } from "@testing-library/svelte";
|
|
import { beforeEach, describe, expect, test } from "vitest";
|
|
|
|
import { i18n } from "../src/lib/i18n/index.svelte";
|
|
import {
|
|
registerSyntheticBattle,
|
|
resetSyntheticBattles,
|
|
} from "../src/api/synthetic-battle";
|
|
import type { BattleReport } from "../src/api/battle-fetch";
|
|
|
|
import MapView from "../src/lib/active-view/map.svelte";
|
|
import TableView from "../src/lib/active-view/table.svelte";
|
|
import ReportView from "../src/lib/active-view/report.svelte";
|
|
import BattleView from "../src/lib/active-view/battle.svelte";
|
|
import MailView from "../src/lib/active-view/mail.svelte";
|
|
|
|
beforeEach(() => {
|
|
i18n.resetForTests("en");
|
|
resetSyntheticBattles();
|
|
});
|
|
|
|
describe("active-view stubs", () => {
|
|
test("map view renders loading overlay when no game-state context is provided", () => {
|
|
// The live integration in `lib/active-view/map.svelte` (Phase 11)
|
|
// reads its data from a `GameStateStore` provided through context
|
|
// by `routes/games/[id]/+layout.svelte`. Without the context the
|
|
// store reference is `undefined` and the view stays in the
|
|
// `idle` branch, surfacing the localised loading overlay so the
|
|
// shell never renders an empty active-view slot.
|
|
const ui = render(MapView);
|
|
const node = ui.getByTestId("active-view-map");
|
|
expect(node).toHaveAttribute("data-status", "idle");
|
|
expect(ui.getByTestId("map-loading")).toBeInTheDocument();
|
|
expect(ui.getByTestId("map-canvas-wrap")).toBeInTheDocument();
|
|
});
|
|
|
|
test("table stub falls back for not-yet-implemented entities", () => {
|
|
const ui = render(TableView, { props: { entity: "planets" } });
|
|
const node = ui.getByTestId("active-view-table");
|
|
expect(node).toHaveAttribute("data-entity", "planets");
|
|
expect(node).toHaveTextContent("planets");
|
|
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 TOC and the back-to-map link", () => {
|
|
// Phase 23 replaces the Phase 10 stub with the full report
|
|
// orchestrator. The orchestrator mounts the table of contents
|
|
// regardless of report state; the inner sections render
|
|
// loading copy until a `RenderedReportSource` lands via
|
|
// context. This test only smokes the orchestrator scaffold —
|
|
// per-section assertions live in `report-section-*.test.ts`.
|
|
const r = render(ReportView);
|
|
expect(r.getByTestId("active-view-report")).toBeInTheDocument();
|
|
expect(r.getByTestId("report-toc")).toBeInTheDocument();
|
|
expect(r.getByTestId("report-back-to-map")).toBeInTheDocument();
|
|
});
|
|
|
|
test("mail stub renders its localised title", () => {
|
|
const m = render(MailView);
|
|
expect(m.getByTestId("active-view-mail")).toHaveTextContent(
|
|
"diplomatic mail",
|
|
);
|
|
});
|
|
|
|
test("battle view stamps the battleId and shows the loading placeholder for a live game", () => {
|
|
// Phase 27 replaces the Phase 10 stub with the Battle Viewer
|
|
// wrapper. The latest layout iteration moved the back-
|
|
// navigation buttons inside `BattleViewer` so they only mount
|
|
// once the BattleReport finishes loading. The wrapper itself
|
|
// always renders the `active-view-battle` host with the
|
|
// `data-battle-id` stamp and a localized loading copy until
|
|
// the fetcher resolves. For a live game id the wrapper also
|
|
// waits for the surrounding layout to publish a `GalaxyClient`
|
|
// before issuing the fetch — without the context here the
|
|
// effect stays in `loading` as designed.
|
|
const ui = render(BattleView, {
|
|
props: {
|
|
gameId: "00000000-0000-0000-0000-000000000010",
|
|
turn: 0,
|
|
battleId: "b-42",
|
|
},
|
|
});
|
|
const node = ui.getByTestId("active-view-battle");
|
|
expect(node).toHaveAttribute("data-battle-id", "b-42");
|
|
expect(ui.getByTestId("battle-loading")).toBeInTheDocument();
|
|
});
|
|
|
|
test("battle view surfaces the not-found state for an empty battleId", () => {
|
|
const ui = render(BattleView, {
|
|
props: { gameId: "synthetic-test", turn: 0, battleId: "" },
|
|
});
|
|
expect(ui.getByTestId("active-view-battle")).toHaveAttribute(
|
|
"data-battle-id",
|
|
"",
|
|
);
|
|
expect(ui.getByTestId("battle-not-found")).toBeInTheDocument();
|
|
});
|
|
|
|
test("battle view surfaces not-found for a synthetic game when no fixture is registered", async () => {
|
|
// Synthetic games never publish a GalaxyClient — the in-game
|
|
// shell layout deliberately skips `galaxyClient.set(...)` on
|
|
// that branch. The viewer must resolve the fixture (or its
|
|
// absence) without waiting on the client handle; if it did
|
|
// wait, the view would sit on `loading` indefinitely because
|
|
// the handle never lands. `fetchBattle` itself is `async`, so
|
|
// even the synchronous fixture-miss path resolves on a
|
|
// microtask — `findByTestId` lets the BattleViewer wrapper
|
|
// flush its rejection handler before the assertion.
|
|
const ui = render(BattleView, {
|
|
props: {
|
|
gameId: "synthetic-test",
|
|
turn: 0,
|
|
battleId: "missing-fixture",
|
|
},
|
|
});
|
|
await ui.findByTestId("battle-not-found");
|
|
});
|
|
|
|
test("battle view renders a synthetic fixture without a GalaxyClient context", async () => {
|
|
// Regression for the dev-deploy bug where the viewer
|
|
// short-circuited to `loading` for every cross click on a
|
|
// synthetic-report game. The fixture below mirrors the shape
|
|
// `fetchBattle` returns for the live path; once
|
|
// `lookupSyntheticBattle` resolves it, the wrapper transitions
|
|
// to `ready` and mounts the BattleViewer scene.
|
|
const fixture: BattleReport = {
|
|
id: "fixture-battle",
|
|
planet: 1,
|
|
planetName: "Earth",
|
|
races: { "0": "00000000-0000-0000-0000-0000000000aa" },
|
|
ships: {},
|
|
protocol: [],
|
|
};
|
|
registerSyntheticBattle(fixture);
|
|
|
|
const ui = render(BattleView, {
|
|
props: {
|
|
gameId: "synthetic-test",
|
|
turn: 0,
|
|
battleId: "fixture-battle",
|
|
},
|
|
});
|
|
await ui.findByTestId("battle-viewer");
|
|
expect(ui.queryByTestId("battle-loading")).not.toBeInTheDocument();
|
|
});
|
|
});
|