Implement the 004-edit-combatant feature that adds the possibility to change a combatants name

This commit is contained in:
Lukas
2026-03-04 10:05:13 +01:00
parent aed234de7b
commit a9df826fef
16 changed files with 854 additions and 5 deletions

View File

@@ -0,0 +1,24 @@
import {
type CombatantId,
type DomainError,
type DomainEvent,
editCombatant,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function editCombatantUseCase(
store: EncounterStore,
id: CombatantId,
newName: string,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = editCombatant(encounter, id, newName);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -1,4 +1,5 @@
export { addCombatantUseCase } from "./add-combatant-use-case.js";
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
export type { EncounterStore } from "./ports.js";
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";

View File

@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import { editCombatant } from "../edit-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 Alice = makeCombatant("Alice");
const Bob = makeCombatant("Bob");
function enc(
combatants: Combatant[],
activeIndex = 0,
roundNumber = 1,
): Encounter {
return { combatants, activeIndex, roundNumber };
}
function successResult(encounter: Encounter, id: string, newName: string) {
const result = editCombatant(encounter, combatantId(id), newName);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
// --- Acceptance Scenarios (T004) ---
describe("editCombatant", () => {
describe("acceptance scenarios", () => {
it("scenario 1: rename succeeds with correct event containing combatantId, oldName, newName", () => {
const e = enc([Alice, Bob]);
const { encounter, events } = successResult(e, "Bob", "Robert");
expect(encounter.combatants[1]).toEqual({
id: combatantId("Bob"),
name: "Robert",
});
expect(events).toEqual([
{
type: "CombatantUpdated",
combatantId: combatantId("Bob"),
oldName: "Bob",
newName: "Robert",
},
]);
});
it("scenario 2: activeIndex and roundNumber preserved when renaming the active combatant", () => {
const e = enc([Alice, Bob], 1, 3);
const { encounter } = successResult(e, "Bob", "Robert");
expect(encounter.activeIndex).toBe(1);
expect(encounter.roundNumber).toBe(3);
expect(encounter.combatants[1].name).toBe("Robert");
});
it("scenario 3: combatant list order preserved", () => {
const Cael = makeCombatant("Cael");
const e = enc([Alice, Bob, Cael]);
const { encounter } = successResult(e, "Bob", "Robert");
expect(encounter.combatants.map((c) => c.name)).toEqual([
"Alice",
"Robert",
"Cael",
]);
});
it("scenario 4: renaming to same name still emits event", () => {
const e = enc([Alice, Bob]);
const { encounter, events } = successResult(e, "Bob", "Bob");
expect(encounter.combatants[1].name).toBe("Bob");
expect(events).toHaveLength(1);
expect(events[0]).toEqual({
type: "CombatantUpdated",
combatantId: combatantId("Bob"),
oldName: "Bob",
newName: "Bob",
});
});
});
// --- Invariant Tests (T005) ---
describe("invariants", () => {
it("INV-1: determinism — same inputs produce same outputs", () => {
const e = enc([Alice, Bob], 1, 3);
const result1 = editCombatant(e, combatantId("Alice"), "Aria");
const result2 = editCombatant(e, combatantId("Alice"), "Aria");
expect(result1).toEqual(result2);
});
it("INV-2: exactly one event emitted on success", () => {
const e = enc([Alice, Bob]);
const { events } = successResult(e, "Alice", "Aria");
expect(events).toHaveLength(1);
expect(events[0].type).toBe("CombatantUpdated");
});
it("INV-3: original encounter is not mutated", () => {
const e = enc([Alice, Bob], 0, 1);
const originalCombatants = [...e.combatants];
const originalActiveIndex = e.activeIndex;
const originalRoundNumber = e.roundNumber;
successResult(e, "Alice", "Aria");
expect(e.combatants).toEqual(originalCombatants);
expect(e.activeIndex).toBe(originalActiveIndex);
expect(e.roundNumber).toBe(originalRoundNumber);
});
});
// --- Error Scenarios (T011) ---
describe("error scenarios", () => {
it("non-existent id returns combatant-not-found error", () => {
const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("nonexistent"), "NewName");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
it("empty name returns invalid-name error", () => {
const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("Alice"), "");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
it("whitespace-only name returns invalid-name error", () => {
const e = enc([Alice, Bob]);
const result = editCombatant(e, combatantId("Alice"), " ");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-name");
}
});
it("empty encounter returns combatant-not-found for any id", () => {
const e = enc([]);
const result = editCombatant(e, combatantId("any"), "Name");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
});
});

View File

@@ -0,0 +1,62 @@
import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js";
export interface EditCombatantSuccess {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
/**
* Pure function that renames a combatant in an encounter by ID.
*
* FR-001: Accepts Encounter, CombatantId, and newName; returns next state + events.
* FR-002: Emits a CombatantUpdated event with combatantId, oldName, newName.
* FR-004: Rejects empty/whitespace-only names with DomainError.
* FR-005: Preserves activeIndex and roundNumber.
* FR-006: Preserves combatant list order.
*/
export function editCombatant(
encounter: Encounter,
id: CombatantId,
newName: string,
): EditCombatantSuccess | DomainError {
const trimmed = newName.trim();
if (trimmed === "") {
return {
kind: "domain-error",
code: "invalid-name",
message: "Combatant name must not be empty",
};
}
const index = encounter.combatants.findIndex((c) => c.id === id);
if (index === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${id}"`,
};
}
const oldName = encounter.combatants[index].name;
return {
encounter: {
combatants: encounter.combatants.map((c) =>
c.id === id ? { ...c, name: trimmed } : c,
),
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
},
events: [
{
type: "CombatantUpdated",
combatantId: id,
oldName,
newName: trimmed,
},
],
};
}

View File

@@ -25,8 +25,16 @@ export interface CombatantRemoved {
readonly name: string;
}
export interface CombatantUpdated {
readonly type: "CombatantUpdated";
readonly combatantId: CombatantId;
readonly oldName: string;
readonly newName: string;
}
export type DomainEvent =
| TurnAdvanced
| RoundAdvanced
| CombatantAdded
| CombatantRemoved;
| CombatantRemoved
| CombatantUpdated;

View File

@@ -1,8 +1,13 @@
export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js";
export { advanceTurn } from "./advance-turn.js";
export {
type EditCombatantSuccess,
editCombatant,
} from "./edit-combatant.js";
export type {
CombatantAdded,
CombatantRemoved,
CombatantUpdated,
DomainEvent,
RoundAdvanced,
TurnAdvanced,