1150 lines
44 KiB
TypeScript
1150 lines
44 KiB
TypeScript
/**
|
||
* Simulation Parts Tests
|
||
*
|
||
* Tests the component simulation logic registered in PartSimulationRegistry.
|
||
* All DOM elements are mocked as plain objects; no real browser APIs are needed.
|
||
*
|
||
* Covers:
|
||
* - PartSimulationRegistry lookup
|
||
* - LED, Pushbutton, Pushbutton-6mm, Slide-switch, DIP-switch-8
|
||
* - LED-bar-graph, 7segment
|
||
* - RGB LED (digital + PWM)
|
||
* - Potentiometer, Slide-potentiometer, Photoresistor-sensor, Analog-joystick
|
||
* - Servo (OCR register polling via RAF)
|
||
* - Buzzer (Web Audio API mock)
|
||
* - LCD1602 (4-bit mode pin registration)
|
||
*/
|
||
|
||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||
import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry';
|
||
import { useElectricalStore } from '../store/useElectricalStore';
|
||
|
||
// Side-effect imports — register all parts
|
||
import '../simulation/parts/BasicParts';
|
||
import '../simulation/parts/ComplexParts';
|
||
import '../simulation/parts/ChipParts';
|
||
import '../simulation/parts/SensorParts';
|
||
|
||
// ─── RAF depth-limited mock ───────────────────────────────────────────────────
|
||
// Calls the callback once synchronously but prevents re-entrancy so that
|
||
// animation loops (servo poll → rAF → poll → rAF → …) don't recurse infinitely.
|
||
beforeEach(() => {
|
||
let counter = 0;
|
||
let depth = 0;
|
||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||
if (depth === 0) {
|
||
depth++;
|
||
cb(0);
|
||
depth--;
|
||
}
|
||
return ++counter;
|
||
});
|
||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||
});
|
||
afterEach(() => vi.unstubAllGlobals());
|
||
|
||
// ─── Mock factories ───────────────────────────────────────────────────────────
|
||
|
||
/** Create a mock DOM element with vi.fn() event listeners */
|
||
function makeElement(props: Record<string, unknown> = {}): HTMLElement {
|
||
return {
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
...props,
|
||
} as unknown as HTMLElement;
|
||
}
|
||
|
||
/** Create a mock ADC with 8 channels */
|
||
function makeADC() {
|
||
return { channelValues: new Array(8).fill(0) };
|
||
}
|
||
|
||
/** Create a mock simulator whose pinManager is accessible via (sim as any).pinManager */
|
||
function makeSimulator(adc?: ReturnType<typeof makeADC> | null) {
|
||
const pinManager = {
|
||
onPinChange: vi.fn().mockReturnValue(() => {}),
|
||
onPwmChange: vi.fn().mockReturnValue(() => {}),
|
||
getPwmValue: vi.fn().mockReturnValue(0),
|
||
updatePwm: vi.fn(),
|
||
triggerPinChange: vi.fn(),
|
||
};
|
||
return {
|
||
pinManager,
|
||
getADC: vi.fn().mockReturnValue(adc ?? null),
|
||
setPinState: vi.fn(),
|
||
isRunning: vi.fn().mockReturnValue(true),
|
||
getCurrentCycles: vi.fn().mockReturnValue(1000),
|
||
getClockHz: vi.fn().mockReturnValue(16_000_000),
|
||
cpu: { data: new Uint8Array(512).fill(0), cycles: 1000 },
|
||
};
|
||
}
|
||
|
||
/** Pin helper that always returns the same pin number regardless of pin name */
|
||
const pinHelper =
|
||
(pin: number) =>
|
||
(_name: string): number | null =>
|
||
pin;
|
||
|
||
/** Pin helper that returns null for every name (no connections) */
|
||
const noPins = (_name: string): number | null => null;
|
||
|
||
/** Multi-pin helper: pass an object mapping pin names to pin numbers */
|
||
const pinMap =
|
||
(map: Record<string, number>) =>
|
||
(name: string): number | null =>
|
||
name in map ? map[name] : null;
|
||
|
||
// ─── PartSimulationRegistry ───────────────────────────────────────────────────
|
||
|
||
describe('PartSimulationRegistry — registration', () => {
|
||
const EXPECTED = [
|
||
'pushbutton',
|
||
'pushbutton-6mm',
|
||
'slide-switch',
|
||
'dip-switch-8',
|
||
'led',
|
||
'led-bar-graph',
|
||
'7segment',
|
||
'rgb-led',
|
||
'potentiometer',
|
||
'slide-potentiometer',
|
||
'photoresistor-sensor',
|
||
'analog-joystick',
|
||
'servo',
|
||
'buzzer',
|
||
'lcd1602',
|
||
'lcd2004',
|
||
];
|
||
|
||
it('registers all expected component types', () => {
|
||
for (const id of EXPECTED) {
|
||
expect(PartSimulationRegistry.get(id), `missing: ${id}`).toBeDefined();
|
||
}
|
||
});
|
||
|
||
it('returns undefined for an unknown component id', () => {
|
||
expect(PartSimulationRegistry.get('wokwi-banana')).toBeUndefined();
|
||
});
|
||
|
||
it('each registered part has onPinStateChange or attachEvents (or both)', () => {
|
||
for (const id of EXPECTED) {
|
||
const logic = PartSimulationRegistry.get(id)!;
|
||
const hasSomething = !!(logic.onPinStateChange || logic.attachEvents);
|
||
expect(hasSomething, `${id} has no simulation logic`).toBe(true);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── LED ─────────────────────────────────────────────────────────────────────
|
||
|
||
describe('LED — attachEvents (anode + cathode check)', () => {
|
||
it('LED turns on when anode HIGH and cathode wired to GND', () => {
|
||
const logic = PartSimulationRegistry.get('led')!;
|
||
const el = makeElement({ value: false });
|
||
const sim = makeSimulator();
|
||
// A → GPIO pin 13, C → GND (-1)
|
||
logic.attachEvents!(el, sim as any, pinMap({ A: 13, C: -1 }), 'led-1');
|
||
|
||
// pinManager.onPinChange should be called for anode (pin 13)
|
||
const calls = sim.pinManager.onPinChange.mock.calls;
|
||
const anodeCall = calls.find((c: any) => c[0] === 13);
|
||
expect(anodeCall).toBeDefined();
|
||
|
||
// Simulate anode going HIGH
|
||
anodeCall;
|
||
expect((el as any).value).toBe(true);
|
||
});
|
||
|
||
it('LED stays off when anode HIGH but cathode not wired', () => {
|
||
const logic = PartSimulationRegistry.get('led')!;
|
||
const el = makeElement({ value: false });
|
||
const sim = makeSimulator();
|
||
// A → pin 13, C → not wired (null)
|
||
logic.attachEvents!(el, sim as any, pinMap({ A: 13 }), 'led-2');
|
||
|
||
const calls = sim.pinManager.onPinChange.mock.calls;
|
||
const anodeCall = calls.find((c: any) => c[0] === 13);
|
||
expect(anodeCall).toBeDefined();
|
||
|
||
// Simulate anode going HIGH — should NOT light up
|
||
anodeCall;
|
||
expect((el as any).value).toBe(false);
|
||
});
|
||
|
||
it('LED turns off when anode goes LOW', () => {
|
||
const logic = PartSimulationRegistry.get('led')!;
|
||
const el = makeElement({ value: false });
|
||
const sim = makeSimulator();
|
||
logic.attachEvents!(el, sim as any, pinMap({ A: 13, C: -1 }), 'led-3');
|
||
|
||
const anodeCall = sim.pinManager.onPinChange.mock.calls.find((c: any) => c[0] === 13);
|
||
anodeCall;
|
||
expect((el as any).value).toBe(true);
|
||
anodeCall;
|
||
expect((el as any).value).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─── LED overcurrent burnout (SPICE forward current) ──────────────────────────
|
||
describe('LED — overcurrent burnout', () => {
|
||
afterEach(() => {
|
||
useElectricalStore.setState({ branchCurrents: {}, timeWaveforms: undefined });
|
||
});
|
||
|
||
function attachLed(id: string) {
|
||
const logic = PartSimulationRegistry.get('led')!;
|
||
const el = makeElement({ value: false, brightness: 0 });
|
||
const sim = makeSimulator();
|
||
logic.attachEvents!(el, sim as any, pinMap({ A: 13, C: -1 }), id);
|
||
const anode = sim.pinManager.onPinChange.mock.calls.find((c: any) => c[0] === 13)![1];
|
||
return { el: el as any, trigger: () => anode(13, true) };
|
||
}
|
||
|
||
it('burns out (goes dark) at destructive forward current', () => {
|
||
const { el, trigger } = attachLed('led-burn');
|
||
useElectricalStore.setState({ branchCurrents: { 'v_led-burn_sense': 4.6 } }); // 9V/no resistor
|
||
trigger();
|
||
expect(el.brightness).toBe(0);
|
||
expect(el.value).toBe(false);
|
||
// latched: stays dark even if the current later drops to a safe value
|
||
useElectricalStore.setState({ branchCurrents: { 'v_led-burn_sense': 0.01 } });
|
||
trigger();
|
||
expect(el.brightness).toBe(0);
|
||
expect(el.value).toBe(false);
|
||
});
|
||
|
||
it('does NOT burn out at a normal bright current (15 mA)', () => {
|
||
const { el, trigger } = attachLed('led-ok');
|
||
useElectricalStore.setState({ branchCurrents: { 'v_led-ok_sense': 0.015 } });
|
||
trigger();
|
||
expect(el.value).toBe(true);
|
||
expect(el.brightness).toBeCloseTo(0.75, 2); // 15 mA / 20 mA rated
|
||
});
|
||
|
||
it('does NOT burn out just over the rated max (25 mA) — only destructive current', () => {
|
||
const { el, trigger } = attachLed('led-warm');
|
||
useElectricalStore.setState({ branchCurrents: { 'v_led-warm_sense': 0.025 } });
|
||
trigger();
|
||
expect(el.value).toBe(true);
|
||
expect(el.brightness).toBe(1); // bright (clamped), still alive
|
||
});
|
||
|
||
it('does NOT burn out at a legitimate high-power current (80 mA)', () => {
|
||
// High-power / RGB channels can pull ~100-150 mA legitimately; the burnout
|
||
// threshold (100 mA) must sit above the bright-but-fine range.
|
||
const { el, trigger } = attachLed('led-hp');
|
||
useElectricalStore.setState({ branchCurrents: { 'v_led-hp_sense': 0.08 } });
|
||
trigger();
|
||
expect(el.value).toBe(true);
|
||
expect(el.brightness).toBe(1);
|
||
});
|
||
|
||
it('burns out when the solver returns a non-finite current (no-resistor short)', () => {
|
||
// A diode straight across a supply with no series resistor often has no
|
||
// stable operating point → ngspice returns NaN/Infinity. That must burn
|
||
// the LED out, NOT fall through to the digital fallback and glow.
|
||
const { el, trigger } = attachLed('led-nan');
|
||
useElectricalStore.setState({ branchCurrents: { 'v_led-nan_sense': NaN } });
|
||
trigger();
|
||
expect(el.value).toBe(false);
|
||
expect(el.brightness).toBe(0);
|
||
// latched
|
||
useElectricalStore.setState({ branchCurrents: { 'v_led-nan_sense': 0.01 } });
|
||
trigger();
|
||
expect(el.value).toBe(false);
|
||
});
|
||
|
||
it('recovers after a fresh re-attach (Reset bumps hexEpoch → new closure)', () => {
|
||
// Burn one instance...
|
||
const first = attachLed('led-fix');
|
||
useElectricalStore.setState({ branchCurrents: { 'v_led-fix_sense': 4.6 } });
|
||
first.trigger();
|
||
expect(first.el.value).toBe(false);
|
||
// ...fix the circuit, then re-attach (what a Reset does via hexEpoch).
|
||
// The new closure starts un-burnt, so the now-safe circuit lights again.
|
||
useElectricalStore.setState({ branchCurrents: { 'v_led-fix_sense': 0.015 } });
|
||
const second = attachLed('led-fix');
|
||
second.trigger();
|
||
expect(second.el.value).toBe(true);
|
||
expect(second.el.brightness).toBeCloseTo(0.75, 2);
|
||
});
|
||
});
|
||
|
||
// ─── Pushbutton ──────────────────────────────────────────────────────────────
|
||
|
||
describe('Pushbutton — attachEvents', () => {
|
||
it('registers button-press and button-release event listeners', () => {
|
||
const logic = PartSimulationRegistry.get('pushbutton')!;
|
||
const el = makeElement({ pressed: false });
|
||
const sim = makeSimulator();
|
||
logic.attachEvents!(el, sim as any, pinHelper(7));
|
||
|
||
const events = (el.addEventListener as ReturnType<typeof vi.fn>).mock.calls.map(
|
||
(c: unknown[]) => c[0],
|
||
);
|
||
expect(events).toContain('button-press');
|
||
expect(events).toContain('button-release');
|
||
});
|
||
|
||
it('calls setPinState(pin, false) on button-press (active LOW)', () => {
|
||
const logic = PartSimulationRegistry.get('pushbutton')!;
|
||
const el = makeElement({ pressed: false });
|
||
const sim = makeSimulator();
|
||
|
||
let pressHandler!: () => void;
|
||
(el.addEventListener as ReturnType<typeof vi.fn>).mockImplementation(
|
||
(event: string, handler: () => void) => {
|
||
if (event === 'button-press') pressHandler = handler;
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinHelper(7));
|
||
pressHandler();
|
||
|
||
expect(sim.setPinState).toHaveBeenCalledWith(7, false);
|
||
expect((el as any).pressed).toBe(true);
|
||
});
|
||
|
||
it('calls setPinState(pin, true) on button-release', () => {
|
||
const logic = PartSimulationRegistry.get('pushbutton')!;
|
||
const el = makeElement({ pressed: true });
|
||
const sim = makeSimulator();
|
||
|
||
let releaseHandler!: () => void;
|
||
(el.addEventListener as ReturnType<typeof vi.fn>).mockImplementation(
|
||
(event: string, handler: () => void) => {
|
||
if (event === 'button-release') releaseHandler = handler;
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinHelper(7));
|
||
releaseHandler();
|
||
|
||
expect(sim.setPinState).toHaveBeenCalledWith(7, true);
|
||
expect((el as any).pressed).toBe(false);
|
||
});
|
||
|
||
it('cleanup removes both event listeners', () => {
|
||
const logic = PartSimulationRegistry.get('pushbutton')!;
|
||
const el = makeElement();
|
||
const sim = makeSimulator();
|
||
const cleanup = logic.attachEvents!(el, sim as any, pinHelper(2));
|
||
cleanup();
|
||
expect(
|
||
(el.removeEventListener as ReturnType<typeof vi.fn>).mock.calls.length,
|
||
).toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
it('returns no-op cleanup when no pin is connected', () => {
|
||
const logic = PartSimulationRegistry.get('pushbutton')!;
|
||
const el = makeElement();
|
||
const sim = makeSimulator();
|
||
expect(() => logic.attachEvents!(el, sim as any, noPins)()).not.toThrow();
|
||
});
|
||
});
|
||
|
||
// ─── Pushbutton-6mm ──────────────────────────────────────────────────────────
|
||
|
||
describe('Pushbutton-6mm — attachEvents', () => {
|
||
it('behaves identically to pushbutton (active LOW on press)', () => {
|
||
const logic = PartSimulationRegistry.get('pushbutton-6mm')!;
|
||
const el = makeElement({ pressed: false });
|
||
const sim = makeSimulator();
|
||
|
||
let pressHandler!: () => void;
|
||
(el.addEventListener as ReturnType<typeof vi.fn>).mockImplementation(
|
||
(event: string, handler: () => void) => {
|
||
if (event === 'button-press') pressHandler = handler;
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinHelper(4));
|
||
pressHandler();
|
||
expect(sim.setPinState).toHaveBeenCalledWith(4, false);
|
||
expect((el as any).pressed).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ─── Slide-switch ─────────────────────────────────────────────────────────────
|
||
|
||
describe('Slide-switch — attachEvents', () => {
|
||
it('reads initial value=1 and drives pin HIGH immediately', () => {
|
||
const logic = PartSimulationRegistry.get('slide-switch')!;
|
||
const el = makeElement({ value: 1 });
|
||
const sim = makeSimulator();
|
||
logic.attachEvents!(el, sim as any, pinHelper(5));
|
||
expect(sim.setPinState).toHaveBeenCalledWith(5, true);
|
||
});
|
||
|
||
it('reads initial value=0 and drives pin LOW immediately', () => {
|
||
const logic = PartSimulationRegistry.get('slide-switch')!;
|
||
const el = makeElement({ value: 0 });
|
||
const sim = makeSimulator();
|
||
logic.attachEvents!(el, sim as any, pinHelper(5));
|
||
expect(sim.setPinState).toHaveBeenCalledWith(5, false);
|
||
});
|
||
|
||
it('updates pin state when change event fires', () => {
|
||
const logic = PartSimulationRegistry.get('slide-switch')!;
|
||
const el = makeElement({ value: 0 });
|
||
const sim = makeSimulator();
|
||
|
||
let changeHandler!: () => void;
|
||
(el.addEventListener as ReturnType<typeof vi.fn>).mockImplementation(
|
||
(event: string, handler: () => void) => {
|
||
if (event === 'change') changeHandler = handler;
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinHelper(6));
|
||
(el as any).value = 1;
|
||
changeHandler();
|
||
|
||
expect(sim.setPinState).toHaveBeenLastCalledWith(6, true);
|
||
});
|
||
|
||
it('returns no-op when no pin is connected', () => {
|
||
const logic = PartSimulationRegistry.get('slide-switch')!;
|
||
const el = makeElement({ value: 0 });
|
||
const sim = makeSimulator();
|
||
expect(() => logic.attachEvents!(el, sim as any, noPins)()).not.toThrow();
|
||
});
|
||
});
|
||
|
||
// ─── DIP-switch-8 ────────────────────────────────────────────────────────────
|
||
|
||
describe('DIP-switch-8 — attachEvents', () => {
|
||
it('syncs all 8 initial switch states on attach', () => {
|
||
const logic = PartSimulationRegistry.get('dip-switch-8')!;
|
||
const values = [1, 0, 1, 0, 1, 0, 1, 0];
|
||
const el = makeElement({ values });
|
||
const sim = makeSimulator();
|
||
|
||
const helper = pinMap({
|
||
'1A': 2,
|
||
'2A': 3,
|
||
'3A': 4,
|
||
'4A': 5,
|
||
'5A': 6,
|
||
'6A': 7,
|
||
'7A': 8,
|
||
'8A': 9,
|
||
});
|
||
logic.attachEvents!(el, sim as any, helper);
|
||
|
||
expect(sim.setPinState.mock.calls.length).toBe(8);
|
||
expect(sim.setPinState).toHaveBeenCalledWith(2, true); // switch 1 ON
|
||
expect(sim.setPinState).toHaveBeenCalledWith(3, false); // switch 2 OFF
|
||
expect(sim.setPinState).toHaveBeenCalledWith(9, false); // switch 8 OFF
|
||
});
|
||
|
||
it('updates pins when change event fires', () => {
|
||
const logic = PartSimulationRegistry.get('dip-switch-8')!;
|
||
const el = makeElement({ values: new Array(8).fill(0) });
|
||
const sim = makeSimulator();
|
||
|
||
let changeHandler!: () => void;
|
||
(el.addEventListener as ReturnType<typeof vi.fn>).mockImplementation(
|
||
(event: string, handler: () => void) => {
|
||
if (event === 'change') changeHandler = handler;
|
||
},
|
||
);
|
||
|
||
const helper = pinMap({ '1A': 2 });
|
||
logic.attachEvents!(el, sim as any, helper);
|
||
|
||
(el as any).values = [1, 0, 0, 0, 0, 0, 0, 0];
|
||
changeHandler();
|
||
expect(sim.setPinState).toHaveBeenLastCalledWith(2, true);
|
||
});
|
||
});
|
||
|
||
// ─── LED Bar Graph ────────────────────────────────────────────────────────────
|
||
|
||
describe('LED-bar-graph — attachEvents', () => {
|
||
it('registers onPinChange for all 10 LED pins (A1-A10)', () => {
|
||
const logic = PartSimulationRegistry.get('led-bar-graph')!;
|
||
const el = makeElement({ values: new Array(10).fill(0) });
|
||
const sim = makeSimulator();
|
||
|
||
const helper = (name: string): number | null => {
|
||
const idx = parseInt(name.replace('A', ''));
|
||
return isNaN(idx) || idx < 1 || idx > 10 ? null : idx + 1;
|
||
};
|
||
|
||
logic.attachEvents!(el, sim as any, helper);
|
||
expect(sim.pinManager.onPinChange.mock.calls.length).toBe(10);
|
||
});
|
||
|
||
it('updates element.values[0] when LED A1 pin goes HIGH', () => {
|
||
const logic = PartSimulationRegistry.get('led-bar-graph')!;
|
||
const el = makeElement({ values: new Array(10).fill(0) });
|
||
const sim = makeSimulator();
|
||
|
||
const callbacks: Array<(pin: number, state: boolean) => void> = [];
|
||
sim.pinManager.onPinChange.mockImplementation(
|
||
(_pin: number, cb: (pin: number, state: boolean) => void) => {
|
||
callbacks.push(cb);
|
||
return () => {};
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ A1: 2 }));
|
||
callbacks[0](2, true);
|
||
expect((el as any).values[0]).toBe(1);
|
||
});
|
||
|
||
it('cleanup unsubscribes all 10 listeners', () => {
|
||
const unsubMock = vi.fn();
|
||
const logic = PartSimulationRegistry.get('led-bar-graph')!;
|
||
const el = makeElement({ values: new Array(10).fill(0) });
|
||
const sim = makeSimulator();
|
||
sim.pinManager.onPinChange.mockReturnValue(unsubMock);
|
||
|
||
const helper = (name: string): number | null => {
|
||
const idx = parseInt(name.replace('A', ''));
|
||
return isNaN(idx) ? null : idx;
|
||
};
|
||
|
||
const cleanup = logic.attachEvents!(el, sim as any, helper);
|
||
cleanup();
|
||
expect(unsubMock).toHaveBeenCalledTimes(10);
|
||
});
|
||
});
|
||
|
||
// ─── 7-Segment Display ───────────────────────────────────────────────────────
|
||
|
||
describe('7segment — attachEvents', () => {
|
||
it('registers onPinChange for all 8 segments (A B C D E F G DP)', () => {
|
||
const logic = PartSimulationRegistry.get('7segment')!;
|
||
const el = makeElement({ values: new Array(8).fill(0) });
|
||
const sim = makeSimulator();
|
||
|
||
const helper = pinMap({ A: 2, B: 3, C: 4, D: 5, E: 6, F: 7, G: 8, DP: 9 });
|
||
logic.attachEvents!(el, sim as any, helper);
|
||
|
||
expect(sim.pinManager.onPinChange.mock.calls.length).toBe(8);
|
||
});
|
||
|
||
it('updates element.values[0] when segment A pin goes HIGH', () => {
|
||
const logic = PartSimulationRegistry.get('7segment')!;
|
||
const el = makeElement({ values: new Array(8).fill(0) });
|
||
const sim = makeSimulator();
|
||
|
||
let segACallback!: (pin: number, state: boolean) => void;
|
||
sim.pinManager.onPinChange.mockImplementation(
|
||
(_pin: number, cb: (pin: number, state: boolean) => void) => {
|
||
if (!segACallback) segACallback = cb;
|
||
return () => {};
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ A: 2 }));
|
||
segACallback(2, true);
|
||
expect((el as any).values[0]).toBe(1); // 'A' is index 0
|
||
});
|
||
|
||
it('cleanup unsubscribes all 8 segment listeners', () => {
|
||
const unsubMock = vi.fn();
|
||
const logic = PartSimulationRegistry.get('7segment')!;
|
||
const el = makeElement({ values: new Array(8).fill(0) });
|
||
const sim = makeSimulator();
|
||
sim.pinManager.onPinChange.mockReturnValue(unsubMock);
|
||
|
||
const helper = pinMap({ A: 2, B: 3, C: 4, D: 5, E: 6, F: 7, G: 8, DP: 9 });
|
||
const cleanup = logic.attachEvents!(el, sim as any, helper);
|
||
cleanup();
|
||
expect(unsubMock).toHaveBeenCalledTimes(8);
|
||
});
|
||
|
||
it('re-attach after digits changes 1→4 subscribes DIG pins (agent build order)', () => {
|
||
// The agent adds the display with default digits=1, THEN sets digits=4,
|
||
// then wires, compiles (hexEpoch bump → re-attach) and runs. The cached
|
||
// per-element state must rebuild for the new digit count, or the
|
||
// re-attach keeps subscribing COM.1/COM.2 and the display stays blank
|
||
// until a page reload.
|
||
const logic = PartSimulationRegistry.get('7segment')!;
|
||
const el = makeElement({ values: new Array(8).fill(0), digits: 1 });
|
||
const sim = makeSimulator();
|
||
const subscribed: number[] = [];
|
||
sim.pinManager.onPinChange.mockImplementation((pin: number) => {
|
||
subscribed.push(pin);
|
||
return () => {};
|
||
});
|
||
|
||
// First attach while digits=1 (mid-build): subscribes COM pins only.
|
||
const pins1 = pinMap({ 'COM.1': 30, 'COM.2': 31, DIG1: 10, DIG2: 11, DIG3: 12, DIG4: 13, A: 2 });
|
||
const cleanup1 = logic.attachEvents!(el, sim as any, pins1);
|
||
expect(subscribed).toContain(30); // COM.1 (single-digit mode)
|
||
expect(subscribed).not.toContain(10); // DIG1 not subscribed yet
|
||
cleanup1();
|
||
|
||
// Property set later: digits=4. Re-attach (compile/run does this).
|
||
(el as any).digits = 4;
|
||
subscribed.length = 0;
|
||
const callbacks = new Map<number, (pin: number, state: boolean) => void>();
|
||
sim.pinManager.onPinChange.mockImplementation(
|
||
(pin: number, cb: (pin: number, state: boolean) => void) => {
|
||
subscribed.push(pin);
|
||
callbacks.set(pin, cb);
|
||
return () => {};
|
||
},
|
||
);
|
||
logic.attachEvents!(el, sim as any, pins1);
|
||
expect(subscribed).toEqual(expect.arrayContaining([10, 11, 12, 13])); // DIG1..4
|
||
|
||
// And the display actually lights: segment A high + DIG1 enabled.
|
||
callbacks.get(2)!(2, true); // segment A high
|
||
callbacks.get(10)!(10, true); // DIG1 on → latches segments
|
||
const values = (el as any).values as number[];
|
||
expect(values.length).toBe(32); // 4 digits × 8 segments
|
||
expect(values[0]).toBe(1); // digit 0, segment A lit
|
||
});
|
||
|
||
it('CLN pin drives colon + colonValue (attachEvents and onPinStateChange)', () => {
|
||
const logic = PartSimulationRegistry.get('7segment')!;
|
||
|
||
// attachEvents path (a clock face wired to a local-sim board).
|
||
const el = makeElement({ values: new Array(8).fill(0), colon: false, colonValue: false });
|
||
const sim = makeSimulator();
|
||
let clnCallback!: (pin: number, state: boolean) => void;
|
||
sim.pinManager.onPinChange.mockImplementation(
|
||
(pin: number, cb: (pin: number, state: boolean) => void) => {
|
||
if (pin === 10) clnCallback = cb;
|
||
return () => {};
|
||
},
|
||
);
|
||
logic.attachEvents!(el, sim as any, pinMap({ A: 2, CLN: 10 }));
|
||
clnCallback(10, true);
|
||
expect((el as any).colon).toBe(true);
|
||
expect((el as any).colonValue).toBe(true);
|
||
clnCallback(10, false);
|
||
expect((el as any).colonValue).toBe(false);
|
||
|
||
// onPinStateChange path (QEMU-backed boards dispatch by pin name).
|
||
const el2 = makeElement({ values: new Array(8).fill(0), colon: false, colonValue: false });
|
||
logic.onPinStateChange!('CLN', true, el2);
|
||
expect((el2 as any).colon).toBe(true);
|
||
expect((el2 as any).colonValue).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ─── RGB LED ──────────────────────────────────────────────────────────────────
|
||
|
||
describe('RGB LED — attachEvents', () => {
|
||
it('subscribes to 3 digital (onPinChange) and 3 PWM (onPwmChange) listeners', () => {
|
||
const logic = PartSimulationRegistry.get('rgb-led')!;
|
||
const el = makeElement({ ledRed: 0, ledGreen: 0, ledBlue: 0 });
|
||
const sim = makeSimulator();
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ R: 9, G: 10, B: 11 }));
|
||
|
||
expect(sim.pinManager.onPinChange.mock.calls.length).toBe(3);
|
||
expect(sim.pinManager.onPwmChange.mock.calls.length).toBe(3);
|
||
});
|
||
|
||
it('sets ledRed to 255 on digital HIGH, 0 on LOW', () => {
|
||
const logic = PartSimulationRegistry.get('rgb-led')!;
|
||
const el = makeElement({ ledRed: 0, ledGreen: 0, ledBlue: 0 });
|
||
const sim = makeSimulator();
|
||
|
||
const digitalCbs: Record<number, (pin: number, state: boolean) => void> = {};
|
||
sim.pinManager.onPinChange.mockImplementation(
|
||
(pin: number, cb: (pin: number, state: boolean) => void) => {
|
||
digitalCbs[pin] = cb;
|
||
return () => {};
|
||
},
|
||
);
|
||
sim.pinManager.onPwmChange.mockReturnValue(() => {});
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ R: 9 }));
|
||
digitalCbs[9](9, true);
|
||
expect((el as any).ledRed).toBe(255);
|
||
digitalCbs[9](9, false);
|
||
expect((el as any).ledRed).toBe(0);
|
||
});
|
||
|
||
it('sets ledGreen via PWM duty cycle (0.5 → 128)', () => {
|
||
const logic = PartSimulationRegistry.get('rgb-led')!;
|
||
const el = makeElement({ ledRed: 0, ledGreen: 0, ledBlue: 0 });
|
||
const sim = makeSimulator();
|
||
|
||
const pwmCbs: Record<number, (pin: number, dc: number) => void> = {};
|
||
sim.pinManager.onPinChange.mockReturnValue(() => {});
|
||
sim.pinManager.onPwmChange.mockImplementation(
|
||
(pin: number, cb: (pin: number, dc: number) => void) => {
|
||
pwmCbs[pin] = cb;
|
||
return () => {};
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ G: 10 }));
|
||
pwmCbs[10](10, 0.5);
|
||
expect((el as any).ledGreen).toBe(128); // Math.round(0.5 * 255)
|
||
});
|
||
|
||
it('sets ledBlue via PWM full brightness (1.0 → 255)', () => {
|
||
const logic = PartSimulationRegistry.get('rgb-led')!;
|
||
const el = makeElement({ ledBlue: 0 });
|
||
const sim = makeSimulator();
|
||
|
||
const pwmCbs: Record<number, (pin: number, dc: number) => void> = {};
|
||
sim.pinManager.onPinChange.mockReturnValue(() => {});
|
||
sim.pinManager.onPwmChange.mockImplementation(
|
||
(pin: number, cb: (pin: number, dc: number) => void) => {
|
||
pwmCbs[pin] = cb;
|
||
return () => {};
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ B: 11 }));
|
||
pwmCbs[11](11, 1.0);
|
||
expect((el as any).ledBlue).toBe(255);
|
||
});
|
||
|
||
it('cleanup unsubscribes all 6 listeners', () => {
|
||
const unsubMock = vi.fn();
|
||
const logic = PartSimulationRegistry.get('rgb-led')!;
|
||
const el = makeElement({ ledRed: 0, ledGreen: 0, ledBlue: 0 });
|
||
const sim = makeSimulator();
|
||
sim.pinManager.onPinChange.mockReturnValue(unsubMock);
|
||
sim.pinManager.onPwmChange.mockReturnValue(unsubMock);
|
||
|
||
const cleanup = logic.attachEvents!(el, sim as any, pinMap({ R: 9, G: 10, B: 11 }));
|
||
cleanup();
|
||
expect(unsubMock).toHaveBeenCalledTimes(6);
|
||
});
|
||
});
|
||
|
||
// ─── Potentiometer ───────────────────────────────────────────────────────────
|
||
|
||
describe('Potentiometer — attachEvents', () => {
|
||
it('injects ADC voltage on input event (512/1023 * 5V ≈ 2.5V)', () => {
|
||
const logic = PartSimulationRegistry.get('potentiometer')!;
|
||
const adc = makeADC();
|
||
const el = makeElement({ value: '512' });
|
||
const sim = makeSimulator(adc);
|
||
|
||
let inputHandler!: () => void;
|
||
(el.addEventListener as ReturnType<typeof vi.fn>).mockImplementation(
|
||
(event: string, handler: () => void) => {
|
||
if (event === 'input') inputHandler = handler;
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ SIG: 14 })); // A0 = pin 14
|
||
inputHandler();
|
||
|
||
expect(adc.channelValues[0]).toBeCloseTo(2.5, 1); // channel 0 = A0
|
||
});
|
||
|
||
it('injects 0V when value is 0', () => {
|
||
const logic = PartSimulationRegistry.get('potentiometer')!;
|
||
const adc = makeADC();
|
||
const el = makeElement({ value: '0' });
|
||
const sim = makeSimulator(adc);
|
||
|
||
let inputHandler!: () => void;
|
||
(el.addEventListener as ReturnType<typeof vi.fn>).mockImplementation(
|
||
(event: string, handler: () => void) => {
|
||
if (event === 'input') inputHandler = handler;
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ SIG: 14 }));
|
||
inputHandler();
|
||
|
||
expect(adc.channelValues[0]).toBeCloseTo(0, 5);
|
||
});
|
||
|
||
it('injects 5V when value is 1023', () => {
|
||
const logic = PartSimulationRegistry.get('potentiometer')!;
|
||
const adc = makeADC();
|
||
const el = makeElement({ value: '1023' });
|
||
const sim = makeSimulator(adc);
|
||
|
||
let inputHandler!: () => void;
|
||
(el.addEventListener as ReturnType<typeof vi.fn>).mockImplementation(
|
||
(event: string, handler: () => void) => {
|
||
if (event === 'input') inputHandler = handler;
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ SIG: 14 }));
|
||
inputHandler();
|
||
|
||
expect(adc.channelValues[0]).toBeCloseTo(5.0, 2);
|
||
});
|
||
|
||
it('returns no-op when SIG pin is not connected', () => {
|
||
const logic = PartSimulationRegistry.get('potentiometer')!;
|
||
const el = makeElement({ value: '100' });
|
||
const sim = makeSimulator(makeADC());
|
||
expect(() => logic.attachEvents!(el, sim as any, noPins)()).not.toThrow();
|
||
});
|
||
});
|
||
|
||
// ─── Slide Potentiometer ─────────────────────────────────────────────────────
|
||
|
||
describe('Slide-potentiometer — attachEvents', () => {
|
||
it('injects mid-range voltage on input event', () => {
|
||
const logic = PartSimulationRegistry.get('slide-potentiometer')!;
|
||
const adc = makeADC();
|
||
const el = makeElement({ value: 512, min: 0, max: 1023 });
|
||
const sim = makeSimulator(adc);
|
||
|
||
let inputHandler!: () => void;
|
||
(el.addEventListener as ReturnType<typeof vi.fn>).mockImplementation(
|
||
(event: string, handler: () => void) => {
|
||
if (event === 'input') inputHandler = handler;
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ SIG: 14 }));
|
||
inputHandler();
|
||
|
||
expect(adc.channelValues[0]).toBeCloseTo(2.5, 1);
|
||
});
|
||
});
|
||
|
||
// ─── Photoresistor Sensor ────────────────────────────────────────────────────
|
||
|
||
describe('Photoresistor-sensor — attachEvents', () => {
|
||
it('injects initial 2.5V (moderate light) on the AO pin immediately', () => {
|
||
const logic = PartSimulationRegistry.get('photoresistor-sensor')!;
|
||
const adc = makeADC();
|
||
const el = makeElement();
|
||
const sim = makeSimulator(adc);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ AO: 14 }));
|
||
expect(adc.channelValues[0]).toBeCloseTo(2.5, 1);
|
||
});
|
||
|
||
it('updates ADC voltage to 5V on input event (full light)', () => {
|
||
const logic = PartSimulationRegistry.get('photoresistor-sensor')!;
|
||
const adc = makeADC();
|
||
const el = makeElement({ value: 1023 });
|
||
const sim = makeSimulator(adc);
|
||
|
||
let inputHandler!: () => void;
|
||
(el.addEventListener as ReturnType<typeof vi.fn>).mockImplementation(
|
||
(event: string, handler: () => void) => {
|
||
if (event === 'input') inputHandler = handler;
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ AO: 14 }));
|
||
inputHandler();
|
||
expect(adc.channelValues[0]).toBeCloseTo(5.0, 1);
|
||
});
|
||
|
||
it('registers DO indicator listener when digital output pin is connected', () => {
|
||
const logic = PartSimulationRegistry.get('photoresistor-sensor')!;
|
||
const el = makeElement({ ledDO: false });
|
||
const sim = makeSimulator(makeADC());
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ AO: 14, DO: 4 }));
|
||
expect(sim.pinManager.onPinChange.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
});
|
||
|
||
// ─── Analog Joystick ─────────────────────────────────────────────────────────
|
||
|
||
describe('Analog-joystick — attachEvents', () => {
|
||
it('injects 2.5V on both X and Y axes initially (center)', () => {
|
||
const logic = PartSimulationRegistry.get('analog-joystick')!;
|
||
const adc = makeADC();
|
||
const el = makeElement();
|
||
const sim = makeSimulator(adc);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ VRX: 14, VRY: 15 }));
|
||
|
||
expect(adc.channelValues[0]).toBeCloseTo(2.5, 1); // A0 = X
|
||
expect(adc.channelValues[1]).toBeCloseTo(2.5, 1); // A1 = Y
|
||
});
|
||
|
||
it('calls setPinState(pin, false) on button-press (active LOW)', () => {
|
||
const logic = PartSimulationRegistry.get('analog-joystick')!;
|
||
const el = makeElement({ pressed: false });
|
||
const sim = makeSimulator(makeADC());
|
||
|
||
let pressHandler!: () => void;
|
||
(el.addEventListener as ReturnType<typeof vi.fn>).mockImplementation(
|
||
(event: string, handler: () => void) => {
|
||
if (event === 'button-press') pressHandler = handler;
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ SW: 2 }));
|
||
pressHandler();
|
||
expect(sim.setPinState).toHaveBeenCalledWith(2, false);
|
||
expect((el as any).pressed).toBe(true);
|
||
});
|
||
|
||
it('calls setPinState(pin, true) on button-release', () => {
|
||
const logic = PartSimulationRegistry.get('analog-joystick')!;
|
||
const el = makeElement({ pressed: true });
|
||
const sim = makeSimulator(makeADC());
|
||
|
||
let releaseHandler!: () => void;
|
||
(el.addEventListener as ReturnType<typeof vi.fn>).mockImplementation(
|
||
(event: string, handler: () => void) => {
|
||
if (event === 'button-release') releaseHandler = handler;
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ SW: 2 }));
|
||
releaseHandler();
|
||
expect(sim.setPinState).toHaveBeenCalledWith(2, true);
|
||
expect((el as any).pressed).toBe(false);
|
||
});
|
||
|
||
it('updates ADC voltages on joystick-move event', () => {
|
||
// wokwi-analog-joystick exposes xValue / yValue as direction {-1, 0, +1},
|
||
// NOT 0..1023. The handler maps -1 → 0V, 0 → Vcc/2, +1 → Vcc.
|
||
// AVR mock simulator → Vcc = 5V.
|
||
const logic = PartSimulationRegistry.get('analog-joystick')!;
|
||
const adc = makeADC();
|
||
const el = makeElement({ xValue: -1, yValue: 1 });
|
||
const sim = makeSimulator(adc);
|
||
|
||
let moveHandler!: () => void;
|
||
(el.addEventListener as ReturnType<typeof vi.fn>).mockImplementation(
|
||
(event: string, handler: () => void) => {
|
||
if (event === 'joystick-move') moveHandler = handler;
|
||
},
|
||
);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ VRX: 14, VRY: 15 }));
|
||
moveHandler();
|
||
|
||
expect(adc.channelValues[0]).toBeCloseTo(0.0, 1); // X = -1 → 0V (full left)
|
||
expect(adc.channelValues[1]).toBeCloseTo(5.0, 1); // Y = +1 → 5V (full down)
|
||
});
|
||
});
|
||
|
||
// ─── Servo ───────────────────────────────────────────────────────────────────
|
||
|
||
describe('Servo — attachEvents', () => {
|
||
it('starts polling via requestAnimationFrame immediately', () => {
|
||
const logic = PartSimulationRegistry.get('servo')!;
|
||
const el = makeElement({ angle: 0 });
|
||
const sim = makeSimulator();
|
||
const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame');
|
||
|
||
logic.attachEvents!(el, sim as any, noPins);
|
||
expect(rafSpy).toHaveBeenCalled();
|
||
});
|
||
|
||
it('calculates 90° when OCR1A = ICR1/2 (servo midpoint)', () => {
|
||
// Real Arduino Servo.h: prescaler=8, 16 MHz → 0.5 µs/tick
|
||
// ICR1 = 40000 ticks = 20 ms period (50 Hz)
|
||
// 90° midpoint: 1472 µs → OCR1A = 1472 / 0.5 = 2944
|
||
// pulseUs = (2944/40000)*20000 = 1472 µs → angle = (1472-544)/1856*180 = 90°
|
||
const logic = PartSimulationRegistry.get('servo')!;
|
||
const el = makeElement({ angle: -1 });
|
||
const sim = makeSimulator();
|
||
|
||
// ICR1L=0x86, ICR1H=0x87; OCR1AL=0x88, OCR1AH=0x89
|
||
sim.cpu.data[0x88] = 2944 & 0xff; // OCR1AL
|
||
sim.cpu.data[0x89] = (2944 >> 8) & 0xff; // OCR1AH
|
||
sim.cpu.data[0x86] = 40000 & 0xff; // ICR1L
|
||
sim.cpu.data[0x87] = (40000 >> 8) & 0xff; // ICR1H
|
||
|
||
logic.attachEvents!(el, sim as any, noPins);
|
||
expect((el as any).angle).toBe(90);
|
||
});
|
||
|
||
it('calculates 0° when OCR1A = 0 (minimum pulse)', () => {
|
||
// OCR1A=0 → pulseUs=0 → clamped to MIN_PULSE_US=544 µs → 0°
|
||
const logic = PartSimulationRegistry.get('servo')!;
|
||
const el = makeElement({ angle: -1 });
|
||
const sim = makeSimulator();
|
||
|
||
sim.cpu.data[0x86] = 40000 & 0xff;
|
||
sim.cpu.data[0x87] = (40000 >> 8) & 0xff;
|
||
// OCR1A = 0 (default)
|
||
|
||
logic.attachEvents!(el, sim as any, noPins);
|
||
expect((el as any).angle).toBe(0);
|
||
});
|
||
|
||
it('calculates 180° when OCR1A = ICR1 (maximum pulse)', () => {
|
||
// 180° maximum: 2400 µs → OCR1A = 2400 / 0.5 = 4800 (ICR1=40000, 50 Hz)
|
||
// pulseUs = (4800/40000)*20000 = 2400 µs → angle = (2400-544)/1856*180 = 180°
|
||
const logic = PartSimulationRegistry.get('servo')!;
|
||
const el = makeElement({ angle: -1 });
|
||
const sim = makeSimulator();
|
||
|
||
sim.cpu.data[0x88] = 4800 & 0xff;
|
||
sim.cpu.data[0x89] = (4800 >> 8) & 0xff;
|
||
sim.cpu.data[0x86] = 40000 & 0xff;
|
||
sim.cpu.data[0x87] = (40000 >> 8) & 0xff;
|
||
|
||
logic.attachEvents!(el, sim as any, noPins);
|
||
expect((el as any).angle).toBe(180);
|
||
});
|
||
|
||
it('cleanup cancels the RAF loop', () => {
|
||
const logic = PartSimulationRegistry.get('servo')!;
|
||
const el = makeElement({ angle: 0 });
|
||
const sim = makeSimulator();
|
||
const cancelSpy = vi.spyOn(globalThis, 'cancelAnimationFrame');
|
||
|
||
const cleanup = logic.attachEvents!(el, sim as any, noPins);
|
||
cleanup();
|
||
expect(cancelSpy).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
// ─── Buzzer ──────────────────────────────────────────────────────────────────
|
||
|
||
describe('Buzzer — attachEvents', () => {
|
||
// Mock Web Audio API before each test in this suite.
|
||
// Must use a real function/class (not arrow) so `new AudioContext()` works.
|
||
beforeEach(() => {
|
||
const mockOscillator = {
|
||
type: 'square',
|
||
frequency: {
|
||
value: 440,
|
||
setTargetAtTime: vi.fn(),
|
||
setValueAtTime: vi.fn(),
|
||
linearRampToValueAtTime: vi.fn(),
|
||
},
|
||
connect: vi.fn(),
|
||
start: vi.fn(),
|
||
stop: vi.fn(),
|
||
disconnect: vi.fn(),
|
||
onended: null,
|
||
};
|
||
const mockGain = {
|
||
gain: {
|
||
value: 0.1,
|
||
setTargetAtTime: vi.fn(),
|
||
setValueAtTime: vi.fn(),
|
||
linearRampToValueAtTime: vi.fn(),
|
||
},
|
||
connect: vi.fn(),
|
||
disconnect: vi.fn(),
|
||
};
|
||
function MockAudioContext(this: any) {
|
||
this.createOscillator = vi.fn().mockReturnValue(mockOscillator);
|
||
this.createGain = vi.fn().mockReturnValue(mockGain);
|
||
this.destination = {};
|
||
this.currentTime = 0;
|
||
this.close = vi.fn().mockResolvedValue(undefined);
|
||
}
|
||
vi.stubGlobal('AudioContext', MockAudioContext);
|
||
});
|
||
|
||
it('subscribes to onPwmChange and onPinChange for the buzzer pin', () => {
|
||
const logic = PartSimulationRegistry.get('buzzer')!;
|
||
const el = makeElement({ playing: false });
|
||
const sim = makeSimulator();
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ '1': 3 }));
|
||
|
||
expect(sim.pinManager.onPwmChange.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||
expect(sim.pinManager.onPinChange.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
|
||
it('creates AudioContext when PWM duty cycle goes above 0', () => {
|
||
const logic = PartSimulationRegistry.get('buzzer')!;
|
||
const el = makeElement({ playing: false });
|
||
const sim = makeSimulator();
|
||
|
||
let pwmCallback!: (pin: number, dc: number) => void;
|
||
sim.pinManager.onPwmChange.mockImplementation(
|
||
(_pin: number, cb: (pin: number, dc: number) => void) => {
|
||
pwmCallback = cb;
|
||
return () => {};
|
||
},
|
||
);
|
||
sim.pinManager.onPinChange.mockReturnValue(() => {});
|
||
|
||
// Track instantiation via a flag (AudioContext is a regular function, not a spy)
|
||
let audioCtxCreated = false;
|
||
const PrevMock = (globalThis as any).AudioContext;
|
||
function TrackingCtx(this: any) {
|
||
audioCtxCreated = true;
|
||
PrevMock.call(this);
|
||
}
|
||
TrackingCtx.prototype = Object.create(PrevMock.prototype);
|
||
vi.stubGlobal('AudioContext', TrackingCtx);
|
||
|
||
logic.attachEvents!(el, sim as any, pinMap({ '1': 3 }));
|
||
pwmCallback(3, 0.5); // 50% duty cycle → should start tone
|
||
|
||
expect(audioCtxCreated).toBe(true);
|
||
});
|
||
|
||
it('cleanup calls unsubscribers for both PWM and digital listeners', () => {
|
||
const unsubMock = vi.fn();
|
||
const logic = PartSimulationRegistry.get('buzzer')!;
|
||
const el = makeElement({ playing: false });
|
||
const sim = makeSimulator();
|
||
sim.pinManager.onPwmChange.mockReturnValue(unsubMock);
|
||
sim.pinManager.onPinChange.mockReturnValue(unsubMock);
|
||
|
||
const cleanup = logic.attachEvents!(el, sim as any, pinMap({ '1': 3 }));
|
||
cleanup();
|
||
expect(unsubMock).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('returns no-op cleanup when no pin is connected', () => {
|
||
const logic = PartSimulationRegistry.get('buzzer')!;
|
||
const el = makeElement({ playing: false });
|
||
const sim = makeSimulator();
|
||
expect(() => logic.attachEvents!(el, sim as any, noPins)()).not.toThrow();
|
||
});
|
||
});
|
||
|
||
// ─── LCD1602 ─────────────────────────────────────────────────────────────────
|
||
|
||
describe('LCD1602 — attachEvents', () => {
|
||
const LCD_PINS = pinMap({ RS: 2, E: 3, D4: 4, D5: 5, D6: 6, D7: 7 });
|
||
|
||
it('registers onPinChange for RS, E, D4-D7 (6 pins total)', () => {
|
||
const logic = PartSimulationRegistry.get('lcd1602')!;
|
||
const el = makeElement({ characters: null, cursor: false, blink: false });
|
||
const sim = makeSimulator();
|
||
|
||
logic.attachEvents!(el, sim as any, LCD_PINS);
|
||
expect(sim.pinManager.onPinChange.mock.calls.length).toBe(6);
|
||
});
|
||
|
||
it('cleanup unsubscribes all 6 pin listeners', () => {
|
||
const unsubMock = vi.fn();
|
||
const logic = PartSimulationRegistry.get('lcd1602')!;
|
||
const el = makeElement({ characters: null });
|
||
const sim = makeSimulator();
|
||
sim.pinManager.onPinChange.mockReturnValue(unsubMock);
|
||
|
||
const cleanup = logic.attachEvents!(el, sim as any, LCD_PINS);
|
||
cleanup();
|
||
expect(unsubMock).toHaveBeenCalledTimes(6);
|
||
});
|
||
|
||
it('returns no-op cleanup when pinManager is unavailable', () => {
|
||
const logic = PartSimulationRegistry.get('lcd1602')!;
|
||
const el = makeElement({ characters: null });
|
||
// Simulator without pinManager
|
||
const sim = { getADC: vi.fn(), setPinState: vi.fn(), cpu: { data: new Uint8Array(512) } };
|
||
expect(() => logic.attachEvents!(el, sim as any, LCD_PINS)()).not.toThrow();
|
||
});
|
||
});
|
||
|
||
// ─── LCD2004 ─────────────────────────────────────────────────────────────────
|
||
|
||
describe('LCD2004 — attachEvents', () => {
|
||
it('registers 6 pin listeners (same pinout as LCD1602)', () => {
|
||
const logic = PartSimulationRegistry.get('lcd2004')!;
|
||
const el = makeElement({ characters: null });
|
||
const sim = makeSimulator();
|
||
|
||
const pins = pinMap({ RS: 2, E: 3, D4: 4, D5: 5, D6: 6, D7: 7 });
|
||
logic.attachEvents!(el, sim as any, pins);
|
||
expect(sim.pinManager.onPinChange.mock.calls.length).toBe(6);
|
||
});
|
||
});
|