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>
125 lines
4.0 KiB
TypeScript
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);
|
|
});
|
|
});
|