feat(ui): F8-05 — game-mode chrome cleanup + inspector compact rows (#48)
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:
@@ -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 }) => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user