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:
parent
310271bdb2
commit
c66a5b0514
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue