feat(ui): F8-05 — game-mode chrome cleanup + inspector compact rows (#48)
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Waiting to run

Drains six F8 polish items (parent #43) in one feature:

а) Chrome cleanup
- п.6 — remove the AccountMenu (settings/sessions/theme/language/logout
  ∼ rudimentary in-game) and replace it with a single icon-button
  light/dark theme toggle. The toggle flips an in-memory `theme.override`;
  game-shell unmount calls `theme.clearOverride()` so the lobby (and
  any re-entry) re-projects the persisted lobby choice.
- п.8 — remove the wrap-scrolling radio from the map gear popover. The
  per-game `wrapMode` store and the renderer's no-wrap path stay in
  place for a future engine-side topology feature; only the UI surface
  is dropped (wrap is a server-side concept, not a per-session UI
  affordance).

б) Inspector compact rows (single idiom: select + ✓ apply / ✗ cancel,
or contextual edit/remove/add)
- п.13 — planet name is now click-to-edit: clicking the name opens an
  inline `<input>` + ✓ confirm icon; Escape cancels; the explicit
  Rename action button and Cancel button are gone.
- п.14 — production becomes one row: primary `<select>` picks
  industry/materials/research/ship, conditional secondary `<select>`
  picks the target (tech / science / ship class) for research and
  ship contexts. Apply is gated until row state differs from the
  planet's current effective production; auto-submit-on-click is
  replaced by the apply-gate.
- п.16 — cargo routes collapse to one row: a single dropdown
  (COL/CAP/MAT/EMP plus a placeholder that absorbs the old section
  title) and contextual action buttons (add / edit + remove) to the
  right. After a successful pick or remove the dropdown stays on the
  type the user just acted on.
- п.32 — stationed ship groups hoist the race column into a dropdown
  above the table. The dropdown seeds with the player's own race when
  local groups are stationed here, otherwise the first race
  alphabetically; rendered only when more than one race is in orbit.
  The race column is dropped in both single- and multi-race modes —
  the dropdown's value already names the active race.

Tests: unit and Playwright e2e updated for every changed test-id and
flow; new coverage added for `theme.override`, the in-game toggle, the
apply-gate behaviour, and the stationed-race dropdown. i18n keys for
the removed menu items, the wrap radios, the cargo title, and the
explicit `rename.cancel` are dropped from both locales; new
`game.shell.theme_toggle.*`, `production.main/target.*`,
`production.apply/cancel`, `cargo.placeholder`, and
`ship_groups.race_filter.aria` keys land.

