velxio/frontend/src/__tests__/circuit-simulation-service....

503 lines
18 KiB
TypeScript
Raw Normal View History

feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator) The service is the single owner of the simulation loop. Replaces the trio of wireElectricalSolver + connectLegacySolverToMixedMode + connectMixedModeSchedulerToStore once G* lands. Architecture: - Depends on PORTS only — SimulatorStorePort, ElectricalStorePort, MixedModeSchedulerPort. Zero coupling to useSimulatorStore / useElectricalStore / WASM. Easy to test with fakes (and that's what circuit-simulation-service.test.ts does). - Single tick(): build netlist → load → solve → extract → publish. Coalesces concurrent triggers so rapid store changes collapse to one trailing solve. - Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents + pinNetMap + timeWaveforms + analysisMode + warnings. Shape matches what the 12 existing useElectricalStore consumers read. NetlistBuilder extension: BuildNetlistResult now reports `nets` (every non-ground SPICE net) and `voltageSources` (every V card the builder emitted). The service uses these to construct the full vectorsOfInterest list — every node voltage + every branch current — so the solver returns the data the legacy consumers want. Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the orchestrator add to the per-pin set. Branch currents (i(v_*)) flow through this hook. 8 service tests cover initial solve, branch current extraction, re-solve on store change, no-spurious-solve, coalescing, .tran waveforms, warnings forwarding, error-tolerance. Next: C1+C2 — extract ADC injection / waveform replay into a solver-agnostic module that just subscribes to useElectricalStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:28:26 +07:00
/**
* CircuitSimulationService tests.
*
* Fully isolated from useSimulatorStore + useElectricalStore + the
* WASM scheduler. Uses fakes for every port so the service's
* orchestration logic is exercised standalone.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
CircuitSimulationService,
type SimulatorStorePort,
type ElectricalStorePort,
type MixedModeSchedulerPort,
type ElectricalSnapshot,
} from '../simulation/spice/CircuitSimulationService';
import {
getMixedModeScheduler,
__resetMixedModeScheduler,
__setSchedulerSolverFactoryForTests,
} from '../simulation/spice/MixedModeScheduler';
import { FakeSolverAdapter } from '../simulation/spice/adapters/FakeSolverAdapter';
fix(circuit-sim): stop() guard + drop edges with no V-source post-rebuild Two related bugs that surfaced as "Vitest worker exited unexpectedly / Timeout terminating forks worker" on the circuit-simulation-service test file. Bug 1 — tick() recursively re-schedules itself in its finally block. After afterEach disposes the scheduler via __resetMixedModeScheduler(), those re-scheduled ticks throw "call loadCircuit first", get caught by the console.warn, and the finally schedules ANOTHER tick. Infinite Promise loop survives until the worker OOMs. Fix: add CircuitSimulationService.stop() that flips a `stopped` flag short-circuiting tick() + handleMcuEdge(). The test harness now tracks each started service in _activeServices and calls stop() in afterEach alongside the existing unsubscribe sweep. Bug 2 — when an MCU edge fires on a pin that's NOT wired into any net (buildNetlist skips it because netLookup returns null), handleMcuEdge sees hasSource=false, self-heals by queueing the edge + tick(). The rebuild still doesn't emit the V-source (no wire), so tick.finally replays the edge → self-heal again → tick again → infinite loop AT RUNTIME, not just in tests. A user toggling a digital pin without a wire freezes the whole circuit simulation. Fix: in tick.finally's pendingMcuEdges replay loop, check whether the rebuilt netlist now contains a V-source for each pending edge's pin. If not, drop the edge silently — a future canvas tick triggered by adding the wire will pick it up via the normal subscription path. Also fix the "coalesces an edge with an in-flight full solve" test fixture: simpleBoardWithBoard leaves pin 9 unwired, so V_uno_9 was never emitted and the test was racing the (now-bounded) self-heal rebuild. Replaced with an inline fixture wiring pin 9 → resistor → GND, mirroring the wired fixture used by the alter+republish test right above it. Full vitest --shard 1/2 + 2/2 pass cleanly (1886 tests, 22-29s per shard) with no worker-exit warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:49:29 +07:00
// Tracks unsubscribe handles + services so afterEach can fully shut
// the orchestrator down. Two leaks fixed together:
//
// 1. service.start() returns an unsubscribe handle for the store
// subscription. If the test never called it, the listener pinned
// the simStore (+ service + scheduler via closure) and vitest's
// forks pool stopped exiting cleanly.
// 2. Even with the subscription released, service.tick() is async
// and its finally-block recursively re-schedules itself when
// `pendingMcuEdges` is non-empty. After afterEach disposes the
// scheduler, those re-scheduled ticks throw "call loadCircuit
// first", get caught, and schedule ANOTHER tick — infinite
// Promise loop in the event queue that survives until the worker
// OOMs (the manifest of the original "circuit-sim solve failed"
// console.warn that kept appearing across files). Fix: call
// `service.stop()` which flips an internal `stopped` flag that
// short-circuits tick() + handleMcuEdge().
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
const _activeUnsubs: Array<() => void> = [];
fix(circuit-sim): stop() guard + drop edges with no V-source post-rebuild Two related bugs that surfaced as "Vitest worker exited unexpectedly / Timeout terminating forks worker" on the circuit-simulation-service test file. Bug 1 — tick() recursively re-schedules itself in its finally block. After afterEach disposes the scheduler via __resetMixedModeScheduler(), those re-scheduled ticks throw "call loadCircuit first", get caught by the console.warn, and the finally schedules ANOTHER tick. Infinite Promise loop survives until the worker OOMs. Fix: add CircuitSimulationService.stop() that flips a `stopped` flag short-circuiting tick() + handleMcuEdge(). The test harness now tracks each started service in _activeServices and calls stop() in afterEach alongside the existing unsubscribe sweep. Bug 2 — when an MCU edge fires on a pin that's NOT wired into any net (buildNetlist skips it because netLookup returns null), handleMcuEdge sees hasSource=false, self-heals by queueing the edge + tick(). The rebuild still doesn't emit the V-source (no wire), so tick.finally replays the edge → self-heal again → tick again → infinite loop AT RUNTIME, not just in tests. A user toggling a digital pin without a wire freezes the whole circuit simulation. Fix: in tick.finally's pendingMcuEdges replay loop, check whether the rebuilt netlist now contains a V-source for each pending edge's pin. If not, drop the edge silently — a future canvas tick triggered by adding the wire will pick it up via the normal subscription path. Also fix the "coalesces an edge with an in-flight full solve" test fixture: simpleBoardWithBoard leaves pin 9 unwired, so V_uno_9 was never emitted and the test was racing the (now-bounded) self-heal rebuild. Replaced with an inline fixture wiring pin 9 → resistor → GND, mirroring the wired fixture used by the alter+republish test right above it. Full vitest --shard 1/2 + 2/2 pass cleanly (1886 tests, 22-29s per shard) with no worker-exit warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:49:29 +07:00
const _activeServices: Array<{ stop: () => void }> = [];
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
fix(circuit-sim): stop() guard + drop edges with no V-source post-rebuild Two related bugs that surfaced as "Vitest worker exited unexpectedly / Timeout terminating forks worker" on the circuit-simulation-service test file. Bug 1 — tick() recursively re-schedules itself in its finally block. After afterEach disposes the scheduler via __resetMixedModeScheduler(), those re-scheduled ticks throw "call loadCircuit first", get caught by the console.warn, and the finally schedules ANOTHER tick. Infinite Promise loop survives until the worker OOMs. Fix: add CircuitSimulationService.stop() that flips a `stopped` flag short-circuiting tick() + handleMcuEdge(). The test harness now tracks each started service in _activeServices and calls stop() in afterEach alongside the existing unsubscribe sweep. Bug 2 — when an MCU edge fires on a pin that's NOT wired into any net (buildNetlist skips it because netLookup returns null), handleMcuEdge sees hasSource=false, self-heals by queueing the edge + tick(). The rebuild still doesn't emit the V-source (no wire), so tick.finally replays the edge → self-heal again → tick again → infinite loop AT RUNTIME, not just in tests. A user toggling a digital pin without a wire freezes the whole circuit simulation. Fix: in tick.finally's pendingMcuEdges replay loop, check whether the rebuilt netlist now contains a V-source for each pending edge's pin. If not, drop the edge silently — a future canvas tick triggered by adding the wire will pick it up via the normal subscription path. Also fix the "coalesces an edge with an in-flight full solve" test fixture: simpleBoardWithBoard leaves pin 9 unwired, so V_uno_9 was never emitted and the test was racing the (now-bounded) self-heal rebuild. Replaced with an inline fixture wiring pin 9 → resistor → GND, mirroring the wired fixture used by the alter+republish test right above it. Full vitest --shard 1/2 + 2/2 pass cleanly (1886 tests, 22-29s per shard) with no worker-exit warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:49:29 +07:00
function startTracked(service: {
start: () => () => void;
stop: () => void;
}): () => void {
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
const unsub = service.start();
_activeUnsubs.push(unsub);
fix(circuit-sim): stop() guard + drop edges with no V-source post-rebuild Two related bugs that surfaced as "Vitest worker exited unexpectedly / Timeout terminating forks worker" on the circuit-simulation-service test file. Bug 1 — tick() recursively re-schedules itself in its finally block. After afterEach disposes the scheduler via __resetMixedModeScheduler(), those re-scheduled ticks throw "call loadCircuit first", get caught by the console.warn, and the finally schedules ANOTHER tick. Infinite Promise loop survives until the worker OOMs. Fix: add CircuitSimulationService.stop() that flips a `stopped` flag short-circuiting tick() + handleMcuEdge(). The test harness now tracks each started service in _activeServices and calls stop() in afterEach alongside the existing unsubscribe sweep. Bug 2 — when an MCU edge fires on a pin that's NOT wired into any net (buildNetlist skips it because netLookup returns null), handleMcuEdge sees hasSource=false, self-heals by queueing the edge + tick(). The rebuild still doesn't emit the V-source (no wire), so tick.finally replays the edge → self-heal again → tick again → infinite loop AT RUNTIME, not just in tests. A user toggling a digital pin without a wire freezes the whole circuit simulation. Fix: in tick.finally's pendingMcuEdges replay loop, check whether the rebuilt netlist now contains a V-source for each pending edge's pin. If not, drop the edge silently — a future canvas tick triggered by adding the wire will pick it up via the normal subscription path. Also fix the "coalesces an edge with an in-flight full solve" test fixture: simpleBoardWithBoard leaves pin 9 unwired, so V_uno_9 was never emitted and the test was racing the (now-bounded) self-heal rebuild. Replaced with an inline fixture wiring pin 9 → resistor → GND, mirroring the wired fixture used by the alter+republish test right above it. Full vitest --shard 1/2 + 2/2 pass cleanly (1886 tests, 22-29s per shard) with no worker-exit warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:49:29 +07:00
_activeServices.push(service);
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
return unsub;
}
feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator) The service is the single owner of the simulation loop. Replaces the trio of wireElectricalSolver + connectLegacySolverToMixedMode + connectMixedModeSchedulerToStore once G* lands. Architecture: - Depends on PORTS only — SimulatorStorePort, ElectricalStorePort, MixedModeSchedulerPort. Zero coupling to useSimulatorStore / useElectricalStore / WASM. Easy to test with fakes (and that's what circuit-simulation-service.test.ts does). - Single tick(): build netlist → load → solve → extract → publish. Coalesces concurrent triggers so rapid store changes collapse to one trailing solve. - Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents + pinNetMap + timeWaveforms + analysisMode + warnings. Shape matches what the 12 existing useElectricalStore consumers read. NetlistBuilder extension: BuildNetlistResult now reports `nets` (every non-ground SPICE net) and `voltageSources` (every V card the builder emitted). The service uses these to construct the full vectorsOfInterest list — every node voltage + every branch current — so the solver returns the data the legacy consumers want. Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the orchestrator add to the per-pin set. Branch currents (i(v_*)) flow through this hook. 8 service tests cover initial solve, branch current extraction, re-solve on store change, no-spurious-solve, coalescing, .tran waveforms, warnings forwarding, error-tolerance. Next: C1+C2 — extract ADC injection / waveform replay into a solver-agnostic module that just subscribes to useElectricalStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:28:26 +07:00
afterEach(() => {
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
for (const unsub of _activeUnsubs.splice(0)) {
try { unsub(); } catch { /* ignore */ }
}
fix(circuit-sim): stop() guard + drop edges with no V-source post-rebuild Two related bugs that surfaced as "Vitest worker exited unexpectedly / Timeout terminating forks worker" on the circuit-simulation-service test file. Bug 1 — tick() recursively re-schedules itself in its finally block. After afterEach disposes the scheduler via __resetMixedModeScheduler(), those re-scheduled ticks throw "call loadCircuit first", get caught by the console.warn, and the finally schedules ANOTHER tick. Infinite Promise loop survives until the worker OOMs. Fix: add CircuitSimulationService.stop() that flips a `stopped` flag short-circuiting tick() + handleMcuEdge(). The test harness now tracks each started service in _activeServices and calls stop() in afterEach alongside the existing unsubscribe sweep. Bug 2 — when an MCU edge fires on a pin that's NOT wired into any net (buildNetlist skips it because netLookup returns null), handleMcuEdge sees hasSource=false, self-heals by queueing the edge + tick(). The rebuild still doesn't emit the V-source (no wire), so tick.finally replays the edge → self-heal again → tick again → infinite loop AT RUNTIME, not just in tests. A user toggling a digital pin without a wire freezes the whole circuit simulation. Fix: in tick.finally's pendingMcuEdges replay loop, check whether the rebuilt netlist now contains a V-source for each pending edge's pin. If not, drop the edge silently — a future canvas tick triggered by adding the wire will pick it up via the normal subscription path. Also fix the "coalesces an edge with an in-flight full solve" test fixture: simpleBoardWithBoard leaves pin 9 unwired, so V_uno_9 was never emitted and the test was racing the (now-bounded) self-heal rebuild. Replaced with an inline fixture wiring pin 9 → resistor → GND, mirroring the wired fixture used by the alter+republish test right above it. Full vitest --shard 1/2 + 2/2 pass cleanly (1886 tests, 22-29s per shard) with no worker-exit warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:49:29 +07:00
for (const service of _activeServices.splice(0)) {
try { service.stop(); } catch { /* ignore */ }
}
feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator) The service is the single owner of the simulation loop. Replaces the trio of wireElectricalSolver + connectLegacySolverToMixedMode + connectMixedModeSchedulerToStore once G* lands. Architecture: - Depends on PORTS only — SimulatorStorePort, ElectricalStorePort, MixedModeSchedulerPort. Zero coupling to useSimulatorStore / useElectricalStore / WASM. Easy to test with fakes (and that's what circuit-simulation-service.test.ts does). - Single tick(): build netlist → load → solve → extract → publish. Coalesces concurrent triggers so rapid store changes collapse to one trailing solve. - Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents + pinNetMap + timeWaveforms + analysisMode + warnings. Shape matches what the 12 existing useElectricalStore consumers read. NetlistBuilder extension: BuildNetlistResult now reports `nets` (every non-ground SPICE net) and `voltageSources` (every V card the builder emitted). The service uses these to construct the full vectorsOfInterest list — every node voltage + every branch current — so the solver returns the data the legacy consumers want. Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the orchestrator add to the per-pin set. Branch currents (i(v_*)) flow through this hook. 8 service tests cover initial solve, branch current extraction, re-solve on store change, no-spurious-solve, coalescing, .tran waveforms, warnings forwarding, error-tolerance. Next: C1+C2 — extract ADC injection / waveform replay into a solver-agnostic module that just subscribes to useElectricalStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:28:26 +07:00
__resetMixedModeScheduler();
});
function makeSimStore(initial: {
components: Array<{ id: string; metadataId: string; properties: Record<string, unknown> }>;
wires: Array<{
id: string;
start: { componentId: string; pinName: string };
end: { componentId: string; pinName: string };
}>;
boards: Array<{ id: string; boardKind: string }>;
}): { port: SimulatorStorePort; set(next: Partial<typeof initial>): void } {
let state: typeof initial = initial;
const listeners: Array<(s: unknown, p: unknown) => void> = [];
return {
port: {
getState: () => state,
subscribe(l) {
listeners.push(l);
return () => {
const i = listeners.indexOf(l);
if (i >= 0) listeners.splice(i, 1);
};
},
},
set(next) {
const prev = state;
state = { ...state, ...next };
for (const l of listeners) l(state, prev);
},
};
}
function makeElectricalStore(): {
port: ElectricalStorePort;
snapshots: ElectricalSnapshot[];
} {
const snapshots: ElectricalSnapshot[] = [];
return {
snapshots,
port: {
publish(s) {
snapshots.push(s);
},
},
};
}
const simpleBoardWithBoard = {
components: [{ id: 'r1', metadataId: 'resistor', properties: { value: '1k' } }],
wires: [
{
id: 'w1',
start: { componentId: 'uno', pinName: '5V' },
end: { componentId: 'r1', pinName: '1' },
},
{
id: 'w2',
start: { componentId: 'r1', pinName: '2' },
end: { componentId: 'uno', pinName: 'GND' },
},
],
boards: [{ id: 'uno', boardKind: 'arduino-uno' }],
};
describe('CircuitSimulationService — orchestration', () => {
it('runs an initial solve when started', async () => {
const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } });
__setSchedulerSolverFactoryForTests(() => fake);
const sim = makeSimStore(simpleBoardWithBoard);
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
{ collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) },
);
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
startTracked(service);
feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator) The service is the single owner of the simulation loop. Replaces the trio of wireElectricalSolver + connectLegacySolverToMixedMode + connectMixedModeSchedulerToStore once G* lands. Architecture: - Depends on PORTS only — SimulatorStorePort, ElectricalStorePort, MixedModeSchedulerPort. Zero coupling to useSimulatorStore / useElectricalStore / WASM. Easy to test with fakes (and that's what circuit-simulation-service.test.ts does). - Single tick(): build netlist → load → solve → extract → publish. Coalesces concurrent triggers so rapid store changes collapse to one trailing solve. - Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents + pinNetMap + timeWaveforms + analysisMode + warnings. Shape matches what the 12 existing useElectricalStore consumers read. NetlistBuilder extension: BuildNetlistResult now reports `nets` (every non-ground SPICE net) and `voltageSources` (every V card the builder emitted). The service uses these to construct the full vectorsOfInterest list — every node voltage + every branch current — so the solver returns the data the legacy consumers want. Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the orchestrator add to the per-pin set. Branch currents (i(v_*)) flow through this hook. 8 service tests cover initial solve, branch current extraction, re-solve on store change, no-spurious-solve, coalescing, .tran waveforms, warnings forwarding, error-tolerance. Next: C1+C2 — extract ADC injection / waveform replay into a solver-agnostic module that just subscribes to useElectricalStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:28:26 +07:00
await new Promise((r) => setTimeout(r, 20));
expect(fake.calls.loadCircuit.length).toBe(1);
expect(fake.calls.solve.length).toBe(1);
expect(elec.snapshots.length).toBe(1);
expect(elec.snapshots[0]?.analysisMode).toBe('op');
expect(elec.snapshots[0]?.nodeVoltages.vcc_rail).toBeCloseTo(5);
});
it('extracts branch currents from i(v_*) vectors', async () => {
const fake = new FakeSolverAdapter({
vectors: { 'v(vcc_rail)': 5, 'i(v_vcc_rail)': -0.005 },
});
__setSchedulerSolverFactoryForTests(() => fake);
const sim = makeSimStore(simpleBoardWithBoard);
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
{ collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) },
);
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
startTracked(service);
feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator) The service is the single owner of the simulation loop. Replaces the trio of wireElectricalSolver + connectLegacySolverToMixedMode + connectMixedModeSchedulerToStore once G* lands. Architecture: - Depends on PORTS only — SimulatorStorePort, ElectricalStorePort, MixedModeSchedulerPort. Zero coupling to useSimulatorStore / useElectricalStore / WASM. Easy to test with fakes (and that's what circuit-simulation-service.test.ts does). - Single tick(): build netlist → load → solve → extract → publish. Coalesces concurrent triggers so rapid store changes collapse to one trailing solve. - Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents + pinNetMap + timeWaveforms + analysisMode + warnings. Shape matches what the 12 existing useElectricalStore consumers read. NetlistBuilder extension: BuildNetlistResult now reports `nets` (every non-ground SPICE net) and `voltageSources` (every V card the builder emitted). The service uses these to construct the full vectorsOfInterest list — every node voltage + every branch current — so the solver returns the data the legacy consumers want. Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the orchestrator add to the per-pin set. Branch currents (i(v_*)) flow through this hook. 8 service tests cover initial solve, branch current extraction, re-solve on store change, no-spurious-solve, coalescing, .tran waveforms, warnings forwarding, error-tolerance. Next: C1+C2 — extract ADC injection / waveform replay into a solver-agnostic module that just subscribes to useElectricalStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:28:26 +07:00
await new Promise((r) => setTimeout(r, 20));
const snap = elec.snapshots[0];
expect(snap?.branchCurrents.v_vcc_rail).toBeCloseTo(-0.005);
});
it('re-solves on components / wires / boards changes', async () => {
const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } });
__setSchedulerSolverFactoryForTests(() => fake);
const sim = makeSimStore(simpleBoardWithBoard);
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
{ collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) },
);
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
startTracked(service);
feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator) The service is the single owner of the simulation loop. Replaces the trio of wireElectricalSolver + connectLegacySolverToMixedMode + connectMixedModeSchedulerToStore once G* lands. Architecture: - Depends on PORTS only — SimulatorStorePort, ElectricalStorePort, MixedModeSchedulerPort. Zero coupling to useSimulatorStore / useElectricalStore / WASM. Easy to test with fakes (and that's what circuit-simulation-service.test.ts does). - Single tick(): build netlist → load → solve → extract → publish. Coalesces concurrent triggers so rapid store changes collapse to one trailing solve. - Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents + pinNetMap + timeWaveforms + analysisMode + warnings. Shape matches what the 12 existing useElectricalStore consumers read. NetlistBuilder extension: BuildNetlistResult now reports `nets` (every non-ground SPICE net) and `voltageSources` (every V card the builder emitted). The service uses these to construct the full vectorsOfInterest list — every node voltage + every branch current — so the solver returns the data the legacy consumers want. Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the orchestrator add to the per-pin set. Branch currents (i(v_*)) flow through this hook. 8 service tests cover initial solve, branch current extraction, re-solve on store change, no-spurious-solve, coalescing, .tran waveforms, warnings forwarding, error-tolerance. Next: C1+C2 — extract ADC injection / waveform replay into a solver-agnostic module that just subscribes to useElectricalStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:28:26 +07:00
await new Promise((r) => setTimeout(r, 10));
expect(fake.calls.solve.length).toBe(1);
sim.set({ components: [...simpleBoardWithBoard.components, { id: 'r2', metadataId: 'resistor', properties: {} }] });
await new Promise((r) => setTimeout(r, 10));
expect(fake.calls.solve.length).toBe(2);
});
it('does NOT re-solve when an unrelated field changes', async () => {
const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } });
__setSchedulerSolverFactoryForTests(() => fake);
const sim = makeSimStore(simpleBoardWithBoard);
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
{ collectBoardPinStates: () => ({}) },
);
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
startTracked(service);
feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator) The service is the single owner of the simulation loop. Replaces the trio of wireElectricalSolver + connectLegacySolverToMixedMode + connectMixedModeSchedulerToStore once G* lands. Architecture: - Depends on PORTS only — SimulatorStorePort, ElectricalStorePort, MixedModeSchedulerPort. Zero coupling to useSimulatorStore / useElectricalStore / WASM. Easy to test with fakes (and that's what circuit-simulation-service.test.ts does). - Single tick(): build netlist → load → solve → extract → publish. Coalesces concurrent triggers so rapid store changes collapse to one trailing solve. - Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents + pinNetMap + timeWaveforms + analysisMode + warnings. Shape matches what the 12 existing useElectricalStore consumers read. NetlistBuilder extension: BuildNetlistResult now reports `nets` (every non-ground SPICE net) and `voltageSources` (every V card the builder emitted). The service uses these to construct the full vectorsOfInterest list — every node voltage + every branch current — so the solver returns the data the legacy consumers want. Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the orchestrator add to the per-pin set. Branch currents (i(v_*)) flow through this hook. 8 service tests cover initial solve, branch current extraction, re-solve on store change, no-spurious-solve, coalescing, .tran waveforms, warnings forwarding, error-tolerance. Next: C1+C2 — extract ADC injection / waveform replay into a solver-agnostic module that just subscribes to useElectricalStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:28:26 +07:00
await new Promise((r) => setTimeout(r, 10));
sim.set({}); // same arrays — should NOT trigger a re-solve
await new Promise((r) => setTimeout(r, 10));
expect(fake.calls.solve.length).toBe(1);
});
it('coalesces solves when one is in flight', async () => {
const fake = new FakeSolverAdapter({
vectors: { 'v(vcc_rail)': 5 },
solveDelayMs: 30,
});
__setSchedulerSolverFactoryForTests(() => fake);
const sim = makeSimStore(simpleBoardWithBoard);
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
{ collectBoardPinStates: () => ({}) },
);
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
startTracked(service);
feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator) The service is the single owner of the simulation loop. Replaces the trio of wireElectricalSolver + connectLegacySolverToMixedMode + connectMixedModeSchedulerToStore once G* lands. Architecture: - Depends on PORTS only — SimulatorStorePort, ElectricalStorePort, MixedModeSchedulerPort. Zero coupling to useSimulatorStore / useElectricalStore / WASM. Easy to test with fakes (and that's what circuit-simulation-service.test.ts does). - Single tick(): build netlist → load → solve → extract → publish. Coalesces concurrent triggers so rapid store changes collapse to one trailing solve. - Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents + pinNetMap + timeWaveforms + analysisMode + warnings. Shape matches what the 12 existing useElectricalStore consumers read. NetlistBuilder extension: BuildNetlistResult now reports `nets` (every non-ground SPICE net) and `voltageSources` (every V card the builder emitted). The service uses these to construct the full vectorsOfInterest list — every node voltage + every branch current — so the solver returns the data the legacy consumers want. Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the orchestrator add to the per-pin set. Branch currents (i(v_*)) flow through this hook. 8 service tests cover initial solve, branch current extraction, re-solve on store change, no-spurious-solve, coalescing, .tran waveforms, warnings forwarding, error-tolerance. Next: C1+C2 — extract ADC injection / waveform replay into a solver-agnostic module that just subscribes to useElectricalStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:28:26 +07:00
// While initial solve runs, fire 3 store changes — should coalesce
// into 1 trailing solve.
sim.set({ components: [{ id: 'a', metadataId: 'resistor', properties: {} }] });
sim.set({ components: [{ id: 'b', metadataId: 'resistor', properties: {} }] });
sim.set({ components: [{ id: 'c', metadataId: 'resistor', properties: {} }] });
await new Promise((r) => setTimeout(r, 100));
expect(fake.calls.solve.length).toBe(2); // initial + 1 trailing
});
it('publishes .tran waveforms when analysis is transient', async () => {
const fake = new FakeSolverAdapter({
vectors: {
'v(n_out)': new Float64Array([0, 1, 2, 3, 4]),
'i(v_src)': new Float64Array([0.01, 0.02, 0.03, 0.04, 0.05]),
},
timeAxis: new Float64Array([0, 1e-4, 2e-4, 3e-4, 4e-4]),
});
__setSchedulerSolverFactoryForTests(() => fake);
const sim = makeSimStore({
components: [
// A signal-generator forces .tran in buildInputFromStore.
{
id: 'sg1',
metadataId: 'signal-generator',
properties: { waveform: 'sine', frequency: 100 },
},
],
wires: [],
boards: [{ id: 'uno', boardKind: 'arduino-uno' }],
});
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
{ collectBoardPinStates: () => ({}) },
);
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
startTracked(service);
feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator) The service is the single owner of the simulation loop. Replaces the trio of wireElectricalSolver + connectLegacySolverToMixedMode + connectMixedModeSchedulerToStore once G* lands. Architecture: - Depends on PORTS only — SimulatorStorePort, ElectricalStorePort, MixedModeSchedulerPort. Zero coupling to useSimulatorStore / useElectricalStore / WASM. Easy to test with fakes (and that's what circuit-simulation-service.test.ts does). - Single tick(): build netlist → load → solve → extract → publish. Coalesces concurrent triggers so rapid store changes collapse to one trailing solve. - Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents + pinNetMap + timeWaveforms + analysisMode + warnings. Shape matches what the 12 existing useElectricalStore consumers read. NetlistBuilder extension: BuildNetlistResult now reports `nets` (every non-ground SPICE net) and `voltageSources` (every V card the builder emitted). The service uses these to construct the full vectorsOfInterest list — every node voltage + every branch current — so the solver returns the data the legacy consumers want. Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the orchestrator add to the per-pin set. Branch currents (i(v_*)) flow through this hook. 8 service tests cover initial solve, branch current extraction, re-solve on store change, no-spurious-solve, coalescing, .tran waveforms, warnings forwarding, error-tolerance. Next: C1+C2 — extract ADC injection / waveform replay into a solver-agnostic module that just subscribes to useElectricalStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:28:26 +07:00
await new Promise((r) => setTimeout(r, 20));
const snap = elec.snapshots[0];
expect(snap?.analysisMode).toBe('tran');
expect(snap?.timeWaveforms).toBeDefined();
expect(snap?.timeWaveforms?.time.length).toBe(5);
});
it('publishes warnings from the solver', async () => {
const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } });
// FakeSolverAdapter does not currently emit warnings; vetting that the
// service forwards them is sufficient — see solver-port-contract for
// the warnings-field contract.
__setSchedulerSolverFactoryForTests(() => fake);
const sim = makeSimStore(simpleBoardWithBoard);
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
{ collectBoardPinStates: () => ({}) },
);
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
startTracked(service);
feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator) The service is the single owner of the simulation loop. Replaces the trio of wireElectricalSolver + connectLegacySolverToMixedMode + connectMixedModeSchedulerToStore once G* lands. Architecture: - Depends on PORTS only — SimulatorStorePort, ElectricalStorePort, MixedModeSchedulerPort. Zero coupling to useSimulatorStore / useElectricalStore / WASM. Easy to test with fakes (and that's what circuit-simulation-service.test.ts does). - Single tick(): build netlist → load → solve → extract → publish. Coalesces concurrent triggers so rapid store changes collapse to one trailing solve. - Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents + pinNetMap + timeWaveforms + analysisMode + warnings. Shape matches what the 12 existing useElectricalStore consumers read. NetlistBuilder extension: BuildNetlistResult now reports `nets` (every non-ground SPICE net) and `voltageSources` (every V card the builder emitted). The service uses these to construct the full vectorsOfInterest list — every node voltage + every branch current — so the solver returns the data the legacy consumers want. Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the orchestrator add to the per-pin set. Branch currents (i(v_*)) flow through this hook. 8 service tests cover initial solve, branch current extraction, re-solve on store change, no-spurious-solve, coalescing, .tran waveforms, warnings forwarding, error-tolerance. Next: C1+C2 — extract ADC injection / waveform replay into a solver-agnostic module that just subscribes to useElectricalStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:28:26 +07:00
await new Promise((r) => setTimeout(r, 10));
// The FakeSolverAdapter returns empty warnings, so the snapshot
// also has empty warnings — but the field exists.
expect(elec.snapshots[0]?.warnings).toEqual([]);
});
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
});
describe('handleMcuEdge (Phase 1c D1)', () => {
it('runs an initial full solve, then alter + republish on edge', async () => {
let gateV = 0;
let drainV = 4.9;
const fake = new FakeSolverAdapter({
vectors: () => ({
'v(net_gate)': gateV,
'v(net_drain)': drainV,
'v(vcc_rail)': 5,
'i(v_vcc_rail)': -0.005,
}),
});
fake.onAlter = (name, value) => {
if (name === 'V_uno_9') {
gateV = value;
drainV = value >= 1.6 ? 0.05 : 4.9;
}
};
__setSchedulerSolverFactoryForTests(() => fake);
const sim = makeSimStore({
components: [
{ id: 'q1', metadataId: 'bjt-2n2222', properties: {} },
{ id: 'rb', metadataId: 'resistor', properties: { value: '1k' } },
],
wires: [
{
id: 'w1',
start: { componentId: 'uno', pinName: '9' },
end: { componentId: 'rb', pinName: '1' },
},
{
id: 'w2',
start: { componentId: 'rb', pinName: '2' },
end: { componentId: 'q1', pinName: 'B' },
},
],
boards: [{ id: 'uno', boardKind: 'arduino-uno' }],
});
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
fix(circuit-sim): stop() guard + drop edges with no V-source post-rebuild Two related bugs that surfaced as "Vitest worker exited unexpectedly / Timeout terminating forks worker" on the circuit-simulation-service test file. Bug 1 — tick() recursively re-schedules itself in its finally block. After afterEach disposes the scheduler via __resetMixedModeScheduler(), those re-scheduled ticks throw "call loadCircuit first", get caught by the console.warn, and the finally schedules ANOTHER tick. Infinite Promise loop survives until the worker OOMs. Fix: add CircuitSimulationService.stop() that flips a `stopped` flag short-circuiting tick() + handleMcuEdge(). The test harness now tracks each started service in _activeServices and calls stop() in afterEach alongside the existing unsubscribe sweep. Bug 2 — when an MCU edge fires on a pin that's NOT wired into any net (buildNetlist skips it because netLookup returns null), handleMcuEdge sees hasSource=false, self-heals by queueing the edge + tick(). The rebuild still doesn't emit the V-source (no wire), so tick.finally replays the edge → self-heal again → tick again → infinite loop AT RUNTIME, not just in tests. A user toggling a digital pin without a wire freezes the whole circuit simulation. Fix: in tick.finally's pendingMcuEdges replay loop, check whether the rebuilt netlist now contains a V-source for each pending edge's pin. If not, drop the edge silently — a future canvas tick triggered by adding the wire will pick it up via the normal subscription path. Also fix the "coalesces an edge with an in-flight full solve" test fixture: simpleBoardWithBoard leaves pin 9 unwired, so V_uno_9 was never emitted and the test was racing the (now-bounded) self-heal rebuild. Replaced with an inline fixture wiring pin 9 → resistor → GND, mirroring the wired fixture used by the alter+republish test right above it. Full vitest --shard 1/2 + 2/2 pass cleanly (1886 tests, 22-29s per shard) with no worker-exit warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:49:29 +07:00
// Mark pin 9 as a digital MCU output so the netlist emits
// V_uno_9 from the very first solve. Without this the
// self-heal path in handleMcuEdge triggers a rebuild (full
// tick) instead of the alter + resolveDc fast path the test
// is verifying, and the assertion below races the rebuild's
// own solve completing.
{ collectBoardPinStates: () => ({ '9': { type: 'digital', v: 0 } }) },
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
);
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
startTracked(service);
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
await new Promise((r) => setTimeout(r, 20));
const initialSolves = fake.calls.solve.length;
const initialSnapshots = elec.snapshots.length;
expect(initialSolves).toBe(1); // initial full solve
expect(initialSnapshots).toBe(1);
await service.handleMcuEdge('uno', '9', true, 5);
// Solve count went up by exactly 1 (alter + .op), no new loadCircuit.
expect(fake.calls.solve.length).toBe(initialSolves + 1);
expect(fake.calls.loadCircuit.length).toBe(1); // still 1
expect(fake.calls.alterSource).toEqual([['V_uno_9', 5]]);
expect(elec.snapshots.length).toBe(initialSnapshots + 1);
});
it('coalesces an edge with an in-flight full solve', async () => {
const fake = new FakeSolverAdapter({
vectors: { 'v(vcc_rail)': 5 },
solveDelayMs: 30,
});
__setSchedulerSolverFactoryForTests(() => fake);
fix(circuit-sim): stop() guard + drop edges with no V-source post-rebuild Two related bugs that surfaced as "Vitest worker exited unexpectedly / Timeout terminating forks worker" on the circuit-simulation-service test file. Bug 1 — tick() recursively re-schedules itself in its finally block. After afterEach disposes the scheduler via __resetMixedModeScheduler(), those re-scheduled ticks throw "call loadCircuit first", get caught by the console.warn, and the finally schedules ANOTHER tick. Infinite Promise loop survives until the worker OOMs. Fix: add CircuitSimulationService.stop() that flips a `stopped` flag short-circuiting tick() + handleMcuEdge(). The test harness now tracks each started service in _activeServices and calls stop() in afterEach alongside the existing unsubscribe sweep. Bug 2 — when an MCU edge fires on a pin that's NOT wired into any net (buildNetlist skips it because netLookup returns null), handleMcuEdge sees hasSource=false, self-heals by queueing the edge + tick(). The rebuild still doesn't emit the V-source (no wire), so tick.finally replays the edge → self-heal again → tick again → infinite loop AT RUNTIME, not just in tests. A user toggling a digital pin without a wire freezes the whole circuit simulation. Fix: in tick.finally's pendingMcuEdges replay loop, check whether the rebuilt netlist now contains a V-source for each pending edge's pin. If not, drop the edge silently — a future canvas tick triggered by adding the wire will pick it up via the normal subscription path. Also fix the "coalesces an edge with an in-flight full solve" test fixture: simpleBoardWithBoard leaves pin 9 unwired, so V_uno_9 was never emitted and the test was racing the (now-bounded) self-heal rebuild. Replaced with an inline fixture wiring pin 9 → resistor → GND, mirroring the wired fixture used by the alter+republish test right above it. Full vitest --shard 1/2 + 2/2 pass cleanly (1886 tests, 22-29s per shard) with no worker-exit warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:49:29 +07:00
// Pin 9 must be wired into the netlist so buildNetlist emits
// V_uno_9 (NetlistBuilder line 158 skips board pins whose net
// lookup returns null). Without a wire, hasSource stays false
// forever and handleMcuEdge's self-heal path would loop. Mirrors
// the wired fixture used by the alter+republish test above.
const sim = makeSimStore({
components: [
{ id: 'rb', metadataId: 'resistor', properties: { value: '1k' } },
],
wires: [
{
id: 'w1',
start: { componentId: 'uno', pinName: '9' },
end: { componentId: 'rb', pinName: '1' },
},
{
id: 'w2',
start: { componentId: 'rb', pinName: '2' },
end: { componentId: 'uno', pinName: 'GND' },
},
],
boards: [{ id: 'uno', boardKind: 'arduino-uno' }],
});
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
fix(circuit-sim): stop() guard + drop edges with no V-source post-rebuild Two related bugs that surfaced as "Vitest worker exited unexpectedly / Timeout terminating forks worker" on the circuit-simulation-service test file. Bug 1 — tick() recursively re-schedules itself in its finally block. After afterEach disposes the scheduler via __resetMixedModeScheduler(), those re-scheduled ticks throw "call loadCircuit first", get caught by the console.warn, and the finally schedules ANOTHER tick. Infinite Promise loop survives until the worker OOMs. Fix: add CircuitSimulationService.stop() that flips a `stopped` flag short-circuiting tick() + handleMcuEdge(). The test harness now tracks each started service in _activeServices and calls stop() in afterEach alongside the existing unsubscribe sweep. Bug 2 — when an MCU edge fires on a pin that's NOT wired into any net (buildNetlist skips it because netLookup returns null), handleMcuEdge sees hasSource=false, self-heals by queueing the edge + tick(). The rebuild still doesn't emit the V-source (no wire), so tick.finally replays the edge → self-heal again → tick again → infinite loop AT RUNTIME, not just in tests. A user toggling a digital pin without a wire freezes the whole circuit simulation. Fix: in tick.finally's pendingMcuEdges replay loop, check whether the rebuilt netlist now contains a V-source for each pending edge's pin. If not, drop the edge silently — a future canvas tick triggered by adding the wire will pick it up via the normal subscription path. Also fix the "coalesces an edge with an in-flight full solve" test fixture: simpleBoardWithBoard leaves pin 9 unwired, so V_uno_9 was never emitted and the test was racing the (now-bounded) self-heal rebuild. Replaced with an inline fixture wiring pin 9 → resistor → GND, mirroring the wired fixture used by the alter+republish test right above it. Full vitest --shard 1/2 + 2/2 pass cleanly (1886 tests, 22-29s per shard) with no worker-exit warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:49:29 +07:00
{ collectBoardPinStates: () => ({ '9': { type: 'digital', v: 0 } }) },
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
);
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
startTracked(service);
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
// While initial solve is running, fire an edge.
void service.handleMcuEdge('uno', '9', true, 5);
await new Promise((r) => setTimeout(r, 100));
// After initial solve, the pending edge replays — total solves = 2.
expect(fake.calls.solve.length).toBe(2);
expect(fake.calls.alterSource).toEqual([['V_uno_9', 5]]);
});
fix(perf): un-freeze the editor during fast-toggling simulations (ESP32 clock) Running a multiplexed 4-digit 7-segment clock on ESP32/QEMU froze the browser for minutes after Run — evaluate probes waited 40-90 s, and before the first fixes the sim WebSocket eventually died (code 1006) with the page never recovering. CPU-profiled on staging; four compounding per-GPIO-edge costs, in profile order: updateComponentState minted a new components array per edge ------------------------------------------------------------ The store setter rebuilt `components` (and one properties object) on EVERY edge even when the state didn't change. The breadboard is direct-wired to 13 board pins, so segment toggles produced thousands of store sets per second; every subscriber re-rendered each time, and the canvas subscription effect (deps: [components, ...]) re-subscribed all pin listeners in a loop. Now a no-op guard returns prevState unchanged, and breadboards are treated as self-managed (they have no visual on/off state to echo). CompilationConsole re-rendered every log line per editor render ---------------------------------------------------------------- The post-compile console holds hundreds of lines; each render called Date.toLocaleTimeString per line (~0.2 ms each — it builds a fresh Intl formatter every call). Profile: 162 s of self time in LogLine over a 337 s window, in ~150 ms tasks. LogLine is now memoized (entries are immutable), timestamps go through one shared Intl.DateTimeFormat, and the console itself is React.memo'd against parent re-renders. Per-edge full SPICE re-solves ------------------------------ PinManager requested a FULL netlist rebuild+solve on every 'mcu' edge. Now only the edge that newly classifies a pin as MCU-output triggers the rebuild (that's what emits the pin's V-source); steady-state updates flow through connectMcuEdgesToService's per-pin coalesced alterSource path. The start.ts resolve hook is trailing-throttled (33 ms) for the other per-edge callers (RP2040, custom chips), the service's pending-edge queue drains on a 33 ms gap timer instead of replaying back-to-back, and new edges arriving inside the gap queue instead of soloing a solve. STM32 / Pi reverse pin-name mappings added to connectMcuEdgesToService so those boards keep fine-grained updates now that the full-tick storm is gone (PA0/PC13-style and GPIO-style names never matched before). wokwi-7segment re-rendered per segment write --------------------------------------------- element.values now flushes at most every 8 ms per display (trailing write guaranteed), instead of re-rendering the 32-shape SVG per edge. Also: CLN (colon) pin support for 7-segment clock faces — wired CLN now drives colon/colonValue in both the attachEvents path and the QEMU onPinStateChange path; it was silently ignored, so clock colons never lit. Verified on staging with the failing project: main-thread probes drop from 40-90 s waits (324 long tasks, 52.6 s blocked in 150 s) to 5-11 ms (2 long tasks, 179 ms), display shows 12:00 with the colon blinking at 1 Hz from the first seconds after Run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:15:59 +07:00
it('bounds solve rate under a sustained edge storm (multiplexed display)', async () => {
// Regression: a 4-digit 7-segment clock over QEMU keeps ~13 pins hot
// (thousands of GPIO edges/second). Replaying queued edges IMMEDIATELY
// after each solve ran the solver at 100% duty with no idle gap — the
// main thread starved for minutes until the sim WebSocket dropped.
// The drain timer must space solves out: over a ~200 ms storm window
// the solve count stays bounded (~1 per 33 ms gap), nowhere near the
// one-solve-per-edge fire hose.
const fake = new FakeSolverAdapter({
vectors: { 'v(vcc_rail)': 5 },
solveDelayMs: 2,
});
__setSchedulerSolverFactoryForTests(() => fake);
const sim = makeSimStore({
components: [
{ id: 'rb', metadataId: 'resistor', properties: { value: '1k' } },
],
wires: [
{
id: 'w1',
start: { componentId: 'uno', pinName: '9' },
end: { componentId: 'rb', pinName: '1' },
},
{
id: 'w2',
start: { componentId: 'rb', pinName: '2' },
end: { componentId: 'uno', pinName: 'GND' },
},
],
boards: [{ id: 'uno', boardKind: 'arduino-uno' }],
});
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
{ collectBoardPinStates: () => ({ '9': { type: 'digital', v: 0 } }) },
);
startTracked(service);
await new Promise((r) => setTimeout(r, 30)); // let the initial solve land
// Storm: toggle the pin every 2 ms for 200 ms (~100 edges).
let state = false;
for (let i = 0; i < 100; i++) {
state = !state;
void service.handleMcuEdge('uno', '9', state, 5);
await new Promise((r) => setTimeout(r, 2));
}
await new Promise((r) => setTimeout(r, 80)); // trailing drain
// 100 edges in ~200 ms with a 33 ms drain gap → ~7 solves + initial.
// Generous ceiling; the pre-fix behaviour was 1 solve per edge (100+).
const total = fake.calls.solve.length;
expect(total).toBeGreaterThanOrEqual(2); // it DID keep solving
expect(total).toBeLessThanOrEqual(30);
});
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
it('kicks a full tick when no circuit has been loaded yet', async () => {
const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } });
__setSchedulerSolverFactoryForTests(() => fake);
const sim = makeSimStore(simpleBoardWithBoard);
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
{ collectBoardPinStates: () => ({}) },
);
// No service.start() — first call is handleMcuEdge.
await service.handleMcuEdge('uno', '9', true, 5);
await new Promise((r) => setTimeout(r, 10));
// Should have done a FULL tick (loadCircuit + solve), not just alter.
expect(fake.calls.loadCircuit.length).toBe(1);
expect(fake.calls.alterSource).toEqual([]);
});
});
feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator) The service is the single owner of the simulation loop. Replaces the trio of wireElectricalSolver + connectLegacySolverToMixedMode + connectMixedModeSchedulerToStore once G* lands. Architecture: - Depends on PORTS only — SimulatorStorePort, ElectricalStorePort, MixedModeSchedulerPort. Zero coupling to useSimulatorStore / useElectricalStore / WASM. Easy to test with fakes (and that's what circuit-simulation-service.test.ts does). - Single tick(): build netlist → load → solve → extract → publish. Coalesces concurrent triggers so rapid store changes collapse to one trailing solve. - Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents + pinNetMap + timeWaveforms + analysisMode + warnings. Shape matches what the 12 existing useElectricalStore consumers read. NetlistBuilder extension: BuildNetlistResult now reports `nets` (every non-ground SPICE net) and `voltageSources` (every V card the builder emitted). The service uses these to construct the full vectorsOfInterest list — every node voltage + every branch current — so the solver returns the data the legacy consumers want. Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the orchestrator add to the per-pin set. Branch currents (i(v_*)) flow through this hook. 8 service tests cover initial solve, branch current extraction, re-solve on store change, no-spurious-solve, coalescing, .tran waveforms, warnings forwarding, error-tolerance. Next: C1+C2 — extract ADC injection / waveform replay into a solver-agnostic module that just subscribes to useElectricalStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:28:26 +07:00
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
describe('CircuitSimulationService — error handling', () => {
feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator) The service is the single owner of the simulation loop. Replaces the trio of wireElectricalSolver + connectLegacySolverToMixedMode + connectMixedModeSchedulerToStore once G* lands. Architecture: - Depends on PORTS only — SimulatorStorePort, ElectricalStorePort, MixedModeSchedulerPort. Zero coupling to useSimulatorStore / useElectricalStore / WASM. Easy to test with fakes (and that's what circuit-simulation-service.test.ts does). - Single tick(): build netlist → load → solve → extract → publish. Coalesces concurrent triggers so rapid store changes collapse to one trailing solve. - Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents + pinNetMap + timeWaveforms + analysisMode + warnings. Shape matches what the 12 existing useElectricalStore consumers read. NetlistBuilder extension: BuildNetlistResult now reports `nets` (every non-ground SPICE net) and `voltageSources` (every V card the builder emitted). The service uses these to construct the full vectorsOfInterest list — every node voltage + every branch current — so the solver returns the data the legacy consumers want. Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the orchestrator add to the per-pin set. Branch currents (i(v_*)) flow through this hook. 8 service tests cover initial solve, branch current extraction, re-solve on store change, no-spurious-solve, coalescing, .tran waveforms, warnings forwarding, error-tolerance. Next: C1+C2 — extract ADC injection / waveform replay into a solver-agnostic module that just subscribes to useElectricalStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:28:26 +07:00
it('logs but does not throw when solver fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const fake = new FakeSolverAdapter();
// Override solve to reject on first call.
let calls = 0;
fake.solve = async () => {
calls++;
throw new Error('boom');
};
__setSchedulerSolverFactoryForTests(() => fake);
const sim = makeSimStore(simpleBoardWithBoard);
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
{ collectBoardPinStates: () => ({}) },
);
fix: regenerate components-metadata + plug vitest worker leak Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:38:22 +07:00
startTracked(service);
feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator) The service is the single owner of the simulation loop. Replaces the trio of wireElectricalSolver + connectLegacySolverToMixedMode + connectMixedModeSchedulerToStore once G* lands. Architecture: - Depends on PORTS only — SimulatorStorePort, ElectricalStorePort, MixedModeSchedulerPort. Zero coupling to useSimulatorStore / useElectricalStore / WASM. Easy to test with fakes (and that's what circuit-simulation-service.test.ts does). - Single tick(): build netlist → load → solve → extract → publish. Coalesces concurrent triggers so rapid store changes collapse to one trailing solve. - Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents + pinNetMap + timeWaveforms + analysisMode + warnings. Shape matches what the 12 existing useElectricalStore consumers read. NetlistBuilder extension: BuildNetlistResult now reports `nets` (every non-ground SPICE net) and `voltageSources` (every V card the builder emitted). The service uses these to construct the full vectorsOfInterest list — every node voltage + every branch current — so the solver returns the data the legacy consumers want. Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the orchestrator add to the per-pin set. Branch currents (i(v_*)) flow through this hook. 8 service tests cover initial solve, branch current extraction, re-solve on store change, no-spurious-solve, coalescing, .tran waveforms, warnings forwarding, error-tolerance. Next: C1+C2 — extract ADC injection / waveform replay into a solver-agnostic module that just subscribes to useElectricalStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:28:26 +07:00
await new Promise((r) => setTimeout(r, 10));
expect(warn).toHaveBeenCalled();
expect(elec.snapshots.length).toBe(0); // no publish on failure
expect(calls).toBeGreaterThan(0);
warn.mockRestore();
});
});