Implement the 004-edit-combatant feature that adds the possibility to change a combatants name
This commit is contained in:
24
packages/application/src/edit-combatant-use-case.ts
Normal file
24
packages/application/src/edit-combatant-use-case.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
163
packages/domain/src/__tests__/edit-combatant.test.ts
Normal file
163
packages/domain/src/__tests__/edit-combatant.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
62
packages/domain/src/edit-combatant.ts
Normal file
62
packages/domain/src/edit-combatant.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user