Files
initiative/packages/domain/src/__tests__/add-combatant.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

194 lines
5.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { addCombatant } from "../add-combatant.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): Combatant {
return { id: combatantId(name), name };
}
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, name: string) {
const result = addCombatant(encounter, combatantId(id), name);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
// --- Acceptance Scenarios ---
describe("addCombatant", () => {
describe("acceptance scenarios", () => {
it("scenario 1: add to empty encounter", () => {
const e = enc([], 0, 1);
const { encounter, events } = successResult(e, "gandalf", "Gandalf");
expect(encounter.combatants).toEqual([
{ id: combatantId("gandalf"), name: "Gandalf" },
]);
expect(encounter.activeIndex).toBe(0);
expect(encounter.roundNumber).toBe(1);
expect(events).toEqual([
{
type: "CombatantAdded",
combatantId: combatantId("gandalf"),
name: "Gandalf",
position: 0,
},
]);
});
it("scenario 2: add to encounter with [A, B]", () => {
const e = enc([A, B], 0, 1);
const { encounter, events } = successResult(e, "C", "C");
expect(encounter.combatants).toEqual([
A,
B,
{ id: combatantId("C"), name: "C" },
]);
expect(encounter.activeIndex).toBe(0);
expect(encounter.roundNumber).toBe(1);
expect(events).toEqual([
{
type: "CombatantAdded",
combatantId: combatantId("C"),
name: "C",
position: 2,
},
]);
});
it("scenario 3: add during mid-round does not change active combatant", () => {
const e = enc([A, B, C], 2, 3);
const { encounter, events } = successResult(e, "D", "D");
expect(encounter.combatants).toHaveLength(4);
expect(encounter.combatants[3]).toEqual({
id: combatantId("D"),
name: "D",
});
expect(encounter.activeIndex).toBe(2);
expect(encounter.roundNumber).toBe(3);
expect(events).toEqual([
{
type: "CombatantAdded",
combatantId: combatantId("D"),
name: "D",
position: 3,
},
]);
});
it("scenario 4: two sequential adds preserve order", () => {
const e = enc([A]);
const first = successResult(e, "B", "B");
const second = successResult(first.encounter, "C", "C");
expect(second.encounter.combatants).toEqual([
A,
{ id: combatantId("B"), name: "B" },
{ id: combatantId("C"), name: "C" },
]);
expect(first.events).toHaveLength(1);
expect(second.events).toHaveLength(1);
});
it("scenario 5: empty name returns error", () => {
const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), "");
expectDomainError(result, "invalid-name");
});
it("scenario 6: whitespace-only name returns error", () => {
const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), " ");
expectDomainError(result, "invalid-name");
});
});
describe("invariants", () => {
it("INV-1: encounter may have zero combatants (adding to empty is valid)", () => {
const e = enc([]);
const result = addCombatant(e, combatantId("a"), "A");
expect(isDomainError(result)).toBe(false);
});
it("INV-2: activeIndex remains valid after adding", () => {
const scenarios: Encounter[] = [
enc([], 0, 1),
enc([A], 0, 1),
enc([A, B, C], 2, 3),
];
for (const e of scenarios) {
const result = successResult(e, "new", "New");
const { combatants, activeIndex } = result.encounter;
// After adding a combatant, list is always non-empty
expect(combatants.length).toBeGreaterThan(0);
expect(activeIndex).toBeGreaterThanOrEqual(0);
expect(activeIndex).toBeLessThan(combatants.length);
}
});
it("INV-3: roundNumber is preserved (never decreases)", () => {
const e = enc([A, B], 1, 5);
const { encounter } = successResult(e, "C", "C");
expect(encounter.roundNumber).toBe(5);
});
it("INV-4: determinism — same input produces same output", () => {
const e = enc([A, B], 1, 3);
const result1 = addCombatant(e, combatantId("x"), "X");
const result2 = addCombatant(e, combatantId("x"), "X");
expect(result1).toEqual(result2);
});
it("INV-5: every success emits exactly one CombatantAdded event", () => {
const scenarios: Encounter[] = [enc([]), enc([A]), enc([A, B, C], 2, 5)];
for (const e of scenarios) {
const result = successResult(e, "z", "Z");
expect(result.events).toHaveLength(1);
expect(result.events[0].type).toBe("CombatantAdded");
}
});
it("INV-6: addCombatant does not change activeIndex or roundNumber", () => {
const e = enc([A, B, C], 2, 7);
const { encounter } = successResult(e, "D", "D");
expect(encounter.activeIndex).toBe(2);
expect(encounter.roundNumber).toBe(7);
});
it("INV-7: new combatant is always appended at the end", () => {
const e = enc([A, B]);
const { encounter } = successResult(e, "C", "C");
expect(encounter.combatants.at(-1)).toEqual({
id: combatantId("C"),
name: "C",
});
// Existing combatants preserve order
expect(encounter.combatants[0]).toEqual(A);
expect(encounter.combatants[1]).toEqual(B);
});
});
});