Implement the 002-add-combatant feature that adds the possibility to add new combatants to an encounter

This commit is contained in:
Lukas
2026-03-03 23:11:07 +01:00
parent 187f98fc52
commit 0de68100c8
15 changed files with 914 additions and 16 deletions

View File

@@ -0,0 +1,200 @@
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";
// --- 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"), "");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
it("scenario 6: whitespace-only name returns error", () => {
const e = enc([A, B]);
const result = addCombatant(e, combatantId("x"), " ");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("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;
if (combatants.length > 0) {
expect(activeIndex).toBeGreaterThanOrEqual(0);
expect(activeIndex).toBeLessThan(combatants.length);
} else {
expect(activeIndex).toBe(0);
}
}
});
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[encounter.combatants.length - 1]).toEqual({
id: combatantId("C"),
name: "C",
});
// Existing combatants preserve order
expect(encounter.combatants[0]).toEqual(A);
expect(encounter.combatants[1]).toEqual(B);
});
});
});