// Vitest coverage for `lib/util/ship-class-validation.ts`. The // validator is a TS port of `pkg/calc/validator.go` plus the // `validateEntityName` rules and a UX-only duplicate-name check. // Each branch is exercised explicitly so a future engine // validator change cannot drift silently. import { describe, expect, test } from "vitest"; import { validateShipClass, type ShipClassDraft, } from "../src/lib/util/ship-class-validation"; function draft(overrides: Partial): ShipClassDraft { return { name: "Scout", drive: 1, armament: 0, weapons: 0, shields: 0, cargo: 0, ...overrides, }; } describe("validateShipClass", () => { test("accepts a minimal valid drone-style class", () => { const result = validateShipClass( draft({ name: "Drone", drive: 1, armament: 0, weapons: 0 }), ); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.name).toBe("Drone"); }); test("trims surrounding whitespace from the name", () => { const result = validateShipClass(draft({ name: " Scout " })); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.name).toBe("Scout"); }); test("rejects empty name", () => { const result = validateShipClass(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 = validateShipClass(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 = validateShipClass(draft({ name: "Big Ship" })); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("whitespace"); }); test.each([ { value: 0.5, reason: "drive_value" as const, field: "drive" as const }, { value: -1, reason: "drive_value" as const, field: "drive" as const }, { value: Number.POSITIVE_INFINITY, reason: "drive_value" as const, field: "drive" as const, }, { value: 0.5, reason: "weapons_value" as const, field: "weapons" as const }, { value: 0.5, reason: "shields_value" as const, field: "shields" as const }, { value: 0.5, reason: "cargo_value" as const, field: "cargo" as const }, ])( "rejects $field = $value with reason $reason", ({ value, reason, field }) => { // Make sure both armament/weapons stay coupled (both nonzero or both zero) so we // trip the per-field rule before the pair rule kicks in. const overrides: Partial = { drive: 1, armament: 0, weapons: 0, shields: 0, cargo: 0, }; if (field === "weapons") { overrides.armament = 1; } (overrides as Record)[field] = value; const result = validateShipClass(draft(overrides)); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe(reason); }, ); test("rejects negative armament", () => { const result = validateShipClass(draft({ armament: -1 })); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("armament_value"); }); test("rejects fractional armament", () => { const result = validateShipClass(draft({ armament: 1.5, weapons: 1 })); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("armament_not_integer"); }); test("rejects nonzero armament with zero weapons", () => { const result = validateShipClass(draft({ armament: 2, weapons: 0 })); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("armament_weapons_pair"); }); test("rejects zero armament with nonzero weapons", () => { const result = validateShipClass(draft({ armament: 0, weapons: 5 })); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("armament_weapons_pair"); }); test("rejects all-zero values", () => { const result = validateShipClass( draft({ drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0, }), ); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("all_zero"); }); test("accepts the canonical Cruiser fixture from rules.txt", () => { const result = validateShipClass( draft({ name: "Cruiser", drive: 15, armament: 1, weapons: 15, shields: 15, cargo: 0, }), ); expect(result.ok).toBe(true); }); test("accepts the canonical Megafreighter fixture", () => { const result = validateShipClass( draft({ name: "Megafreighter", drive: 80, armament: 2, weapons: 2, shields: 30, cargo: 100, }), ); expect(result.ok).toBe(true); }); test("flags duplicate names against existingNames", () => { const result = validateShipClass(draft({ name: "Scout" }), { existingNames: ["Scout", "Destroyer"], }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toBe("duplicate_name"); }); test("compares duplicate names after trimming", () => { const result = validateShipClass(draft({ name: " Scout " }), { existingNames: ["Scout"], }); 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 = validateShipClass(draft({ name: "Scout" }), { existingNames: [], }); expect(result.ok).toBe(true); }); });