cbf7f65916
Owner review on PR #61: - п.9 (option B). Hide the native spinner on EVERY numeric input in the calculator (DWSC blocks, armament, tech, planet MAT, custom load, lock value, modernization target tech) and drive every step through ArrowUp / ArrowDown. The column widths stay stable and the inputs read consistently across the whole row. The ship blocks keep the smart (0 ↔ 1) jump on ArrowUp/ArrowDown; armament steps ±1 with a JS handler instead of relying on the native spinner. Other inputs step by their natural grain (±0.001 for tech / lock, ±0.01 for MAT / load). - п.10. Tech-level labels (`tech-val`) and the planet MAT label (`mat-val`) now read through the same `Ceil3` formatter as the derived results, so plain-text numeric values share the report's 3-decimal tabular formatting. The design-area component receives `formatNumber` as a prop; the resolved (goal-seek) cell uses the same formatter, so the read-only computed value matches the rest of the row. - п.12. `computeCalculator` now validates the back-solved block against the same DWSC rule the live validator enforces (`0` or `≥ 1`). When the solver lands in the `(0, 1)` gap (e.g. attack 0.5 / weaponsTech 1.5 → weapons 0.333…) the lock is flagged infeasible — the lock input flips red and the claimed block is NOT back-solved into the invalid range, so the design preview keeps reading the user's own typed values instead of silently showing a sub-1 block. - new. Selecting an existing ship class from the name datalist now loads it immediately. `change` fires only on blur in Firefox, which is why the previous behaviour looked delayed; switching the load to `oninput` with an `InputEvent.inputType` check makes the load synchronous everywhere (datalist replacement carries `"insertReplacementText"` in Chromium / WebKit, `undefined` in Firefox; keyboard typing always carries a typing `inputType`). Before loading we compare the live blocks to the previously loaded class (or to the empty defaults) and, if they differ, ask through a `window.confirm`. On decline we revert the name field and leave the design untouched. Tests: calculator-tab and calc-model gain six cases (armament step, tech/MAT formatter labels, lock infeasible on (0, 1) for both attack→weapons and emptyMass→cargo, lock-value Arrow step, dropdown immediate load + confirm-blocks-load + confirm-allows-load), all 779 vitest tests green. docs/calculator-ux.md follows the new behaviour. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
563 lines
19 KiB
TypeScript
563 lines
19 KiB
TypeScript
// Component coverage for the Phase 30 ship-class calculator: forward
|
|
// results, single-target goal-seek wired through a mounted component, the
|
|
// Create flow against a real OrderDraftStore, and the planet area. The
|
|
// math itself is covered by `calc-model.test.ts` and the Go parity tests;
|
|
// here we assert the component renders and orchestrates them.
|
|
|
|
import "@testing-library/jest-dom/vitest";
|
|
import "fake-indexeddb/auto";
|
|
import { fireEvent, render } from "@testing-library/svelte";
|
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
|
// The calculator reads `page.params.id` to scope its long-lived state to
|
|
// the active game; stub a stable id so the component test has a router.
|
|
vi.mock("$app/state", () => ({ page: { params: { id: "calc-test-game" } } }));
|
|
|
|
import { i18n } from "../src/lib/i18n/index.svelte";
|
|
import CalculatorTab from "../src/lib/sidebar/calculator-tab.svelte";
|
|
import { calculatorState } from "../src/lib/calculator/calc-state.svelte";
|
|
import { CORE_CONTEXT_KEY, CoreHolder } from "../src/lib/core-context.svelte";
|
|
import {
|
|
ORDER_DRAFT_CONTEXT_KEY,
|
|
OrderDraftStore,
|
|
} from "../src/sync/order-draft.svelte";
|
|
import {
|
|
SELECTION_CONTEXT_KEY,
|
|
SelectionStore,
|
|
} from "../src/lib/selection.svelte";
|
|
import {
|
|
RENDERED_REPORT_CONTEXT_KEY,
|
|
type RenderedReportSource,
|
|
} from "../src/lib/rendered-report.svelte";
|
|
import type { GameReport, ReportPlanet } from "../src/api/game-state";
|
|
import type { Core } from "../src/platform/core/index";
|
|
import { makeFakeCore } from "./fake-core";
|
|
import { IDBCache } from "../src/platform/store/idb-cache";
|
|
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
|
import type { IDBPDatabase } from "idb";
|
|
|
|
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
|
|
|
let db: IDBPDatabase<GalaxyDB>;
|
|
let dbName: string;
|
|
let draft: OrderDraftStore;
|
|
|
|
const LOCAL_PLANET: ReportPlanet = {
|
|
number: 17,
|
|
name: "Castle",
|
|
x: 100,
|
|
y: 100,
|
|
kind: "local",
|
|
owner: "me",
|
|
size: 1000,
|
|
resources: 10,
|
|
industryStockpile: 0,
|
|
materialsStockpile: 100,
|
|
industry: 1000,
|
|
population: 1000,
|
|
colonists: 0,
|
|
production: "Cruiser",
|
|
freeIndustry: 1000,
|
|
};
|
|
|
|
function makeReport(over: Partial<GameReport> = {}): GameReport {
|
|
return {
|
|
localPlayerDrive: 1.2,
|
|
localPlayerWeapons: 1.5,
|
|
localPlayerShields: 1,
|
|
localPlayerCargo: 1,
|
|
localShipClass: [],
|
|
planets: [],
|
|
...over,
|
|
} as unknown as GameReport;
|
|
}
|
|
|
|
function mount(opts: {
|
|
core?: Core | null;
|
|
report?: GameReport;
|
|
selection?: SelectionStore;
|
|
} = {}) {
|
|
const holder = new CoreHolder();
|
|
holder.set(opts.core === undefined ? makeFakeCore() : opts.core);
|
|
const selection = opts.selection ?? new SelectionStore();
|
|
const report = opts.report ?? makeReport();
|
|
const source: RenderedReportSource = {
|
|
get report() {
|
|
return report;
|
|
},
|
|
};
|
|
const context = new Map<unknown, unknown>([
|
|
[RENDERED_REPORT_CONTEXT_KEY, source],
|
|
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
|
[CORE_CONTEXT_KEY, holder],
|
|
[SELECTION_CONTEXT_KEY, selection],
|
|
]);
|
|
return render(CalculatorTab, { context });
|
|
}
|
|
|
|
async function setBlock(
|
|
ui: { getByTestId(id: string): HTMLElement },
|
|
key: string,
|
|
value: number,
|
|
): Promise<void> {
|
|
await fireEvent.input(ui.getByTestId(`calculator-block-${key}`), {
|
|
target: { value: String(value) },
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
dbName = `galaxy-calculator-${crypto.randomUUID()}`;
|
|
db = await openGalaxyDB(dbName);
|
|
draft = new OrderDraftStore();
|
|
await draft.init({ cache: new IDBCache(db), gameId: GAME_ID });
|
|
i18n.resetForTests("en");
|
|
// The calculator state is a module singleton shared across cases.
|
|
calculatorState.reset();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
draft.dispose();
|
|
db.close();
|
|
await new Promise<void>((resolve) => {
|
|
const req = indexedDB.deleteDatabase(dbName);
|
|
req.onsuccess = () => resolve();
|
|
req.onerror = () => resolve();
|
|
req.onblocked = () => resolve();
|
|
});
|
|
});
|
|
|
|
describe("calculator-tab", () => {
|
|
test("computes results once the blocks are valid", async () => {
|
|
const ui = mount();
|
|
// All-zero blocks are invalid: results read as unavailable.
|
|
expect(ui.getByTestId("calculator-out-emptyMass")).toHaveTextContent("—");
|
|
await setBlock(ui, "drive", 10);
|
|
await setBlock(ui, "shields", 5);
|
|
await setBlock(ui, "cargo", 5);
|
|
// empty mass = 10 + 5 + 5 = 20.
|
|
expect(ui.getByTestId("calculator-out-emptyMass")).toHaveTextContent("20");
|
|
});
|
|
|
|
test("locking attack back-solves the weapons block", async () => {
|
|
const ui = mount();
|
|
await setBlock(ui, "drive", 10);
|
|
await setBlock(ui, "armament", 2);
|
|
await setBlock(ui, "weapons", 5);
|
|
await setBlock(ui, "shields", 5);
|
|
await setBlock(ui, "cargo", 5);
|
|
await fireEvent.click(ui.getByTestId("calculator-lock-attack"));
|
|
await fireEvent.input(ui.getByTestId("calculator-locked-attack"), {
|
|
target: { value: "30" },
|
|
});
|
|
// weapons = 30 / weaponsTech(1.5) = 20, shown read-only.
|
|
const weapons = ui.getByTestId("calculator-block-weapons");
|
|
expect(weapons).toHaveValue(20);
|
|
expect(weapons).toHaveAttribute("readonly");
|
|
});
|
|
|
|
test("flags an unreachable speed target as infeasible", async () => {
|
|
const ui = mount();
|
|
await setBlock(ui, "drive", 10);
|
|
await setBlock(ui, "shields", 5);
|
|
await setBlock(ui, "cargo", 5);
|
|
await fireEvent.click(ui.getByTestId("calculator-lock-speedEmpty"));
|
|
// ceiling is 20 * driveTech(1.2) = 24; 100 is unreachable.
|
|
await fireEvent.input(ui.getByTestId("calculator-locked-speedEmpty"), {
|
|
target: { value: "100" },
|
|
});
|
|
const locked = ui.getByTestId("calculator-locked-speedEmpty");
|
|
expect(locked).toHaveAttribute("title", expect.stringMatching(/cannot be reached/i));
|
|
});
|
|
|
|
test("create adds a ship-class command once the name is valid", async () => {
|
|
const ui = mount();
|
|
await setBlock(ui, "drive", 10);
|
|
await setBlock(ui, "shields", 5);
|
|
await setBlock(ui, "cargo", 5);
|
|
const create = ui.getByTestId("calculator-create");
|
|
expect(create).toBeDisabled();
|
|
await fireEvent.input(ui.getByTestId("calculator-name"), {
|
|
target: { value: "Cruiser" },
|
|
});
|
|
expect(create).not.toBeDisabled();
|
|
await fireEvent.click(create);
|
|
expect(draft.commands).toHaveLength(1);
|
|
expect(draft.commands[0]).toMatchObject({
|
|
kind: "createShipClass",
|
|
name: "Cruiser",
|
|
drive: 10,
|
|
shields: 5,
|
|
cargo: 5,
|
|
});
|
|
});
|
|
|
|
test("planet area prompts for a selection when none is active", () => {
|
|
const ui = mount();
|
|
expect(ui.getByTestId("calculator-planet-none")).toBeInTheDocument();
|
|
});
|
|
|
|
test("planet area shows build stats for a selected own planet", async () => {
|
|
const selection = new SelectionStore();
|
|
selection.selectPlanet(17);
|
|
const ui = mount({
|
|
report: makeReport({ planets: [LOCAL_PLANET] }),
|
|
selection,
|
|
});
|
|
await setBlock(ui, "drive", 10);
|
|
await setBlock(ui, "shields", 5);
|
|
await setBlock(ui, "cargo", 5);
|
|
expect(ui.getByTestId("calculator-planet-name")).toHaveTextContent("Castle");
|
|
expect(
|
|
ui.getByTestId("calculator-ships-per-turn"),
|
|
).not.toHaveTextContent("—");
|
|
});
|
|
|
|
test("zero cargo disables the load toggle", async () => {
|
|
const ui = mount();
|
|
await setBlock(ui, "drive", 10);
|
|
await setBlock(ui, "shields", 5);
|
|
await setBlock(ui, "cargo", 0);
|
|
expect(ui.getByTestId("calculator-load-full")).toBeDisabled();
|
|
expect(ui.getByTestId("calculator-load-custom")).toBeDisabled();
|
|
});
|
|
|
|
test("full load shows the cargo capacity", async () => {
|
|
const ui = mount();
|
|
await setBlock(ui, "drive", 10);
|
|
await setBlock(ui, "shields", 5);
|
|
await setBlock(ui, "cargo", 5);
|
|
// A fresh design starts with cargo 0, which pins load to empty;
|
|
// pick full now that there is a hold.
|
|
await fireEvent.click(ui.getByTestId("calculator-load-full"));
|
|
// capacity = cargoTech(1) * (5 + 25/20) = 6.25.
|
|
expect(ui.getByTestId("calculator-full-capacity")).toHaveTextContent("6.25");
|
|
});
|
|
|
|
test("flags a custom load above cargo capacity", async () => {
|
|
const ui = mount();
|
|
await setBlock(ui, "drive", 10);
|
|
await setBlock(ui, "shields", 5);
|
|
await setBlock(ui, "cargo", 5);
|
|
await fireEvent.click(ui.getByTestId("calculator-load-custom"));
|
|
await fireEvent.input(ui.getByTestId("calculator-custom-load"), {
|
|
target: { value: "100" },
|
|
});
|
|
expect(ui.getByTestId("calculator-custom-load")).toHaveAttribute(
|
|
"aria-invalid",
|
|
"true",
|
|
);
|
|
});
|
|
|
|
test("marks an invalid block value with aria-invalid", async () => {
|
|
const ui = mount();
|
|
// 0.5 is neither 0 nor ≥ 1.
|
|
await setBlock(ui, "drive", 0.5);
|
|
expect(ui.getByTestId("calculator-block-drive")).toHaveAttribute(
|
|
"aria-invalid",
|
|
"true",
|
|
);
|
|
});
|
|
|
|
test("disables the speed lock when drive is zero", async () => {
|
|
const ui = mount();
|
|
await setBlock(ui, "drive", 0);
|
|
await setBlock(ui, "shields", 5);
|
|
await setBlock(ui, "cargo", 5);
|
|
expect(ui.getByTestId("calculator-lock-speedEmpty")).toBeDisabled();
|
|
});
|
|
|
|
test("displays computed values rounded up to three decimals", async () => {
|
|
const ui = mount();
|
|
await setBlock(ui, "drive", 7);
|
|
await setBlock(ui, "shields", 3);
|
|
await setBlock(ui, "cargo", 1);
|
|
// empty mass = 11; max speed = 11 * driveTech... use a value that is
|
|
// not already 3-decimal: speedEmpty = 20*7*1.2 / 11 = 15.2727…
|
|
// ceil to 3 → 15.273.
|
|
expect(ui.getByTestId("calculator-out-speedEmpty")).toHaveTextContent(
|
|
"15.273",
|
|
);
|
|
});
|
|
|
|
test("tech defaults render as a number with an open-lock affordance", () => {
|
|
const ui = mount();
|
|
// Default state: no override → number + open lock, no input.
|
|
expect(ui.getByTestId("calculator-tech-value-drive")).toHaveTextContent(
|
|
"1.2",
|
|
);
|
|
expect(
|
|
ui.getByTestId("calculator-tech-override-drive"),
|
|
).toBeInTheDocument();
|
|
expect(ui.queryByTestId("calculator-tech-drive")).toBeNull();
|
|
});
|
|
|
|
test("clicking the open tech lock reveals the input + closed lock", async () => {
|
|
const ui = mount();
|
|
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
|
|
// Now an input is rendered and the lock turned closed (reset).
|
|
expect(ui.getByTestId("calculator-tech-drive")).toHaveValue(1.2);
|
|
expect(ui.getByTestId("calculator-tech-reset-drive")).toBeInTheDocument();
|
|
expect(ui.queryByTestId("calculator-tech-value-drive")).toBeNull();
|
|
});
|
|
|
|
test("flags a tech override below the player's current tech", async () => {
|
|
const ui = mount();
|
|
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
|
|
// Player drive is 1.2; setting 0.5 is below the floor.
|
|
await fireEvent.input(ui.getByTestId("calculator-tech-drive"), {
|
|
target: { value: "0.5" },
|
|
});
|
|
expect(ui.getByTestId("calculator-tech-drive")).toHaveAttribute(
|
|
"aria-invalid",
|
|
"true",
|
|
);
|
|
});
|
|
|
|
test("smart step jumps from 0 to 1 on ArrowUp for ship blocks", async () => {
|
|
const ui = mount();
|
|
const drive = ui.getByTestId("calculator-block-drive") as HTMLInputElement;
|
|
drive.focus();
|
|
await fireEvent.keyDown(drive, { key: "ArrowUp" });
|
|
expect(drive).toHaveValue(1);
|
|
await fireEvent.keyDown(drive, { key: "ArrowUp" });
|
|
expect(drive).toHaveValue(1.1);
|
|
await fireEvent.keyDown(drive, { key: "ArrowDown" });
|
|
expect(drive).toHaveValue(1);
|
|
await fireEvent.keyDown(drive, { key: "ArrowDown" });
|
|
expect(drive).toHaveValue(0);
|
|
});
|
|
|
|
test("regression: speed lock works at the ceiling with all-zero non-drive blocks", async () => {
|
|
const ui = mount();
|
|
await setBlock(ui, "drive", 1);
|
|
// Override drive tech to 1 so the ceiling math is plain.
|
|
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
|
|
await fireEvent.input(ui.getByTestId("calculator-tech-drive"), {
|
|
target: { value: "1" },
|
|
});
|
|
// With D=1, W=A=S=C=0 the only achievable speed is 20*driveTech=20.
|
|
expect(ui.getByTestId("calculator-out-speedEmpty")).toHaveTextContent("20");
|
|
await fireEvent.click(ui.getByTestId("calculator-lock-speedEmpty"));
|
|
const locked = ui.getByTestId("calculator-locked-speedEmpty");
|
|
expect(locked).toHaveValue(20);
|
|
// The lock is feasible — no infeasible title and no red error class.
|
|
expect(locked).not.toHaveAttribute(
|
|
"title",
|
|
expect.stringMatching(/cannot be reached/i),
|
|
);
|
|
});
|
|
|
|
test("planet MAT defaults to a value + open lock and opens an input on click", async () => {
|
|
const selection = new SelectionStore();
|
|
selection.selectPlanet(17);
|
|
const ui = mount({
|
|
report: makeReport({ planets: [LOCAL_PLANET] }),
|
|
selection,
|
|
});
|
|
// Initial state: the MAT shows the planet's value via the number cell
|
|
// and an open lock; no input until the override is activated.
|
|
expect(
|
|
ui.getByTestId("calculator-planet-mat-value"),
|
|
).toHaveTextContent("100");
|
|
expect(
|
|
ui.getByTestId("calculator-mat-override"),
|
|
).toBeInTheDocument();
|
|
expect(ui.queryByTestId("calculator-planet-mat")).toBeNull();
|
|
await fireEvent.click(ui.getByTestId("calculator-mat-override"));
|
|
expect(ui.getByTestId("calculator-planet-mat")).toHaveValue(100);
|
|
expect(ui.getByTestId("calculator-mat-reset")).toBeInTheDocument();
|
|
});
|
|
|
|
test("flags a modernization target below the player's current tech", async () => {
|
|
const ui = mount();
|
|
await fireEvent.click(ui.getByTestId("calculator-mode-modernization"));
|
|
// Player drive is 1.2; the target is seeded with the same value.
|
|
await fireEvent.input(ui.getByTestId("calculator-target-drive"), {
|
|
target: { value: "0.5" },
|
|
});
|
|
expect(ui.getByTestId("calculator-target-drive")).toHaveAttribute(
|
|
"aria-invalid",
|
|
"true",
|
|
);
|
|
});
|
|
|
|
test("armament Arrow keys step the integer block by ±1 (clamped at 0)", async () => {
|
|
const ui = mount();
|
|
const armament = ui.getByTestId(
|
|
"calculator-block-armament",
|
|
) as HTMLInputElement;
|
|
armament.focus();
|
|
await fireEvent.keyDown(armament, { key: "ArrowUp" });
|
|
expect(armament).toHaveValue(1);
|
|
await fireEvent.keyDown(armament, { key: "ArrowUp" });
|
|
expect(armament).toHaveValue(2);
|
|
await fireEvent.keyDown(armament, { key: "ArrowDown" });
|
|
expect(armament).toHaveValue(1);
|
|
await fireEvent.keyDown(armament, { key: "ArrowDown" });
|
|
expect(armament).toHaveValue(0);
|
|
// Clamped at zero — another ArrowDown is a no-op.
|
|
await fireEvent.keyDown(armament, { key: "ArrowDown" });
|
|
expect(armament).toHaveValue(0);
|
|
});
|
|
|
|
test("renders unoverridden tech as a 3-decimal label (matches the report)", () => {
|
|
// Player drive tech 1.2 → "1.200" via the shared ceil3 formatter.
|
|
const ui = mount();
|
|
expect(ui.getByTestId("calculator-tech-value-drive")).toHaveTextContent(
|
|
"1.2",
|
|
);
|
|
// Stable column-aligned formatting (3 decimals) is what the report
|
|
// uses, so the tech labels read consistently.
|
|
const tech = ui.getByTestId("calculator-tech-value-drive");
|
|
expect(tech.textContent ?? "").toMatch(/^1\.20?0?$/);
|
|
});
|
|
|
|
test("planet MAT label renders through the 3-decimal formatter", () => {
|
|
const selection = new SelectionStore();
|
|
selection.selectPlanet(17);
|
|
const ui = mount({
|
|
report: makeReport({ planets: [LOCAL_PLANET] }),
|
|
selection,
|
|
});
|
|
// Planet MAT is 100 → "100" through the shared formatter; the
|
|
// label is monospaced + right-aligned via the existing `.mat-val`
|
|
// rule. Formatting check: no stray fractional digits on integers.
|
|
expect(
|
|
ui.getByTestId("calculator-planet-mat-value"),
|
|
).toHaveTextContent("100");
|
|
});
|
|
|
|
test("lock spinner step is replaced by ArrowUp/ArrowDown (±0.001)", async () => {
|
|
const ui = mount();
|
|
await setBlock(ui, "drive", 10);
|
|
await setBlock(ui, "shields", 5);
|
|
await setBlock(ui, "cargo", 5);
|
|
await fireEvent.click(ui.getByTestId("calculator-lock-attack"));
|
|
const locked = ui.getByTestId(
|
|
"calculator-locked-attack",
|
|
) as HTMLInputElement;
|
|
// Lock value is seeded from outputs.attack (0 with no weapons).
|
|
const start = Number(locked.value);
|
|
locked.focus();
|
|
await fireEvent.keyDown(locked, { key: "ArrowUp" });
|
|
expect(Number(locked.value)).toBeCloseTo(start + 0.001, 9);
|
|
await fireEvent.keyDown(locked, { key: "ArrowDown" });
|
|
expect(Number(locked.value)).toBeCloseTo(start, 9);
|
|
});
|
|
|
|
test("flags the lock as infeasible when the back-solved block falls in (0, 1)", async () => {
|
|
// attack lock → weapons = targetAttack / weaponsTech. weaponsTech
|
|
// is 1.5; a target of 0.5 would force weapons = 0.333… which
|
|
// fails the DWSC rule (must be 0 or ≥ 1).
|
|
const ui = mount();
|
|
await setBlock(ui, "drive", 10);
|
|
await setBlock(ui, "armament", 2);
|
|
await setBlock(ui, "weapons", 5);
|
|
await setBlock(ui, "shields", 5);
|
|
await setBlock(ui, "cargo", 5);
|
|
await fireEvent.click(ui.getByTestId("calculator-lock-attack"));
|
|
await fireEvent.input(ui.getByTestId("calculator-locked-attack"), {
|
|
target: { value: "0.5" },
|
|
});
|
|
const locked = ui.getByTestId("calculator-locked-attack");
|
|
expect(locked).toHaveAttribute(
|
|
"title",
|
|
expect.stringMatching(/cannot be reached/i),
|
|
);
|
|
// The claimed block is not back-solved into the invalid (0, 1)
|
|
// range — the weapons input keeps the user's typed value (5).
|
|
expect(ui.getByTestId("calculator-block-weapons")).toHaveValue(5);
|
|
});
|
|
|
|
test("dropdown selection loads the class immediately (no blur needed)", async () => {
|
|
const ui = mount({
|
|
report: makeReport({
|
|
localShipClass: [
|
|
{
|
|
name: "Scout",
|
|
drive: 3,
|
|
armament: 0,
|
|
weapons: 0,
|
|
shields: 2,
|
|
cargo: 1,
|
|
},
|
|
],
|
|
} as unknown as GameReport),
|
|
});
|
|
// A datalist option click sets the whole value at once — Firefox
|
|
// reports no `inputType`, Chromium reports "insertReplacementText".
|
|
// Simulate the latter; the calculator should load before any
|
|
// `change` event.
|
|
await fireEvent.input(ui.getByTestId("calculator-name"), {
|
|
target: { value: "Scout" },
|
|
inputType: "insertReplacementText",
|
|
});
|
|
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3);
|
|
expect(ui.getByTestId("calculator-block-shields")).toHaveValue(2);
|
|
});
|
|
|
|
test("dropdown selection asks before discarding manual edits", async () => {
|
|
const ui = mount({
|
|
report: makeReport({
|
|
localShipClass: [
|
|
{
|
|
name: "Scout",
|
|
drive: 3,
|
|
armament: 0,
|
|
weapons: 0,
|
|
shields: 2,
|
|
cargo: 1,
|
|
},
|
|
],
|
|
} as unknown as GameReport),
|
|
});
|
|
// The user has hand-edited the design.
|
|
await setBlock(ui, "drive", 7);
|
|
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
|
await fireEvent.input(ui.getByTestId("calculator-name"), {
|
|
target: { value: "Scout" },
|
|
inputType: "insertReplacementText",
|
|
});
|
|
expect(confirm).toHaveBeenCalledTimes(1);
|
|
// The user said no — the manual edits stay.
|
|
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(7);
|
|
// The name field is reverted to the previously loaded class (or
|
|
// empty), so the field does not pretend the load happened.
|
|
expect(ui.getByTestId("calculator-name")).toHaveValue("");
|
|
|
|
confirm.mockReturnValue(true);
|
|
await fireEvent.input(ui.getByTestId("calculator-name"), {
|
|
target: { value: "Scout" },
|
|
inputType: "insertReplacementText",
|
|
});
|
|
// Confirmed — the class is now loaded.
|
|
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3);
|
|
confirm.mockRestore();
|
|
});
|
|
|
|
test("dropdown selection loads silently when the design is clean", async () => {
|
|
const ui = mount({
|
|
report: makeReport({
|
|
localShipClass: [
|
|
{
|
|
name: "Scout",
|
|
drive: 3,
|
|
armament: 0,
|
|
weapons: 0,
|
|
shields: 2,
|
|
cargo: 1,
|
|
},
|
|
],
|
|
} as unknown as GameReport),
|
|
});
|
|
const confirm = vi.spyOn(window, "confirm");
|
|
await fireEvent.input(ui.getByTestId("calculator-name"), {
|
|
target: { value: "Scout" },
|
|
inputType: "insertReplacementText",
|
|
});
|
|
expect(confirm).not.toHaveBeenCalled();
|
|
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3);
|
|
confirm.mockRestore();
|
|
});
|
|
});
|