389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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<CADStateSnapshot, 'timestamp' | 'label'> {
|
|||
|
|
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
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|