fix(ui): calculator polish — smart input steps, unified tech/MAT lock idiom, tech floor, speed-lock ceiling fix
- 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:
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user