import { expect, it } from "bun:test"; import { StateManager } from ".."; import { HistoryEntries } from "../historyEntries"; it("edits", () => { const sm = new StateManager({ count: 0 }); expect(sm.state.count).toEqual(0); expect(sm.canUndo()).toEqual(false); expect(sm.canRedo()).toEqual(false); sm.edit((draft) => { draft.count++; }); expect(sm.state.count).toEqual(1); expect(sm.canUndo()).toEqual(true); expect(sm.canRedo()).toEqual(false); sm.undo(); expect(sm.state.count).toEqual(0); expect(sm.canUndo()).toEqual(false); expect(sm.canRedo()).toEqual(true); sm.redo(); expect(sm.state.count).toEqual(1); }); it("performs action", () => { const sm = new StateManager({ count: 0 }); sm.performAction({ run: (state) => ({ count: state.count + 1 }), undo: (state) => ({ count: state.count - 1 }), }); expect(sm.state.count).toEqual(1); sm.undo(); expect(sm.state.count).toEqual(0); sm.redo(); expect(sm.state.count).toEqual(1); }); it("gets state", () => { type S = { count: number; foo: { bar: string } }; const s: S = { count: 0, foo: { bar: "baz" } }; const sm = new StateManager(s); expect(sm.getState()).toEqual(s); expect(sm.getState("count")).toEqual(0); expect(sm.getState("foo/bar")).toEqual("baz"); expect(sm.getState("/foo/bar")).toEqual("baz"); }); it("edit with metadata", () => { const sm = new StateManager<{ count: number }, string>({ count: 0 }); sm.edit("increment", (draft) => { draft.count++; }); expect(sm.state.count).toEqual(1); sm.undo(); expect(sm.state.count).toEqual(0); sm.redo(); expect(sm.state.count).toEqual(1); }); it("object metadata", () => { const sm = new StateManager<{ count: number }, { type: string }>({ count: 0, }); sm.edit({ type: "increment" }, (draft) => { draft.count++; }); expect(sm.state.count).toEqual(1); }); it("fires listeners", () => { const sm = new StateManager({ count: 0 }); let calledTimes = 0; sm.addListener(() => { calledTimes++; }); sm.edit((draft) => { draft.count++; }); expect(calledTimes).toEqual(1); expect(sm.state.count).toEqual(1); sm.edit((draft) => { draft.count++; }); expect(calledTimes).toEqual(2); expect(sm.state.count).toEqual(2); // Doesn't fire listener since state doesn't change sm.clearHistory(); expect(calledTimes).toEqual(2); }); it("fires history listeners", () => { const sm = new StateManager({ count: 0 }); let calledTimes = 0; sm.historyEmittor.addListener(() => { calledTimes++; }); sm.edit((draft) => { draft.count++; }); sm.clearHistory(); expect(calledTimes).toEqual(2); }); it("clears history", () => { const sm = new StateManager({ count: 0 }); sm.edit((draft) => { draft.count++; }); expect(sm.canUndo()).toEqual(true); sm.clearHistory(); expect(sm.canUndo()).toEqual(false); }); it("limits history to maxHistoryEntries", () => { const sm = new StateManager({ count: 0 }, { maxHistoryEntries: 2 }); sm.edit((draft) => { draft.count++; }); sm.edit((draft) => { draft.count++; }); sm.edit((draft) => { draft.count++; }); expect(sm.history.length).toEqual(2); expect(sm.historyIndex).toEqual(2); }); it("replaces state", () => { const sm = new StateManager({ count: 0 }); sm.edit((draft) => { draft.count++; }); expect(sm.state.count).toEqual(1); expect(sm.history.length).toEqual(1); sm.replaceState({ count: 10 }); expect(sm.state.count).toEqual(10); expect(sm.history.length).toEqual(0); }); it("rebases", () => { const sm = new StateManager<{ count: number; name?: string }, void>({ count: 0, name: "original", }); sm.edit((draft) => { draft.name = "updated"; }); expect(sm.state).toEqual({ count: 0, name: "updated" }); expect(sm.history.length).toEqual(1); sm.rebase({ count: 10 }); expect(sm.state).toEqual({ count: 10, name: "updated" }); expect(sm.history.length).toEqual(1); // History should still work after rebasing sm.undo(); expect(sm.state).toEqual({ count: 10, name: "original" }); }); it("ignores empty edits", () => { const sm = new StateManager({ count: 0 }); sm.edit(() => {}); expect(sm.history.length).toEqual(0); }); it("keeps empty edits if allowEmptyEdits option is true", () => { const sm = new StateManager({ count: 0 }, { allowEmptyEdits: true }); sm.edit(() => {}); expect(sm.history.length).toEqual(1); }); it("combines edits", () => { const sm = new StateManager<{ count: number }, { timestamp: number }>( { count: 0 }, { mergeHistoryEntries({ previous, next }) { if (previous.metadata.timestamp + 1000 > next.metadata.timestamp) { return HistoryEntries.merge({ previous, next }); } }, } ); sm.edit({ timestamp: 100 }, (draft) => { draft.count++; }); sm.edit({ timestamp: 200 }, (draft) => { draft.count++; }); expect(sm.history.length).toEqual(1); expect(sm.history[0].undoPatches).toEqual([ { op: "replace", path: ["count"], value: 0 }, ]); expect(sm.history[0].redoPatches).toEqual([ { op: "replace", path: ["count"], value: 2 }, ]); expect(sm.state.count).toEqual(2); // Undo should restore original state sm.undo(); expect(sm.state.count).toEqual(0); // Redo should restore combined state sm.redo(); expect(sm.state.count).toEqual(2); // Keep history entries that are too far apart sm.edit({ timestamp: 3000 }, (draft) => { draft.count++; }); expect(sm.history.length).toEqual(2); }); it("goes to history index", () => { const sm = new StateManager({ count: 0 }); sm.edit((draft) => { draft.count++; }); sm.edit((draft) => { draft.count++; }); expect(sm.state.count).toEqual(2); expect(sm.historyIndex).toEqual(2); sm.goToHistoryIndex(0); expect(sm.state.count).toEqual(0); expect(sm.historyIndex).toEqual(0); sm.goToHistoryIndex(1); expect(sm.state.count).toEqual(1); expect(sm.historyIndex).toEqual(1); sm.goToHistoryIndex(0); sm.goToHistoryIndex(2); expect(sm.state.count).toEqual(2); expect(sm.historyIndex).toEqual(2); // Shouldn't go out of bounds sm.goToHistoryIndex(3); expect(sm.historyIndex).toEqual(2); }); it("gets history snapshot", () => { const sm = new StateManager({ count: 0 }); const s1 = sm.getHistorySnapshot(); expect(s1.history.length).toEqual(0); expect(s1.historyIndex).toEqual(0); expect(s1.canUndo).toEqual(false); expect(s1.canRedo).toEqual(false); expect(s1.version).toEqual(0); // Returns the same object if nothing has changed expect(s1).toEqual(sm.getHistorySnapshot()); sm.edit((draft) => { draft.count++; }); const s2 = sm.getHistorySnapshot(); expect(s2.history.length).toEqual(1); expect(s2.historyIndex).toEqual(1); expect(s2.canUndo).toEqual(true); expect(s2.canRedo).toEqual(false); expect(s2.version).toEqual(1); // A different object is returned after an edit expect(s1 === s2).toEqual(false); });