velxio/frontend/src/__tests__/pin-resolver-phase1b.test.ts

177 lines
5.9 KiB
TypeScript
Raw Normal View History

feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection Adds the architecture pieces for mixed-mode coupling without yet driving the SPICE engine. Components on a path that crosses an active device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route through a new SPICE-resolved PinResolver variant; everything else keeps the digital fast-path from Phase 0. What ships: - simulation/PinResolver.ts * `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES` list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay). * `DetailedPinTrace` / `DetailedPinTracer` types — the trace function now reports whether it crossed an active device, on top of the Arduino pin number. * `createSpiceResolvedPinResolver()` — new factory; reads voltages from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW with hysteresis (thresholdHigh != thresholdLow → Schmitt-like). - simulation/spice/MixedModeScheduler.ts (new) * Singleton orchestrator that holds the NgSpiceInteractive engine and the SpiceVoltageSource subscription registry. * `start()` / `stop()` / `dispose()` lifecycle. * `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource. * `onMcuPinChange()` placeholder for the alter+tran event loop. * Skeleton: subscribers register but never receive events yet. Phase 1b continued will wire NgSpiceInteractive into the loop. - components/DynamicComponent.tsx * Trace function extended with `traceDetailed()` that tracks whether the BFS crossed an active component. * PinResolver factory branches: active-path → SPICE-resolved (uses the scheduler), digital-only → existing default impl. Default threshold = vcc/2 with no hysteresis; Phase 3 will replace with per-logic-family Vil/Vih. Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved path falls back to FLOATING until Phase 1b continued wires the engine. Tests: - pin-resolver-phase1b.test.ts (new) — 8/8 passing. Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator metadata id; SPICE-resolved resolver state reporting, threshold conversion, hysteresis dead-band, unsubscribe. - pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression). - tsc --noEmit on the new files: clean. No deploy in this commit — staged for end-of-session rebuild + push per user preference.
2026-05-15 21:38:30 +07:00
/**
* Phase 1b unit tests for PinResolver:
* - isActiveDevice predicate against the full active-device list
* - createSpiceResolvedPinResolver threshold conversion behavior
* - SpiceVoltageSource subscription wiring
*
* Doesn't exercise actual SPICE because Phase 1b is the skeleton.
* Phase 1b continued will add tests against MixedModeScheduler with a
* real (mocked) NgSpiceInteractive.
*/
import { describe, it, expect, vi } from 'vitest';
import {
isActiveDevice,
createSpiceResolvedPinResolver,
type SpiceVoltageSource,
} from '../simulation/PinResolver';
function mockSource(opts: { initialVoltage?: number | null } = {}): {
source: SpiceVoltageSource;
fire: (state: 'HIGH' | 'LOW' | 'FLOATING' | 'CONFLICT', voltage: number) => void;
subscribers: Array<{ componentId: string; pinName: string; cb: (state: string, v: number) => void }>;
} {
const subscribers: Array<{ componentId: string; pinName: string; cb: (state: string, v: number) => void }> = [];
let voltage = opts.initialVoltage ?? null;
const source: SpiceVoltageSource = {
subscribe(componentId, componentPinName, cb) {
const entry = { componentId, pinName: componentPinName, cb: cb as (s: string, v: number) => void };
subscribers.push(entry);
return () => {
const idx = subscribers.indexOf(entry);
if (idx >= 0) subscribers.splice(idx, 1);
};
},
getCurrentVoltage() {
return voltage;
},
};
return {
source,
subscribers,
fire(state, v) {
voltage = v;
for (const s of subscribers) s.cb(state, v);
},
};
}
describe('isActiveDevice', () => {
it('reports true for every BJT/MOSFET/op-amp/diode part-number', () => {
expect(isActiveDevice('bjt-2n2222')).toBe(true);
expect(isActiveDevice('bjt-bc547')).toBe(true);
expect(isActiveDevice('mosfet-2n7000')).toBe(true);
expect(isActiveDevice('mosfet-irf540')).toBe(true);
expect(isActiveDevice('opamp-lm358')).toBe(true);
expect(isActiveDevice('opamp-ideal')).toBe(true);
expect(isActiveDevice('diode-1n4148')).toBe(true);
expect(isActiveDevice('led')).toBe(true);
expect(isActiveDevice('relay')).toBe(true);
expect(isActiveDevice('7805')).toBe(true);
expect(isActiveDevice('lm317')).toBe(true);
});
it('reports false for passives and digital ICs', () => {
expect(isActiveDevice('resistor')).toBe(false);
expect(isActiveDevice('resistor-220')).toBe(false);
expect(isActiveDevice('capacitor')).toBe(false);
expect(isActiveDevice('cap-1u')).toBe(false);
expect(isActiveDevice('inductor')).toBe(false);
// Digital ICs are NOT routed through SPICE — they have their own
// pre-decoded handlers
expect(isActiveDevice('ic-74hc14')).toBe(false);
expect(isActiveDevice('74hc595')).toBe(false);
});
});
describe('createSpiceResolvedPinResolver', () => {
it('reports FLOATING when the voltage source has no value yet', () => {
const { source } = mockSource({ initialVoltage: null });
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
thresholdHigh: 2.5,
thresholdLow: 2.5,
vcc: 5,
});
expect(r.getCurrentState()).toBe('FLOATING');
expect(r.getCurrentVoltage()).toBeNull();
});
it('reports HIGH when voltage is above thresholdHigh', () => {
const { source } = mockSource({ initialVoltage: 4.5 });
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
thresholdHigh: 2.5,
thresholdLow: 2.5,
vcc: 5,
});
expect(r.getCurrentState()).toBe('HIGH');
expect(r.getCurrentVoltage()).toBeCloseTo(4.5);
});
it('reports LOW when voltage is below thresholdLow', () => {
const { source } = mockSource({ initialVoltage: 0.3 });
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
thresholdHigh: 2.5,
thresholdLow: 2.5,
vcc: 5,
});
expect(r.getCurrentState()).toBe('LOW');
expect(r.getCurrentVoltage()).toBeCloseTo(0.3);
});
it('emits state changes via onChange when the voltage crosses a threshold', () => {
const { source, fire } = mockSource({ initialVoltage: 0 });
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
thresholdHigh: 2.5,
thresholdLow: 2.5,
vcc: 5,
});
const cb = vi.fn();
r.onChange(cb);
fire('LOW', 0.5);
expect(cb).not.toHaveBeenCalled(); // still LOW, no transition
fire('HIGH', 4.5);
expect(cb).toHaveBeenCalledWith('HIGH', 4.5);
fire('HIGH', 3.0);
expect(cb).toHaveBeenCalledTimes(1); // already HIGH, no new transition
fire('LOW', 0.5);
expect(cb).toHaveBeenCalledTimes(2);
expect(cb).toHaveBeenLastCalledWith('LOW', 0.5);
});
it('respects the hysteresis dead band (different thresholds)', () => {
const { source, fire } = mockSource({ initialVoltage: 0 });
const r = createSpiceResolvedPinResolver('schmitt-1', 'A', source, {
thresholdHigh: 3.0,
thresholdLow: 1.5,
vcc: 5,
});
const cb = vi.fn();
r.onChange(cb);
fire('LOW', 2.0); // in the dead band — stays LOW
expect(cb).not.toHaveBeenCalled();
fire('HIGH', 3.5); // crosses upper threshold → HIGH
expect(cb).toHaveBeenCalledWith('HIGH', 3.5);
cb.mockClear();
fire('HIGH', 2.0); // back in dead band — stays HIGH (hysteresis)
expect(cb).not.toHaveBeenCalled();
fire('LOW', 1.0); // crosses lower threshold → LOW
expect(cb).toHaveBeenCalledWith('LOW', 1.0);
});
it('returns an unsubscribe handle that detaches from the source', () => {
const { source, subscribers, fire } = mockSource({ initialVoltage: 0 });
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
thresholdHigh: 2.5,
thresholdLow: 2.5,
vcc: 5,
});
const cb = vi.fn();
const cancel = r.onChange(cb);
expect(subscribers.length).toBe(1);
cancel();
expect(subscribers.length).toBe(0);
fire('HIGH', 4.5);
expect(cb).not.toHaveBeenCalled();
});
});