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

177 lines
5.9 KiB
TypeScript

/**
* 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();
});
});