Files

389 lines
13 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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
});
});
});