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

141 lines
4.6 KiB
TypeScript
Raw Normal View History

feat(sim): introduce PinResolver abstraction (Phase 0 of mixed-mode rewrite) Decouple per-component handlers from direct pinManager.onPinChange + getArduinoPinHelper subscriptions by introducing a small PinResolver interface. The Phase 0 default impl is functionally identical to the legacy path — it just routes through PinResolver instead of being inlined in every handler. Zero behavior change. The point is to make Phase 1 possible: swap the default impl for a SPICE-resolved version that watches node voltages and threshold- converts to digital events, without rewriting every handler. Files: - simulation/PinResolver.ts (new) — interface + default factory - parts/PartSimulationRegistry.ts — additive 5th arg to attachEvents (getPinResolver?), legacy 4-arg signatures keep working unchanged - components/DynamicComponent.tsx — assembles the PinResolver from the wire-trace logic + PinManager subscriptions + board Vcc lookup, passes it as the 5th arg to attachEvents - parts/BasicParts.ts — LED handler migrated as proof of concept (resolver-first path, legacy 4-arg path kept as fallback for tests / unmigrated harnesses) - __tests__/pin-resolver.test.ts (new) — 8 unit tests covering FLOATING / GND / HIGH / LOW / GPIO subscriptions / unsubscribe Vitest: 8/8 pin-resolver tests pass. 1300+ existing tests still pass; the one pre-existing flake (spice-rectifier-live-repro timing out >60s) is unrelated to this commit — verified by running the test on plain HEAD without these changes (same timeout). See project/sim-mixedmode/phase-00-pin-resolver.md (in the velxio-prod repo) for full phase context.
2026-05-15 20:50:09 +07:00
/**
* Unit tests for the default PinResolver.
*
* These cover the Phase-0 deliverable: a PinResolver that wraps the
* existing getArduinoPin trace + pinManager subscription. Behavior
* must be identical to the legacy path; the abstraction just makes
* the swap to a SPICE-resolved implementation possible in Phase 1.
*
* See project/sim-mixedmode/phase-00-pin-resolver.md
*/
import { describe, it, expect, vi } from 'vitest';
import {
createDefaultPinResolver,
type PinResolverContext,
type PinTracer,
} from '../simulation/PinResolver';
function makeCtx(overrides: Partial<PinResolverContext> = {}): PinResolverContext {
return {
components: [],
boards: [],
wires: [],
ownerBoard: null,
ownerBoardVcc: 5,
subscribeArduinoPin: () => () => {},
readArduinoPin: () => null,
...overrides,
};
}
describe('PinResolver — default impl', () => {
it('reports FLOATING when the pin is not wired to any board pin', () => {
const tracer: PinTracer = () => null;
const r = createDefaultPinResolver('led-1', 'A', makeCtx(), tracer);
expect(r.getCurrentState()).toBe('FLOATING');
expect(r.getCurrentVoltage()).toBeNull();
});
it('reports LOW with voltage 0 when the pin is tied to GND (tracer returns -1)', () => {
const tracer: PinTracer = () => -1;
const r = createDefaultPinResolver('led-1', 'C', makeCtx(), tracer);
expect(r.getCurrentState()).toBe('LOW');
expect(r.getCurrentVoltage()).toBe(0);
});
it('reads HIGH/LOW from readArduinoPin when wired to a GPIO', () => {
const tracer: PinTracer = () => 13;
let pinValue = false;
const ctx = makeCtx({
readArduinoPin: (pin) => (pin === 13 ? pinValue : null),
});
const r = createDefaultPinResolver('led-1', 'A', ctx, tracer);
pinValue = false;
expect(r.getCurrentState()).toBe('LOW');
expect(r.getCurrentVoltage()).toBe(0);
});
it('synthesises voltage from owner board Vcc on HIGH', () => {
const tracer: PinTracer = () => 13;
const ctx = makeCtx({
ownerBoardVcc: 3.3,
readArduinoPin: () => true,
});
const r = createDefaultPinResolver('led-1', 'A', ctx, tracer);
expect(r.getCurrentState()).toBe('HIGH');
expect(r.getCurrentVoltage()).toBeCloseTo(3.3);
});
it('subscribes to pin changes via subscribeArduinoPin and fires callbacks', () => {
const tracer: PinTracer = () => 7;
const subscribers: Array<(pin: number, state: boolean) => void> = [];
const unsub = vi.fn();
const ctx = makeCtx({
subscribeArduinoPin: (_pin, cb) => {
subscribers.push(cb);
return unsub;
},
});
const r = createDefaultPinResolver('led-1', 'A', ctx, tracer);
const onChange = vi.fn();
const cancel = r.onChange(onChange);
// Manually fire as if PinManager delivered an event
subscribers[0]!(7, true);
expect(onChange).toHaveBeenCalledWith('HIGH', 5);
subscribers[0]!(7, false);
expect(onChange).toHaveBeenCalledWith('LOW', 0);
// Caller can unsubscribe cleanly
cancel();
expect(unsub).toHaveBeenCalled();
});
it('returns a no-op unsubscribe when the pin is unwired (tracer returns null)', () => {
const tracer: PinTracer = () => null;
const ctx = makeCtx({
subscribeArduinoPin: vi.fn(),
});
const r = createDefaultPinResolver('led-1', 'A', ctx, tracer);
const onChange = vi.fn();
const cancel = r.onChange(onChange);
expect(ctx.subscribeArduinoPin).not.toHaveBeenCalled();
expect(typeof cancel).toBe('function');
cancel(); // should not throw
});
it('emits an initial LOW event asynchronously when wired to GND', async () => {
const tracer: PinTracer = () => -1;
const r = createDefaultPinResolver('led-1', 'C', makeCtx(), tracer);
const onChange = vi.fn();
r.onChange(onChange);
// Microtask queue flush — the resolver schedules the LOW event via
// queueMicrotask so subscribers see initial state without blocking
// construction.
await Promise.resolve();
expect(onChange).toHaveBeenCalledWith('LOW', 0);
});
it('updates getCurrentState immediately after a subscribed callback fires', () => {
const tracer: PinTracer = () => 9;
const subscribers: Array<(pin: number, state: boolean) => void> = [];
const ctx = makeCtx({
subscribeArduinoPin: (_pin, cb) => {
subscribers.push(cb);
return () => {};
},
readArduinoPin: () => false,
});
const r = createDefaultPinResolver('led-1', 'A', ctx, tracer);
r.onChange(() => {});
expect(r.getCurrentState()).toBe('LOW');
subscribers[0]!(9, true);
expect(r.getCurrentState()).toBe('HIGH');
expect(r.getCurrentVoltage()).toBe(5);
});
});