/** * interactive-parts.test.ts * * Tests simulation logic for the six new components: * neopixel, pir-motion-sensor, ks2e-m-dc5, hc-sr04, membrane-keypad, rotary-dialer */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry'; // Side-effect imports — register all parts import '../simulation/parts/BasicParts'; import '../simulation/parts/ComplexParts'; import '../simulation/parts/ChipParts'; import '../simulation/parts/SensorParts'; // ─── Globals ────────────────────────────────────────────────────────────────── beforeEach(() => { let requestAnimationFrameCounter = 0; vi.stubGlobal( 'requestAnimationFrame', (_cb: FrameRequestCallback) => ++requestAnimationFrameCounter, ); vi.stubGlobal('cancelAnimationFrame', vi.fn()); vi.stubGlobal('setTimeout', vi.fn().mockReturnValue(1)); vi.stubGlobal('clearTimeout', vi.fn()); vi.stubGlobal('setInterval', vi.fn().mockReturnValue(42)); vi.stubGlobal('clearInterval', vi.fn()); }); afterEach(() => vi.unstubAllGlobals()); // ─── Mock factories ─────────────────────────────────────────────────────────── function makeElement(props: Record = {}): HTMLElement { return { addEventListener: vi.fn(), removeEventListener: vi.fn(), ...props, } as unknown as HTMLElement; } function makeSimulator() { const pinManager = { onPinChange: vi.fn().mockReturnValue(() => {}), onPwmChange: vi.fn().mockReturnValue(() => {}), triggerPinChange: vi.fn(), }; return { pinManager, getADC: vi.fn().mockReturnValue(null), setPinState: vi.fn(), cpu: { data: new Uint8Array(512).fill(0), cycles: 0 }, }; } const pinMap = (map: Record) => (name: string): number | null => name in map ? map[name] : null; const noPins = (_name: string): number | null => null; // ─── Registration ───────────────────────────────────────────────────────────── describe('Interactive parts — registration', () => { const NEW_IDS = [ 'neopixel', 'pir-motion-sensor', 'ks2e-m-dc5', 'hc-sr04', 'membrane-keypad', 'rotary-dialer', ]; it('registers all six new component types', () => { for (const id of NEW_IDS) { expect(PartSimulationRegistry.get(id), `missing: ${id}`).toBeDefined(); } }); }); // ─── neopixel ───────────────────────────────────────────────────────────────── describe('neopixel — attachEvents', () => { it('registers onPinChange listener for DIN pin', () => { const logic = PartSimulationRegistry.get('neopixel'); const el = makeElement(); const sim = makeSimulator(); const cleanup = logic!.attachEvents!(el, sim as any, pinMap({ DIN: 4 })); expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(4, expect.any(Function)); cleanup(); }); it('returns no-op cleanup when DIN pin is not connected', () => { const logic = PartSimulationRegistry.get('neopixel'); const el = makeElement(); const sim = makeSimulator(); const cleanup = logic!.attachEvents!(el, sim as any, noPins); expect(sim.pinManager.onPinChange).not.toHaveBeenCalled(); expect(() => cleanup()).not.toThrow(); }); }); // ─── pir-motion-sensor ──────────────────────────────────────────────────────── describe('pir-motion-sensor — attachEvents', () => { it('sets OUT LOW on init and registers click listener', () => { const logic = PartSimulationRegistry.get('pir-motion-sensor'); const el = makeElement(); const sim = makeSimulator(); logic!.attachEvents!(el, sim as any, pinMap({ OUT: 7 })); expect(sim.setPinState).toHaveBeenCalledWith(7, false); // idle LOW expect(el.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); }); it('drives OUT HIGH on click and schedules timer for LOW', () => { const logic = PartSimulationRegistry.get('pir-motion-sensor'); const el = makeElement(); const sim = makeSimulator(); logic!.attachEvents!(el, sim as any, pinMap({ OUT: 7 })); // Extract the click handler const clickCb = (el.addEventListener as ReturnType).mock.calls.find( ([event]: [string]) => event === 'click', )![1] as () => void; clickCb(); expect(sim.setPinState).toHaveBeenCalledWith(7, true); // HIGH on click expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 3000); }); it('cleans up click listener and timer on cleanup', () => { const logic = PartSimulationRegistry.get('pir-motion-sensor'); const el = makeElement(); const sim = makeSimulator(); const cleanup = logic!.attachEvents!(el, sim as any, pinMap({ OUT: 7 })); // Fire a click so the timer is started const clickCb = (el.addEventListener as ReturnType).mock.calls.find( ([event]: [string]) => event === 'click', )![1] as () => void; clickCb(); // starts the 3s timer cleanup(); expect(el.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function)); expect(clearTimeout).toHaveBeenCalled(); // timer is now non-null → cleared }); it('returns no-op when OUT pin is not connected', () => { const logic = PartSimulationRegistry.get('pir-motion-sensor'); const el = makeElement(); const sim = makeSimulator(); const cleanup = logic!.attachEvents!(el, sim as any, noPins); expect(sim.setPinState).not.toHaveBeenCalled(); expect(() => cleanup()).not.toThrow(); }); }); // ─── ks2e-m-dc5 (relay) ─────────────────────────────────────────────────────── describe('ks2e-m-dc5 — onPinStateChange', () => { it('has onPinStateChange handler but no attachEvents', () => { const logic = PartSimulationRegistry.get('ks2e-m-dc5'); expect(logic).toBeDefined(); expect(logic!.onPinStateChange).toBeTypeOf('function'); expect(logic!.attachEvents).toBeUndefined(); }); it('does not throw when COIL1 goes HIGH', () => { const logic = PartSimulationRegistry.get('ks2e-m-dc5'); const el = makeElement(); expect(() => logic!.onPinStateChange!('COIL1', true, el)).not.toThrow(); }); it('does not throw when COIL2 goes LOW', () => { const logic = PartSimulationRegistry.get('ks2e-m-dc5'); const el = makeElement(); expect(() => logic!.onPinStateChange!('COIL2', false, el)).not.toThrow(); }); }); // ─── hc-sr04 ────────────────────────────────────────────────────────────────── describe('hc-sr04 — attachEvents', () => { it('sets ECHO LOW on init and watches TRIG pin', () => { const logic = PartSimulationRegistry.get('hc-sr04'); const el = makeElement(); const sim = makeSimulator(); logic!.attachEvents!(el, sim as any, pinMap({ TRIG: 2, ECHO: 3 })); expect(sim.setPinState).toHaveBeenCalledWith(3, false); // ECHO initially LOW expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(2, expect.any(Function)); }); it('fires ECHO HIGH pulse when TRIG goes HIGH', () => { const logic = PartSimulationRegistry.get('hc-sr04'); const el = makeElement(); const sim = makeSimulator(); logic!.attachEvents!(el, sim as any, pinMap({ TRIG: 2, ECHO: 3 })); // Extract the onPinChange callback for TRIG const trigCb = (sim.pinManager.onPinChange as ReturnType).mock.calls.find( ([pin]: [number]) => pin === 2, )![1] as (_: number, state: boolean) => void; trigCb(2, true); // TRIG HIGH expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1); }); it('returns no-op when TRIG or ECHO is not connected', () => { const logic = PartSimulationRegistry.get('hc-sr04'); const el = makeElement(); const sim = makeSimulator(); const cleanup = logic!.attachEvents!(el, sim as any, noPins); expect(sim.pinManager.onPinChange).not.toHaveBeenCalled(); expect(() => cleanup()).not.toThrow(); }); }); // ─── membrane-keypad ────────────────────────────────────────────────────────── describe('membrane-keypad — attachEvents', () => { it('registers onPinChange for each connected row and button-press/release', () => { const logic = PartSimulationRegistry.get('membrane-keypad'); const el = makeElement(); const sim = makeSimulator(); logic!.attachEvents!( el, sim as any, pinMap({ R1: 2, R2: 3, R3: 4, R4: 5, C1: 6, C2: 7, C3: 8, C4: 9 }), ); // 4 row pin change listeners expect(sim.pinManager.onPinChange).toHaveBeenCalledTimes(4); // button-press and button-release listeners expect(el.addEventListener).toHaveBeenCalledWith('button-press', expect.any(Function)); expect(el.addEventListener).toHaveBeenCalledWith('button-release', expect.any(Function)); }); it('drives COL LOW when ROW is LOW and matching key is pressed', () => { const logic = PartSimulationRegistry.get('membrane-keypad'); const el = makeElement(); const sim = makeSimulator(); logic!.attachEvents!( el, sim as any, pinMap({ R1: 2, R2: 3, R3: 4, R4: 5, C1: 6, C2: 7, C3: 8, C4: 9 }), ); // Simulate key '1' press (row=0, col=0) via button-press event const pressHandler = (el.addEventListener as ReturnType).mock.calls.find( ([ev]: [string]) => ev === 'button-press', )![1] as (e: Event) => void; pressHandler(new CustomEvent('button-press', { detail: { key: '1', row: 0, column: 0 } })); // Simulate row R1 going LOW (scanned by Arduino) const rowCb = (sim.pinManager.onPinChange as ReturnType).mock.calls.find( ([pin]: [number]) => pin === 2, // R1 )![1] as (_: number, state: boolean) => void; rowCb(2, false); // R1 LOW // C1 (pin 6) should be driven LOW expect(sim.setPinState).toHaveBeenCalledWith(6, false); }); it('releases COL HIGH when ROW returns HIGH', () => { const logic = PartSimulationRegistry.get('membrane-keypad'); const el = makeElement(); const sim = makeSimulator(); logic!.attachEvents!( el, sim as any, pinMap({ R1: 2, R2: 3, R3: 4, R4: 5, C1: 6, C2: 7, C3: 8, C4: 9 }), ); // Scan R1 low (no keys pressed) const rowCb = (sim.pinManager.onPinChange as ReturnType).mock.calls.find( ([pin]: [number]) => pin === 2, )![1] as (_: number, state: boolean) => void; rowCb(2, false); // R1 LOW rowCb(2, true); // R1 HIGH // All cols must have been set HIGH at some point expect(sim.setPinState).toHaveBeenCalledWith(6, true); }); it('cleans up all listeners on cleanup', () => { const logic = PartSimulationRegistry.get('membrane-keypad'); const el = makeElement(); const sim = makeSimulator(); const cleanup = logic!.attachEvents!( el, sim as any, pinMap({ R1: 2, R2: 3, R3: 4, R4: 5, C1: 6, C2: 7, C3: 8, C4: 9 }), ); cleanup(); expect(el.removeEventListener).toHaveBeenCalledWith('button-press', expect.any(Function)); expect(el.removeEventListener).toHaveBeenCalledWith('button-release', expect.any(Function)); }); }); // ─── rotary-dialer ──────────────────────────────────────────────────────────── describe('rotary-dialer — attachEvents', () => { it('initialises DIAL and PULSE HIGH (idle)', () => { const logic = PartSimulationRegistry.get('rotary-dialer'); const el = makeElement(); const sim = makeSimulator(); logic!.attachEvents!(el, sim as any, pinMap({ DIAL: 10, PULSE: 11 })); expect(sim.setPinState).toHaveBeenCalledWith(10, true); expect(sim.setPinState).toHaveBeenCalledWith(11, true); }); it('registers dial-start and dial-end event listeners', () => { const logic = PartSimulationRegistry.get('rotary-dialer'); const el = makeElement(); const sim = makeSimulator(); logic!.attachEvents!(el, sim as any, pinMap({ DIAL: 10, PULSE: 11 })); expect(el.addEventListener).toHaveBeenCalledWith('dial-start', expect.any(Function)); expect(el.addEventListener).toHaveBeenCalledWith('dial-end', expect.any(Function)); }); it('drives DIAL LOW on dial-start', () => { const logic = PartSimulationRegistry.get('rotary-dialer'); const el = makeElement(); const sim = makeSimulator(); logic!.attachEvents!(el, sim as any, pinMap({ DIAL: 10, PULSE: 11 })); const startCb = (el.addEventListener as ReturnType).mock.calls.find( ([ev]: [string]) => ev === 'dial-start', )![1] as () => void; startCb(); expect(sim.setPinState).toHaveBeenCalledWith(10, false); // DIAL LOW }); it('schedules pulse train on dial-end', () => { const logic = PartSimulationRegistry.get('rotary-dialer'); const el = makeElement(); const sim = makeSimulator(); logic!.attachEvents!(el, sim as any, pinMap({ DIAL: 10, PULSE: 11 })); const endCb = (el.addEventListener as ReturnType).mock.calls.find( ([ev]: [string]) => ev === 'dial-end', )![1] as (e: Event) => void; endCb(new CustomEvent('dial-end', { detail: { digit: 3 } })); // setTimeout should have been scheduled to start the pulse train expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 100); }); it('returns no-op cleanup when pins are not connected', () => { const logic = PartSimulationRegistry.get('rotary-dialer'); const el = makeElement(); const sim = makeSimulator(); const cleanup = logic!.attachEvents!(el, sim as any, noPins); expect(sim.setPinState).not.toHaveBeenCalled(); expect(() => cleanup()).not.toThrow(); }); });