diff --git a/frontend/src/__tests__/undo-redo.test.ts b/frontend/src/__tests__/undo-redo.test.ts new file mode 100644 index 00000000..bf9b2d3c --- /dev/null +++ b/frontend/src/__tests__/undo-redo.test.ts @@ -0,0 +1,227 @@ +/** + * 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); + }); +}); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index d0a76843..8e85489a 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -335,6 +335,32 @@ interface Component { properties: Record; } +// ── Undo/redo history ──────────────────────────────────────────────────── +/** + * One entry on the canvas undo/redo stack. + * + * description — human-readable label shown as the undo/redo button + * tooltip ("Undo: Move LED"). + * execute() — applied on redo. Should be idempotent against the + * current state at redo time (the user may have undone + * several steps then started a new branch). + * undo() — reverts the change. Same idempotency contract. + * + * Commands that capture the inverse on construction (e.g. `recordMove` + * captures fromX/fromY) are pushed with `applyNow:false` because the + * mutation already happened — the command only needs to remember how to + * undo/redo it later. Commands that ARE the canonical mutation (e.g. + * `recordAddComponent`) are pushed with `applyNow:true` so a single call + * both performs the action and stores the undo path. + */ +export interface CanvasCommand { + description: string; + execute(): void; + undo(): void; +} + +const HISTORY_MAX = 50; + // ── Store interface ─────────────────────────────────────────────────────── interface SimulatorState { // ── Multi-board state ─────────────────────────────────────────────────── @@ -436,6 +462,44 @@ interface SimulatorState { updateWirePositions: (componentId: string) => void; recalculateAllWirePositions: () => void; + // ── Undo/redo ──────────────────────────────────────────────────────────── + /** Bounded ring buffer of canvas mutations (HISTORY_MAX = 50). */ + history: CanvasCommand[]; + /** Index of the last APPLIED command. -1 = empty / fully undone. */ + historyIndex: number; + /** Push a command and (by default) execute it. Truncates the redo stack. */ + pushCommand: (cmd: CanvasCommand, opts?: { applyNow?: boolean }) => void; + undo: () => void; + redo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; + /** Wipe the stack (called on project load / clear). */ + clearHistory: () => void; + /** + * Recorded canvas actions — these are the public API the UI and agent + * tools should use to mutate the canvas. Each one wraps a raw mutator + * with a CanvasCommand so the change is undoable. Drag-preview frames + * still use the raw mutators (addComponent / updateComponent / addWire + * / removeWire / updateWire) which DO NOT touch history. + */ + recordAddComponent: (component: Component) => void; + recordRemoveComponent: (id: string) => void; + recordMove: ( + id: string, + from: { x: number; y: number }, + to: { x: number; y: number }, + ) => void; + recordRotate: (id: string, prevRotation: number, nextRotation: number) => void; + recordSetProperty: (id: string, key: string, prevValue: unknown, nextValue: unknown) => void; + recordAddWire: (wire: Wire) => void; + recordRemoveWire: (wireId: string) => void; + recordUpdateWire: ( + wireId: string, + prev: Partial, + next: Partial, + description?: string, + ) => void; + // ── Serial monitor ────────────────────────────────────────────────────── toggleSerialMonitor: () => void; serialWrite: (text: string) => void; @@ -1503,7 +1567,11 @@ export const useSimulatorStore = create((set, get) => { handleComponentEvent: (_componentId, _eventName, _data) => {}, - setComponents: (components) => set({ components }), + setComponents: (components) => { + // Bulk replacement (project load / clear) — any pending undo/redo + // would point at component IDs that no longer exist after this. + set({ components, history: [], historyIndex: -1 }); + }, addWire: (wire) => set((state) => ({ wires: [...state.wires, wire] })), @@ -1524,6 +1592,9 @@ export const useSimulatorStore = create((set, get) => { set({ // Ensure every wire has waypoints (backwards-compatible with saved projects) wires: wires.map((w) => ({ waypoints: [], ...w })), + // Bulk replacement clears history for the same reason as setComponents. + history: [], + historyIndex: -1, }), startWireCreation: (endpoint, color) => @@ -1645,6 +1716,239 @@ export const useSimulatorStore = create((set, get) => { set({ wires: updatedWires }); }, + // ── Undo/redo ────────────────────────────────────────────────────────── + history: [], + historyIndex: -1, + + pushCommand: (cmd, opts) => { + const applyNow = opts?.applyNow ?? true; + if (applyNow) cmd.execute(); + set((state) => { + // Truncate the redo branch — once you push a new command, the + // entries you'd previously redone are abandoned. + const truncated = state.history.slice(0, state.historyIndex + 1); + let next = [...truncated, cmd]; + let nextIdx = next.length - 1; + // Cap at HISTORY_MAX. When over, drop the oldest entry and shift + // the index down so it still points at the just-pushed command. + if (next.length > HISTORY_MAX) { + const overflow = next.length - HISTORY_MAX; + next = next.slice(overflow); + nextIdx = next.length - 1; + } + return { history: next, historyIndex: nextIdx }; + }); + }, + + undo: () => { + const state = get(); + if (state.historyIndex < 0) return; + const cmd = state.history[state.historyIndex]; + try { + cmd.undo(); + } catch (err) { + // A failing undo would otherwise leave the index pointing at a + // half-applied command. Bail out cleanly. + // eslint-disable-next-line no-console + console.error('[history] undo failed:', cmd.description, err); + return; + } + set({ historyIndex: state.historyIndex - 1 }); + }, + + redo: () => { + const state = get(); + if (state.historyIndex >= state.history.length - 1) return; + const cmd = state.history[state.historyIndex + 1]; + try { + cmd.execute(); + } catch (err) { + // eslint-disable-next-line no-console + console.error('[history] redo failed:', cmd.description, err); + return; + } + set({ historyIndex: state.historyIndex + 1 }); + }, + + canUndo: () => get().historyIndex >= 0, + canRedo: () => { + const s = get(); + return s.historyIndex < s.history.length - 1; + }, + + clearHistory: () => set({ history: [], historyIndex: -1 }), + + // ── Recorded canvas actions ──────────────────────────────────────────── + // Each `record*` builds a CanvasCommand that captures both directions + // and pushes it. Naming intent: the user has *committed* a change + // (drag-end, click finalised, agent tool execute) — distinct from the + // raw mutators above which can be called per-frame during a drag. + + recordAddComponent: (component) => { + get().pushCommand({ + description: `Add ${component.metadataId}`, + execute: () => + set((s) => ({ components: [...s.components, component] })), + undo: () => + set((s) => ({ + components: s.components.filter((c) => c.id !== component.id), + // Mirror the cascade in removeComponent so a redo→undo round + // trip of an add-then-wired pair stays consistent. + wires: s.wires.filter( + (w) => + w.start.componentId !== component.id && w.end.componentId !== component.id, + ), + })), + }); + }, + + recordRemoveComponent: (id) => { + const state = get(); + const removed = state.components.find((c) => c.id === id); + if (!removed) return; + // Capture wires that will be cascaded too — undo must restore both + // the component AND its wires together. + const removedWires = state.wires.filter( + (w) => w.start.componentId === id || w.end.componentId === id, + ); + get().pushCommand({ + description: `Remove ${removed.metadataId}`, + execute: () => + set((s) => ({ + components: s.components.filter((c) => c.id !== id), + wires: s.wires.filter( + (w) => w.start.componentId !== id && w.end.componentId !== id, + ), + })), + undo: () => + set((s) => ({ + components: [...s.components, removed], + wires: [...s.wires, ...removedWires], + })), + }); + }, + + recordMove: (id, from, to) => { + // The state is already at `to` (caller mutated during drag). We push + // applyNow:false so we don't redundantly re-apply on first push; + // execute()/undo() are only invoked on future redo/undo. + get().pushCommand( + { + description: 'Move component', + execute: () => { + set((s) => ({ + components: s.components.map((c) => + c.id === id ? { ...c, x: to.x, y: to.y } : c, + ), + })); + get().updateWirePositions(id); + }, + undo: () => { + set((s) => ({ + components: s.components.map((c) => + c.id === id ? { ...c, x: from.x, y: from.y } : c, + ), + })); + get().updateWirePositions(id); + }, + }, + { applyNow: false }, + ); + }, + + recordRotate: (id, prevRotation, nextRotation) => { + get().pushCommand( + { + description: 'Rotate component', + execute: () => + set((s) => ({ + components: s.components.map((c) => + c.id === id + ? { ...c, properties: { ...c.properties, rotation: nextRotation } } + : c, + ), + })), + undo: () => + set((s) => ({ + components: s.components.map((c) => + c.id === id + ? { ...c, properties: { ...c.properties, rotation: prevRotation } } + : c, + ), + })), + }, + { applyNow: false }, + ); + }, + + recordSetProperty: (id, key, prevValue, nextValue) => { + get().pushCommand( + { + description: `Change ${key}`, + execute: () => + set((s) => ({ + components: s.components.map((c) => + c.id === id + ? { ...c, properties: { ...c.properties, [key]: nextValue } } + : c, + ), + })), + undo: () => + set((s) => ({ + components: s.components.map((c) => + c.id === id + ? { ...c, properties: { ...c.properties, [key]: prevValue } } + : c, + ), + })), + }, + { applyNow: false }, + ); + }, + + recordAddWire: (wire) => { + get().pushCommand({ + description: 'Add wire', + execute: () => set((s) => ({ wires: [...s.wires, wire] })), + undo: () => + set((s) => ({ + wires: s.wires.filter((w) => w.id !== wire.id), + selectedWireId: s.selectedWireId === wire.id ? null : s.selectedWireId, + })), + }); + }, + + recordRemoveWire: (wireId) => { + const removed = get().wires.find((w) => w.id === wireId); + if (!removed) return; + get().pushCommand({ + description: 'Remove wire', + execute: () => + set((s) => ({ + wires: s.wires.filter((w) => w.id !== wireId), + selectedWireId: s.selectedWireId === wireId ? null : s.selectedWireId, + })), + undo: () => set((s) => ({ wires: [...s.wires, removed] })), + }); + }, + + recordUpdateWire: (wireId, prev, next, description = 'Update wire') => { + get().pushCommand( + { + description, + execute: () => + set((s) => ({ + wires: s.wires.map((w) => (w.id === wireId ? { ...w, ...next } : w)), + })), + undo: () => + set((s) => ({ + wires: s.wires.map((w) => (w.id === wireId ? { ...w, ...prev } : w)), + })), + }, + { applyNow: false }, + ); + }, + toggleSerialMonitor: () => set((s) => ({ serialMonitorOpen: !s.serialMonitorOpen })), serialWrite: (text: string) => {