Files
initiative/packages/domain/src/__tests__/set-initiative.test.ts
Lukas 36768d3aa1 Upgrade Biome to 2.4.7 and enable 54 additional lint rules
Add rules covering bug prevention (noLeakedRender, noFloatingPromises,
noImportCycles, noReactForwardRef), security (noScriptUrl, noAlert),
performance (noAwaitInLoops, useTopLevelRegex), and code style
(noNestedTernary, useGlobalThis, useNullishCoalescing, useSortedClasses,
plus ~40 more). Fix all violations: extract top-level regex constants,
guard React && renders with boolean coercion, refactor nested ternaries,
replace window with globalThis, sort Tailwind classes, and introduce
expectDomainError test helper to eliminate conditional expects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 14:25:09 +01:00

310 lines
9.3 KiB
TypeScript

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