cbf7f65916
Owner review on PR #61: - п.9 (option B). Hide the native spinner on EVERY numeric input in the calculator (DWSC blocks, armament, tech, planet MAT, custom load, lock value, modernization target tech) and drive every step through ArrowUp / ArrowDown. The column widths stay stable and the inputs read consistently across the whole row. The ship blocks keep the smart (0 ↔ 1) jump on ArrowUp/ArrowDown; armament steps ±1 with a JS handler instead of relying on the native spinner. Other inputs step by their natural grain (±0.001 for tech / lock, ±0.01 for MAT / load). - п.10. Tech-level labels (`tech-val`) and the planet MAT label (`mat-val`) now read through the same `Ceil3` formatter as the derived results, so plain-text numeric values share the report's 3-decimal tabular formatting. The design-area component receives `formatNumber` as a prop; the resolved (goal-seek) cell uses the same formatter, so the read-only computed value matches the rest of the row. - п.12. `computeCalculator` now validates the back-solved block against the same DWSC rule the live validator enforces (`0` or `≥ 1`). When the solver lands in the `(0, 1)` gap (e.g. attack 0.5 / weaponsTech 1.5 → weapons 0.333…) the lock is flagged infeasible — the lock input flips red and the claimed block is NOT back-solved into the invalid range, so the design preview keeps reading the user's own typed values instead of silently showing a sub-1 block. - new. Selecting an existing ship class from the name datalist now loads it immediately. `change` fires only on blur in Firefox, which is why the previous behaviour looked delayed; switching the load to `oninput` with an `InputEvent.inputType` check makes the load synchronous everywhere (datalist replacement carries `"insertReplacementText"` in Chromium / WebKit, `undefined` in Firefox; keyboard typing always carries a typing `inputType`). Before loading we compare the live blocks to the previously loaded class (or to the empty defaults) and, if they differ, ask through a `window.confirm`. On decline we revert the name field and leave the design untouched. Tests: calculator-tab and calc-model gain six cases (armament step, tech/MAT formatter labels, lock infeasible on (0, 1) for both attack→weapons and emptyMass→cargo, lock-value Arrow step, dropdown immediate load + confirm-blocks-load + confirm-allows-load), all 779 vitest tests green. docs/calculator-ux.md follows the new behaviour. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
233 lines
7.9 KiB
TypeScript
233 lines
7.9 KiB
TypeScript
import { describe, expect, test, vi } from "vitest";
|
|
|
|
import {
|
|
computeCalculator,
|
|
computePlanetBuild,
|
|
type CalculatorInput,
|
|
} from "../src/lib/calculator/calc-model";
|
|
import { makeFakeCore } from "./fake-core";
|
|
|
|
function input(overrides: Partial<CalculatorInput> = {}): CalculatorInput {
|
|
return {
|
|
blocks: { drive: 10, armament: 0, weapons: 0, shields: 5, cargo: 5 },
|
|
driveTech: 1.2,
|
|
weaponsTech: 1.5,
|
|
shieldsTech: 1,
|
|
cargoTech: 1,
|
|
loadMode: "full",
|
|
customLoad: 0,
|
|
lock: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("computeCalculator forward", () => {
|
|
test("returns null outputs without a Core", () => {
|
|
const result = computeCalculator(input(), null);
|
|
expect(result.outputs).toBeNull();
|
|
expect(result.valuesValid).toBe(false);
|
|
});
|
|
|
|
test("computes outputs for a valid design", () => {
|
|
const core = makeFakeCore();
|
|
const result = computeCalculator(input(), core);
|
|
expect(result.valuesValid).toBe(true);
|
|
expect(result.outputs).not.toBeNull();
|
|
// empty mass = drive + shields + cargo = 20 (no weapons block).
|
|
expect(result.outputs?.emptyMass).toBeCloseTo(20, 9);
|
|
// cargo capacity = 1 * (5 + 25/20) = 6.25, full load mass = 26.25.
|
|
expect(result.load).toBeCloseTo(6.25, 9);
|
|
expect(result.outputs?.loadedMass).toBeCloseTo(26.25, 9);
|
|
});
|
|
|
|
test("hides outputs when blocks are invalid (armament without weapons)", () => {
|
|
const core = makeFakeCore();
|
|
const result = computeCalculator(
|
|
input({ blocks: { drive: 10, armament: 3, weapons: 0, shields: 5, cargo: 5 } }),
|
|
core,
|
|
);
|
|
expect(result.valuesValid).toBe(false);
|
|
expect(result.valueReason).toBe("armament_weapons_pair");
|
|
expect(result.outputs).toBeNull();
|
|
});
|
|
|
|
test("empty load mode yields loaded mass equal to empty mass", () => {
|
|
const core = makeFakeCore();
|
|
const result = computeCalculator(input({ loadMode: "empty" }), core);
|
|
expect(result.load).toBe(0);
|
|
expect(result.outputs?.loadedMass).toBeCloseTo(
|
|
result.outputs?.emptyMass ?? -1,
|
|
9,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("computeCalculator goal-seek", () => {
|
|
test("attack lock back-solves the weapons block", () => {
|
|
const core = makeFakeCore();
|
|
const result = computeCalculator(
|
|
input({ blocks: { drive: 10, armament: 2, weapons: 5, shields: 5, cargo: 5 }, lock: { output: "attack", value: 30 } }),
|
|
core,
|
|
);
|
|
expect(result.lockFeasible).toBe(true);
|
|
expect(result.computedInput).toBe("weapons");
|
|
// weapons = 30 / weaponsTech(1.5) = 20.
|
|
expect(result.blocks.weapons).toBeCloseTo(20, 9);
|
|
expect(result.outputs?.attack).toBeCloseTo(30, 6);
|
|
});
|
|
|
|
test("loaded-speed lock back-solves the drive block", () => {
|
|
const core = makeFakeCore();
|
|
const result = computeCalculator(
|
|
input({ lock: { output: "speedLoaded", value: 5 } }),
|
|
core,
|
|
);
|
|
expect(result.lockFeasible).toBe(true);
|
|
expect(result.computedInput).toBe("drive");
|
|
expect(result.outputs?.speedLoaded).toBeCloseTo(5, 6);
|
|
});
|
|
|
|
test("defence lock back-solves the shields block", () => {
|
|
const core = makeFakeCore();
|
|
const result = computeCalculator(
|
|
input({ lock: { output: "defense", value: 4 } }),
|
|
core,
|
|
);
|
|
expect(result.lockFeasible).toBe(true);
|
|
expect(result.computedInput).toBe("shields");
|
|
expect(result.outputs?.defense).toBeCloseTo(4, 5);
|
|
});
|
|
|
|
test("empty-mass lock back-solves the cargo block", () => {
|
|
const core = makeFakeCore();
|
|
const result = computeCalculator(
|
|
input({ lock: { output: "emptyMass", value: 25 } }),
|
|
core,
|
|
);
|
|
expect(result.computedInput).toBe("cargo");
|
|
// cargo = 25 - (drive 10 + shields 5) = 10.
|
|
expect(result.blocks.cargo).toBeCloseTo(10, 9);
|
|
expect(result.outputs?.emptyMass).toBeCloseTo(25, 9);
|
|
});
|
|
|
|
test("loaded-mass lock back-solves the cargo load", () => {
|
|
const core = makeFakeCore();
|
|
const result = computeCalculator(
|
|
input({ lock: { output: "loadedMass", value: 30 } }),
|
|
core,
|
|
);
|
|
expect(result.computedInput).toBe("load");
|
|
// load = (30 - emptyMass 20) * cargoTech 1 = 10.
|
|
expect(result.load).toBeCloseTo(10, 9);
|
|
expect(result.outputs?.loadedMass).toBeCloseTo(30, 9);
|
|
});
|
|
|
|
test("an unreachable speed marks the lock infeasible", () => {
|
|
const core = makeFakeCore();
|
|
const result = computeCalculator(
|
|
// ceiling is 20 * driveTech = 24; 100 is unreachable.
|
|
input({ lock: { output: "speedEmpty", value: 100 } }),
|
|
core,
|
|
);
|
|
expect(result.lockFeasible).toBe(false);
|
|
expect(result.computedInput).toBeNull();
|
|
// the claimed block keeps its raw value.
|
|
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", () => {
|
|
// 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 });
|
|
const result = computeCalculator(
|
|
input({ blocks: { drive: 10, armament: 2, weapons: 5, shields: 5, cargo: 5 }, lock: { output: "attack", value: 30 } }),
|
|
core,
|
|
);
|
|
expect(weaponsForAttack).toHaveBeenCalledWith({
|
|
targetAttack: 30,
|
|
weaponsTech: 1.5,
|
|
});
|
|
expect(result.blocks.weapons).toBe(7);
|
|
expect(result.computedInput).toBe("weapons");
|
|
});
|
|
});
|
|
|
|
describe("computePlanetBuild", () => {
|
|
test("returns null without a Core", () => {
|
|
expect(computePlanetBuild({ shipMass: 10, freeIndustry: 100, material: 0, resources: 10 }, null)).toBeNull();
|
|
});
|
|
|
|
test("derives ships-per-turn from the per-turn build loop", () => {
|
|
const core = makeFakeCore();
|
|
// shipMass 1, ample material: 100 production / (10 per ship) = 10 ships.
|
|
const result = computePlanetBuild(
|
|
{ shipMass: 1, freeIndustry: 100, material: 100, resources: 10 },
|
|
core,
|
|
);
|
|
expect(result?.wholeShips).toBe(10);
|
|
expect(result?.shipsPerTurn).toBeCloseTo(10, 9);
|
|
expect(result?.turnsPerShip).toBeCloseTo(0.1, 9);
|
|
});
|
|
|
|
test("reports turns-per-ship when under one ship per turn", () => {
|
|
const core = makeFakeCore();
|
|
// shipMass 10, no material, resources 0.5: cost 120, 60 production → 0.5 ship.
|
|
const result = computePlanetBuild(
|
|
{ shipMass: 10, freeIndustry: 60, material: 0, resources: 0.5 },
|
|
core,
|
|
);
|
|
expect(result?.wholeShips).toBe(0);
|
|
expect(result?.shipsPerTurn).toBeCloseTo(0.5, 9);
|
|
expect(result?.turnsPerShip).toBeCloseTo(2, 9);
|
|
});
|
|
});
|