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
+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",
);
});
});