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>
This commit is contained in:
124
packages/domain/src/__tests__/undo-redo.test.ts
Normal file
124
packages/domain/src/__tests__/undo-redo.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user