T007–T011: implement AdvanceTurn domain logic (pure function, events, invariants, 8 acceptance tests)
This commit is contained in:
233
packages/domain/src/__tests__/advance-turn.test.ts
Normal file
233
packages/domain/src/__tests__/advance-turn.test.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { advanceTurn, isDomainError } from "../advance-turn.js";
|
||||||
|
import type { DomainEvent } from "../events.js";
|
||||||
|
import {
|
||||||
|
type Combatant,
|
||||||
|
combatantId,
|
||||||
|
createEncounter,
|
||||||
|
type Encounter,
|
||||||
|
} 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 encounter(
|
||||||
|
combatants: Combatant[],
|
||||||
|
activeIndex: number,
|
||||||
|
roundNumber: number,
|
||||||
|
): Encounter {
|
||||||
|
const result = createEncounter(combatants, activeIndex, roundNumber);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Test setup failed: ${result.message}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function successResult(enc: Encounter) {
|
||||||
|
const result = advanceTurn(enc);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Acceptance Scenarios ---
|
||||||
|
|
||||||
|
describe("advanceTurn", () => {
|
||||||
|
describe("acceptance scenarios", () => {
|
||||||
|
it("scenario 1: advances from first to second combatant", () => {
|
||||||
|
const enc = encounter([A, B, C], 0, 1);
|
||||||
|
const { encounter: next, events } = successResult(enc);
|
||||||
|
|
||||||
|
expect(next.activeIndex).toBe(1);
|
||||||
|
expect(next.roundNumber).toBe(1);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "TurnAdvanced",
|
||||||
|
previousCombatantId: combatantId("A"),
|
||||||
|
newCombatantId: combatantId("B"),
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 2: advances from second to third combatant", () => {
|
||||||
|
const enc = encounter([A, B, C], 1, 1);
|
||||||
|
const { encounter: next, events } = successResult(enc);
|
||||||
|
|
||||||
|
expect(next.activeIndex).toBe(2);
|
||||||
|
expect(next.roundNumber).toBe(1);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "TurnAdvanced",
|
||||||
|
previousCombatantId: combatantId("B"),
|
||||||
|
newCombatantId: combatantId("C"),
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 3: wraps from last combatant to first, increments round", () => {
|
||||||
|
const enc = encounter([A, B, C], 2, 1);
|
||||||
|
const { encounter: next, events } = successResult(enc);
|
||||||
|
|
||||||
|
expect(next.activeIndex).toBe(0);
|
||||||
|
expect(next.roundNumber).toBe(2);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "TurnAdvanced",
|
||||||
|
previousCombatantId: combatantId("C"),
|
||||||
|
newCombatantId: combatantId("A"),
|
||||||
|
roundNumber: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "RoundAdvanced",
|
||||||
|
newRoundNumber: 2,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 4: wraps at round 5 to round 6 (not hardcoded)", () => {
|
||||||
|
const enc = encounter([A, B, C], 2, 5);
|
||||||
|
const { encounter: next, events } = successResult(enc);
|
||||||
|
|
||||||
|
expect(next.activeIndex).toBe(0);
|
||||||
|
expect(next.roundNumber).toBe(6);
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
type: "TurnAdvanced",
|
||||||
|
roundNumber: 6,
|
||||||
|
});
|
||||||
|
expect(events[1]).toEqual({
|
||||||
|
type: "RoundAdvanced",
|
||||||
|
newRoundNumber: 6,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 5: single combatant always wraps", () => {
|
||||||
|
const enc = encounter([A], 0, 1);
|
||||||
|
const { encounter: next, events } = successResult(enc);
|
||||||
|
|
||||||
|
expect(next.activeIndex).toBe(0);
|
||||||
|
expect(next.roundNumber).toBe(2);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "TurnAdvanced",
|
||||||
|
previousCombatantId: combatantId("A"),
|
||||||
|
newCombatantId: combatantId("A"),
|
||||||
|
roundNumber: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "RoundAdvanced",
|
||||||
|
newRoundNumber: 2,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 6: two advances on a 2-combatant encounter completes a round", () => {
|
||||||
|
const enc = encounter([A, B], 0, 1);
|
||||||
|
|
||||||
|
const first = successResult(enc);
|
||||||
|
expect(first.encounter.activeIndex).toBe(1);
|
||||||
|
expect(first.encounter.roundNumber).toBe(1);
|
||||||
|
|
||||||
|
const second = successResult(first.encounter);
|
||||||
|
expect(second.encounter.activeIndex).toBe(0);
|
||||||
|
expect(second.encounter.roundNumber).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 7: empty combatant list returns error", () => {
|
||||||
|
const enc: Encounter = {
|
||||||
|
combatants: [],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const result = advanceTurn(enc);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("invalid-encounter");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 8: three advances on [A,B,C] completes a full round cycle", () => {
|
||||||
|
let enc = encounter([A, B, C], 0, 1);
|
||||||
|
|
||||||
|
enc = successResult(enc).encounter;
|
||||||
|
enc = successResult(enc).encounter;
|
||||||
|
enc = successResult(enc).encounter;
|
||||||
|
|
||||||
|
expect(enc.activeIndex).toBe(0);
|
||||||
|
expect(enc.roundNumber).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invariants", () => {
|
||||||
|
it("INV-1: createEncounter rejects empty combatant list", () => {
|
||||||
|
const result = createEncounter([]);
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("INV-2: activeIndex always in bounds across all scenarios", () => {
|
||||||
|
const combatants = [A, B, C];
|
||||||
|
let enc = encounter(combatants, 0, 1);
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const result = successResult(enc);
|
||||||
|
expect(result.encounter.activeIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(result.encounter.activeIndex).toBeLessThan(combatants.length);
|
||||||
|
enc = result.encounter;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("INV-3: roundNumber never decreases", () => {
|
||||||
|
let enc = encounter([A, B, C], 0, 1);
|
||||||
|
let prevRound = enc.roundNumber;
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const result = successResult(enc);
|
||||||
|
expect(result.encounter.roundNumber).toBeGreaterThanOrEqual(prevRound);
|
||||||
|
prevRound = result.encounter.roundNumber;
|
||||||
|
enc = result.encounter;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("INV-4: determinism — same input produces same output", () => {
|
||||||
|
const enc = encounter([A, B, C], 1, 3);
|
||||||
|
const result1 = advanceTurn(enc);
|
||||||
|
const result2 = advanceTurn(enc);
|
||||||
|
expect(result1).toEqual(result2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("INV-5: every success emits at least TurnAdvanced", () => {
|
||||||
|
const scenarios: Encounter[] = [
|
||||||
|
encounter([A, B, C], 0, 1),
|
||||||
|
encounter([A, B, C], 2, 1),
|
||||||
|
encounter([A], 0, 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const enc of scenarios) {
|
||||||
|
const result = successResult(enc);
|
||||||
|
const hasTurnAdvanced = result.events.some(
|
||||||
|
(e: DomainEvent) => e.type === "TurnAdvanced",
|
||||||
|
);
|
||||||
|
expect(hasTurnAdvanced).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("event ordering: on wrap, events are [TurnAdvanced, RoundAdvanced]", () => {
|
||||||
|
const enc = encounter([A, B, C], 2, 1);
|
||||||
|
const { events } = successResult(enc);
|
||||||
|
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
expect(events[0].type).toBe("TurnAdvanced");
|
||||||
|
expect(events[1].type).toBe("RoundAdvanced");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
65
packages/domain/src/advance-turn.ts
Normal file
65
packages/domain/src/advance-turn.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { DomainEvent } from "./events.js";
|
||||||
|
import type { DomainError, Encounter } from "./types.js";
|
||||||
|
import { isDomainError } from "./types.js";
|
||||||
|
|
||||||
|
interface AdvanceTurnSuccess {
|
||||||
|
readonly encounter: Encounter;
|
||||||
|
readonly events: DomainEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function that advances the turn to the next combatant.
|
||||||
|
*
|
||||||
|
* FR-001: Accepts an Encounter and returns next state + events.
|
||||||
|
* FR-002: Increments activeIndex by 1, wrapping to 0.
|
||||||
|
* FR-003: When wrapping, increments roundNumber by 1.
|
||||||
|
* FR-004: Empty encounter returns error (no state change, no events).
|
||||||
|
* FR-005: Events returned as values, not dispatched via side effects.
|
||||||
|
*/
|
||||||
|
export function advanceTurn(
|
||||||
|
encounter: Encounter,
|
||||||
|
): AdvanceTurnSuccess | DomainError {
|
||||||
|
// FR-004 / INV-1: reject empty encounters
|
||||||
|
if (encounter.combatants.length === 0) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-encounter",
|
||||||
|
message: "Cannot advance turn on an encounter with no combatants",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousIndex = encounter.activeIndex;
|
||||||
|
const nextIndex = (previousIndex + 1) % encounter.combatants.length;
|
||||||
|
const wraps = nextIndex === 0;
|
||||||
|
const newRoundNumber = wraps
|
||||||
|
? encounter.roundNumber + 1
|
||||||
|
: encounter.roundNumber;
|
||||||
|
|
||||||
|
const events: DomainEvent[] = [
|
||||||
|
{
|
||||||
|
type: "TurnAdvanced",
|
||||||
|
previousCombatantId: encounter.combatants[previousIndex].id,
|
||||||
|
newCombatantId: encounter.combatants[nextIndex].id,
|
||||||
|
roundNumber: newRoundNumber,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Event ordering contract: TurnAdvanced first, then RoundAdvanced
|
||||||
|
if (wraps) {
|
||||||
|
events.push({
|
||||||
|
type: "RoundAdvanced",
|
||||||
|
newRoundNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounter: {
|
||||||
|
combatants: encounter.combatants,
|
||||||
|
activeIndex: nextIndex,
|
||||||
|
roundNumber: newRoundNumber,
|
||||||
|
},
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isDomainError };
|
||||||
15
packages/domain/src/events.ts
Normal file
15
packages/domain/src/events.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { CombatantId } from "./types.js";
|
||||||
|
|
||||||
|
export interface TurnAdvanced {
|
||||||
|
readonly type: "TurnAdvanced";
|
||||||
|
readonly previousCombatantId: CombatantId;
|
||||||
|
readonly newCombatantId: CombatantId;
|
||||||
|
readonly roundNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundAdvanced {
|
||||||
|
readonly type: "RoundAdvanced";
|
||||||
|
readonly newRoundNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DomainEvent = TurnAdvanced | RoundAdvanced;
|
||||||
@@ -1 +1,16 @@
|
|||||||
export {};
|
export { advanceTurn } from "./advance-turn.js";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
DomainEvent,
|
||||||
|
RoundAdvanced,
|
||||||
|
TurnAdvanced,
|
||||||
|
} from "./events.js";
|
||||||
|
export {
|
||||||
|
type Combatant,
|
||||||
|
type CombatantId,
|
||||||
|
combatantId,
|
||||||
|
createEncounter,
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
isDomainError,
|
||||||
|
} from "./types.js";
|
||||||
|
|||||||
68
packages/domain/src/types.ts
Normal file
68
packages/domain/src/types.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/** Branded string type for combatant identity. */
|
||||||
|
export type CombatantId = string & { readonly __brand: "CombatantId" };
|
||||||
|
|
||||||
|
export function combatantId(id: string): CombatantId {
|
||||||
|
return id as CombatantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Combatant {
|
||||||
|
readonly id: CombatantId;
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Encounter {
|
||||||
|
readonly combatants: readonly Combatant[];
|
||||||
|
readonly activeIndex: number;
|
||||||
|
readonly roundNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainError {
|
||||||
|
readonly kind: "domain-error";
|
||||||
|
readonly code: string;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainError(code: string, message: string): DomainError {
|
||||||
|
return { kind: "domain-error", code, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a valid Encounter, enforcing INV-1, INV-2, INV-3.
|
||||||
|
* - INV-1: At least one combatant required.
|
||||||
|
* - INV-2: activeIndex defaults to 0 (always in bounds).
|
||||||
|
* - INV-3: roundNumber defaults to 1 (positive integer).
|
||||||
|
*/
|
||||||
|
export function createEncounter(
|
||||||
|
combatants: readonly Combatant[],
|
||||||
|
activeIndex = 0,
|
||||||
|
roundNumber = 1,
|
||||||
|
): Encounter | DomainError {
|
||||||
|
if (combatants.length === 0) {
|
||||||
|
return domainError(
|
||||||
|
"invalid-encounter",
|
||||||
|
"An encounter must have at least one combatant",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (activeIndex < 0 || activeIndex >= combatants.length) {
|
||||||
|
return domainError(
|
||||||
|
"invalid-encounter",
|
||||||
|
`activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (roundNumber < 1 || !Number.isInteger(roundNumber)) {
|
||||||
|
return domainError(
|
||||||
|
"invalid-encounter",
|
||||||
|
`roundNumber must be a positive integer, got ${roundNumber}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { combatants, activeIndex, roundNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDomainError(value: unknown): value is DomainError {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"kind" in value &&
|
||||||
|
(value as DomainError).kind === "domain-error"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
|
|
||||||
**Independent Test**: Pure state transition — given an Encounter value and AdvanceTurn action, assert resulting Encounter and emitted domain events. No I/O, persistence, or UI needed.
|
**Independent Test**: Pure state transition — given an Encounter value and AdvanceTurn action, assert resulting Encounter and emitted domain events. No I/O, persistence, or UI needed.
|
||||||
|
|
||||||
- [ ] T007 [US1] Define domain types in `packages/domain/src/types.ts` — `CombatantId` (branded/opaque), `Combatant`, `Encounter` (combatants, activeIndex, roundNumber), factory `createEncounter` enforcing INV-1, INV-2, INV-3
|
- [X] T007 [US1] Define domain types in `packages/domain/src/types.ts` — `CombatantId` (branded/opaque), `Combatant`, `Encounter` (combatants, activeIndex, roundNumber), factory `createEncounter` enforcing INV-1, INV-2, INV-3
|
||||||
- [ ] T008 [P] [US1] Define domain events in `packages/domain/src/events.ts` — `TurnAdvanced`, `RoundAdvanced`, `DomainEvent` union (plain data, no classes)
|
- [X] T008 [P] [US1] Define domain events in `packages/domain/src/events.ts` — `TurnAdvanced`, `RoundAdvanced`, `DomainEvent` union (plain data, no classes)
|
||||||
- [ ] T009 [US1] Implement `advanceTurn` in `packages/domain/src/advance-turn.ts` — pure function `(Encounter) => { encounter, events } | DomainError`, implements FR-001 through FR-005
|
- [X] T009 [US1] Implement `advanceTurn` in `packages/domain/src/advance-turn.ts` — pure function `(Encounter) => { encounter, events } | DomainError`, implements FR-001 through FR-005
|
||||||
- [ ] T010 [US1] Write tests for all 8 acceptance scenarios + invariants in `packages/domain/src/__tests__/advance-turn.test.ts` — scenarios 1–8, INV-1 through INV-5, event ordering on round wrap
|
- [X] T010 [US1] Write tests for all 8 acceptance scenarios + invariants in `packages/domain/src/__tests__/advance-turn.test.ts` — scenarios 1–8, INV-1 through INV-5, event ordering on round wrap
|
||||||
- [ ] T011 [US1] Export public API from `packages/domain/src/index.ts` — re-export types, events, `advanceTurn`, `createEncounter`
|
- [X] T011 [US1] Export public API from `packages/domain/src/index.ts` — re-export types, events, `advanceTurn`, `createEncounter`
|
||||||
|
|
||||||
**Checkpoint (Milestone 1)**: `pnpm check` passes (format + lint + typecheck + test + layer boundaries). All 8 scenarios + invariants green. No React/Vite imports in domain or application.
|
**Checkpoint (Milestone 1)**: `pnpm check` passes (format + lint + typecheck + test + layer boundaries). All 8 scenarios + invariants green. No React/Vite imports in domain or application.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user