feat(ui-calculator): input validation, load caps, ceil display, modernization layout
Tests · Go / test (push) Successful in 2m26s
Tests · UI / test (push) Successful in 2m26s

- custom load capped at cargo capacity (error when exceeded); full load shows the cargo capacity; zero cargo pins load to empty and disables the toggle

- per-input red border + tooltip for every invalid value (blocks, techs, load, MAT, modernization target); no value may be negative; locking a speed is disabled when drive is zero

- display every computed number (results + goal-seek back-solved input) rounded up to 3 decimals via a shared pkg/calc Ceil3 bridged to wasm; engine keeps its own round-to-nearest util.Fixed*

- modernization total upgrade cost spans two columns (single line)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-21 21:24:40 +02:00
parent 3ea29cf8b5
commit b1b87c8521
17 changed files with 343 additions and 9 deletions
+67
View File
@@ -211,4 +211,71 @@ describe("calculator-tab", () => {
ui.getByTestId("calculator-ships-per-turn"),
).not.toHaveTextContent("—");
});
test("zero cargo disables the load toggle", async () => {
const ui = mount();
await setBlock(ui, "drive", 10);
await setBlock(ui, "shields", 5);
await setBlock(ui, "cargo", 0);
expect(ui.getByTestId("calculator-load-full")).toBeDisabled();
expect(ui.getByTestId("calculator-load-custom")).toBeDisabled();
});
test("full load shows the cargo capacity", async () => {
const ui = mount();
await setBlock(ui, "drive", 10);
await setBlock(ui, "shields", 5);
await setBlock(ui, "cargo", 5);
// A fresh design starts with cargo 0, which pins load to empty;
// pick full now that there is a hold.
await fireEvent.click(ui.getByTestId("calculator-load-full"));
// capacity = cargoTech(1) * (5 + 25/20) = 6.25.
expect(ui.getByTestId("calculator-full-capacity")).toHaveTextContent("6.25");
});
test("flags a custom load above cargo capacity", async () => {
const ui = mount();
await setBlock(ui, "drive", 10);
await setBlock(ui, "shields", 5);
await setBlock(ui, "cargo", 5);
await fireEvent.click(ui.getByTestId("calculator-load-custom"));
await fireEvent.input(ui.getByTestId("calculator-custom-load"), {
target: { value: "100" },
});
expect(ui.getByTestId("calculator-custom-load")).toHaveAttribute(
"aria-invalid",
"true",
);
});
test("marks an invalid block value with aria-invalid", async () => {
const ui = mount();
// 0.5 is neither 0 nor ≥ 1.
await setBlock(ui, "drive", 0.5);
expect(ui.getByTestId("calculator-block-drive")).toHaveAttribute(
"aria-invalid",
"true",
);
});
test("disables the speed lock when drive is zero", async () => {
const ui = mount();
await setBlock(ui, "drive", 0);
await setBlock(ui, "shields", 5);
await setBlock(ui, "cargo", 5);
expect(ui.getByTestId("calculator-lock-speedEmpty")).toBeDisabled();
});
test("displays computed values rounded up to three decimals", async () => {
const ui = mount();
await setBlock(ui, "drive", 7);
await setBlock(ui, "shields", 3);
await setBlock(ui, "cargo", 1);
// empty mass = 11; max speed = 11 * driveTech... use a value that is
// not already 3-decimal: speedEmpty = 20*7*1.2 / 11 = 15.2727…
// ceil to 3 → 15.273.
expect(ui.getByTestId("calculator-out-speedEmpty")).toHaveTextContent(
"15.273",
);
});
});
+1
View File
@@ -123,6 +123,7 @@ export function makeFakeCore(overrides: Partial<Core> = {}): Core {
cargoTech <= 0 || targetFullMass < emptyMass
? null
: (targetFullMass - emptyMass) * cargoTech,
ceil3: ({ value }) => Math.ceil(Math.round(value * 1e9) / 1e6) / 1000,
};
return { ...base, ...overrides };
}
+6
View File
@@ -70,4 +70,10 @@ describe("WasmCore calculator bridge (Phase 30)", () => {
core.driveForSpeed({ targetSpeed: 100, driveTech: 1.2, restMass: 35 }),
).toBeNull();
});
test("ceil3 rounds up to three decimals", () => {
expect(core.ceil3({ value: 5.0003 })).toBeCloseTo(5.001, 9);
expect(core.ceil3({ value: 4.2761 })).toBeCloseTo(4.277, 9);
expect(core.ceil3({ value: 5 })).toBeCloseTo(5, 9);
});
});