fix(ui): F8-06 calculator polish — input steps, lock idiom, tech floor, speed-lock fix #61
+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,
|
||||||
@@ -225,8 +237,18 @@ export function computeCalculator(
|
|||||||
if (solved === null) {
|
if (solved === null) {
|
||||||
lockFeasible = false;
|
lockFeasible = false;
|
||||||
} else {
|
} else {
|
||||||
blocks[claimed] = solved;
|
// The solver may produce a value that is mathematically
|
||||||
computedInput = claimed;
|
// 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 {
|
||||||
|
blocks[claimed] = solved;
|
||||||
|
computedInput = claimed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
event.preventDefault();
|
if (dir === 0) return;
|
||||||
blocks[key] = bumpBlock(blocks[key], 1);
|
event.preventDefault();
|
||||||
} else if (event.key === "ArrowDown") {
|
blocks[key] = smart
|
||||||
event.preventDefault();
|
? bumpBlock(blocks[key], dir)
|
||||||
blocks[key] = bumpBlock(blocks[key], -1);
|
: bumpArmament(blocks[key], dir);
|
||||||
}
|
}
|
||||||
|
// 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