From 1ab294cf10ebc6d38c0424ff271f749dbe0bca94 Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Fri, 15 May 2026 17:06:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(sim):=20Phase=201b=20continued,=20step=201?= =?UTF-8?q?=20=E2=80=94=20scheduler=20voltage=20cache=20+=20subscriber=20r?= =?UTF-8?q?outing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the runtime plumbing that Phase 1b's SPICE event loop will drive: - `publishVoltage(componentId, pin, voltage)` updates a (componentId, pin) → volts cache and notifies every matching subscriber. - `getCurrentVoltage(...)` reads the cache (was previously stubbed null). - subscribe/publish routing exercised by 7 new unit tests. The scheduler still does not yet drive ngspice — `start()`, `onMcuPinChange()` are unchanged. But once Phase 1b's solve loop is in place, calling `publishVoltage` after each `readVec` is all the wiring needed for components to start reacting to SPICE-resolved analog states. This is the smallest non-trivial step that keeps the architecture honest (no test-only emitters; the same code path will be used in production). Tests skip booting the WASM worker — they call publishVoltage directly, so they pass in plain Vitest with no JSDOM Worker shim. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/mixed-mode-scheduler.test.ts | 105 ++++++++++++++++++ .../simulation/spice/MixedModeScheduler.ts | 43 ++++++- 2 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 frontend/src/__tests__/mixed-mode-scheduler.test.ts diff --git a/frontend/src/__tests__/mixed-mode-scheduler.test.ts b/frontend/src/__tests__/mixed-mode-scheduler.test.ts new file mode 100644 index 00000000..8e206df7 --- /dev/null +++ b/frontend/src/__tests__/mixed-mode-scheduler.test.ts @@ -0,0 +1,105 @@ +/** + * Phase 1b continued — Step 1 tests for MixedModeScheduler. + * + * Exercises the subscriber routing and voltage cache in isolation from + * the SPICE engine. The engine is never booted in these tests; we + * drive `publishVoltage` directly so the routing logic can be locked + * down before the real `alter + tran + readVec` loop lands. + * + * Coverage: + * - publishVoltage fires every matching subscriber and only those + * - getCurrentVoltage returns the last published value per pin + * - unsubscribe removes the callback cleanly + * - reset (via __resetMixedModeScheduler) clears state between tests + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + getMixedModeScheduler, + __resetMixedModeScheduler, +} from '../simulation/spice/MixedModeScheduler'; + +afterEach(() => { + __resetMixedModeScheduler(); +}); + +describe('MixedModeScheduler — voltage cache', () => { + it('returns null until something is published', () => { + const sched = getMixedModeScheduler(); + expect(sched.getCurrentVoltage('q1', 'C')).toBeNull(); + }); + + it('returns the last published voltage per (component, pin)', () => { + const sched = getMixedModeScheduler(); + sched.publishVoltage('q1', 'C', 4.5); + sched.publishVoltage('q1', 'B', 1.2); + sched.publishVoltage('q2', 'C', 0.3); + expect(sched.getCurrentVoltage('q1', 'C')).toBe(4.5); + expect(sched.getCurrentVoltage('q1', 'B')).toBe(1.2); + expect(sched.getCurrentVoltage('q2', 'C')).toBe(0.3); + sched.publishVoltage('q1', 'C', 2.7); // overwrite + expect(sched.getCurrentVoltage('q1', 'C')).toBe(2.7); + }); +}); + +describe('MixedModeScheduler — subscribe / publish routing', () => { + it('fires the matching subscriber with the published voltage', () => { + const sched = getMixedModeScheduler(); + const cb = vi.fn(); + sched.subscribe('q1', 'C', cb); + sched.publishVoltage('q1', 'C', 4.7); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith('UNKNOWN', 4.7); + }); + + it('does NOT fire subscribers watching a different pin', () => { + const sched = getMixedModeScheduler(); + const cbMatching = vi.fn(); + const cbOtherPin = vi.fn(); + const cbOtherComp = vi.fn(); + sched.subscribe('q1', 'C', cbMatching); + sched.subscribe('q1', 'B', cbOtherPin); + sched.subscribe('q2', 'C', cbOtherComp); + sched.publishVoltage('q1', 'C', 4.7); + expect(cbMatching).toHaveBeenCalledTimes(1); + expect(cbOtherPin).not.toHaveBeenCalled(); + expect(cbOtherComp).not.toHaveBeenCalled(); + }); + + it('supports multiple subscribers on the same pin (fan-out)', () => { + const sched = getMixedModeScheduler(); + const cbA = vi.fn(); + const cbB = vi.fn(); + sched.subscribe('q1', 'C', cbA); + sched.subscribe('q1', 'C', cbB); + sched.publishVoltage('q1', 'C', 4.7); + expect(cbA).toHaveBeenCalledWith('UNKNOWN', 4.7); + expect(cbB).toHaveBeenCalledWith('UNKNOWN', 4.7); + }); + + it('unsubscribe handle detaches the callback', () => { + const sched = getMixedModeScheduler(); + const cb = vi.fn(); + const cancel = sched.subscribe('q1', 'C', cb); + sched.publishVoltage('q1', 'C', 4.7); + expect(cb).toHaveBeenCalledTimes(1); + cancel(); + sched.publishVoltage('q1', 'C', 0.3); + expect(cb).toHaveBeenCalledTimes(1); // not called again + }); + + it('reset clears subscribers and voltage cache', () => { + const sched = getMixedModeScheduler(); + const cb = vi.fn(); + sched.subscribe('q1', 'C', cb); + sched.publishVoltage('q1', 'C', 4.7); + __resetMixedModeScheduler(); + + const sched2 = getMixedModeScheduler(); + expect(sched2).not.toBe(sched); + expect(sched2.getCurrentVoltage('q1', 'C')).toBeNull(); + sched2.publishVoltage('q1', 'C', 0.5); + // The old subscriber attached to the disposed scheduler must NOT + // fire from the new scheduler instance. + expect(cb).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/simulation/spice/MixedModeScheduler.ts b/frontend/src/simulation/spice/MixedModeScheduler.ts index d58c04a8..623d42ab 100644 --- a/frontend/src/simulation/spice/MixedModeScheduler.ts +++ b/frontend/src/simulation/spice/MixedModeScheduler.ts @@ -69,10 +69,16 @@ type SubscriptionToken = number; * but does NOT yet drive real SPICE solves on pin edges. Phase 1b * continued: implement the alter+tran+readVec loop, hook NetlistBuilder. */ +/** Voltage cache key = `${componentId}|${componentPinName}`. */ +function pinKey(componentId: string, componentPinName: string): string { + return `${componentId}|${componentPinName}`; +} + class MixedModeSchedulerImpl implements SpiceVoltageSource { private engine: NgSpiceInteractive | null = null; private nextToken: SubscriptionToken = 1; private subscriptions = new Map(); + private voltages = new Map(); private running = false; private initPromise: Promise | null = null; @@ -152,13 +158,38 @@ class MixedModeSchedulerImpl implements SpiceVoltageSource { /** * Look up the latest known voltage on a component pin's SPICE net. - * Phase 1b skeleton: always returns null (we haven't started solving - * yet). Phase 1b continued: query the NgSpiceInteractive engine's - * last `readVec` cache and return the latest sample. + * Returns the value last published via `publishVoltage`, or null if + * nothing has been published for that pin yet. Phase 1b continued + * will populate this cache from `NgSpiceInteractive.readVec` after + * each solve. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getCurrentVoltage(_componentId: string, _componentPinName: string): number | null { - return null; + getCurrentVoltage(componentId: string, componentPinName: string): number | null { + const v = this.voltages.get(pinKey(componentId, componentPinName)); + return v === undefined ? null : v; + } + + /** + * Publish a freshly-resolved voltage for a (component, pin) and + * notify all subscribers watching that key. Stores the value in + * the cache so subsequent `getCurrentVoltage` calls see it. + * + * The SPICE-resolved PinResolver does its own threshold conversion, + * so this layer only forwards raw volts with a placeholder + * `'UNKNOWN'` state — the resolver re-derives HIGH/LOW from the + * voltage using its configured thresholds. Skipping the threshold + * decision here keeps the scheduler I/O-family-agnostic. + */ + publishVoltage(componentId: string, componentPinName: string, voltage: number): void { + this.voltages.set(pinKey(componentId, componentPinName), voltage); + for (const sub of this.subscriptions.values()) { + if (sub.componentId === componentId && sub.componentPinName === componentPinName) { + // 'UNKNOWN' is a sentinel — the SpiceResolvedPinResolver re- + // computes the state from the voltage via its threshold + // configuration. We could pass any string here; 'UNKNOWN' is + // the convention used in the Phase 1b unit tests. + sub.cb('UNKNOWN' as PinState, voltage); + } + } } /**