fix(sim): reconcile running flag on board removal + clear multi-board residue

Three reported circuit bugs:

- Deleting the active/running board left the global `running` flag stale
  at true. That flag mirrors the active board, but removeBoard reassigned
  activeBoardId without re-deriving running, so the circuit looked
  "running" (toolbar stuck on Stop, canvas locked) and SimulatorCanvas's
  master-switch effect auto-started sibling remote boards. New Project
  hits the same path (it removes every board in a loop). removeBoard now
  re-derives running from the new active board (false if none remain).
- loadExample's single-board path called setBoardType when boards already
  existed but never dropped the extra boards a previous multi-board
  example had added, so they lingered as residue. It now removes every
  board past the first before retyping, matching the multi-board and
  board-less paths.

Adds board-removal-running-reconcile.test.ts (6 regression tests; full
suite 1917 passing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-05-31 21:19:37 +02:00
parent 310271bdb2
commit c66a5b0514
3 changed files with 171 additions and 1 deletions

View File

@ -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);
});
});

View File

@ -1231,7 +1231,20 @@ export const useSimulatorStore = create<SimulatorState>((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) {

View File

@ -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);
}
}