fix(ui): F8-06 calculator polish — unified spinner UX, lock-infeasible on (0, 1), dropdown reset-changes
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>
This commit is contained in:
+42
-18
@@ -30,12 +30,19 @@ in as a per-ship result rather than a separate mode.
|
|||||||
override at or above their current tech. Clicking the closed
|
override at or above their current tech. Clicking the closed
|
||||||
padlock resets to the default. The padlock slot is always reserved,
|
padlock resets to the default. The padlock slot is always reserved,
|
||||||
so the column width does not shift as the lock state toggles. The
|
so the column width does not shift as the lock state toggles. The
|
||||||
four ship-class blocks (drive, weapons, shields, cargo) use a smart
|
inherited tech value reads through the same 3-decimal `Ceil3`
|
||||||
keyboard step that respects the engine value rule (`0` or `≥ 1`):
|
formatter the report uses, so the column lines up with derived
|
||||||
ArrowUp from 0 jumps straight to 1, otherwise +0.1; ArrowDown from
|
values. **Every numeric input in the calculator hides the native
|
||||||
1 collapses to 0, otherwise −0.1, never producing an invalid value
|
spinner and drives stepping through ArrowUp / ArrowDown.** This keeps
|
||||||
in `(0, 1)`. The native spinner is hidden on these inputs (it would
|
the column widths stable, makes the inputs read consistently, and
|
||||||
produce invalid intermediates); armament keeps its native step 1.
|
gives each row a step that matches its purpose. The four ship-class
|
||||||
|
blocks (drive, weapons, shields, cargo) use a smart step that
|
||||||
|
respects the engine value rule (`0` or `≥ 1`): ArrowUp from 0 jumps
|
||||||
|
straight to 1, otherwise +0.1; ArrowDown from 1 collapses to 0,
|
||||||
|
otherwise −0.1, never producing an invalid value in `(0, 1)`.
|
||||||
|
Armament steps ±1 (clamped at 0). Tech, planet MAT, custom load,
|
||||||
|
lock value, and modernization target tech each step by their natural
|
||||||
|
grain (±0.001 for tech and lock values, ±0.01 for MAT and load).
|
||||||
2. **Calculator area** — derived results: empty/loaded mass, empty/
|
2. **Calculator area** — derived results: empty/loaded mass, empty/
|
||||||
loaded speed, attack, defence, bombing (per ship), cargo capacity.
|
loaded speed, attack, defence, bombing (per ship), cargo capacity.
|
||||||
A load toggle (empty / full / custom) sets the cargo load (in cargo
|
A load toggle (empty / full / custom) sets the cargo load (in cargo
|
||||||
@@ -48,8 +55,10 @@ in as a per-ship result rather than a separate mode.
|
|||||||
turns per ship). The MAT follows the same lock idiom as the tech
|
turns per ship). The MAT follows the same lock idiom as the tech
|
||||||
cells: the planet number renders with an open padlock, clicking
|
cells: the planet number renders with an open padlock, clicking
|
||||||
opens an input with a closed padlock, and the closed padlock resets
|
opens an input with a closed padlock, and the closed padlock resets
|
||||||
to the planet value. The realistic multi-turn forecast with CAP/COL
|
to the planet value. The MAT label reads through the same 3-decimal
|
||||||
supply is planned (see ../ROADMAP.md).
|
`Ceil3` formatter, matching the rest of the calculator's label
|
||||||
|
values. The realistic multi-turn forecast with CAP/COL supply is
|
||||||
|
planned (see ../ROADMAP.md).
|
||||||
|
|
||||||
## Locks and goal-seek
|
## Locks and goal-seek
|
||||||
|
|
||||||
@@ -80,16 +89,19 @@ pinned by the player, click to reset*:
|
|||||||
|
|
||||||
Only **one** result may be locked at a time (the others' lock
|
Only **one** result may be locked at a time (the others' lock
|
||||||
affordances disable with a tooltip). An unreachable target — e.g. a
|
affordances disable with a tooltip). An unreachable target — e.g. a
|
||||||
speed above the stripped-hull ceiling `20 × driveTech`, or a
|
speed above the stripped-hull ceiling `20 × driveTech`, or a solved
|
||||||
solved block that fails the value rules — leaves the locked cell in a
|
block that fails the value rules (a DWSC value in the `(0, 1)` gap)
|
||||||
red error state and does not apply. Inverse solving lives in
|
— leaves the locked cell in a red error state and does not apply.
|
||||||
`pkg/calc/solve.go`; the bisection for defence → shields is the only
|
When that happens the claimed block is **not** back-solved into the
|
||||||
non-analytic case. Locking a speed is disabled when the drive block is
|
invalid range; the design preview keeps reading the user's typed
|
||||||
zero (a deliberately immobile ship has no speed to back-solve). With
|
values, so the row never silently shows a sub-1 block. Inverse
|
||||||
the drive block as the only non-zero mass the displayed speed equals
|
solving lives in `pkg/calc/solve.go`; the bisection for defence →
|
||||||
the ceiling exactly (every positive drive gives the same speed), so
|
shields is the only non-analytic case. Locking a speed is disabled
|
||||||
the solver accepts that ceiling target as a feasible lock and any
|
when the drive block is zero (a deliberately immobile ship has no
|
||||||
positive drive solves it.
|
speed to back-solve). With the drive block as the only non-zero mass
|
||||||
|
the displayed speed equals the ceiling exactly (every positive drive
|
||||||
|
gives the same speed), so the solver accepts that ceiling target as
|
||||||
|
a feasible lock and any positive drive solves it.
|
||||||
|
|
||||||
## Validation and display
|
## Validation and display
|
||||||
|
|
||||||
@@ -119,6 +131,18 @@ overlay reflects the change immediately. Ship classes are immutable after
|
|||||||
creation (per `game/rules.txt`), so there is no edit — only Create-new
|
creation (per `game/rules.txt`), so there is no edit — only Create-new
|
||||||
and Delete.
|
and Delete.
|
||||||
|
|
||||||
|
Selecting a class from the dropdown loads it **immediately**, the
|
||||||
|
moment the option is clicked. (Native `change` only fires on blur in
|
||||||
|
Firefox; switching the load trigger to `input` makes the load
|
||||||
|
synchronous everywhere, since the `InputEvent.inputType` flags a
|
||||||
|
datalist replacement as `"insertReplacementText"` in Chromium / WebKit
|
||||||
|
or `undefined` in Firefox — keyboard typing always carries a typing
|
||||||
|
`inputType`.) If the live blocks differ from the previously loaded
|
||||||
|
class (or, when nothing is loaded, from the empty defaults), the
|
||||||
|
calculator first asks `Discard unsaved changes and load class «…»?`
|
||||||
|
through a `window.confirm`; declining reverts the name field and
|
||||||
|
leaves the current blocks untouched.
|
||||||
|
|
||||||
## Reach circles
|
## Reach circles
|
||||||
|
|
||||||
When an own planet is selected in calculator mode, the calculator
|
When an own planet is selected in calculator mode, the calculator
|
||||||
|
|||||||
@@ -92,6 +92,18 @@ export interface CalculatorResult {
|
|||||||
outputs: CalculatorOutputs | null;
|
outputs: CalculatorOutputs | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isClaimedBlockValid checks that a solver result, before we apply it
|
||||||
|
// to the resolved blocks, satisfies the same per-field rules the live
|
||||||
|
// validator enforces on user-typed values (`pkg/calc/validator.go` /
|
||||||
|
// `lib/util/ship-class-validation`). The four claimable blocks all
|
||||||
|
// share the DWSC rule, so a single predicate suffices. Used to flag
|
||||||
|
// a goal-seek target as infeasible when the only block that would
|
||||||
|
// reach it falls in the (0, 1) gap.
|
||||||
|
function isClaimedBlockValid(solved: number): boolean {
|
||||||
|
if (!Number.isFinite(solved)) return false;
|
||||||
|
return solved === 0 || solved >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveLoad(
|
function resolveLoad(
|
||||||
mode: LoadMode,
|
mode: LoadMode,
|
||||||
customLoad: number,
|
customLoad: number,
|
||||||
@@ -224,12 +236,22 @@ export function computeCalculator(
|
|||||||
);
|
);
|
||||||
if (solved === null) {
|
if (solved === null) {
|
||||||
lockFeasible = false;
|
lockFeasible = false;
|
||||||
|
} else {
|
||||||
|
// The solver may produce a value that is mathematically
|
||||||
|
// correct yet rejected by the ship-class value rules —
|
||||||
|
// most commonly a DWSC block in the (0, 1) gap. Surface
|
||||||
|
// that as an infeasible lock so the lock input flips
|
||||||
|
// red and the outputs are suppressed, instead of
|
||||||
|
// silently showing an invalid design.
|
||||||
|
if (!isClaimedBlockValid(solved)) {
|
||||||
|
lockFeasible = false;
|
||||||
} else {
|
} else {
|
||||||
blocks[claimed] = solved;
|
blocks[claimed] = solved;
|
||||||
computedInput = claimed;
|
computedInput = claimed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let load: number;
|
let load: number;
|
||||||
if (input.lock !== null && CLAIM_MAP[input.lock.output] === "load") {
|
if (input.lock !== null && CLAIM_MAP[input.lock.output] === "load") {
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
techFloor: TechState;
|
techFloor: TechState;
|
||||||
computedInput?: ClaimedInput | null;
|
computedInput?: ClaimedInput | null;
|
||||||
blocksReadonly?: boolean;
|
blocksReadonly?: boolean;
|
||||||
|
// Formatter applied to the read-only tech value and to the
|
||||||
|
// resolved (goal-seek) ship-block value. Same `fmt` as the
|
||||||
|
// rest of the calculator, passed in so the design area stays
|
||||||
|
// presentational and the parent owns the rounding policy.
|
||||||
|
formatNumber: (value: number) => string;
|
||||||
onTechInput: (key: TechKey) => void;
|
onTechInput: (key: TechKey) => void;
|
||||||
onResetTech: (key: TechKey) => void;
|
onResetTech: (key: TechKey) => void;
|
||||||
};
|
};
|
||||||
@@ -58,6 +63,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
techFloor,
|
techFloor,
|
||||||
computedInput = null,
|
computedInput = null,
|
||||||
blocksReadonly = false,
|
blocksReadonly = false,
|
||||||
|
formatNumber,
|
||||||
onTechInput,
|
onTechInput,
|
||||||
onResetTech,
|
onResetTech,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -94,7 +100,9 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
// cargo): values must be 0 or ≥ 1 per `pkg/calc/validator.go`, so the
|
// cargo): values must be 0 or ≥ 1 per `pkg/calc/validator.go`, so the
|
||||||
// native 0.01 step would produce invalid intermediates like 0.01.
|
// native 0.01 step would produce invalid intermediates like 0.01.
|
||||||
// Up: 0 jumps straight to 1; otherwise +0.1. Down: 1 collapses to 0;
|
// Up: 0 jumps straight to 1; otherwise +0.1. Down: 1 collapses to 0;
|
||||||
// otherwise −0.1 down to 1, clamped at 0. Armament keeps native step 1.
|
// otherwise −0.1 down to 1, clamped at 0. Armament uses a plain
|
||||||
|
// integer step (±1, clamped at 0) so it follows the same
|
||||||
|
// JS-driven idiom and we can hide the native spinner uniformly.
|
||||||
function bumpBlock(value: number, dir: 1 | -1): number {
|
function bumpBlock(value: number, dir: 1 | -1): number {
|
||||||
if (dir === 1) {
|
if (dir === 1) {
|
||||||
if (value < 1) return 1;
|
if (value < 1) return 1;
|
||||||
@@ -103,31 +111,46 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
if (value <= 1) return 0;
|
if (value <= 1) return 0;
|
||||||
return Math.round((value - 0.1) * 10) / 10;
|
return Math.round((value - 0.1) * 10) / 10;
|
||||||
}
|
}
|
||||||
|
function bumpArmament(value: number, dir: 1 | -1): number {
|
||||||
|
const next = Math.trunc(value) + dir;
|
||||||
|
return next < 0 ? 0 : next;
|
||||||
|
}
|
||||||
function onBlockKey(
|
function onBlockKey(
|
||||||
event: KeyboardEvent,
|
event: KeyboardEvent,
|
||||||
key: keyof DesignBlocksState,
|
key: keyof DesignBlocksState,
|
||||||
|
smart: boolean,
|
||||||
): void {
|
): void {
|
||||||
if (event.key === "ArrowUp") {
|
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
|
||||||
|
if (dir === 0) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
blocks[key] = bumpBlock(blocks[key], 1);
|
blocks[key] = smart
|
||||||
} else if (event.key === "ArrowDown") {
|
? bumpBlock(blocks[key], dir)
|
||||||
event.preventDefault();
|
: bumpArmament(blocks[key], dir);
|
||||||
blocks[key] = bumpBlock(blocks[key], -1);
|
|
||||||
}
|
}
|
||||||
|
// Tech / modernization-target inputs all use the same ±0.001 step
|
||||||
|
// with a per-row floor; lifted into a helper so the parent can
|
||||||
|
// reuse it (modernization area in `calculator-tab`).
|
||||||
|
function bumpTech(event: KeyboardEvent, key: TechKey): void {
|
||||||
|
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
|
||||||
|
if (dir === 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const current = techs[key];
|
||||||
|
const next = Math.round((current + dir * 0.001) * 1000) / 1000;
|
||||||
|
const floor = techFloor[key];
|
||||||
|
techs[key] = next < floor ? floor : next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BLOCK_ROWS: {
|
const BLOCK_ROWS: {
|
||||||
key: keyof DesignBlocksState;
|
key: keyof DesignBlocksState;
|
||||||
label: () => string;
|
label: () => string;
|
||||||
step: string;
|
|
||||||
tech: TechKey | null;
|
tech: TechKey | null;
|
||||||
smartStep: boolean;
|
smartStep: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.1", tech: "drive", smartStep: true },
|
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), tech: "drive", smartStep: true },
|
||||||
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null, smartStep: false },
|
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), tech: null, smartStep: false },
|
||||||
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.1", tech: "weapons", smartStep: true },
|
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), tech: "weapons", smartStep: true },
|
||||||
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.1", tech: "shields", smartStep: true },
|
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), tech: "shields", smartStep: true },
|
||||||
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.1", tech: "cargo", smartStep: true },
|
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), tech: "cargo", smartStep: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const techInputEls: Partial<Record<TechKey, HTMLInputElement>> = {};
|
const techInputEls: Partial<Record<TechKey, HTMLInputElement>> = {};
|
||||||
@@ -152,31 +175,27 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
<span class="label">{row.label()}</span>
|
<span class="label">{row.label()}</span>
|
||||||
{#if isComputed}
|
{#if isComputed}
|
||||||
<input
|
<input
|
||||||
class="ship"
|
class="ship no-spin"
|
||||||
class:smart-step={row.smartStep}
|
|
||||||
type="number"
|
type="number"
|
||||||
step={row.step}
|
step="any"
|
||||||
readonly
|
readonly
|
||||||
value={resolved[row.key]}
|
value={formatNumber(resolved[row.key])}
|
||||||
data-computed="true"
|
data-computed="true"
|
||||||
data-testid={`calculator-block-${row.key}`}
|
data-testid={`calculator-block-${row.key}`}
|
||||||
title={i18n.t("game.calculator.lock.reset")}
|
title={i18n.t("game.calculator.lock.reset")}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<input
|
<input
|
||||||
class="ship"
|
class="ship no-spin"
|
||||||
class:smart-step={row.smartStep}
|
|
||||||
type="number"
|
type="number"
|
||||||
step={row.step}
|
step="any"
|
||||||
min="0"
|
min="0"
|
||||||
bind:value={blocks[row.key]}
|
bind:value={blocks[row.key]}
|
||||||
readonly={blocksReadonly}
|
readonly={blocksReadonly}
|
||||||
aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
|
aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
|
||||||
title={blockError(row.key)}
|
title={blockError(row.key)}
|
||||||
data-testid={`calculator-block-${row.key}`}
|
data-testid={`calculator-block-${row.key}`}
|
||||||
onkeydown={row.smartStep
|
onkeydown={(e) => onBlockKey(e, row.key, row.smartStep)}
|
||||||
? (e) => onBlockKey(e, row.key)
|
|
||||||
: null}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if row.tech !== null}
|
{#if row.tech !== null}
|
||||||
@@ -185,14 +204,15 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
{#if techOverridden[techKey]}
|
{#if techOverridden[techKey]}
|
||||||
<input
|
<input
|
||||||
bind:this={techInputEls[techKey]}
|
bind:this={techInputEls[techKey]}
|
||||||
class="tech"
|
class="tech no-spin"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.001"
|
step="any"
|
||||||
min={techFloor[techKey]}
|
min={techFloor[techKey]}
|
||||||
bind:value={techs[techKey]}
|
bind:value={techs[techKey]}
|
||||||
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
|
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
|
||||||
title={techError(techKey)}
|
title={techError(techKey)}
|
||||||
data-testid={`calculator-tech-${techKey}`}
|
data-testid={`calculator-tech-${techKey}`}
|
||||||
|
onkeydown={(e) => bumpTech(e, techKey)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -209,7 +229,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
class="tech-val"
|
class="tech-val"
|
||||||
data-testid={`calculator-tech-value-${techKey}`}
|
data-testid={`calculator-tech-value-${techKey}`}
|
||||||
>
|
>
|
||||||
{techs[techKey]}
|
{formatNumber(techs[techKey])}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -265,15 +285,15 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
/* Drive/weapons/shields/cargo use the JS-driven smart step (0→1 jump
|
/* Hide native spinners across the design area — the row drives
|
||||||
then 0.1 increments) for keyboard arrows; hide the native spinner
|
every numeric edit through ArrowUp/ArrowDown so the column
|
||||||
on those inputs so it cannot produce invalid 0.01 intermediates. */
|
width stays stable and the inputs read consistently. */
|
||||||
input.smart-step::-webkit-inner-spin-button,
|
input.no-spin::-webkit-inner-spin-button,
|
||||||
input.smart-step::-webkit-outer-spin-button {
|
input.no-spin::-webkit-outer-spin-button {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
input.smart-step {
|
input.no-spin {
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
appearance: textfield;
|
appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ const en = {
|
|||||||
"game.calculator.invalid.tech_below_current": "tech level cannot be below your current tech this turn",
|
"game.calculator.invalid.tech_below_current": "tech level cannot be below your current tech this turn",
|
||||||
"game.calculator.invalid.load_over_capacity": "load exceeds the ship's cargo capacity",
|
"game.calculator.invalid.load_over_capacity": "load exceeds the ship's cargo capacity",
|
||||||
"game.calculator.lock.no_drive": "set a non-zero drive before locking speed",
|
"game.calculator.lock.no_drive": "set a non-zero drive before locking speed",
|
||||||
|
"game.calculator.confirm_reset_for_load": "Discard unsaved changes and load class «{name}»?",
|
||||||
|
|
||||||
"game.table.sciences.title": "sciences",
|
"game.table.sciences.title": "sciences",
|
||||||
"game.table.sciences.column.name": "name",
|
"game.table.sciences.column.name": "name",
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.calculator.invalid.tech_below_current": "технологический уровень не может быть ниже ваших текущих технологий на этом ходу",
|
"game.calculator.invalid.tech_below_current": "технологический уровень не может быть ниже ваших текущих технологий на этом ходу",
|
||||||
"game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля",
|
"game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля",
|
||||||
"game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость",
|
"game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость",
|
||||||
|
"game.calculator.confirm_reset_for_load": "Сбросить несохранённые изменения и загрузить класс «{name}»?",
|
||||||
|
|
||||||
"game.table.sciences.title": "науки",
|
"game.table.sciences.title": "науки",
|
||||||
"game.table.sciences.column.name": "название",
|
"game.table.sciences.column.name": "название",
|
||||||
|
|||||||
@@ -323,6 +323,30 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
cs.lock = null;
|
cs.lock = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generic ±step keyboard handler for the calculator's free-form
|
||||||
|
// number inputs (MAT, custom-load, lock value, modernization
|
||||||
|
// target tech). Pairs with `class="no-spin"` so the native spinner
|
||||||
|
// is hidden everywhere and the column width is stable; ArrowUp /
|
||||||
|
// ArrowDown is the only step affordance. The smart 0↔1 jump on
|
||||||
|
// the ship-class blocks lives in `ship-design-area.svelte` —
|
||||||
|
// these other inputs accept any non-negative number.
|
||||||
|
function onStepKey(
|
||||||
|
event: KeyboardEvent,
|
||||||
|
current: number,
|
||||||
|
step: number,
|
||||||
|
min: number,
|
||||||
|
apply: (next: number) => void,
|
||||||
|
): void {
|
||||||
|
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
|
||||||
|
if (dir === 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
// Snap to the same fractional grid as `step` so 0.001 stays
|
||||||
|
// at three decimals instead of drifting via float math.
|
||||||
|
const inv = 1 / step;
|
||||||
|
const next = Math.round((current + dir * step) * inv) / inv;
|
||||||
|
apply(next < min ? min : next);
|
||||||
|
}
|
||||||
|
|
||||||
function loadExisting(clsName: string): void {
|
function loadExisting(clsName: string): void {
|
||||||
const cls = localShipClass.find((c) => c.name === clsName);
|
const cls = localShipClass.find((c) => c.name === clsName);
|
||||||
if (cls === undefined) return;
|
if (cls === undefined) return;
|
||||||
@@ -338,6 +362,78 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
cs.lock = null;
|
cs.lock = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compare the live blocks to the baseline they were last loaded
|
||||||
|
// from — or to the empty defaults if no class has been loaded. The
|
||||||
|
// dropdown selection flow uses this to ask before discarding manual
|
||||||
|
// edits. Tech overrides are independent of class loading, so they
|
||||||
|
// don't count as "dirty" here.
|
||||||
|
function baselineBlocks(): {
|
||||||
|
drive: number;
|
||||||
|
armament: number;
|
||||||
|
weapons: number;
|
||||||
|
shields: number;
|
||||||
|
cargo: number;
|
||||||
|
} {
|
||||||
|
if (cs.loadedExisting !== null) {
|
||||||
|
const cls = localShipClass.find((c) => c.name === cs.loadedExisting);
|
||||||
|
if (cls !== undefined) {
|
||||||
|
return {
|
||||||
|
drive: cls.drive,
|
||||||
|
armament: cls.armament,
|
||||||
|
weapons: cls.weapons,
|
||||||
|
shields: cls.shields,
|
||||||
|
cargo: cls.cargo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 };
|
||||||
|
}
|
||||||
|
function isDesignDirty(): boolean {
|
||||||
|
const base = baselineBlocks();
|
||||||
|
return (
|
||||||
|
cs.blocks.drive !== base.drive ||
|
||||||
|
cs.blocks.armament !== base.armament ||
|
||||||
|
cs.blocks.weapons !== base.weapons ||
|
||||||
|
cs.blocks.shields !== base.shields ||
|
||||||
|
cs.blocks.cargo !== base.cargo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function tryLoadByName(name: string): void {
|
||||||
|
const cls = localShipClass.find((c) => c.name === name);
|
||||||
|
if (cls === undefined) return;
|
||||||
|
if (cs.loadedExisting === cls.name) return;
|
||||||
|
if (isDesignDirty()) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
i18n.t("game.calculator.confirm_reset_for_load", {
|
||||||
|
name: cls.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
cs.name = cs.loadedExisting ?? "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadExisting(name);
|
||||||
|
}
|
||||||
|
// Catch the datalist option click immediately. Native `change` only
|
||||||
|
// fires on blur in Firefox, which is what made dropdown selection
|
||||||
|
// look delayed; `input` fires the moment the value is set. Typed
|
||||||
|
// keystrokes carry an `inputType` ("insertText", "deleteContent…");
|
||||||
|
// a datalist selection replaces the value in one shot, so its
|
||||||
|
// `inputType` is undefined (Firefox) or "insertReplacementText"
|
||||||
|
// (Chromium / WebKit). We treat that as a selection.
|
||||||
|
function onNameInput(event: Event): void {
|
||||||
|
const ev = event as InputEvent;
|
||||||
|
const isSelection =
|
||||||
|
ev.inputType === undefined ||
|
||||||
|
ev.inputType === "insertReplacementText";
|
||||||
|
if (!isSelection) {
|
||||||
|
cs.loadedExisting = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tryLoadByName(cs.name);
|
||||||
|
}
|
||||||
|
|
||||||
// React to the ship-classes table / bottom-tabs asking to load a
|
// React to the ship-classes table / bottom-tabs asking to load a
|
||||||
// class (or start a fresh design) into the calculator. The layout
|
// class (or start a fresh design) into the calculator. The layout
|
||||||
// flips the sidebar to this tab in parallel.
|
// flips the sidebar to this tab in parallel.
|
||||||
@@ -394,9 +490,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
{#if cs.lock === output}
|
{#if cs.lock === output}
|
||||||
<span class="cell locked" class:infeasible={!result.lockFeasible}>
|
<span class="cell locked" class:infeasible={!result.lockFeasible}>
|
||||||
<input
|
<input
|
||||||
|
class="no-spin"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.001"
|
step="any"
|
||||||
bind:value={cs.lockValue}
|
bind:value={cs.lockValue}
|
||||||
|
onkeydown={(e) =>
|
||||||
|
onStepKey(e, cs.lockValue, 0.001, 0, (v) => (cs.lockValue = v))}
|
||||||
data-testid={`calculator-locked-${output}`}
|
data-testid={`calculator-locked-${output}`}
|
||||||
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
|
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
|
||||||
/>
|
/>
|
||||||
@@ -462,8 +561,8 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
placeholder={i18n.t("game.calculator.name.placeholder")}
|
placeholder={i18n.t("game.calculator.name.placeholder")}
|
||||||
maxlength="30"
|
maxlength="30"
|
||||||
bind:value={cs.name}
|
bind:value={cs.name}
|
||||||
oninput={() => (cs.loadedExisting = null)}
|
oninput={onNameInput}
|
||||||
onchange={() => loadExisting(cs.name)}
|
onchange={() => tryLoadByName(cs.name)}
|
||||||
aria-invalid={nameValidation.ok ? "false" : "true"}
|
aria-invalid={nameValidation.ok ? "false" : "true"}
|
||||||
data-testid="calculator-name"
|
data-testid="calculator-name"
|
||||||
/>
|
/>
|
||||||
@@ -503,6 +602,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
techOverridden={cs.techOverridden}
|
techOverridden={cs.techOverridden}
|
||||||
techFloor={playerTech}
|
techFloor={playerTech}
|
||||||
computedInput={result.computedInput}
|
computedInput={result.computedInput}
|
||||||
|
formatNumber={fmt}
|
||||||
{onTechInput}
|
{onTechInput}
|
||||||
{onResetTech}
|
{onResetTech}
|
||||||
/>
|
/>
|
||||||
@@ -526,10 +626,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
{#if cs.loadMode === "custom"}
|
{#if cs.loadMode === "custom"}
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="any"
|
||||||
min="0"
|
min="0"
|
||||||
class="custom-load"
|
class="custom-load no-spin"
|
||||||
bind:value={cs.customLoad}
|
bind:value={cs.customLoad}
|
||||||
|
onkeydown={(e) =>
|
||||||
|
onStepKey(e, cs.customLoad, 0.01, 0, (v) => (cs.customLoad = v))}
|
||||||
aria-invalid={customLoadError !== "" ? "true" : "false"}
|
aria-invalid={customLoadError !== "" ? "true" : "false"}
|
||||||
title={customLoadError}
|
title={customLoadError}
|
||||||
data-testid="calculator-custom-load"
|
data-testid="calculator-custom-load"
|
||||||
@@ -609,10 +711,13 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
{#if cs.matOverridden}
|
{#if cs.matOverridden}
|
||||||
<input
|
<input
|
||||||
bind:this={matInputRef.el}
|
bind:this={matInputRef.el}
|
||||||
|
class="no-spin"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="any"
|
||||||
min="0"
|
min="0"
|
||||||
bind:value={cs.matValue}
|
bind:value={cs.matValue}
|
||||||
|
onkeydown={(e) =>
|
||||||
|
onStepKey(e, cs.matValue, 0.01, 0, (v) => (cs.matValue = v))}
|
||||||
aria-invalid={matError !== "" ? "true" : "false"}
|
aria-invalid={matError !== "" ? "true" : "false"}
|
||||||
title={matError}
|
title={matError}
|
||||||
data-testid="calculator-planet-mat"
|
data-testid="calculator-planet-mat"
|
||||||
@@ -632,7 +737,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
class="mat-val"
|
class="mat-val"
|
||||||
data-testid="calculator-planet-mat-value"
|
data-testid="calculator-planet-mat-value"
|
||||||
>
|
>
|
||||||
{cs.matValue}
|
{fmt(cs.matValue)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -677,10 +782,19 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
|
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
|
||||||
<span class="cell">
|
<span class="cell">
|
||||||
<input
|
<input
|
||||||
|
class="no-spin"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.001"
|
step="any"
|
||||||
min={playerTech[row.key]}
|
min={playerTech[row.key]}
|
||||||
bind:value={cs.targetTech[row.key]}
|
bind:value={cs.targetTech[row.key]}
|
||||||
|
onkeydown={(e) =>
|
||||||
|
onStepKey(
|
||||||
|
e,
|
||||||
|
cs.targetTech[row.key],
|
||||||
|
0.001,
|
||||||
|
playerTech[row.key],
|
||||||
|
(v) => (cs.targetTech[row.key] = v),
|
||||||
|
)}
|
||||||
aria-invalid={targetError !== "" ? "true" : "false"}
|
aria-invalid={targetError !== "" ? "true" : "false"}
|
||||||
title={targetError}
|
title={targetError}
|
||||||
data-testid={`calculator-target-${row.key}`}
|
data-testid={`calculator-target-${row.key}`}
|
||||||
@@ -852,6 +966,19 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
/* Hide the native spinner on every calculator number input — the
|
||||||
|
row drives every numeric edit through ArrowUp / ArrowDown so the
|
||||||
|
column width is stable and the inputs read consistently with the
|
||||||
|
ship-block row inside the design area. */
|
||||||
|
input.no-spin::-webkit-inner-spin-button,
|
||||||
|
input.no-spin::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
input.no-spin {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
.cell.locked input {
|
.cell.locked input {
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
|
|||||||
@@ -135,6 +135,37 @@ describe("computeCalculator goal-seek", () => {
|
|||||||
expect(result.blocks.drive).toBe(10);
|
expect(result.blocks.drive).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("an attack target that back-solves to a (0, 1) weapons block is infeasible", () => {
|
||||||
|
// weapons = targetAttack / weaponsTech; weaponsTech=1.5, a 0.5
|
||||||
|
// target → weapons = 0.333…, which fails the DWSC rule (must be
|
||||||
|
// 0 or ≥ 1). The lock is flagged infeasible so the UI shows the
|
||||||
|
// red border, and the claimed block is left at its raw value so
|
||||||
|
// the design preview keeps reading off the user's own design.
|
||||||
|
const core = makeFakeCore();
|
||||||
|
const result = computeCalculator(
|
||||||
|
input({ lock: { output: "attack", value: 0.5 } }),
|
||||||
|
core,
|
||||||
|
);
|
||||||
|
expect(result.lockFeasible).toBe(false);
|
||||||
|
expect(result.computedInput).toBeNull();
|
||||||
|
// The claimed block stays at its raw value.
|
||||||
|
expect(result.blocks.weapons).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty-mass target that back-solves to a (0, 1) cargo block is infeasible", () => {
|
||||||
|
// emptyMass = drive + shields + cargo; with drive=10 shields=5,
|
||||||
|
// rest excluding cargo = 15. Target 15.5 → cargo = 0.5, in the
|
||||||
|
// invalid gap, so the lock is flagged.
|
||||||
|
const core = makeFakeCore();
|
||||||
|
const result = computeCalculator(
|
||||||
|
input({ lock: { output: "emptyMass", value: 15.5 } }),
|
||||||
|
core,
|
||||||
|
);
|
||||||
|
expect(result.lockFeasible).toBe(false);
|
||||||
|
expect(result.computedInput).toBeNull();
|
||||||
|
expect(result.blocks.cargo).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
test("speed lock is feasible at the ceiling when rest mass is zero", () => {
|
test("speed lock is feasible at the ceiling when rest mass is zero", () => {
|
||||||
// Regression for the D=1, W=A=S=C=0 case: every block except
|
// Regression for the D=1, W=A=S=C=0 case: every block except
|
||||||
// drive is zero, so speed equals 20*driveTech (the ceiling); the
|
// drive is zero, so speed equals 20*driveTech (the ceiling); the
|
||||||
|
|||||||
@@ -380,4 +380,183 @@ describe("calculator-tab", () => {
|
|||||||
"true",
|
"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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user