velxio/frontend/src/__tests__/undo-redo.test.ts

228 lines
8.1 KiB
TypeScript

/**
* Unit tests for the undo/redo history slice in useSimulatorStore.
*
* The store is a singleton, so each test resets it to a known baseline
* via the existing `setComponents([])` / `setWires([])` mutators (which
* also clear history — see beforeEach).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useSimulatorStore } from '../store/useSimulatorStore';
import type { Wire } from '../types/wire';
// Stub out anything that touches a custom-element or a real simulator —
// these tests only exercise the store's data layer.
vi.mock('../utils/pinPositionCalculator', () => ({
// updateWirePositions calls this; returning null = "fall back to raw x/y".
calculatePinPosition: () => null,
}));
const led = (id: string, x = 0, y = 0) => ({
id,
metadataId: 'led',
x,
y,
properties: { color: 'red' },
});
const wire = (id: string, fromId: string, toId: string): Wire => ({
id,
start: { componentId: fromId, pinName: 'A', x: 0, y: 0 },
end: { componentId: toId, pinName: 'C', x: 0, y: 0 },
waypoints: [],
color: '#22c55e',
});
beforeEach(() => {
// Wipe the canvas + history before every test.
const s = useSimulatorStore.getState();
s.setComponents([]);
s.setWires([]);
});
describe('history primitives', () => {
it('starts empty', () => {
const s = useSimulatorStore.getState();
expect(s.history).toEqual([]);
expect(s.historyIndex).toBe(-1);
expect(s.canUndo()).toBe(false);
expect(s.canRedo()).toBe(false);
});
it('caps at HISTORY_MAX (50)', () => {
const s = useSimulatorStore.getState();
for (let i = 0; i < 60; i++) {
s.recordAddComponent(led(`led-${i}`));
}
const after = useSimulatorStore.getState();
expect(after.history.length).toBe(50);
expect(after.historyIndex).toBe(49);
// The 10 oldest commands were dropped — undoing 50 times should NOT
// restore the canvas to fully empty.
for (let i = 0; i < 50; i++) after.undo();
const afterUndo = useSimulatorStore.getState();
// The first 10 leds were never undoable — they stay on the canvas.
expect(afterUndo.components.length).toBe(10);
});
it('truncates the redo branch when a new command pushes mid-history', () => {
const s = useSimulatorStore.getState();
s.recordAddComponent(led('a'));
s.recordAddComponent(led('b'));
s.recordAddComponent(led('c'));
s.undo(); // c removed, history still has 3 entries, index=1
s.undo(); // b removed, index=0
expect(useSimulatorStore.getState().historyIndex).toBe(0);
expect(useSimulatorStore.getState().history.length).toBe(3);
s.recordAddComponent(led('d')); // truncates b/c, pushes d
const after = useSimulatorStore.getState();
expect(after.history.length).toBe(2); // a + d
expect(after.historyIndex).toBe(1);
expect(after.components.map((c) => c.id).sort()).toEqual(['a', 'd']);
});
});
describe('recordAddComponent', () => {
it('adds the component and pushes a command', () => {
const s = useSimulatorStore.getState();
s.recordAddComponent(led('x'));
expect(useSimulatorStore.getState().components).toHaveLength(1);
expect(useSimulatorStore.getState().history).toHaveLength(1);
expect(useSimulatorStore.getState().canUndo()).toBe(true);
});
it('undo removes it; redo restores it', () => {
const s = useSimulatorStore.getState();
s.recordAddComponent(led('x'));
s.undo();
expect(useSimulatorStore.getState().components).toHaveLength(0);
expect(useSimulatorStore.getState().canUndo()).toBe(false);
expect(useSimulatorStore.getState().canRedo()).toBe(true);
s.redo();
expect(useSimulatorStore.getState().components).toHaveLength(1);
});
});
describe('recordRemoveComponent (cascade)', () => {
it('undo restores BOTH the component and its connected wires', () => {
const s = useSimulatorStore.getState();
s.addComponent(led('a'));
s.addComponent(led('b'));
s.addWire(wire('w1', 'a', 'b'));
s.addWire(wire('w2', 'a', 'b'));
// Remove 'a' — cascade should kill both wires.
s.recordRemoveComponent('a');
expect(useSimulatorStore.getState().components.map((c) => c.id)).toEqual(['b']);
expect(useSimulatorStore.getState().wires).toEqual([]);
s.undo();
const after = useSimulatorStore.getState();
expect(after.components.map((c) => c.id).sort()).toEqual(['a', 'b']);
expect(after.wires.map((w) => w.id).sort()).toEqual(['w1', 'w2']);
});
it('no-ops cleanly on a missing id', () => {
const s = useSimulatorStore.getState();
s.recordRemoveComponent('does-not-exist');
expect(useSimulatorStore.getState().history).toHaveLength(0);
});
});
describe('recordMove', () => {
it('captures from/to, undo restores from, redo restores to', () => {
const s = useSimulatorStore.getState();
s.addComponent(led('m', 100, 100));
// Simulate a drag — UI mutated to (200,200) directly via raw mutator.
s.updateComponent('m', { x: 200, y: 200 });
// Drag-end records the diff.
s.recordMove('m', { x: 100, y: 100 }, { x: 200, y: 200 });
expect(useSimulatorStore.getState().components[0]).toMatchObject({ x: 200, y: 200 });
s.undo();
expect(useSimulatorStore.getState().components[0]).toMatchObject({ x: 100, y: 100 });
s.redo();
expect(useSimulatorStore.getState().components[0]).toMatchObject({ x: 200, y: 200 });
});
});
describe('recordRotate', () => {
it('flips rotation property both directions', () => {
const s = useSimulatorStore.getState();
s.addComponent(led('r'));
s.recordRotate('r', 0, 90);
s.updateComponent('r', { properties: { color: 'red', rotation: 90 } });
s.undo();
expect(useSimulatorStore.getState().components[0].properties.rotation).toBe(0);
s.redo();
expect(useSimulatorStore.getState().components[0].properties.rotation).toBe(90);
});
});
describe('recordSetProperty', () => {
it('undo/redo flips a property value', () => {
const s = useSimulatorStore.getState();
s.addComponent(led('p'));
s.updateComponent('p', { properties: { color: 'green' } });
s.recordSetProperty('p', 'color', 'red', 'green');
s.undo();
expect(useSimulatorStore.getState().components[0].properties.color).toBe('red');
s.redo();
expect(useSimulatorStore.getState().components[0].properties.color).toBe('green');
});
});
describe('recordAddWire / recordRemoveWire', () => {
it('add: undo removes, redo restores', () => {
const s = useSimulatorStore.getState();
s.addComponent(led('a'));
s.addComponent(led('b'));
s.recordAddWire(wire('w', 'a', 'b'));
expect(useSimulatorStore.getState().wires).toHaveLength(1);
s.undo();
expect(useSimulatorStore.getState().wires).toHaveLength(0);
s.redo();
expect(useSimulatorStore.getState().wires).toHaveLength(1);
});
it('remove: undo brings back the wire intact', () => {
const s = useSimulatorStore.getState();
s.addComponent(led('a'));
s.addComponent(led('b'));
const w = wire('w', 'a', 'b');
s.addWire(w);
s.recordRemoveWire('w');
expect(useSimulatorStore.getState().wires).toHaveLength(0);
s.undo();
expect(useSimulatorStore.getState().wires[0]).toEqual(w);
});
});
describe('bulk setters clear history', () => {
it('setComponents wipes the stack', () => {
const s = useSimulatorStore.getState();
s.recordAddComponent(led('x'));
s.recordAddComponent(led('y'));
expect(useSimulatorStore.getState().history).toHaveLength(2);
s.setComponents([]);
expect(useSimulatorStore.getState().history).toHaveLength(0);
expect(useSimulatorStore.getState().historyIndex).toBe(-1);
});
it('setWires wipes the stack', () => {
const s = useSimulatorStore.getState();
s.recordAddComponent(led('x'));
s.setWires([]);
expect(useSimulatorStore.getState().history).toHaveLength(0);
});
});
describe('clearHistory', () => {
it('resets index and entries without touching components/wires', () => {
const s = useSimulatorStore.getState();
s.recordAddComponent(led('keep'));
s.clearHistory();
const after = useSimulatorStore.getState();
expect(after.components).toHaveLength(1); // component still there
expect(after.history).toHaveLength(0);
expect(after.canUndo()).toBe(false);
});
});