Docs synced: `docs/FUNCTIONAL.md` §6.7 + `docs/FUNCTIONAL_ru.md`
mirror drop the torus / no-wrap radio mention; `ui/docs/design-system.md`
documents the lobby-owned persisted picker + the in-game ephemeral
override channel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-27 13:38:42 +02:00
parent 2901ecb21b
commit 4a23c357e5
30 changed files with 1173 additions and 1032 deletions
+10 -10
View File
@@ -44,17 +44,17 @@ test.describe("keyboard navigation", () => {
await expect(page.locator("#active-view-host")).toBeFocused();
});
test("Escape closes the account menu and returns focus to its trigger", async ({
page,
}) => {
test("game-mode theme toggle is keyboard activatable", async ({ page }) => {
await bootShell(page);
await page.getByTestId("account-menu-trigger").click();
await expect(page.getByTestId("account-menu-list")).toBeVisible();
// Move focus into the menu, then dismiss with Escape.
await page.getByTestId("account-menu-theme-select").focus();
await page.keyboard.press("Escape");
await expect(page.getByTestId("account-menu-list")).toBeHidden();
await expect(page.getByTestId("account-menu-trigger")).toBeFocused();
const toggle = page.getByTestId("game-mode-theme-toggle");
await toggle.focus();
await expect(toggle).toBeFocused();
const before = await toggle.getAttribute("data-theme");
await page.keyboard.press("Enter");
const after = await toggle.getAttribute("data-theme");
expect(after).not.toBe(before);
// The toggle keeps focus across an activation.
await expect(toggle).toBeFocused();
});
test("sidebar tabs move with the arrow keys (roving)", async ({ page }) => {
+5 -4
View File
@@ -394,9 +394,8 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
SOURCE_PLANET.name,
);
await expect(
sidebar.getByTestId("inspector-planet-cargo-slot-col-empty"),
).toBeVisible();
const typeSelect = sidebar.getByTestId("inspector-planet-cargo-type");
await typeSelect.selectOption("COL");
// Add a COL route. Expect pick-mode to open with `reachableIds`
// covering only the two near planets.
@@ -470,6 +469,7 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
});
// Add a CAP route to confirm slots coexist.
await typeSelect.selectOption("CAP");
await page
.getByTestId("inspector-planet-cargo-slot-cap-add")
.first()
@@ -495,12 +495,13 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
.toBe(6);
// Remove the COL route.
await typeSelect.selectOption("COL");
await page
.getByTestId("inspector-planet-cargo-slot-col-remove")
.first()
.click();
await expect(
page.getByTestId("inspector-planet-cargo-slot-col-empty").first(),
page.getByTestId("inspector-planet-cargo-slot-col-add").first(),
).toBeVisible({ timeout: 10000 });
await expect
.poll(() => handle.lastRouteRemove, { timeout: 10000 })
+1 -1
View File
@@ -52,7 +52,7 @@ test("shell mounts with header / sidebar / active-view chrome", async ({
"turn",
);
await expect(page.getByTestId("view-menu-trigger")).toBeVisible();
await expect(page.getByTestId("account-menu-trigger")).toBeVisible();
await expect(page.getByTestId("game-mode-theme-toggle")).toBeVisible();
});
test("header view-menu navigates to every active view", async ({ page }) => {
-44
View File
@@ -9,8 +9,6 @@
// flag mirroring the renderer's hide set. The spec counts the
// visible-foreign-planet primitives, etc.
// * `getMapFog()` — the current visibility-fog circle list.
// * `getMapCamera()` — the wrap-mode test reads the centre before
// and after the flip to confirm camera preservation.
// * `getMapRenderCount()` — painted-frame counter used by the
// render-on-demand specs at the bottom of this file: an idle map
// must not keep repainting, and a released drag must not coast
@@ -330,48 +328,6 @@ test("visibility fog toggles between the LOCAL-planet circle list and an empty o
);
});
test("wrap mode radios flip the renderer and the camera centre survives", async ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
// Confirm the renderer starts in torus mode.
await page.waitForFunction(
() => window.__galaxyDebug?.getMapMode?.() === "torus",
);
const initial = await page.evaluate(() =>
window.__galaxyDebug!.getMapCamera!(),
);
expect(initial).not.toBeNull();
const startCentre = initial!.camera;
await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-wrap-no-wrap").click();
// `setWrapMode` triggers a full Pixi remount; wait for the
// renderer to settle into the new mode and the debug surface to
// re-register before reading the camera. The mode provider is
// re-bound inside `runSerializedMount` after `createRenderer`
// resolves, so observing `getMapMode() === "no-wrap"` is the
// canonical "remount complete" signal.
await page.waitForFunction(
() => window.__galaxyDebug?.getMapMode?.() === "no-wrap",
);
const after = await page.evaluate(() =>
window.__galaxyDebug!.getMapCamera!(),
);
expect(after).not.toBeNull();
expect(
Math.abs(after!.camera.centerX - startCentre.centerX),
).toBeLessThanOrEqual(1);
expect(
Math.abs(after!.camera.centerY - startCentre.centerY),
).toBeLessThanOrEqual(1);
});
test("toggle state persists across a page reload", async ({ page }) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
+1 -1
View File
@@ -263,7 +263,7 @@ async function clickPlanetCentre(page: Page): Promise<void> {
async function startRename(page: Page, newName: string): Promise<void> {
await clickPlanetCentre(page);
const sidebar = page.getByTestId("sidebar-tool-inspector");
await sidebar.getByTestId("inspector-planet-rename-action").click();
await sidebar.getByTestId("inspector-planet-name").click();
const input = sidebar.getByTestId("inspector-planet-rename-input");
await input.fill(newName);
await sidebar.getByTestId("inspector-planet-rename-confirm").click();
+22 -20
View File
@@ -301,16 +301,22 @@ test("switching production three times collapses to one auto-synced row", async
const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
// Initial state: report.production = "Drive" → research segment is
// active, sub-row reveals Drive as the highlighted tech.
await expect(
sidebar.getByTestId("inspector-planet-production-segment-research"),
).toHaveClass(/active/);
const mainSelect = sidebar.getByTestId("inspector-planet-production-main");
const targetSelect = sidebar.getByTestId(
"inspector-planet-production-target",
);
const applyBtn = sidebar.getByTestId("inspector-planet-production-apply");
// Click 1: Industry → CAP
await sidebar
.getByTestId("inspector-planet-production-segment-industry")
.click();
// Initial state: report.production = "Drive" → main is "research"
// and the target is "DRIVE"; both apply/cancel start inert.
await expect(mainSelect).toHaveValue("research");
await expect(targetSelect).toHaveValue("DRIVE");
await expect(applyBtn).toBeDisabled();
// Pick 1: Industry + ✓ → CAP
await mainSelect.selectOption("industry");
await expect(applyBtn).toBeEnabled();
await applyBtn.click();
await page.getByTestId("sidebar-tab-order").click();
const orderTool = page.getByTestId("sidebar-tool-order");
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
@@ -323,11 +329,10 @@ test("switching production three times collapses to one auto-synced row", async
"applied",
);
// Click 2: Materials → MAT (replaces CAP via collapse)
// Pick 2: Materials + ✓ → MAT (replaces CAP via collapse)
await page.getByTestId("sidebar-tab-inspector").click();
await sidebar
.getByTestId("inspector-planet-production-segment-materials")
.click();
await mainSelect.selectOption("materials");
await applyBtn.click();
await page.getByTestId("sidebar-tab-order").click();
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
1,
@@ -336,14 +341,11 @@ test("switching production three times collapses to one auto-synced row", async
"Material",
);
// Click 3: Build Ship → expand sub-row → Scout (replaces MAT)
// Pick 3: Build Ship → target select appears → Scout + ✓ (replaces MAT)
await page.getByTestId("sidebar-tab-inspector").click();
await sidebar
.getByTestId("inspector-planet-production-segment-ship")
.click();
await sidebar
.getByTestId(`inspector-planet-production-ship-${SHIP_CLASS}`)
.click();
await mainSelect.selectOption("ship");
await targetSelect.selectOption(SHIP_CLASS);
await applyBtn.click();
await page.getByTestId("sidebar-tab-order").click();
await expect(orderTool.getByTestId("order-list").locator("li")).toHaveCount(
1,
+2 -2
View File
@@ -245,7 +245,7 @@ test("rename a seeded planet auto-syncs and the overlay survives reload", async
const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
await sidebar.getByTestId("inspector-planet-rename-action").click();
await sidebar.getByTestId("inspector-planet-name").click();
const input = sidebar.getByTestId("inspector-planet-rename-input");
await input.fill("New-Earth");
await sidebar.getByTestId("inspector-planet-rename-confirm").click();
@@ -312,7 +312,7 @@ test("rejected submit keeps the old name and surfaces the failure", async ({
);
await clickPlanetCentre(page);
const sidebar = page.getByTestId("sidebar-tool-inspector");
await sidebar.getByTestId("inspector-planet-rename-action").click();
await sidebar.getByTestId("inspector-planet-name").click();
await sidebar.getByTestId("inspector-planet-rename-input").fill("Mars-2");
await sidebar.getByTestId("inspector-planet-rename-confirm").click();
+19 -14
View File
@@ -419,23 +419,28 @@ test("planet production picker exposes user sciences in the Research sub-row", a
const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText("Earth");
// Expand the Research segment.
await sidebar
.getByTestId("inspector-planet-production-segment-research")
.click();
const mainSelect = sidebar.getByTestId("inspector-planet-production-main");
await mainSelect.selectOption("research");
// Tech buttons + the user's science button are both rendered.
await expect(
sidebar.getByTestId("inspector-planet-production-research-drive"),
).toBeVisible();
const scienceButton = sidebar.getByTestId(
"inspector-planet-production-science-FirstStep",
// Tech options and the user's science option are both rendered.
const targetSelect = sidebar.getByTestId(
"inspector-planet-production-target",
);
await expect(scienceButton).toBeVisible();
await expect(
targetSelect.locator(
'[data-testid="inspector-planet-production-target-option-drive"]',
),
).toHaveCount(1);
await expect(
targetSelect.locator(
'[data-testid="inspector-planet-production-target-option-science-FirstStep"]',
),
).toHaveCount(1);
// Click the science → setProductionType("SCIENCE", "FirstStep")
// lands in the draft and auto-syncs.
await scienceButton.click();
// Select the science target + ✓ → setProductionType("SCIENCE",
// "FirstStep") lands in the draft and auto-syncs.
await targetSelect.selectOption("FirstStep");
await sidebar.getByTestId("inspector-planet-production-apply").click();
await expect.poll(() => handle.lastProduce?.subject).toBe("FirstStep");
expect(handle.lastProduce?.planetNumber).toBe(1);
});
+20 -22
View File
@@ -2,11 +2,12 @@
// the identity strip (`<race> @ <game>`, falling back to `?` while
// the lobby / report calls are in flight), the Phase 26 turn
// navigator (`← turn N →` with a popover of every turn), the
// view-menu, and the account-menu. The tests assert the visible
// copy, that every view-menu entry switches the active in-game view
// via `activeView.select(...)` (the single-URL app-shell has no
// per-view routes), and that the Logout entry of the account-menu
// calls `session.signOut("user")`.
// view-menu, and the in-game ephemeral light/dark theme toggle (F8-05
// replaced the previous account-menu — language picker and logout
// now live in the lobby). The tests assert the visible copy, that
// every view-menu entry switches the active in-game view via
// `activeView.select(...)`, and that the theme toggle flips the
// in-memory `theme.override` channel.
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
@@ -20,7 +21,7 @@ import {
} from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import { session } from "../src/lib/session-store.svelte";
import { theme } from "../src/lib/theme/theme.svelte";
import Header from "../src/lib/header/header.svelte";
import {
GAME_STATE_CONTEXT_KEY,
@@ -74,10 +75,11 @@ beforeEach(() => {
i18n.resetForTests("en");
activeViewSelectSpy.mockReset();
appScreenGoSpy.mockReset();
vi.spyOn(session, "signOut").mockResolvedValue(undefined);
theme.clearOverride();
});
afterEach(() => {
theme.clearOverride();
vi.restoreAllMocks();
});
@@ -95,7 +97,7 @@ describe("game-shell header", () => {
"turn ?",
);
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument();
expect(ui.getByTestId("game-mode-theme-toggle")).toBeInTheDocument();
});
test("renders the live race / game / turn from GameStateStore", () => {
@@ -194,22 +196,18 @@ describe("game-shell header", () => {
expect(appScreenGoSpy).toHaveBeenCalledWith("lobby");
});
test("account-menu Logout triggers session.signOut('user')", async () => {
test("theme toggle flips theme.override between light and dark", async () => {
const ui = render(Header, {
props: { sidebarOpen: false, onToggleSidebar: () => {} },
});
await fireEvent.click(ui.getByTestId("account-menu-trigger"));
await fireEvent.click(ui.getByTestId("account-menu-logout"));
expect(session.signOut).toHaveBeenCalledWith("user");
});
test("account-menu language picker switches the i18n locale", async () => {
const ui = render(Header, {
props: { sidebarOpen: false, onToggleSidebar: () => {} },
});
await fireEvent.click(ui.getByTestId("account-menu-trigger"));
const select = ui.getByTestId("account-menu-language-select");
await fireEvent.change(select, { target: { value: "ru" } });
expect(i18n.locale).toBe("ru");
const toggle = ui.getByTestId("game-mode-theme-toggle");
const initialResolved = theme.resolved;
const opposite = initialResolved === "light" ? "dark" : "light";
await fireEvent.click(toggle);
expect(theme.override).toBe(opposite);
expect(theme.resolved).toBe(opposite);
await fireEvent.click(toggle);
expect(theme.override).toBe(initialResolved);
expect(theme.resolved).toBe(initialResolved);
});
});
@@ -1,10 +1,12 @@
// Vitest component coverage for the Phase 16 cargo-routes
// subsection of the planet inspector. Drives the component against
// a real `OrderDraftStore` (with `fake-indexeddb` standing in for
// the browser IDB factory) and a stub `MapPickService` whose
// `pick(...)` resolves to a script-controlled answer. The tests
// assert the four-slot rendering, the picker invocation, the
// per-(source, loadType) collapse rule, and the cancel path.
// Vitest component coverage for the F8-05 cargo-routes subsection of
// the planet inspector. Pre-F8-05 the surface rendered all four
// COL/CAP/MAT/EMP slots side-by-side; F8-05 collapsed it into a
// single `<select>` with a placeholder (absorbing the old section
// title) and contextual `add` / `edit` + `remove` buttons that only
// appear once the player picks a type. The tests drive the component
// against a real `OrderDraftStore` (with `fake-indexeddb` standing
// in for the browser IDB factory) and a stub `MapPickService` whose
// `pick(...)` resolves to a script-controlled answer.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
@@ -129,39 +131,48 @@ function mount(
return { ui, pick };
}
async function selectType(
ui: ReturnType<typeof mount>["ui"],
value: string,
): Promise<void> {
const select = ui.getByTestId(
"inspector-planet-cargo-type",
) as HTMLSelectElement;
await fireEvent.change(select, { target: { value } });
}
describe("planet inspector — cargo routes", () => {
test("renders four slots in COL/CAP/MAT/EMP order", () => {
const { ui, pick } = mount(
test("dropdown exposes COL/CAP/MAT/EMP plus the placeholder; nothing else is rendered until a type is picked", () => {
const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })],
);
const slots = ui.container.querySelectorAll(
"[data-testid^='inspector-planet-cargo-slot-']",
);
const slotIds = Array.from(slots).map((el) =>
el.getAttribute("data-testid"),
);
// Each slot generates several test ids (label + body items);
// pick the row data-testid (slot itself, no suffix).
const rowIds = slotIds.filter((id) =>
/^inspector-planet-cargo-slot-(col|cap|mat|emp)$/.test(id ?? ""),
);
expect(rowIds).toEqual([
"inspector-planet-cargo-slot-col",
"inspector-planet-cargo-slot-cap",
"inspector-planet-cargo-slot-mat",
"inspector-planet-cargo-slot-emp",
const select = ui.getByTestId(
"inspector-planet-cargo-type",
) as HTMLSelectElement;
expect(Array.from(select.options).map((o) => o.value)).toEqual([
"",
"COL",
"CAP",
"MAT",
"EMP",
]);
expect(select.value).toBe("");
// No action buttons surface before a type is picked.
expect(
ui.queryByTestId("inspector-planet-cargo-slot-col-add"),
).toBeNull();
expect(
ui.queryByTestId("inspector-planet-cargo-slot-col-destination"),
).toBeNull();
});
test("an empty slot exposes the Add button and the (no route) marker", () => {
const { ui, pick } = mount(
test("selecting an empty type reveals the Add button", async () => {
const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })],
);
expect(
ui.getByTestId("inspector-planet-cargo-slot-col-empty"),
).toBeInTheDocument();
await selectType(ui, "COL");
expect(
ui.getByTestId("inspector-planet-cargo-slot-col-add"),
).toBeInTheDocument();
@@ -170,8 +181,8 @@ describe("planet inspector — cargo routes", () => {
).toBeNull();
});
test("a filled slot shows the destination name plus Edit and Remove", () => {
const { ui, pick } = mount(
test("selecting a filled type shows the destination plus Edit and Remove", async () => {
const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
@@ -184,6 +195,7 @@ describe("planet inspector — cargo routes", () => {
},
],
);
await selectType(ui, "COL");
expect(
ui.getByTestId("inspector-planet-cargo-slot-col-destination"),
).toHaveTextContent("Mars");
@@ -211,7 +223,10 @@ describe("planet inspector — cargo routes", () => {
[],
2,
);
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-col-add"));
await selectType(ui, "COL");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1));
const invocation = pick.invocations[0]!;
expect(invocation.request.sourcePlanetNumber).toBe(1);
@@ -222,11 +237,7 @@ describe("planet inspector — cargo routes", () => {
});
test("the reachable set spans every planet kind in range, not only own", async () => {
// Reach = 40 * 1.5 = 60. Each candidate at distance 50 — in
// reach. The picker must include the foreign-race planet,
// the uninhabited rock, and the unidentified target so the
// engine's "destinations may be any planet" rule is honoured
// (route.go: only the source's ownership is enforced).
// Reach = 40 * 1.5 = 60. Each candidate at distance 50 — in reach.
const { ui, pick } = mount(
makePlanet({
number: 1,
@@ -269,7 +280,10 @@ describe("planet inspector — cargo routes", () => {
[],
1.5,
);
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-col-add"));
await selectType(ui, "COL");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1));
expect(
Array.from(pick.invocations[0]!.request.reachableIds).sort(),
@@ -304,7 +318,10 @@ describe("planet inspector — cargo routes", () => {
[],
1.5,
);
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-mat-add"));
await selectType(ui, "MAT");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-mat-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(9);
await waitFor(() => expect(draft.commands).toHaveLength(1));
@@ -325,7 +342,10 @@ describe("planet inspector — cargo routes", () => {
[],
2,
);
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-cap-add"));
await selectType(ui, "CAP");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-cap-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(1));
@@ -342,6 +362,29 @@ describe("planet inspector — cargo routes", () => {
);
});
test("dropdown stays on the just-picked type after add resolves", async () => {
const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
],
[],
2,
);
await selectType(ui, "CAP");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-cap-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const select = ui.getByTestId(
"inspector-planet-cargo-type",
) as HTMLSelectElement;
expect(select.value).toBe("CAP");
});
test("cancel resolves null and emits no command", async () => {
const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
@@ -350,7 +393,10 @@ describe("planet inspector — cargo routes", () => {
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
],
);
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-mat-add"));
await selectType(ui, "MAT");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-mat-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(null);
await waitFor(() =>
@@ -361,8 +407,8 @@ describe("planet inspector — cargo routes", () => {
expect(draft.commands).toHaveLength(0);
});
test("Remove emits removeCargoRoute for the slot", async () => {
const { ui, pick } = mount(
test("Remove emits removeCargoRoute for the selected type", async () => {
const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
@@ -375,6 +421,7 @@ describe("planet inspector — cargo routes", () => {
},
],
);
await selectType(ui, "EMP");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-emp-remove"),
);
@@ -401,13 +448,14 @@ describe("planet inspector — cargo routes", () => {
},
],
);
await selectType(ui, "COL");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-edit"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(3);
await waitFor(() => expect(draft.commands).toHaveLength(1));
// Then a second edit to a different planet — collapse keeps a
// A second edit to a different planet — collapse keeps a
// single row.
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-edit"),
@@ -429,11 +477,17 @@ describe("planet inspector — cargo routes", () => {
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
],
);
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-col-add"));
await selectType(ui, "COL");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(1));
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-cap-add"));
await selectType(ui, "CAP");
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-cap-add"),
);
await waitFor(() => expect(pick.invocations.length).toBe(2));
pick.invocations[1]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(2));
@@ -444,8 +498,8 @@ describe("planet inspector — cargo routes", () => {
expect(types).toEqual(["CAP", "COL"]);
});
test("no_destinations message appears when reach is positive but every planet is out of range", () => {
const { ui, pick } = mount(
test("no_destinations message appears once a type is picked and every planet is out of range", async () => {
const { ui } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
@@ -454,6 +508,11 @@ describe("planet inspector — cargo routes", () => {
[],
0.1, // reach 4 — far less than 5000 distance
);
// Hidden until the player engages with the dropdown.
expect(
ui.queryByTestId("inspector-planet-cargo-no-destinations"),
).toBeNull();
await selectType(ui, "COL");
expect(
ui.getByTestId("inspector-planet-cargo-no-destinations"),
).toBeInTheDocument();
@@ -1,13 +1,17 @@
// Vitest component coverage for the Phase 15 production-controls
// subsection of the planet inspector. Drives the component against a
// real `OrderDraftStore` (with `fake-indexeddb` standing in for the
// browser's IDB factory) so the collapse-by-`planetNumber` rule and
// the per-row status side-effects are exercised end-to-end.
// Vitest component coverage for the F8-05 production-controls
// subsection of the planet inspector. The pre-F8-05 surface was four
// segmented main buttons (auto-submitting on click) plus a contextual
// sub-row; F8-05 replaced it with two `<select>`s (main / target) and
// a green ✓ apply / yellow ✗ cancel pair on the same row. The apply
// gate is the new behaviour: row state is dirty when the user picked
// something different from the planet's current effective production,
// and only then can the player commit via the ✓.
//
// The active-segment derivation is covered by direct render-and-
// query assertions: the parser is small enough that a table-driven
// pass over the canonical engine display strings catches every
// branch.
// The tests drive the component against a real `OrderDraftStore`
// (with `fake-indexeddb` standing in for the browser's IDB factory)
// so the collapse-by-`planetNumber` rule remains exercised. The
// active-target derivation is covered by a table-driven pass over the
// canonical engine display strings.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
@@ -106,28 +110,51 @@ function mountProduction(
});
}
function getMain(ui: ReturnType<typeof mountProduction>): HTMLSelectElement {
return ui.getByTestId("inspector-planet-production-main") as HTMLSelectElement;
}
function getTarget(
ui: ReturnType<typeof mountProduction>,
): HTMLSelectElement {
return ui.getByTestId(
"inspector-planet-production-target",
) as HTMLSelectElement;
}
describe("planet inspector — production controls", () => {
test("renders the four main segments with localised labels", () => {
test("renders the main select with localised options and ✓/✗ icons", () => {
const ui = mountProduction(localPlanet({ number: 1 }));
const main = getMain(ui);
expect(main.value).toBe("");
const labels = Array.from(main.options).map((o) => o.textContent?.trim());
// One placeholder + the four production kinds, in the documented order.
expect(labels).toEqual([
"(production)",
"industry",
"materials",
"research",
"build ship",
]);
// No secondary select until research / ship is chosen.
expect(
ui.getByTestId("inspector-planet-production-segment-industry"),
).toHaveTextContent("industry");
ui.queryByTestId("inspector-planet-production-target"),
).toBeNull();
expect(
ui.getByTestId("inspector-planet-production-segment-materials"),
).toHaveTextContent("materials");
ui.getByTestId("inspector-planet-production-apply"),
).toBeDisabled();
expect(
ui.getByTestId("inspector-planet-production-segment-research"),
).toHaveTextContent("research");
expect(
ui.getByTestId("inspector-planet-production-segment-ship"),
).toHaveTextContent("build ship");
ui.getByTestId("inspector-planet-production-cancel"),
).toBeDisabled();
});
test("Industry click emits a CAP setProductionType command", async () => {
test("Industry pick + ✓ emits a CAP setProductionType command", async () => {
const ui = mountProduction(localPlanet({ number: 7 }));
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-industry"),
);
const main = getMain(ui);
await fireEvent.change(main, { target: { value: "industry" } });
const apply = ui.getByTestId("inspector-planet-production-apply");
expect(apply).not.toBeDisabled();
await fireEvent.click(apply);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("setProductionType");
@@ -137,32 +164,29 @@ describe("planet inspector — production controls", () => {
expect(cmd.subject).toBe("");
});
test("Materials click emits a MAT setProductionType command", async () => {
test("Materials pick + ✓ emits a MAT setProductionType command", async () => {
const ui = mountProduction(localPlanet({ number: 7 }));
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-materials"),
);
await fireEvent.change(getMain(ui), { target: { value: "materials" } });
await fireEvent.click(ui.getByTestId("inspector-planet-production-apply"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("MAT");
});
test("Research click reveals the four tech sub-buttons without emitting", async () => {
test("Research pick reveals the target select and apply stays disabled until a tech is chosen", async () => {
const ui = mountProduction(localPlanet({ number: 7 }));
expect(
ui.queryByTestId("inspector-planet-production-research-row"),
ui.queryByTestId("inspector-planet-production-target"),
).toBeNull();
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-research"),
);
await fireEvent.change(getMain(ui), { target: { value: "research" } });
const target = getTarget(ui);
expect(target.value).toBe("");
expect(
ui.getByTestId("inspector-planet-production-research-row"),
).toBeInTheDocument();
expect(draft.commands).toHaveLength(0);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-research-drive"),
);
ui.getByTestId("inspector-planet-production-apply"),
).toBeDisabled();
await fireEvent.change(target, { target: { value: "DRIVE" } });
await fireEvent.click(ui.getByTestId("inspector-planet-production-apply"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
@@ -170,27 +194,39 @@ describe("planet inspector — production controls", () => {
expect(cmd.subject).toBe("");
});
test("Build-Ship segment shows the empty placeholder when no classes designed", async () => {
test("Research target with a science name emits a SCIENCE subject", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), [], [
{ name: "Genetics", drive: 0, weapons: 0, shields: 0, cargo: 0 },
]);
await fireEvent.change(getMain(ui), { target: { value: "research" } });
await fireEvent.change(getTarget(ui), { target: { value: "Genetics" } });
await fireEvent.click(ui.getByTestId("inspector-planet-production-apply"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("SCIENCE");
expect(cmd.subject).toBe("Genetics");
});
test("Build-Ship with no classes shows the empty placeholder option", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), []);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-ship"),
);
await fireEvent.change(getMain(ui), { target: { value: "ship" } });
expect(
ui.getByTestId("inspector-planet-production-ship-empty"),
).toBeInTheDocument();
expect(
ui.getByTestId("inspector-planet-production-apply"),
).toBeDisabled();
});
test("Build-Ship click on a class emits a SHIP setProductionType command", async () => {
test("Build-Ship + class pick + ✓ emits a SHIP setProductionType command", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), [
shipClass({ name: "Scout" }),
shipClass({ name: "Destroyer" }),
]);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-ship"),
);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-ship-Scout"),
);
await fireEvent.change(getMain(ui), { target: { value: "ship" } });
await fireEvent.change(getTarget(ui), { target: { value: "Scout" } });
await fireEvent.click(ui.getByTestId("inspector-planet-production-apply"));
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
@@ -198,32 +234,41 @@ describe("planet inspector — production controls", () => {
expect(cmd.subject).toBe("Scout");
});
test("re-clicks on the same planet collapse to the latest entry via the store", async () => {
const ui = mountProduction(localPlanet({ number: 7 }), [
shipClass({ name: "Scout" }),
]);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-industry"),
test("Cancel resets the row to the current effective production without emitting", async () => {
const ui = mountProduction(
localPlanet({ number: 7, production: "Capital" }),
);
const main = getMain(ui);
expect(main.value).toBe("industry");
await fireEvent.change(main, { target: { value: "research" } });
expect(
ui.getByTestId("inspector-planet-production-cancel"),
).not.toBeDisabled();
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-materials"),
ui.getByTestId("inspector-planet-production-cancel"),
);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-segment-research"),
);
await fireEvent.click(
ui.getByTestId("inspector-planet-production-research-cargo"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
if (cmd.kind !== "setProductionType") throw new Error("wrong kind");
expect(cmd.productionType).toBe("CARGO");
expect(getMain(ui).value).toBe("industry");
expect(draft.commands).toEqual([]);
});
test("active main segment derives from planet.production display string", () => {
test("Apply gate is closed while the row matches the current effective production", () => {
const ui = mountProduction(
localPlanet({ number: 7, production: "Drive" }),
);
// On mount the parser seeds research + DRIVE, so the apply
// button is inert until the player actually changes something.
expect(
ui.getByTestId("inspector-planet-production-apply"),
).toBeDisabled();
expect(
ui.getByTestId("inspector-planet-production-cancel"),
).toBeDisabled();
});
test("active main derivation seeds the select from planet.production", () => {
const cases: ReadonlyArray<{
production: string | null;
expected: "industry" | "materials" | "research" | "ship" | "none";
expected: "" | "industry" | "materials" | "research" | "ship";
}> = [
{ production: "Capital", expected: "industry" },
{ production: "Material", expected: "materials" },
@@ -232,67 +277,46 @@ describe("planet inspector — production controls", () => {
{ production: "Shields", expected: "research" },
{ production: "Cargo", expected: "research" },
{ production: "Scout", expected: "ship" },
{ production: "-", expected: "none" },
{ production: null, expected: "none" },
{ production: "UnknownThing", expected: "none" },
{ production: "-", expected: "" },
{ production: null, expected: "" },
{ production: "UnknownThing", expected: "" },
];
for (const tc of cases) {
const ui = mountProduction(
localPlanet({ number: 1, production: tc.production }),
[shipClass({ name: "Scout" })],
);
const ids: ReadonlyArray<
"industry" | "materials" | "research" | "ship"
> = ["industry", "materials", "research", "ship"];
for (const id of ids) {
const el = ui.getByTestId(
`inspector-planet-production-segment-${id}`,
);
if (tc.expected === id) {
expect(el.classList.contains("active")).toBe(true);
} else {
expect(el.classList.contains("active")).toBe(false);
}
}
expect(getMain(ui).value).toBe(tc.expected);
ui.unmount();
}
});
test("active research sub-button highlights for known display strings", () => {
test("active target seeds the secondary select for research display strings", () => {
const cases: ReadonlyArray<{
production: string;
slug: "drive" | "weapons" | "shields" | "cargo";
expected: "DRIVE" | "WEAPONS" | "SHIELDS" | "CARGO";
}> = [
{ production: "Drive", slug: "drive" },
{ production: "Weapons", slug: "weapons" },
{ production: "Shields", slug: "shields" },
{ production: "Cargo", slug: "cargo" },
{ production: "Drive", expected: "DRIVE" },
{ production: "Weapons", expected: "WEAPONS" },
{ production: "Shields", expected: "SHIELDS" },
{ production: "Cargo", expected: "CARGO" },
];
for (const tc of cases) {
const ui = mountProduction(
localPlanet({ number: 1, production: tc.production }),
);
const el = ui.getByTestId(
`inspector-planet-production-research-${tc.slug}`,
);
expect(el.classList.contains("active")).toBe(true);
expect(getMain(ui).value).toBe("research");
expect(getTarget(ui).value).toBe(tc.expected);
ui.unmount();
}
});
test("ship class sub-row matches when production equals a class name", async () => {
test("target select seeds the ship class when production is a class name", () => {
const ui = mountProduction(
localPlanet({ number: 1, production: "Scout" }),
[shipClass({ name: "Scout" }), shipClass({ name: "Destroyer" })],
);
expect(
ui.getByTestId("inspector-planet-production-ship-Scout").classList
.contains("active"),
).toBe(true);
expect(
ui
.getByTestId("inspector-planet-production-ship-Destroyer")
.classList.contains("active"),
).toBe(false);
expect(getMain(ui).value).toBe("ship");
expect(getTarget(ui).value).toBe("Scout");
});
});
@@ -1,11 +1,17 @@
// Vitest coverage for the Phase 19 follow-up "stationed ship groups"
// subsection of the planet inspector. Phase 19 originally rendered
// every in-orbit group as a small offset point on the map; the
// resulting visual noise pushed the listing into this subsection
// (`lib/inspectors/planet/ship-groups.svelte`) instead.
// Vitest coverage for the "stationed ship groups" subsection of the
// planet inspector. The map deliberately hides on-planet groups; this
// subsection is the player's view of the fleets in orbit.
//
// F8-05 (issue #48 п.32) moved the race column from the row into a
// dropdown above the table. The dropdown only renders when more than
// one race is stationed; it seeds with the player's own race when
// local groups are stationed here, otherwise with the first race
// alphabetically. Single-race cases skip the dropdown and render
// straight through. The race column is dropped in both modes — the
// dropdown's value already names the active race.
import "@testing-library/jest-dom/vitest";
import { render } from "@testing-library/svelte";
import { fireEvent, render } from "@testing-library/svelte";
import { beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
@@ -86,7 +92,7 @@ function otherGroup(
}
describe("planet inspector — stationed ship groups", () => {
test("renders one row per in-orbit local group with the player's race", () => {
test("renders one row per in-orbit local group; the dropdown is hidden with a single race", () => {
const ui = render(ShipGroups, {
props: {
planet: HOME_PLANET,
@@ -100,7 +106,13 @@ describe("planet inspector — stationed ship groups", () => {
});
const rows = ui.getAllByTestId("inspector-planet-ship-groups-row");
expect(rows.length).toBe(2);
expect(rows[0]).toHaveTextContent("Earthlings");
// Race no longer appears in the row (it is hoisted to the
// dropdown — and the dropdown itself is hidden when only one
// race is present).
expect(
ui.queryByTestId("inspector-planet-ship-groups-race-filter"),
).toBeNull();
expect(rows[0]).not.toHaveTextContent("Earthlings");
expect(rows[0]).toHaveTextContent("Frontier");
expect(rows[0]).toHaveTextContent("2");
expect(rows[0]).toHaveTextContent("24");
@@ -108,6 +120,66 @@ describe("planet inspector — stationed ship groups", () => {
expect(rows[1]).toHaveTextContent("173.25");
});
test("multiple races surface a dropdown that filters the table", async () => {
const ui = render(ShipGroups, {
props: {
planet: FOREIGN_PLANET,
localShipGroups: [
localGroup({ id: "own-1", destination: 99, class: "Frontier" }),
],
otherShipGroups: [
otherGroup({ class: "Bird-of-Prey", destination: 99 }),
],
localRace: "Earthlings",
},
});
const select = ui.getByTestId(
"inspector-planet-ship-groups-race-filter",
) as HTMLSelectElement;
// Own ships are stationed → own race wins as the default;
// alphabetical ordering puts the foreign one second.
expect(select.value).toBe("Earthlings");
expect(Array.from(select.options).map((o) => o.value)).toEqual([
"Earthlings",
"Klingons",
]);
expect(
ui.getAllByTestId("inspector-planet-ship-groups-row").length,
).toBe(1);
expect(
ui.getByTestId("inspector-planet-ship-groups-row"),
).toHaveTextContent("Frontier");
await fireEvent.change(select, { target: { value: "Klingons" } });
const rows = ui.getAllByTestId("inspector-planet-ship-groups-row");
expect(rows.length).toBe(1);
expect(rows[0]).toHaveTextContent("Bird-of-Prey");
});
test("with no own ships the dropdown collapses to the single foreign race", () => {
const ui = render(ShipGroups, {
props: {
planet: {
...HOME_PLANET,
owner: "Andorians",
kind: "other",
},
localShipGroups: [],
otherShipGroups: [
otherGroup({ class: "Bird-of-Prey", destination: 17 }),
],
localRace: "Earthlings",
},
});
// Single foreign race → no dropdown.
expect(
ui.queryByTestId("inspector-planet-ship-groups-race-filter"),
).toBeNull();
expect(
ui.getAllByTestId("inspector-planet-ship-groups-row").length,
).toBe(1);
});
test("filters out groups stationed on a different planet", () => {
const ui = render(ShipGroups, {
props: {
@@ -147,20 +219,6 @@ describe("planet inspector — stationed ship groups", () => {
);
});
test("foreign-planet visitors fall back to the planet owner's race", () => {
const ui = render(ShipGroups, {
props: {
planet: FOREIGN_PLANET,
localShipGroups: [],
otherShipGroups: [otherGroup({ destination: 99 })],
localRace: "Earthlings",
},
});
const row = ui.getByTestId("inspector-planet-ship-groups-row");
expect(row).toHaveTextContent("Klingons");
expect(row).toHaveTextContent("Bird-of-Prey");
});
test("subsection collapses entirely when nothing is stationed", () => {
const ui = render(ShipGroups, {
props: {
+13 -8
View File
@@ -245,7 +245,7 @@ describe("planet inspector", () => {
expect(ui.queryByTestId("inspector-planet-field-natural_resources")).toBeNull();
});
test("Rename action is hidden for non-local planets", () => {
test("Name is not editable for non-local planets", () => {
const ui = render(Planet, {
props: {
planet: makePlanet({
@@ -268,10 +268,13 @@ describe("planet inspector", () => {
localRace: "",
},
});
expect(ui.queryByTestId("inspector-planet-rename-action")).toBeNull();
const name = ui.getByTestId("inspector-planet-name");
// Non-local planets render the name as a plain heading, not a
// click-to-edit button.
expect(name.tagName).toBe("H3");
});
test("Rename action opens an inline editor and validates locally", async () => {
test("Clicking the name opens an inline editor and validates locally", async () => {
const dbName = `galaxy-rename-${crypto.randomUUID()}`;
const db = await openGalaxyDB(dbName);
const cache = new IDBCache(db);
@@ -311,8 +314,9 @@ describe("planet inspector", () => {
context,
});
const action = ui.getByTestId("inspector-planet-rename-action");
await fireEvent.click(action);
const name = ui.getByTestId("inspector-planet-name");
expect(name.tagName).toBe("BUTTON");
await fireEvent.click(name);
const input = ui.getByTestId("inspector-planet-rename-input") as HTMLInputElement;
expect(input.value).toBe("Earth");
@@ -344,7 +348,7 @@ describe("planet inspector", () => {
db.close();
});
test("Cancel closes the editor without adding to the draft", async () => {
test("Escape closes the editor without adding to the draft", async () => {
const dbName = `galaxy-rename-${crypto.randomUUID()}`;
const db = await openGalaxyDB(dbName);
const cache = new IDBCache(db);
@@ -382,8 +386,9 @@ describe("planet inspector", () => {
},
context,
});
await fireEvent.click(ui.getByTestId("inspector-planet-rename-action"));
await fireEvent.click(ui.getByTestId("inspector-planet-rename-cancel"));
await fireEvent.click(ui.getByTestId("inspector-planet-name"));
const input = ui.getByTestId("inspector-planet-rename-input");
await fireEvent.keyDown(input, { key: "Escape" });
expect(ui.queryByTestId("inspector-planet-rename")).toBeNull();
expect(draft.commands).toEqual([]);
draft.dispose();
@@ -1,9 +1,11 @@
// Phase 29 component coverage for `lib/active-view/map-toggles.svelte`.
// The popover is a thin view of the `GameStateStore` runes —
// every control fires `setMapToggle` / `setWrapMode` on the store
// and reads the current state through `store.mapToggles` /
// `store.wrapMode`. The tests assert the wiring, the default
// rendering, and the popover lifecycle (open / Escape close).
// every checkbox fires `setMapToggle` on the store and reads the
// current state through `store.mapToggles`. F8-05 (issue #48 п.8)
// dropped the wrap-scrolling radio group from the UI; the
// `wrapMode` rune and the renderer's no-wrap path stay put for a
// future game-server-side feature flag, but no surface exposes
// the choice today.
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
@@ -19,7 +21,6 @@ import {
function buildStore(): GameStateStore {
const store = new GameStateStore();
store.status = "ready";
store.wrapMode = "torus";
store.mapToggles = { ...DEFAULT_MAP_TOGGLES };
return store;
}
@@ -59,8 +60,8 @@ describe("MapTogglesControl", () => {
expect(ui.getByTestId("map-toggles-unidentified-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-unreachable-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-visible-hyperspace")).toBeChecked();
expect(ui.getByTestId("map-toggles-wrap-torus")).toBeChecked();
expect(ui.getByTestId("map-toggles-wrap-no-wrap")).not.toBeChecked();
expect(ui.queryByTestId("map-toggles-wrap-torus")).toBeNull();
expect(ui.queryByTestId("map-toggles-wrap-no-wrap")).toBeNull();
});
test("flipping a checkbox calls setMapToggle with the new value", async () => {
@@ -90,17 +91,6 @@ describe("MapTogglesControl", () => {
expect(setMapToggle).not.toHaveBeenCalledWith("bombingMarkers", false);
});
test("selecting the no-wrap radio calls setWrapMode", async () => {
const store = buildStore();
const setWrapMode = vi
.spyOn(store, "setWrapMode")
.mockResolvedValue(undefined);
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
await fireEvent.click(ui.getByTestId("map-toggles-wrap-no-wrap"));
expect(setWrapMode).toHaveBeenCalledWith("no-wrap");
});
test("Escape closes the popover", async () => {
const store = buildStore();
const ui = render(MapTogglesControl, { props: { store } });
+41
View File
@@ -86,4 +86,45 @@ describe("theme store", () => {
const { theme } = await freshStore();
expect(theme.choice).toBe("system");
});
it("applies an ephemeral override without touching the persisted choice", async () => {
localStorage.setItem(STORAGE_KEY, "dark");
const { theme } = await freshStore();
expect(theme.resolved).toBe("dark");
expect(theme.override).toBeNull();
theme.setOverride("light");
expect(theme.override).toBe("light");
expect(theme.resolved).toBe("light");
expect(document.documentElement.dataset.theme).toBe("light");
// Persisted choice is untouched.
expect(theme.choice).toBe("dark");
expect(localStorage.getItem(STORAGE_KEY)).toBe("dark");
});
it("clearOverride re-projects the persisted choice", async () => {
localStorage.setItem(STORAGE_KEY, "light");
const { theme } = await freshStore();
theme.setOverride("dark");
expect(theme.resolved).toBe("dark");
theme.clearOverride();
expect(theme.override).toBeNull();
expect(theme.resolved).toBe("light");
expect(document.documentElement.dataset.theme).toBe("light");
});
it("override shadows setChoice until cleared", async () => {
const { theme } = await freshStore();
theme.setOverride("light");
theme.setChoice("dark");
// Override wins while it is non-null, but the choice is still
// persisted for the next lobby visit.
expect(theme.resolved).toBe("light");
expect(theme.choice).toBe("dark");
expect(localStorage.getItem(STORAGE_KEY)).toBe("dark");
theme.clearOverride();
expect(theme.resolved).toBe("dark");
});
});