Files
web-cad/frontend/tests/HistoryManager.test.ts

389 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
});
});
});