Files
initiative/packages/domain/src/__tests__/undo-redo.test.ts
Lukas 17cc6ed72c Add undo/redo for all encounter actions
Memento-based undo/redo with full encounter snapshots. Undo stack
capped at 50 entries, persisted to localStorage. Triggered via
buttons in the top bar (inboard of turn navigation) and keyboard
shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac, case-insensitive key
matching). Clear encounter resets both stacks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:30:33 +01:00

125 lines
4.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { Encounter } from "../types.js";
import { isDomainError } from "../types.js";
import {
clearHistory,
EMPTY_UNDO_REDO_STATE,
pushUndo,
redo,
undo,
} from "../undo-redo.js";
function enc(roundNumber = 1, activeIndex = 0): Encounter {
return { combatants: [], activeIndex, roundNumber };
}
describe("pushUndo", () => {
it("adds a snapshot to the undo stack", () => {
const result = pushUndo(EMPTY_UNDO_REDO_STATE, enc(1));
expect(result.undoStack).toHaveLength(1);
expect(result.undoStack[0]).toEqual(enc(1));
});
it("clears the redo stack", () => {
const state = {
undoStack: [enc(1)],
redoStack: [enc(2)],
};
const result = pushUndo(state, enc(3));
expect(result.redoStack).toHaveLength(0);
});
it("caps the undo stack at 50, dropping the oldest", () => {
const undoStack = Array.from({ length: 50 }, (_, i) => enc(i + 1));
const state = { undoStack, redoStack: [] };
const result = pushUndo(state, enc(51));
expect(result.undoStack).toHaveLength(50);
expect(result.undoStack[0]).toEqual(enc(2));
expect(result.undoStack[49]).toEqual(enc(51));
});
});
describe("undo", () => {
it("pops from undo stack and pushes current to redo stack", () => {
const state = { undoStack: [enc(1)], redoStack: [] };
const result = undo(state, enc(2));
expect(isDomainError(result)).toBe(false);
if (isDomainError(result)) return;
expect(result.encounter).toEqual(enc(1));
expect(result.state.undoStack).toHaveLength(0);
expect(result.state.redoStack).toEqual([enc(2)]);
});
it("returns domain error when undo stack is empty", () => {
const result = undo(EMPTY_UNDO_REDO_STATE, enc(1));
expect(isDomainError(result)).toBe(true);
if (!isDomainError(result)) return;
expect(result.code).toBe("nothing-to-undo");
});
it("pops the most recent entry (last in stack)", () => {
const state = { undoStack: [enc(1), enc(2), enc(3)], redoStack: [] };
const result = undo(state, enc(4));
expect(isDomainError(result)).toBe(false);
if (isDomainError(result)) return;
expect(result.encounter).toEqual(enc(3));
expect(result.state.undoStack).toEqual([enc(1), enc(2)]);
});
});
describe("redo", () => {
it("pops from redo stack and pushes current to undo stack", () => {
const state = { undoStack: [], redoStack: [enc(1)] };
const result = redo(state, enc(2));
expect(isDomainError(result)).toBe(false);
if (isDomainError(result)) return;
expect(result.encounter).toEqual(enc(1));
expect(result.state.redoStack).toHaveLength(0);
expect(result.state.undoStack).toEqual([enc(2)]);
});
it("returns domain error when redo stack is empty", () => {
const result = redo(EMPTY_UNDO_REDO_STATE, enc(1));
expect(isDomainError(result)).toBe(true);
if (!isDomainError(result)) return;
expect(result.code).toBe("nothing-to-redo");
});
it("pops the most recent entry (last in stack)", () => {
const state = { undoStack: [], redoStack: [enc(1), enc(2), enc(3)] };
const result = redo(state, enc(4));
expect(isDomainError(result)).toBe(false);
if (isDomainError(result)) return;
expect(result.encounter).toEqual(enc(3));
expect(result.state.redoStack).toEqual([enc(1), enc(2)]);
});
});
describe("undo-then-redo roundtrip", () => {
it("returns the exact same encounter after undo then redo", () => {
const original = enc(5);
const current = enc(6);
const afterPush = pushUndo(EMPTY_UNDO_REDO_STATE, original);
const undoResult = undo(afterPush, current);
expect(isDomainError(undoResult)).toBe(false);
if (isDomainError(undoResult)) return;
expect(undoResult.encounter).toEqual(original);
const redoResult = redo(undoResult.state, undoResult.encounter);
expect(isDomainError(redoResult)).toBe(false);
if (isDomainError(redoResult)) return;
expect(redoResult.encounter).toEqual(current);
});
});
describe("clearHistory", () => {
it("empties both stacks", () => {
const result = clearHistory();
expect(result.undoStack).toHaveLength(0);
expect(result.redoStack).toHaveLength(0);
});
});