feat(sim): Phase 1b continued, step 1 — scheduler voltage cache + subscriber routing

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) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-15 17:06:20 +02:00
parent d9945d13b8
commit 1ab294cf10
2 changed files with 142 additions and 6 deletions

View File

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

View File

@ -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<SubscriptionToken, NodeSubscription>();
private voltages = new Map<string, number>();
private running = false;
private initPromise: Promise<void> | 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);
}
}
}
/**