import { describe, expect, it } from "vitest"; import { setInitiative } from "../set-initiative.js"; import type { Combatant, Encounter } from "../types.js"; import { combatantId, isDomainError } from "../types.js"; import { expectDomainError } from "./test-helpers.js"; // --- Helpers --- function makeCombatant(name: string, initiative?: number): Combatant { return initiative === undefined ? { id: combatantId(name), name } : { id: combatantId(name), name, initiative }; } const A = makeCombatant("A"); const B = makeCombatant("B"); const C = makeCombatant("C"); function enc( combatants: Combatant[], activeIndex = 0, roundNumber = 1, ): Encounter { return { combatants, activeIndex, roundNumber }; } function successResult( encounter: Encounter, id: string, value: number | undefined, ) { const result = setInitiative(encounter, combatantId(id), value); if (isDomainError(result)) { throw new Error(`Expected success, got error: ${result.message}`); } return result; } function names(encounter: Encounter): string[] { return encounter.combatants.map((c) => c.name); } // --- US1: Set Initiative --- describe("setInitiative", () => { describe("US1: set initiative value", () => { it("AS-1: set initiative on combatant with no initiative", () => { const e = enc([A, B], 0); const { encounter, events } = successResult(e, "A", 15); expect(encounter.combatants[0].initiative).toBe(15); expect(events).toEqual([ { type: "InitiativeSet", combatantId: combatantId("A"), previousValue: undefined, newValue: 15, }, ]); }); it("AS-2: change existing initiative value", () => { const e = enc([makeCombatant("A", 15), B], 0); const { encounter, events } = successResult(e, "A", 8); const a = encounter.combatants.find((c) => c.id === combatantId("A")); expect(a?.initiative).toBe(8); expect(events[0]).toMatchObject({ previousValue: 15, newValue: 8, }); }); it("AS-3: reject non-integer initiative value", () => { const e = enc([A, B], 0); const result = setInitiative(e, combatantId("A"), 3.5); expectDomainError(result, "invalid-initiative"); }); it("AS-3b: reject NaN", () => { const e = enc([A, B], 0); const result = setInitiative(e, combatantId("A"), Number.NaN); expect(isDomainError(result)).toBe(true); }); it("AS-3c: reject Infinity", () => { const e = enc([A, B], 0); const result = setInitiative( e, combatantId("A"), Number.POSITIVE_INFINITY, ); expect(isDomainError(result)).toBe(true); }); it("AS-4: clear initiative moves combatant to end", () => { const e = enc([makeCombatant("A", 15), makeCombatant("B", 10)], 0); const { encounter } = successResult(e, "A", undefined); const a = encounter.combatants.find((c) => c.id === combatantId("A")); expect(a?.initiative).toBeUndefined(); // A should be after B now expect(names(encounter)).toEqual(["B", "A"]); }); it("returns error for nonexistent combatant", () => { const e = enc([A, B], 0); const result = setInitiative(e, combatantId("nonexistent"), 10); expectDomainError(result, "combatant-not-found"); }); }); // --- US2: Automatic Ordering --- describe("US2: automatic ordering by initiative", () => { it("AS-1: orders combatants descending by initiative", () => { // Start with A(20), B(5), C(15) → should be A(20), C(15), B(5) const e = enc([ makeCombatant("A", 20), makeCombatant("B", 5), makeCombatant("C", 15), ]); // Set C's initiative to trigger reorder (no-op change to force sort) const { encounter } = successResult(e, "C", 15); expect(names(encounter)).toEqual(["A", "C", "B"]); }); it("AS-2: changing initiative reorders correctly", () => { const e = enc([ makeCombatant("A", 20), makeCombatant("C", 15), makeCombatant("B", 5), ]); const { encounter } = successResult(e, "B", 25); expect(names(encounter)).toEqual(["B", "A", "C"]); }); it("AS-3: stable sort for equal initiative values", () => { const e = enc([makeCombatant("A", 10), makeCombatant("B", 10)]); // Set A's initiative to same value to confirm stable sort const { encounter } = successResult(e, "A", 10); expect(names(encounter)).toEqual(["A", "B"]); }); }); // --- US3: Combatants Without Initiative --- describe("US3: combatants without initiative", () => { it("AS-1: unset combatants appear after those with initiative", () => { const e = enc([ makeCombatant("A", 15), B, // no initiative makeCombatant("C", 10), ]); const { encounter } = successResult(e, "A", 15); expect(names(encounter)).toEqual(["A", "C", "B"]); }); it("AS-2: multiple unset combatants preserve relative order", () => { const e = enc([A, B]); // both no initiative const { encounter } = successResult(e, "A", undefined); expect(names(encounter)).toEqual(["A", "B"]); }); it("AS-3: setting initiative moves combatant to correct position", () => { const e = enc([ makeCombatant("A", 20), B, // no initiative makeCombatant("C", 10), ]); const { encounter } = successResult(e, "B", 12); expect(names(encounter)).toEqual(["A", "B", "C"]); }); }); // --- US4: Active Turn Preservation --- describe("US4: active turn preservation during reorder", () => { it("AS-1: reorder preserves active turn on different combatant", () => { // B is active (index 1), change A's initiative const e = enc( [makeCombatant("A", 10), makeCombatant("B", 15), makeCombatant("C", 5)], 1, ); // Change A's initiative to 20, causing reorder const { encounter } = successResult(e, "A", 20); // New order: A(20), B(15), C(5) expect(names(encounter)).toEqual(["A", "B", "C"]); // B should still be active expect(encounter.combatants[encounter.activeIndex].id).toBe( combatantId("B"), ); }); it("AS-2: active combatant's own initiative change preserves turn", () => { const e = enc( [makeCombatant("A", 20), makeCombatant("B", 15), makeCombatant("C", 5)], 0, // A is active ); // Change A's initiative to 1, causing it to move to the end const { encounter } = successResult(e, "A", 1); // New order: B(15), C(5), A(1) expect(names(encounter)).toEqual(["B", "C", "A"]); // A should still be active expect(encounter.combatants[encounter.activeIndex].id).toBe( combatantId("A"), ); }); }); // --- Invariants --- describe("invariants", () => { it("determinism — same input produces same output", () => { const e = enc([A, B, C], 1, 3); const result1 = setInitiative(e, combatantId("A"), 10); const result2 = setInitiative(e, combatantId("A"), 10); expect(result1).toEqual(result2); }); it("immutability — input encounter is not mutated", () => { const e = enc([A, B], 0, 2); const original = JSON.parse(JSON.stringify(e)); setInitiative(e, combatantId("A"), 10); expect(e).toEqual(original); }); it("event shape includes all required fields", () => { const e = enc([makeCombatant("A", 5), B], 0); const { events } = successResult(e, "A", 10); expect(events).toHaveLength(1); expect(events[0]).toEqual({ type: "InitiativeSet", combatantId: combatantId("A"), previousValue: 5, newValue: 10, }); }); it("roundNumber is never changed", () => { const e = enc([A, B], 0, 7); const { encounter } = successResult(e, "A", 10); expect(encounter.roundNumber).toBe(7); }); it("every success emits exactly one InitiativeSet event", () => { const scenarios: [Encounter, string, number | undefined][] = [ [enc([A]), "A", 10], [enc([A, B], 1), "A", 5], [enc([makeCombatant("A", 10)]), "A", undefined], ]; for (const [e, id, value] of scenarios) { const { events } = successResult(e, id, value); expect(events).toHaveLength(1); expect(events[0].type).toBe("InitiativeSet"); } }); }); // --- Edge Cases --- describe("edge cases", () => { it("zero is a valid initiative value", () => { const e = enc([A, B], 0); const { encounter } = successResult(e, "A", 0); const a = encounter.combatants.find((c) => c.id === combatantId("A")); expect(a?.initiative).toBe(0); }); it("negative initiative is valid", () => { const e = enc([A, B], 0); const { encounter } = successResult(e, "A", -5); const a = encounter.combatants.find((c) => c.id === combatantId("A")); expect(a?.initiative).toBe(-5); }); it("negative sorts below positive", () => { const e = enc([makeCombatant("A", -3), makeCombatant("B", 10)]); const { encounter } = successResult(e, "A", -3); expect(names(encounter)).toEqual(["B", "A"]); }); it("all combatants with same initiative preserve order", () => { const e = enc([ makeCombatant("A", 10), makeCombatant("B", 10), makeCombatant("C", 10), ]); const { encounter } = successResult(e, "B", 10); expect(names(encounter)).toEqual(["A", "B", "C"]); }); it("clearing initiative on last combatant with initiative", () => { const e = enc([makeCombatant("A", 10), B], 0); const { encounter } = successResult(e, "A", undefined); // Both unset now, preserve relative order expect(names(encounter)).toEqual(["A", "B"]); }); it("undefined value skips integer validation", () => { const e = enc([A], 0); const result = setInitiative(e, combatantId("A"), undefined); expect(isDomainError(result)).toBe(false); }); }); });