// Vitest coverage for `lib/util/science-validation.ts`. The // validator is the TS-side mirror of // `pkg/calc/validator.go.ValidateScienceValues` plus the // `validateEntityName` rules and a UX-only duplicate-name check. // The designer composes percentages (`[0, 100]` summing to `100`) // and the validator returns canonical fractions (`[0, 1]` summing // to `1.0`) on success — so the exhaustive coverage below also // pins the percent → fraction conversion contract. import { describe, expect, test } from "vitest"; import { SUM_EPSILON_PERCENT, fractionsToPercent, validateScience, type ScienceDraft, } from "../src/lib/util/science-validation"; function draft(overrides: Partial): ScienceDraft { return { name: "FirstStep", drive: 25, weapons: 25, shields: 25, cargo: 25, ...overrides, }; } describe("validateScience", () => { test("accepts an even-split science and converts to fractions", () => { const result = validateScience(draft({ name: "Even" })); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.name).toBe("Even"); expect(result.value.drive).toBeCloseTo(0.25, 12); expect(result.value.weapons).toBeCloseTo(0.25, 12); expect(result.value.shields).toBeCloseTo(0.25, 12); expect(result.value.cargo).toBeCloseTo(0.25, 12); }); test("trims surrounding whitespace from the name", () => { const result = validateScience(draft({ name: " Beta " })); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.name).toBe("Beta"); }); test("rejects empty name", () => { const result = validateScience(draft({ name: "" })); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("empty"); }); test("rejects name longer than 30 runes", () => { const result = validateScience(draft({ name: "a".repeat(31) })); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("too_long"); }); test("rejects name with whitespace inside", () => { const result = validateScience(draft({ name: "Big Name" })); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("whitespace"); }); test.each([ { value: -0.1, reason: "drive_value" as const, field: "drive" as const }, { value: 100.1, reason: "drive_value" as const, field: "drive" as const }, { value: Number.POSITIVE_INFINITY, reason: "drive_value" as const, field: "drive" as const, }, { value: Number.NaN, reason: "weapons_value" as const, field: "weapons" as const, }, { value: -1, reason: "shields_value" as const, field: "shields" as const, }, { value: 101, reason: "cargo_value" as const, field: "cargo" as const }, ])( "rejects $field = $value with reason $reason", ({ value, reason, field }) => { const overrides: Partial = { drive: 25, weapons: 25, shields: 25, cargo: 25, }; (overrides as Record)[field] = value; const result = validateScience(draft(overrides)); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe(reason); }, ); test("rejects sum off by more than the epsilon", () => { const result = validateScience( draft({ drive: 30, weapons: 30, shields: 30, cargo: 5 }), ); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("sum_not_hundred"); }); test("accepts the canonical First Step fixture from rules.txt", () => { // 10 Drive + 5 Weapons + 30 Shields + 0 Cargo, normalised: // 10/45 ≈ 22.222… %, 5/45 ≈ 11.111… %, 30/45 ≈ 66.666… %, // 0/45 = 0 %. Snapped to one decimal at input time: // 22.2 / 11.1 / 66.7 / 0 → sum = 100. The float arithmetic is // well within `SUM_EPSILON_PERCENT`. const result = validateScience( draft({ name: "FirstStep", drive: 22.2, weapons: 11.1, shields: 66.7, cargo: 0 }), ); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.drive).toBeCloseTo(0.222, 6); expect(result.value.weapons).toBeCloseTo(0.111, 6); expect(result.value.shields).toBeCloseTo(0.667, 6); expect(result.value.cargo).toBe(0); }); test("accepts a 100/0/0/0 single-axis science", () => { const result = validateScience( draft({ name: "PureDrive", drive: 100, weapons: 0, shields: 0, cargo: 0 }), ); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.drive).toBe(1); }); test("accepts sums within the float tolerance", () => { // Build a sum that drifts a hair off due to FP arithmetic but // stays inside `SUM_EPSILON_PERCENT`. const sumOk = 100 - SUM_EPSILON_PERCENT / 2; const result = validateScience( draft({ drive: sumOk, weapons: 0, shields: 0, cargo: 0 }), ); expect(result.ok).toBe(true); }); test("flags duplicate names against existingNames", () => { const result = validateScience(draft({ name: "Beta" }), { existingNames: ["Alpha", "Beta"], }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("duplicate_name"); }); test("compares duplicate names after trimming", () => { const result = validateScience(draft({ name: " Beta " }), { existingNames: ["Beta"], }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("duplicate_name"); }); test("does not flag duplicates when existingNames is empty", () => { const result = validateScience(draft({ name: "Beta" }), { existingNames: [], }); expect(result.ok).toBe(true); }); }); describe("fractionsToPercent", () => { test("inverts the percent → fraction conversion", () => { const back = fractionsToPercent({ drive: 0.25, weapons: 0.111, shields: 0.5, cargo: 0.139, }); expect(back.drive).toBeCloseTo(25, 6); expect(back.weapons).toBeCloseTo(11.1, 6); expect(back.shields).toBeCloseTo(50, 6); expect(back.cargo).toBeCloseTo(13.9, 6); }); });