877 lines
34 KiB
TypeScript
877 lines
34 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';
|
|
|
|
// 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(),
|
|
cpu: { data: new Uint8Array(512).fill(0) },
|
|
};
|
|
}
|
|
|
|
/** 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 — onPinStateChange', () => {
|
|
it('sets element.value = true when anode pin goes HIGH', () => {
|
|
const logic = PartSimulationRegistry.get('led')!;
|
|
const el = makeElement({ value: false });
|
|
logic.onPinStateChange!('A', true, el);
|
|
expect((el as any).value).toBe(true);
|
|
});
|
|
|
|
it('sets element.value = false when anode pin goes LOW', () => {
|
|
const logic = PartSimulationRegistry.get('led')!;
|
|
const el = makeElement({ value: true });
|
|
logic.onPinStateChange!('A', false, el);
|
|
expect((el as any).value).toBe(false);
|
|
});
|
|
|
|
it('ignores non-anode pins (e.g. cathode K)', () => {
|
|
const logic = PartSimulationRegistry.get('led')!;
|
|
const el = makeElement({ value: false });
|
|
logic.onPinStateChange!('K', true, el);
|
|
expect((el as any).value).toBe(false); // unchanged
|
|
});
|
|
});
|
|
|
|
// ─── 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);
|
|
});
|
|
});
|
|
|
|
// ─── 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', () => {
|
|
const logic = PartSimulationRegistry.get('analog-joystick')!;
|
|
const adc = makeADC();
|
|
const el = makeElement({ xValue: 0, yValue: 1023 });
|
|
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 at min = 0V
|
|
expect(adc.channelValues[1]).toBeCloseTo(5.0, 1); // Y at max = 5V
|
|
});
|
|
});
|
|
|
|
// ─── 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)', () => {
|
|
// ICR1 = 20000 (50 Hz at prescaler 8, 16 MHz)
|
|
// OCR1A = 10000 → pulseUs = 1000 + (10000/20000)*1000 = 1500 µs → 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] = 10000 & 0xFF; // OCR1AL
|
|
sim.cpu.data[0x89] = (10000 >> 8) & 0xFF; // OCR1AH
|
|
sim.cpu.data[0x86] = 20000 & 0xFF; // ICR1L
|
|
sim.cpu.data[0x87] = (20000 >> 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, ICR1=20000 → pulseUs = 1000 + 0 = 1000 µs → 0°
|
|
const logic = PartSimulationRegistry.get('servo')!;
|
|
const el = makeElement({ angle: -1 });
|
|
const sim = makeSimulator();
|
|
|
|
sim.cpu.data[0x86] = 20000 & 0xFF;
|
|
sim.cpu.data[0x87] = (20000 >> 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)', () => {
|
|
// OCR1A=20000, ICR1=20000 → pulseUs = 1000 + 1000 = 2000 µs → 180°
|
|
const logic = PartSimulationRegistry.get('servo')!;
|
|
const el = makeElement({ angle: -1 });
|
|
const sim = makeSimulator();
|
|
|
|
sim.cpu.data[0x88] = 20000 & 0xFF;
|
|
sim.cpu.data[0x89] = (20000 >> 8) & 0xFF;
|
|
sim.cpu.data[0x86] = 20000 & 0xFF;
|
|
sim.cpu.data[0x87] = (20000 >> 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() },
|
|
connect: vi.fn(),
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
};
|
|
const mockGain = {
|
|
gain: { value: 0.1 },
|
|
connect: 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);
|
|
});
|
|
});
|