Files
galaxy-game/ui/frontend/tests/calc-model.test.ts
T
Ilia Denisov e9b904332e
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
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.
2026-05-26 14:30:43 +02:00

202 lines
6.6 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("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);
});
});