diff --git a/frontend/src/__tests__/board-removal-running-reconcile.test.ts b/frontend/src/__tests__/board-removal-running-reconcile.test.ts new file mode 100644 index 00000000..ecdbf467 --- /dev/null +++ b/frontend/src/__tests__/board-removal-running-reconcile.test.ts @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +/** + * Regression tests for three reported circuit bugs: + * + * 1. "Deleting a board STARTS the simulation, leaving the circuit + * unresponsive until stopped." + * 2. "Selecting New Project sometimes STARTS the simulation." + * 3. "Load a multi-board example, then a single-board example — the old + * boards stay behind as residue." + * + * Root cause for (1) and (2): the flat `running` flag mirrors the ACTIVE + * board's run state. `removeBoard` reassigned `activeBoardId` but never + * re-derived `running`, so deleting the running/active board left it stale + * at `true`. The UI then looked "running" and SimulatorCanvas's auto-start + * effect (which treats `running` as a master switch for remote boards) span + * a sibling board up. New Project hits the same path — it removes every + * board in a loop. The fix re-derives `running` from the new active board + * inside removeBoard. + * + * Root cause for (3): loadExample's single-board path called setBoardType + * when boards already existed but never dropped the EXTRA boards a previous + * multi-board example had added. The fix removes every board past the first + * before retyping. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useSimulatorStore } from '../store/useSimulatorStore'; +import { useElectricalStore } from '../store/useElectricalStore'; +import { loadExample } from '../utils/loadExample'; +import { exampleProjects } from '../data/examples'; + +function resetStores() { + const sim = useSimulatorStore.getState(); + for (const id of sim.boards.map((b) => b.id)) sim.removeBoard(id); + useElectricalStore.getState().setPaused(false); +} + +function findExample(id: string) { + const e = exampleProjects.find((x) => x.id === id); + if (!e) throw new Error(`Example not found: ${id}`); + return e; +} + +/** Mark a board (and, if active, the flat mirror) as running — without + * spinning up a real simulator/bridge. */ +function markRunning(boardId: string) { + useSimulatorStore.setState((s) => ({ + running: s.activeBoardId === boardId ? true : s.running, + boards: s.boards.map((b) => (b.id === boardId ? { ...b, running: true } : b)), + })); +} + +describe('removeBoard — running-flag reconciliation (bugs 1 & 2)', () => { + beforeEach(() => { + resetStores(); + }); + + it('deleting the running ACTIVE board clears the global running flag', () => { + const { addBoard, setActiveBoardId, removeBoard } = useSimulatorStore.getState(); + const a = addBoard('arduino-uno', 0, 0); + const b = addBoard('arduino-nano', 300, 0); + setActiveBoardId(a); + markRunning(a); + expect(useSimulatorStore.getState().running).toBe(true); + + removeBoard(a); + + const s = useSimulatorStore.getState(); + expect(s.activeBoardId).toBe(b); + // The new active board (b) is NOT running, so the flat flag must follow. + expect(s.running).toBe(false); + }); + + it('deleting the only (running) board leaves running=false with no boards', () => { + const { addBoard, setActiveBoardId, removeBoard } = useSimulatorStore.getState(); + const a = addBoard('arduino-uno', 0, 0); + setActiveBoardId(a); + markRunning(a); + + removeBoard(a); + + const s = useSimulatorStore.getState(); + expect(s.boards).toHaveLength(0); + expect(s.activeBoardId).toBeNull(); + expect(s.running).toBe(false); + }); + + it('deleting a NON-active board does not disturb the active board mirror', () => { + const { addBoard, setActiveBoardId, removeBoard } = useSimulatorStore.getState(); + const a = addBoard('arduino-uno', 0, 0); + const b = addBoard('arduino-nano', 300, 0); + setActiveBoardId(a); + markRunning(a); + + removeBoard(b); // remove the inactive one + + const s = useSimulatorStore.getState(); + expect(s.activeBoardId).toBe(a); + expect(s.running).toBe(true); // active board still running + }); + + it('New Project teardown (remove every board in a loop) ends with running=false', () => { + const { addBoard, setActiveBoardId, removeBoard } = useSimulatorStore.getState(); + const a = addBoard('arduino-uno', 0, 0); + addBoard('arduino-nano', 300, 0); + setActiveBoardId(a); + markRunning(a); + + // Mirror desktop menu.ts::newProject(): drop every board. + for (const board of [...useSimulatorStore.getState().boards]) { + removeBoard(board.id); + } + + const s = useSimulatorStore.getState(); + expect(s.boards).toHaveLength(0); + expect(s.running).toBe(false); + }); +}); + +describe('loadExample — multi-board to single-board leaves no residue (bug 3)', () => { + beforeEach(() => { + resetStores(); + }); + + it('a single-board example after a multi-board example ends with exactly one board', async () => { + // Multi-board example: STM32 Blue Pill + Arduino Uno. + await loadExample(findExample('stm32-uno-gpio-mirror')); + expect(useSimulatorStore.getState().boards.length).toBe(2); + + // Single-board example must reduce the canvas back to one board. + await loadExample(findExample('blink-led')); + expect(useSimulatorStore.getState().boards.length).toBe(1); + }); + + it('residue cleanup also applies when extra boards were added manually', async () => { + const { addBoard } = useSimulatorStore.getState(); + addBoard('arduino-nano', 300, 0); + addBoard('arduino-mega', 600, 0); + expect(useSimulatorStore.getState().boards.length).toBeGreaterThan(1); + + await loadExample(findExample('blink-led')); + expect(useSimulatorStore.getState().boards.length).toBe(1); + }); +}); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 19c65e1d..36373cf1 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -1231,7 +1231,20 @@ export const useSimulatorStore = create((set, get) => { const wires = s.wires.filter( (w) => w.start.componentId !== boardId && w.end.componentId !== boardId, ); - return { boards, activeBoardId, wires }; + // Reconcile the flat `running` mirror. This flag tracks the ACTIVE + // board's run state (see startBoard/stopBoard/setActiveBoardId's + // `isActive` sync). Removing the active board reassigns + // `activeBoardId` above, but used to leave `running` stale — so + // deleting the running/active board left the UI stuck in a fake + // "running" state, and SimulatorCanvas's auto-start effect (which + // treats `running` as a master switch for remote boards) then spun + // a sibling board up. Re-derive it from whatever board is active + // now (false if none remain). + const nextActive = activeBoardId + ? boards.find((b) => b.id === activeBoardId) ?? null + : null; + const running = nextActive ? nextActive.running : false; + return { boards, activeBoardId, wires, running }; }); // Clean up file group in editor store if (board) { diff --git a/frontend/src/utils/loadExample.ts b/frontend/src/utils/loadExample.ts index 41dc649e..e6c5d95d 100644 --- a/frontend/src/utils/loadExample.ts +++ b/frontend/src/utils/loadExample.ts @@ -196,6 +196,17 @@ export async function loadExample( currentIds.forEach((id) => removeBoard(id)); } else { const targetBoard = example.boardType || 'arduino-uno'; + // A previous MULTI-board example may have left several boards on the + // canvas. A single-board example must end with exactly one board, so + // drop every board past the first before retyping it. Without this + // the extra boards (and their editor file groups) linger as residue + // from the previous example. setComponents/setWires below already + // replace the components and wires wholesale; boards were the one + // piece of state this path never reset. + const existing = useSimulatorStore.getState().boards; + if (existing.length > 1) { + existing.slice(1).forEach((b) => removeBoard(b.id)); + } // If boards[] is empty (e.g. a previous analog example removed every // board), setBoardType can't work — it only maps over existing entries. // Add a fresh board instead. @@ -207,6 +218,9 @@ export async function loadExample( ); setActiveBoardId(newId); } else { + // Reuse the surviving board, but make sure it's the active one + // first — setBoardType retypes whatever board is active. + setActiveBoardId(useSimulatorStore.getState().boards[0].id); setBoardType(targetBoard); } }