fix(ui): calculator polish — smart input steps, unified tech/MAT lock idiom, tech floor, speed-lock ceiling fix
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m41s
Tests · Go / test (pull_request) Successful in 3m14s
Tests · UI / test (pull_request) Successful in 2m32s

- pkg/calc: DriveForSpeed treats restMass==0 as a valid ceiling-only
  case (every positive drive solves it), so locking the displayed
  speed of a D=1, W=A=S=C=0 ship is no longer a phantom "infeasible".
- ship-design-area: drive/weapons/shields/cargo inputs use a JS-driven
  smart step on ArrowUp/ArrowDown (0↔1 jump, otherwise ±0.1) and hide
  the native spinner so it cannot produce invalid (0, 1) values;
  armament keeps its native step 1.
- Tech and planet MAT cells follow the same lock idiom as goal-seek
  locks: open padlock (🔓) over the inherited value → click to open
  an input with a closed padlock (🔒). The padlock slot is always
  reserved, so the column width is stable.
- Tech overrides (design area and modernization target) are floored
  at the player's current tech on this turn — a lower value is
  flagged as invalid.
This commit is contained in:
Ilia Denisov
2026-05-26 14:30:43 +02:00
parent 793b709d8f
commit e9b904332e
11 changed files with 384 additions and 55 deletions
+33 -9
View File
@@ -24,8 +24,18 @@ in as a per-ship result rather than a separate mode.
1. **Ship Class design area** — five blocks (drive, armament, weapons,
shields, cargo) and four tech levels (drive, weapons, shields,
cargo). Tech defaults to the player's current tech and shows a lock
icon once overridden; clicking it resets to the default.
cargo). Tech defaults to the player's current tech: the cell renders
the inherited number with an open padlock; clicking the open lock
activates an input (closed padlock), where the player may type an
override at or above their current tech. Clicking the closed
padlock resets to the default. The padlock slot is always reserved,
so the column width does not shift as the lock state toggles. The
four ship-class blocks (drive, weapons, shields, cargo) use a smart
keyboard 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)`. The native spinner is hidden on these inputs (it would
produce invalid intermediates); armament keeps its native step 1.
2. **Calculator area** — derived results: empty/loaded mass, empty/
loaded speed, attack, defence, bombing (per ship), cargo capacity.
A load toggle (empty / full / custom) sets the cargo load (in cargo
@@ -35,17 +45,27 @@ in as a per-ship result rather than a separate mode.
the load is pinned to empty and the toggle is disabled.
3. **Planet area** — when an own planet is selected on the map, shows
its MAT (overridable) and the single-turn build rate (ships per turn,
turns per ship). The realistic multi-turn forecast with CAP/COL
turns per ship). The MAT follows the same lock idiom as the tech
cells: the planet number renders with an open padlock, clicking
opens an input with a closed padlock, and the closed padlock resets
to the planet value. The realistic multi-turn forecast with CAP/COL
supply is planned (see ../ROADMAP.md).
## Locks and goal-seek
Two distinct lock semantics share one icon (a closed padlock; it only
appears once a value is pinned, click to release):
Two distinct lock semantics share one padlock affordance. Both follow
the same idiom — an open padlock (🔓) means *value is inherited /
derived, click to override*; a closed padlock (🔒) means *value is
pinned by the player, click to reset*:
- **Override locks** on inputs that have a default — the four techs and
the planet MAT. Editing one overrides the default; the lock resets it.
Any number may be overridden at once.
the planet MAT. By default the cell shows the inherited number plus
an open padlock; clicking it switches to an input plus a closed
padlock for typing the override. Closing (clicking the closed
padlock) resets to the default. Any number may be overridden at once.
Tech overrides are floored at the player's current tech on this
turn — a lower value is flagged as invalid. The same floor applies
to the modernization target tech.
- **Goal-seek locks** on derived results. Pinning a result back-solves
the single input it claims, which then renders read-only (computed):
@@ -60,12 +80,16 @@ appears once a value is pinned, click to release):
Only **one** result may be locked at a time (the others' lock
affordances disable with a tooltip). An unreachable target — e.g. a
speed at or above the stripped-hull ceiling `20 × driveTech`, or a
speed above the stripped-hull ceiling `20 × driveTech`, or a
solved block that fails the value rules — leaves the locked cell in a
red error state and does not apply. Inverse solving lives in
`pkg/calc/solve.go`; the bisection for defence → shields is the only
non-analytic case. Locking a speed is disabled when the drive block is
zero (a deliberately immobile ship has no speed to back-solve).
zero (a deliberately immobile ship has no 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
@@ -1,13 +1,17 @@
<!--
Reusable "Ship Class design area": the five design blocks (drive,
armament, weapons, shields, cargo) plus the four tech levels they are
built with. Each tech defaults to the player's current level and shows a
lock icon once overridden; clicking the lock resets it. A block claimed
by an active goal-seek lock renders read-only with its own lock marker.
The component is presentational — the parent owns the state and the
built with. Tech and MAT locks follow the same idiom as goal-seek
locks below the design area — by default the value renders as plain
text with an open padlock; clicking it overrides (input + closed
padlock). Reserved space for the padlock keeps the column width
stable as the lock state toggles. A block claimed by an active
goal-seek lock renders read-only with its own lock marker. The
component is presentational — the parent owns the state and the
calculator math — so the ship-group upgrade flow can reuse it later.
-->
<script lang="ts">
import { tick } from "svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
shipClassFieldErrors,
@@ -37,6 +41,10 @@ calculator math — so the ship-group upgrade flow can reuse it later.
resolved: DesignBlocksState;
techs: TechState;
techOverridden: Record<TechKey, boolean>;
// Lower bound for the tech inputs: the player's current tech on
// this turn. A design cannot be built with tech below the player's
// own level, so we surface that as a per-field validation error.
techFloor: TechState;
computedInput?: ClaimedInput | null;
blocksReadonly?: boolean;
onTechInput: (key: TechKey) => void;
@@ -47,6 +55,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
resolved,
techs = $bindable(),
techOverridden,
techFloor,
computedInput = null,
blocksReadonly = false,
onTechInput,
@@ -73,7 +82,38 @@ calculator math — so the ship-group upgrade flow can reuse it later.
return reason === undefined ? "" : i18n.t(VALUE_REASON_KEY[reason]);
}
function techError(key: TechKey): string {
return techs[key] < 0 ? i18n.t("game.calculator.invalid.tech_value") : "";
const value = techs[key];
if (value < 0) return i18n.t("game.calculator.invalid.tech_value");
if (value < techFloor[key]) {
return i18n.t("game.calculator.invalid.tech_below_current");
}
return "";
}
// Smart step on the four ship-class blocks (drive, weapons, shields,
// 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.
// 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.
function bumpBlock(value: number, dir: 1 | -1): number {
if (dir === 1) {
if (value < 1) return 1;
return Math.round((value + 0.1) * 10) / 10;
}
if (value <= 1) return 0;
return Math.round((value - 0.1) * 10) / 10;
}
function onBlockKey(
event: KeyboardEvent,
key: keyof DesignBlocksState,
): void {
if (event.key === "ArrowUp") {
event.preventDefault();
blocks[key] = bumpBlock(blocks[key], 1);
} else if (event.key === "ArrowDown") {
event.preventDefault();
blocks[key] = bumpBlock(blocks[key], -1);
}
}
const BLOCK_ROWS: {
@@ -81,13 +121,23 @@ calculator math — so the ship-group upgrade flow can reuse it later.
label: () => string;
step: string;
tech: TechKey | null;
smartStep: boolean;
}[] = [
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.01", tech: "drive" },
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null },
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.01", tech: "weapons" },
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.01", tech: "shields" },
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.01", tech: "cargo" },
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.1", tech: "drive", smartStep: true },
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null, smartStep: false },
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.1", tech: "weapons", smartStep: true },
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.1", tech: "shields", smartStep: true },
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.1", tech: "cargo", smartStep: true },
];
const techInputEls: Partial<Record<TechKey, HTMLInputElement>> = {};
async function activateTechOverride(key: TechKey): Promise<void> {
onTechInput(key);
await tick();
techInputEls[key]?.focus();
techInputEls[key]?.select();
}
</script>
<div class="design" data-testid="calculator-design-area">
@@ -103,6 +153,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
{#if isComputed}
<input
class="ship"
class:smart-step={row.smartStep}
type="number"
step={row.step}
readonly
@@ -114,6 +165,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
{:else}
<input
class="ship"
class:smart-step={row.smartStep}
type="number"
step={row.step}
min="0"
@@ -122,26 +174,29 @@ calculator math — so the ship-group upgrade flow can reuse it later.
aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
title={blockError(row.key)}
data-testid={`calculator-block-${row.key}`}
onkeydown={row.smartStep
? (e) => onBlockKey(e, row.key)
: null}
/>
{/if}
{#if row.tech !== null}
{@const techKey = row.tech}
<span class="tech-cell">
<input
class="tech"
type="number"
step="0.001"
min="0"
bind:value={techs[techKey]}
oninput={() => onTechInput(techKey)}
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
title={techError(techKey)}
data-testid={`calculator-tech-${techKey}`}
/>
{#if techOverridden[techKey]}
<input
bind:this={techInputEls[techKey]}
class="tech"
type="number"
step="0.001"
min={techFloor[techKey]}
bind:value={techs[techKey]}
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
title={techError(techKey)}
data-testid={`calculator-tech-${techKey}`}
/>
<button
type="button"
class="lock"
class="lock active"
title={i18n.t("game.calculator.tech.reset")}
aria-label={i18n.t("game.calculator.tech.reset")}
data-testid={`calculator-tech-reset-${techKey}`}
@@ -149,6 +204,23 @@ calculator math — so the ship-group upgrade flow can reuse it later.
>
🔒
</button>
{:else}
<span
class="tech-val"
data-testid={`calculator-tech-value-${techKey}`}
>
{techs[techKey]}
</span>
<button
type="button"
class="lock"
title={i18n.t("game.calculator.tech.override")}
aria-label={i18n.t("game.calculator.tech.override")}
data-testid={`calculator-tech-override-${techKey}`}
onclick={() => void activateTechOverride(techKey)}
>
🔓
</button>
{/if}
</span>
{:else}
@@ -193,6 +265,18 @@ calculator math — so the ship-group upgrade flow can reuse it later.
border-radius: 3px;
font-variant-numeric: tabular-nums;
}
/* Drive/weapons/shields/cargo use the JS-driven smart step (0→1 jump
then 0.1 increments) for keyboard arrows; hide the native spinner
on those inputs so it cannot produce invalid 0.01 intermediates. */
input.smart-step::-webkit-inner-spin-button,
input.smart-step::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input.smart-step {
-moz-appearance: textfield;
appearance: textfield;
}
input[data-computed="true"],
input[readonly] {
color: var(--color-accent);
@@ -206,6 +290,14 @@ calculator math — so the ship-group upgrade flow can reuse it later.
align-items: center;
gap: 0.2rem;
}
.tech-val {
flex: 1;
min-width: 0;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
text-align: right;
padding: 0.2rem 0.35rem;
}
.lock {
flex: none;
padding: 0;
@@ -214,5 +306,10 @@ calculator math — so the ship-group upgrade flow can reuse it later.
background: transparent;
border: 0;
cursor: pointer;
opacity: 0.5;
}
.lock.active,
.lock:hover {
opacity: 1;
}
</style>
+3
View File
@@ -393,7 +393,9 @@ const en = {
"game.calculator.lock.reset": "locked — click to release to the computed value",
"game.calculator.lock.infeasible": "this target cannot be reached with the current design",
"game.calculator.lock.max": "release the locked result first — one result at a time",
"game.calculator.tech.override": "click to override your current tech",
"game.calculator.tech.reset": "overridden — click to reset to your current tech",
"game.calculator.mat.override": "click to override the planet value",
"game.calculator.mat.reset": "overridden — click to reset to the planet value",
"game.calculator.modern.current": "current",
"game.calculator.modern.target": "target",
@@ -418,6 +420,7 @@ const en = {
"game.calculator.invalid.all_zero": "at least one value must be nonzero",
"game.calculator.invalid.negative": "value cannot be negative",
"game.calculator.invalid.tech_value": "tech level cannot be negative",
"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.lock.no_drive": "set a non-zero drive before locking speed",
+3
View File
@@ -394,7 +394,9 @@ const ru: Record<keyof typeof en, string> = {
"game.calculator.lock.reset": "зафиксировано — нажмите, чтобы вернуть вычисляемое значение",
"game.calculator.lock.infeasible": "эта цель недостижима при текущих параметрах",
"game.calculator.lock.max": "сначала снимите фиксацию с другого результата — по одному за раз",
"game.calculator.tech.override": "нажмите, чтобы задать свой технологический уровень",
"game.calculator.tech.reset": "переопределено — нажмите, чтобы вернуть ваши текущие технологии",
"game.calculator.mat.override": "нажмите, чтобы задать своё значение MAT",
"game.calculator.mat.reset": "переопределено — нажмите, чтобы вернуть значение планеты",
"game.calculator.modern.current": "текущий",
"game.calculator.modern.target": "целевой",
@@ -419,6 +421,7 @@ const ru: Record<keyof typeof en, string> = {
"game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
"game.calculator.invalid.negative": "значение не может быть отрицательным",
"game.calculator.invalid.tech_value": "технологический уровень не может быть отрицательным",
"game.calculator.invalid.tech_below_current": "технологический уровень не может быть ниже ваших текущих технологий на этом ходу",
"game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля",
"game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость",
@@ -14,7 +14,7 @@ switch (the inspector auto-opens on a planet click) — the calculator is a
long-lived planning tool. `ensureGame` resets it when the game changes.
-->
<script lang="ts">
import { getContext } from "svelte";
import { getContext, tick } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -273,6 +273,18 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cs.matValue < 0 ? i18n.t("game.calculator.invalid.negative") : "",
);
// Modernization target tech mirrors the design-area floor: a target
// below the player's current tech on this turn is meaningless (no
// upgrade), so flag it the same way.
function targetTechError(key: TechKey): string {
const value = cs.targetTech[key];
if (value < 0) return i18n.t("game.calculator.invalid.negative");
if (value < playerTech[key]) {
return i18n.t("game.calculator.invalid.tech_below_current");
}
return "";
}
// Locking a speed back-solves the drive block; with a zero drive the
// ship is deliberately immobile, so disallow it.
function lockDisabledReason(output: LockableOutputId): string {
@@ -291,8 +303,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
function onResetTech(key: TechKey): void {
cs.techOverridden[key] = false;
}
function onMatInput(): void {
const matInputRef: { el?: HTMLInputElement } = {};
async function activateMatOverride(): Promise<void> {
cs.matOverridden = true;
await tick();
matInputRef.el?.focus();
matInputRef.el?.select();
}
function resetMat(): void {
cs.matOverridden = false;
@@ -485,6 +501,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
resolved={resolvedCeil}
bind:techs={cs.techValues}
techOverridden={cs.techOverridden}
techFloor={playerTech}
computedInput={result.computedInput}
{onTechInput}
{onResetTech}
@@ -589,17 +606,17 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<div class="rrow">
<span class="label">{i18n.t("game.calculator.planet.mat")}</span>
<span class="cell">
<input
type="number"
step="0.01"
min="0"
bind:value={cs.matValue}
oninput={onMatInput}
aria-invalid={matError !== "" ? "true" : "false"}
title={matError}
data-testid="calculator-planet-mat"
/>
{#if cs.matOverridden}
<input
bind:this={matInputRef.el}
type="number"
step="0.01"
min="0"
bind:value={cs.matValue}
aria-invalid={matError !== "" ? "true" : "false"}
title={matError}
data-testid="calculator-planet-mat"
/>
<button
type="button"
class="lock active"
@@ -610,6 +627,23 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
>
🔒
</button>
{:else}
<span
class="mat-val"
data-testid="calculator-planet-mat-value"
>
{cs.matValue}
</span>
<button
type="button"
class="lock"
title={i18n.t("game.calculator.mat.override")}
aria-label={i18n.t("game.calculator.mat.override")}
data-testid="calculator-mat-override"
onclick={() => void activateMatOverride()}
>
🔓
</button>
{/if}
</span>
<span></span>
@@ -638,18 +672,17 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
</div>
{#each modernCosts?.perBlock ?? [] as row (row.key)}
{@const targetError = targetTechError(row.key)}
<div class="rrow">
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
<span class="cell">
<input
type="number"
step="0.001"
min="0"
min={playerTech[row.key]}
bind:value={cs.targetTech[row.key]}
aria-invalid={cs.targetTech[row.key] < 0 ? "true" : "false"}
title={cs.targetTech[row.key] < 0
? i18n.t("game.calculator.invalid.negative")
: ""}
aria-invalid={targetError !== "" ? "true" : "false"}
title={targetError}
data-testid={`calculator-target-${row.key}`}
/>
</span>
@@ -899,4 +932,15 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
font-size: 0.8rem;
color: var(--color-accent);
}
/* Plain-text view of the planet MAT (mirrors `.tech-val` in the
design area) so the cell width stays the same whether the value is
the inherited planet number or the player's override. */
.mat-val {
flex: 1;
min-width: 0;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
text-align: right;
padding: 0.15rem 0.3rem;
}
</style>
+19
View File
@@ -135,6 +135,25 @@ describe("computeCalculator goal-seek", () => {
expect(result.blocks.drive).toBe(10);
});
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
// drive is zero, so speed equals 20*driveTech (the ceiling); the
// solver must accept that exact target instead of flagging it
// as unreachable.
const core = makeFakeCore();
const result = computeCalculator(
input({
blocks: { drive: 1, armament: 0, weapons: 0, shields: 0, cargo: 0 },
driveTech: 1,
lock: { output: "speedEmpty", value: 20 },
}),
core,
);
expect(result.lockFeasible).toBe(true);
expect(result.computedInput).toBe("drive");
expect(result.outputs?.speedEmpty).toBeCloseTo(20, 9);
});
test("calls the matching solver with the right context", () => {
const weaponsForAttack = vi.fn(() => 7);
const core = makeFakeCore({ weaponsForAttack });
+102
View File
@@ -278,4 +278,106 @@ describe("calculator-tab", () => {
"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",
);
});
});
@@ -275,6 +275,9 @@ test("calculator draws reach circles for the selected planet", async ({
await calc.getByTestId("calculator-block-drive").fill("10");
await calc.getByTestId("calculator-block-shields").fill("5");
await calc.getByTestId("calculator-block-cargo").fill("5");
// Tech defaults render as a number + open lock; click to reveal the
// input before typing an override (the F8-06 unified lock idiom).
await calc.getByTestId("calculator-tech-override-drive").click();
await calc.getByTestId("calculator-tech-drive").fill("1.2");
await expect.poll(() => countReachCircles(page)).toBeGreaterThan(0);
+5 -2
View File
@@ -94,10 +94,13 @@ export function makeFakeCore(overrides: Partial<Core> = {}): Core {
weaponsForAttack: ({ targetAttack, weaponsTech }) =>
weaponsTech <= 0 || targetAttack < 0 ? null : targetAttack / weaponsTech,
driveForSpeed: ({ targetSpeed, driveTech, restMass }) => {
if (driveTech <= 0 || targetSpeed <= 0) return null;
const ceiling = 20 * driveTech;
if (driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling) {
return null;
if (restMass <= 0) {
if (targetSpeed !== ceiling) return null;
return 1;
}
if (targetSpeed >= ceiling) return null;
return (targetSpeed * restMass) / (ceiling - targetSpeed);
},
shieldsForDefence: ({ targetDefence, shieldsTech, restMass }) => {