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 { 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); }); });