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>
180 lines
4.8 KiB
TypeScript
180 lines
4.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { DomainEvent } from "../events.js";
|
|
import { retreatTurn } from "../retreat-turn.js";
|
|
import {
|
|
type Combatant,
|
|
combatantId,
|
|
createEncounter,
|
|
type Encounter,
|
|
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 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 = retreatTurn(enc);
|
|
if (isDomainError(result)) {
|
|
throw new Error(`Expected success, got error: ${result.message}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// --- Acceptance Scenarios ---
|
|
|
|
describe("retreatTurn", () => {
|
|
describe("acceptance scenarios", () => {
|
|
it("scenario 1: mid-round retreat — retreats from second to first combatant", () => {
|
|
const enc = encounter([A, B, C], 1, 1);
|
|
const { encounter: next, events } = successResult(enc);
|
|
|
|
expect(next.activeIndex).toBe(0);
|
|
expect(next.roundNumber).toBe(1);
|
|
expect(events).toEqual([
|
|
{
|
|
type: "TurnRetreated",
|
|
previousCombatantId: combatantId("B"),
|
|
newCombatantId: combatantId("A"),
|
|
roundNumber: 1,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("scenario 2: round-boundary retreat — wraps from first combatant to last, decrements round", () => {
|
|
const enc = encounter([A, B, C], 0, 2);
|
|
const { encounter: next, events } = successResult(enc);
|
|
|
|
expect(next.activeIndex).toBe(2);
|
|
expect(next.roundNumber).toBe(1);
|
|
expect(events).toEqual([
|
|
{
|
|
type: "TurnRetreated",
|
|
previousCombatantId: combatantId("A"),
|
|
newCombatantId: combatantId("C"),
|
|
roundNumber: 1,
|
|
},
|
|
{
|
|
type: "RoundRetreated",
|
|
newRoundNumber: 1,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("scenario 3: start-of-encounter error — cannot retreat at round 1 index 0", () => {
|
|
const enc = encounter([A, B, C], 0, 1);
|
|
const result = retreatTurn(enc);
|
|
|
|
expectDomainError(result, "no-previous-turn");
|
|
});
|
|
|
|
it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => {
|
|
const enc = encounter([A], 0, 2);
|
|
const { encounter: next, events } = successResult(enc);
|
|
|
|
expect(next.activeIndex).toBe(0);
|
|
expect(next.roundNumber).toBe(1);
|
|
expect(events).toEqual([
|
|
{
|
|
type: "TurnRetreated",
|
|
previousCombatantId: combatantId("A"),
|
|
newCombatantId: combatantId("A"),
|
|
roundNumber: 1,
|
|
},
|
|
{
|
|
type: "RoundRetreated",
|
|
newRoundNumber: 1,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("scenario 5: empty-encounter error", () => {
|
|
const enc: Encounter = {
|
|
combatants: [],
|
|
activeIndex: 0,
|
|
roundNumber: 1,
|
|
};
|
|
const result = retreatTurn(enc);
|
|
|
|
expectDomainError(result, "invalid-encounter");
|
|
});
|
|
});
|
|
|
|
describe("invariants", () => {
|
|
it("determinism — same input produces same output", () => {
|
|
const enc = encounter([A, B, C], 1, 3);
|
|
const result1 = retreatTurn(enc);
|
|
const result2 = retreatTurn(enc);
|
|
expect(result1).toEqual(result2);
|
|
});
|
|
|
|
it("activeIndex always in bounds after retreat", () => {
|
|
const combatants = [A, B, C];
|
|
// Start at round 4 so we can retreat many times
|
|
let enc = encounter(combatants, 2, 4);
|
|
|
|
for (let i = 0; i < 9; i++) {
|
|
const result = successResult(enc);
|
|
expect(result.encounter.activeIndex).toBeGreaterThanOrEqual(0);
|
|
expect(result.encounter.activeIndex).toBeLessThan(combatants.length);
|
|
enc = result.encounter;
|
|
}
|
|
});
|
|
|
|
it("roundNumber never goes below 1", () => {
|
|
let enc = encounter([A, B, C], 2, 2);
|
|
|
|
// Retreat through rounds — should stop at round 1 index 0
|
|
while (!(enc.roundNumber === 1 && enc.activeIndex === 0)) {
|
|
const result = successResult(enc);
|
|
expect(result.encounter.roundNumber).toBeGreaterThanOrEqual(1);
|
|
enc = result.encounter;
|
|
}
|
|
});
|
|
|
|
it("every success emits at least TurnRetreated", () => {
|
|
const scenarios: Encounter[] = [
|
|
encounter([A, B, C], 1, 1),
|
|
encounter([A, B, C], 0, 2),
|
|
encounter([A], 0, 2),
|
|
];
|
|
|
|
for (const enc of scenarios) {
|
|
const result = successResult(enc);
|
|
const hasTurnRetreated = result.events.some(
|
|
(e: DomainEvent) => e.type === "TurnRetreated",
|
|
);
|
|
expect(hasTurnRetreated).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("event ordering: on wrap, events are [TurnRetreated, RoundRetreated]", () => {
|
|
const enc = encounter([A, B, C], 0, 2);
|
|
const { events } = successResult(enc);
|
|
|
|
expect(events).toHaveLength(2);
|
|
expect(events[0].type).toBe("TurnRetreated");
|
|
expect(events[1].type).toBe("RoundRetreated");
|
|
});
|
|
});
|
|
});
|