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:
parent
d9945d13b8
commit
1ab294cf10
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue