/** * HistoryManager Tests – Undo/Redo */ import { describe, it, expect, beforeEach } from 'vitest'; import { HistoryManager } from '../src/history/HistoryManager'; import type { CADStateSnapshot } from '../src/history/HistoryManager'; import type { CADElement, CADLayer, BlockDefinition } from '../src/types/cad.types'; import type { ElementGroup } from '../src/tools/modification/GroupTool'; import type { BackgroundConfig } from '../src/services/backgroundService'; function makeSnapshot(label: string, suffix: string = ''): Omit { const elements: CADElement[] = [ { id: `el-${suffix}-1`, type: 'rect', layerId: 'layer-1', x: 0, y: 0, width: 10, height: 10, properties: {} }, { id: `el-${suffix}-2`, type: 'circle', layerId: 'layer-1', x: 50, y: 50, width: 20, height: 20, properties: {} }, ]; const layers: CADLayer[] = [ { id: 'layer-1', name: 'Layer 1', visible: true, locked: false, color: '#ffffff', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null }, ]; const blocks: BlockDefinition[] = []; const groups: ElementGroup[] = []; const bgConfig: BackgroundConfig | null = null; return { elements, layers, blocks, groups, bgConfig }; } describe('HistoryManager', () => { let hm: HistoryManager; beforeEach(() => { hm = new HistoryManager(); }); describe('initialize', () => { it('should set initial state without undo history', () => { const snap = makeSnapshot('Initial'); hm.initialize(snap); expect(hm.getCurrentState()).not.toBeNull(); expect(hm.getCurrentState()?.label).toBe('Initial'); expect(hm.canUndo()).toBe(false); expect(hm.canRedo()).toBe(false); }); it('should reset undo and redo stacks on initialize', () => { const snap = makeSnapshot('Initial'); hm.initialize(snap); hm.pushSnapshot(makeSnapshot('Change 1'), 'Change 1'); hm.pushSnapshot(makeSnapshot('Change 2'), 'Change 2'); // Re-initialize should clear history hm.initialize(snap); expect(hm.canUndo()).toBe(false); expect(hm.canRedo()).toBe(false); expect(hm.getUndoCount()).toBe(0); expect(hm.getRedoCount()).toBe(0); }); }); describe('pushSnapshot', () => { it('should push current state to undo stack and set new state', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); expect(hm.getCurrentState()?.label).toBe('Change 1'); expect(hm.canUndo()).toBe(true); expect(hm.getUndoCount()).toBe(1); }); it('should clear redo stack on new push', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); hm.undo(); // Now redo stack has 1 entry expect(hm.canRedo()).toBe(true); hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); expect(hm.canRedo()).toBe(false); expect(hm.getRedoCount()).toBe(0); }); it('should store multiple snapshots', () => { hm.initialize(makeSnapshot('Initial')); for (let i = 1; i <= 5; i++) { hm.pushSnapshot(makeSnapshot(`Change ${i}`, `v${i}`), `Change ${i}`); } expect(hm.getUndoCount()).toBe(5); expect(hm.getCurrentState()?.label).toBe('Change 5'); }); }); describe('undo', () => { it('should restore previous state', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); const prev = hm.undo(); expect(prev).not.toBeNull(); expect(prev?.label).toBe('Initial'); expect(hm.getCurrentState()?.label).toBe('Initial'); }); it('should return null when no undo available', () => { hm.initialize(makeSnapshot('Initial')); const result = hm.undo(); expect(result).toBeNull(); }); it('should move current state to redo stack', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); hm.undo(); expect(hm.canRedo()).toBe(true); expect(hm.getRedoCount()).toBe(1); }); it('should handle multiple undos', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); hm.pushSnapshot(makeSnapshot('Change 3', 'v3'), 'Change 3'); hm.undo(); expect(hm.getCurrentState()?.label).toBe('Change 2'); hm.undo(); expect(hm.getCurrentState()?.label).toBe('Change 1'); hm.undo(); expect(hm.getCurrentState()?.label).toBe('Initial'); expect(hm.canUndo()).toBe(false); }); }); describe('redo', () => { it('should restore next state after undo', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); hm.undo(); const next = hm.redo(); expect(next).not.toBeNull(); expect(next?.label).toBe('Change 1'); expect(hm.getCurrentState()?.label).toBe('Change 1'); }); it('should return null when no redo available', () => { hm.initialize(makeSnapshot('Initial')); const result = hm.redo(); expect(result).toBeNull(); }); it('should move current state back to undo stack', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); hm.undo(); hm.redo(); expect(hm.canUndo()).toBe(true); expect(hm.getUndoCount()).toBe(1); }); it('should handle multiple redos', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); hm.pushSnapshot(makeSnapshot('Change 3', 'v3'), 'Change 3'); // Undo all 3 hm.undo(); hm.undo(); hm.undo(); expect(hm.getCurrentState()?.label).toBe('Initial'); // Redo all 3 hm.redo(); expect(hm.getCurrentState()?.label).toBe('Change 1'); hm.redo(); expect(hm.getCurrentState()?.label).toBe('Change 2'); hm.redo(); expect(hm.getCurrentState()?.label).toBe('Change 3'); expect(hm.canRedo()).toBe(false); }); }); describe('canUndo & canRedo', () => { it('canUndo should be false initially', () => { hm.initialize(makeSnapshot('Initial')); expect(hm.canUndo()).toBe(false); }); it('canUndo should be true after push', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); expect(hm.canUndo()).toBe(true); }); it('canRedo should be false initially', () => { hm.initialize(makeSnapshot('Initial')); expect(hm.canRedo()).toBe(false); }); it('canRedo should be true after undo', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); hm.undo(); expect(hm.canRedo()).toBe(true); }); }); describe('getHistory', () => { it('should return history entries with current marked', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); const history = hm.getHistory(); expect(history.length).toBe(3); // 2 undo + 1 current const currentEntries = history.filter(e => e.isCurrent); expect(currentEntries.length).toBe(1); expect(currentEntries[0].label).toBe('Change 2'); }); it('should include redo entries after undo', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); hm.undo(); const history = hm.getHistory(); // 0 undo + 1 current (Initial) + 1 redo (Change 1) expect(history.length).toBe(2); const currentEntries = history.filter(e => e.isCurrent); expect(currentEntries.length).toBe(1); expect(currentEntries[0].label).toBe('Initial'); }); it('should return empty-ish history for fresh manager', () => { const history = hm.getHistory(); expect(history.length).toBe(0); }); }); describe('jumpTo', () => { it('should jump to a specific undo entry', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); hm.pushSnapshot(makeSnapshot('Change 3', 'v3'), 'Change 3'); // Jump to undo-0 (Initial state) const result = hm.jumpTo('undo-0'); expect(result).not.toBeNull(); expect(hm.getCurrentState()?.label).toBe('Initial'); }); it('should jump to current', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); const result = hm.jumpTo('current'); expect(result).not.toBeNull(); expect(result?.label).toBe('Change 1'); }); it('should jump to a redo entry', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); hm.undo(); hm.undo(); // Jump to redo-1 (Change 2) const result = hm.jumpTo('redo-1'); expect(result).not.toBeNull(); expect(hm.getCurrentState()?.label).toBe('Change 2'); }); it('should return null for unknown entry id', () => { hm.initialize(makeSnapshot('Initial')); const result = hm.jumpTo('unknown-id'); expect(result).toBeNull(); }); }); describe('clear', () => { it('should clear all history', () => { hm.initialize(makeSnapshot('Initial')); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); hm.clear(); expect(hm.getCurrentState()).toBeNull(); expect(hm.canUndo()).toBe(false); expect(hm.canRedo()).toBe(false); expect(hm.getUndoCount()).toBe(0); expect(hm.getRedoCount()).toBe(0); }); }); describe('subscribe', () => { it('should notify listeners on state change', () => { let callCount = 0; const unsubscribe = hm.subscribe(() => callCount++); hm.initialize(makeSnapshot('Initial')); expect(callCount).toBe(1); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); expect(callCount).toBe(2); hm.undo(); expect(callCount).toBe(3); unsubscribe(); hm.pushSnapshot(makeSnapshot('Change 2', 'v2'), 'Change 2'); expect(callCount).toBe(3); // No new calls after unsubscribe }); it('should notify on clear', () => { let callCount = 0; hm.subscribe(() => callCount++); hm.initialize(makeSnapshot('Initial')); hm.clear(); expect(callCount).toBe(2); // init + clear }); }); describe('maxStackSize', () => { it('should limit undo stack size', () => { const smallHM = new HistoryManager({ maxStackSize: 3 }); smallHM.initialize(makeSnapshot('Initial')); for (let i = 1; i <= 10; i++) { smallHM.pushSnapshot(makeSnapshot(`Change ${i}`, `v${i}`), `Change ${i}`); } expect(smallHM.getUndoCount()).toBe(3); }); it('should use default max size of 100', () => { const defaultHM = new HistoryManager(); defaultHM.initialize(makeSnapshot('Initial')); for (let i = 1; i <= 150; i++) { defaultHM.pushSnapshot(makeSnapshot(`Change ${i}`, `v${i}`), `Change ${i}`); } expect(defaultHM.getUndoCount()).toBe(100); }); }); describe('getUndoCount & getRedoCount', () => { it('should track counts correctly', () => { hm.initialize(makeSnapshot('Initial')); expect(hm.getUndoCount()).toBe(0); expect(hm.getRedoCount()).toBe(0); hm.pushSnapshot(makeSnapshot('Change 1', 'v1'), 'Change 1'); expect(hm.getUndoCount()).toBe(1); expect(hm.getRedoCount()).toBe(0); hm.undo(); expect(hm.getUndoCount()).toBe(0); expect(hm.getRedoCount()).toBe(1); hm.redo(); expect(hm.getUndoCount()).toBe(1); expect(hm.getRedoCount()).toBe(0); }); }); describe('snapshot data integrity', () => { it('should preserve elements in snapshots', () => { const snap = makeSnapshot('Test'); hm.initialize(snap); const current = hm.getCurrentState(); expect(current?.elements.length).toBe(2); expect(current?.elements[0].id).toBe('el--1'); }); it('should preserve different element sets across snapshots', () => { hm.initialize(makeSnapshot('Initial')); const snap1 = makeSnapshot('Change 1', 'v1'); hm.pushSnapshot(snap1, 'Change 1'); hm.undo(); const afterUndo = hm.getCurrentState(); expect(afterUndo?.elements[0].id).toBe('el--1'); // Initial state hm.redo(); const afterRedo = hm.getCurrentState(); expect(afterRedo?.elements[0].id).toBe('el-v1-1'); // Change 1 state }); }); });