/** * esp32-servo-pot.test.ts * * Tests for the ESP32 Servo + Potentiometer example, focusing on: * 1. Servo subscribes to onPwmChange for ESP32 (not AVR cycle measurement) * 2. Servo uses onPinChange for AVR (existing behavior) * 3. Servo uses onPinChangeWithTime for RP2040 * 4. LEDC update routes to correct GPIO pin (not LEDC channel) * 5. LEDC duty_pct is normalized to 0.0–1.0 * 6. LEDC fallback to channel when gpio=-1 * 7. Servo angle maps correctly from duty cycle (pulse-width based) * 8. Potentiometer setAdcVoltage works for ESP32 via bridge shim * 9. ESP32 ADC channel mapping (GPIO → ADC1 channel) * 10. LEDC polling reads float[] duty (not uint32) from QEMU internals(6) */ import { describe, it, expect, beforeEach, vi } from 'vitest'; // ── Mocks ──────────────────────────────────────────────────────────────────── vi.mock('../simulation/AVRSimulator', () => ({ AVRSimulator: vi.fn(function (this: any) { this.onSerialData = null; this.onBaudRateChange = null; this.onPinChangeWithTime = null; this.start = vi.fn(); this.stop = vi.fn(); this.reset = vi.fn(); this.loadHex = vi.fn(); this.addI2CDevice = vi.fn(); this.setPinState = vi.fn(); this.isRunning = vi.fn().mockReturnValue(true); this.registerSensor = vi.fn().mockReturnValue(false); this.pinManager = { onPinChange: vi.fn().mockReturnValue(() => {}), onPwmChange: vi.fn().mockReturnValue(() => {}), updatePwm: vi.fn(), }; this.getCurrentCycles = vi.fn().mockReturnValue(1000); this.getClockHz = vi.fn().mockReturnValue(16_000_000); this.cpu = { data: new Uint8Array(512).fill(0), cycles: 1000 }; }), })); vi.mock('../simulation/RP2040Simulator', () => ({ RP2040Simulator: vi.fn(function (this: any) { this.onSerialData = null; this.onPinChangeWithTime = null; this.start = vi.fn(); this.stop = vi.fn(); this.reset = vi.fn(); this.loadBinary = vi.fn(); this.addI2CDevice = vi.fn(); this.isRunning = vi.fn().mockReturnValue(true); this.registerSensor = vi.fn().mockReturnValue(false); this.pinManager = { onPinChange: vi.fn().mockReturnValue(() => {}), onPwmChange: vi.fn().mockReturnValue(() => {}), updatePwm: vi.fn(), }; }), })); vi.mock('../simulation/PinManager', () => ({ PinManager: vi.fn(function (this: any) { this.updatePort = vi.fn(); this.onPinChange = vi.fn().mockReturnValue(() => {}); this.onPwmChange = vi.fn().mockReturnValue(() => {}); this.getListenersCount = vi.fn().mockReturnValue(0); this.updatePwm = vi.fn(); this.triggerPinChange = vi.fn(); }), })); vi.mock('../simulation/I2CBusManager', () => ({ VirtualDS1307: vi.fn(function (this: any) {}), VirtualTempSensor: vi.fn(function (this: any) {}), I2CMemoryDevice: vi.fn(function (this: any) {}), })); vi.mock('../store/useOscilloscopeStore', () => ({ useOscilloscopeStore: { getState: vi.fn().mockReturnValue({ channels: [], pushSample: vi.fn() }), }, })); vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1); vi.stubGlobal('cancelAnimationFrame', vi.fn()); vi.stubGlobal('sessionStorage', { getItem: vi.fn().mockReturnValue('test-session-id'), setItem: vi.fn(), }); // ── Imports (after mocks) ──────────────────────────────────────────────────── import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry'; import '../simulation/parts/ComplexParts'; import { PinManager } from '../simulation/PinManager'; import { RP2040Simulator } from '../simulation/RP2040Simulator'; import { setAdcVoltage } from '../simulation/parts/partUtils'; // ─── Mock factories ────────────────────────────────────────────────────────── function makeElement(props: Record = {}): HTMLElement { return { addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), angle: 0, ...props, } as unknown as HTMLElement; } /** Simulator mock that mimics Esp32BridgeShim (no valid CPU cycles) */ function makeEsp32Shim() { let pwmCallback: ((pin: number, duty: number) => void) | null = null; const unsubPwm = vi.fn(); const adcCalls: { channel: number; millivolts: number }[] = []; return { pinManager: { onPinChange: vi.fn().mockReturnValue(() => {}), onPwmChange: vi.fn().mockImplementation((_pin: number, cb: (pin: number, duty: number) => void) => { pwmCallback = cb; return unsubPwm; }), updatePwm: vi.fn(), triggerPinChange: vi.fn(), }, setPinState: vi.fn(), isRunning: vi.fn().mockReturnValue(true), getCurrentCycles: vi.fn().mockReturnValue(-1), // ESP32: no valid cycles getClockHz: vi.fn().mockReturnValue(240_000_000), registerSensor: vi.fn().mockReturnValue(true), updateSensor: vi.fn(), unregisterSensor: vi.fn(), // Esp32BridgeShim.setAdcVoltage — mirrors the real implementation setAdcVoltage: vi.fn().mockImplementation((pin: number, voltage: number) => { let channel = -1; if (pin >= 36 && pin <= 39) channel = pin - 36; else if (pin >= 32 && pin <= 35) channel = pin - 28; if (channel < 0) return false; adcCalls.push({ channel, millivolts: Math.round(voltage * 1000) }); return true; }), // Test helpers _getPwmCallback: () => pwmCallback, _unsubPwm: unsubPwm, _getAdcCalls: () => adcCalls, }; } /** Simulator mock that mimics AVR (has valid CPU cycles) */ function makeAVRSim() { let pinCallback: ((pin: number, state: boolean) => void) | null = null; const unsubPin = vi.fn(); return { pinManager: { onPinChange: vi.fn().mockImplementation((_pin: number, cb: (pin: number, state: boolean) => void) => { pinCallback = cb; return unsubPin; }), onPwmChange: vi.fn().mockReturnValue(() => {}), updatePwm: vi.fn(), }, 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 }, registerSensor: vi.fn().mockReturnValue(false), // Test helpers _getPinCallback: () => pinCallback, _unsubPin: unsubPin, }; } const pinMap = (map: Record) => (name: string): number | null => name in map ? map[name] : null; // ───────────────────────────────────────────────────────────────────────────── // 1. Servo — ESP32 path: subscribes to onPwmChange // ───────────────────────────────────────────────────────────────────────────── describe('Servo — ESP32 PWM subscription', () => { const logic = () => PartSimulationRegistry.get('servo')!; it('subscribes to onPwmChange when simulator has no valid CPU cycles (ESP32 shim)', () => { const shim = makeEsp32Shim(); const el = makeElement(); logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-esp32'); expect(shim.pinManager.onPwmChange).toHaveBeenCalledWith(13, expect.any(Function)); }); it('updates angle when PWM duty cycle changes (pulse-width mapping)', () => { const shim = makeEsp32Shim(); const el = makeElement() as any; logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-esp32-angle'); const cb = shim._getPwmCallback(); expect(cb).not.toBeNull(); // ESP32 servo pulse-width mapping: // MIN_DC = 544/20000 = 0.0272 → 0° // MAX_DC = 2400/20000 = 0.12 → 180° const MIN_DC = 544 / 20000; const MAX_DC = 2400 / 20000; // At min duty → 0° cb!(13, MIN_DC); expect(el.angle).toBe(0); // At max duty → 180° cb!(13, MAX_DC); expect(el.angle).toBe(180); // At mid duty → ~90° const midDC = (MIN_DC + MAX_DC) / 2; cb!(13, midDC); expect(el.angle).toBe(90); }); it('ignores out-of-range duty cycles (noise filtering)', () => { const shim = makeEsp32Shim(); const el = makeElement() as any; logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-noise'); const cb = shim._getPwmCallback(); // Set to a known angle first cb!(13, 0.075); // mid-range const knownAngle = el.angle; // Very low duty (< 1%) is ignored cb!(13, 0.005); expect(el.angle).toBe(knownAngle); // unchanged // Very high duty (> 20%) is ignored cb!(13, 0.5); expect(el.angle).toBe(knownAngle); // unchanged }); it('clamps angle to 0-180 range', () => { const shim = makeEsp32Shim(); const el = makeElement() as any; logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-clamp'); const cb = shim._getPwmCallback(); // Slightly below MIN_DC (but above 1% filter) → clamps to 0° cb!(13, 0.015); expect(el.angle).toBe(0); // Slightly above MAX_DC (but below 20% filter) → clamps to 180° cb!(13, 0.15); expect(el.angle).toBe(180); }); it('cleanup unsubscribes from onPwmChange', () => { const shim = makeEsp32Shim(); const el = makeElement(); const cleanup = logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-cleanup'); cleanup(); expect(shim._unsubPwm).toHaveBeenCalled(); }); it('does NOT subscribe to onPinChange (AVR cycle measurement)', () => { const shim = makeEsp32Shim(); const el = makeElement(); logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-no-pin'); expect(shim.pinManager.onPinChange).not.toHaveBeenCalled(); }); }); // ───────────────────────────────────────────────────────────────────────────── // 2. Servo — AVR path: uses onPinChange + cycle measurement // ───────────────────────────────────────────────────────────────────────────── describe('Servo — AVR cycle-based measurement', () => { const logic = () => PartSimulationRegistry.get('servo')!; it('subscribes to onPinChange (not onPwmChange) when simulator has valid CPU cycles', () => { const avr = makeAVRSim(); const el = makeElement(); logic().attachEvents!(el, avr as any, pinMap({ PWM: 9 }), 'servo-avr'); expect(avr.pinManager.onPinChange).toHaveBeenCalledWith(9, expect.any(Function)); // Should NOT use onPwmChange for AVR expect(avr.pinManager.onPwmChange).not.toHaveBeenCalled(); }); }); // ───────────────────────────────────────────────────────────────────────────── // 3. Servo — RP2040 path: uses onPinChangeWithTime (instanceof check) // ───────────────────────────────────────────────────────────────────────────── describe('Servo — RP2040 timing-based measurement', () => { const logic = () => PartSimulationRegistry.get('servo')!; it('uses onPinChangeWithTime when simulator is RP2040Simulator instance', () => { const rp = new RP2040Simulator() as any; const el = makeElement(); logic().attachEvents!(el, rp as any, pinMap({ PWM: 15 }), 'servo-rp2040'); // RP2040 path sets onPinChangeWithTime expect(rp.onPinChangeWithTime).toBeTypeOf('function'); }); }); // ───────────────────────────────────────────────────────────────────────────── // 4-6. LEDC update routing — PinManager.updatePwm // ───────────────────────────────────────────────────────────────────────────── describe('LEDC update routing', () => { let pm: any; beforeEach(() => { pm = new PinManager(); }); it('routes to GPIO pin when update.gpio >= 0', () => { const update = { channel: 0, duty: 7.5, duty_pct: 7.5, gpio: 13 }; const targetPin = (update.gpio !== undefined && update.gpio >= 0) ? update.gpio : update.channel; pm.updatePwm(targetPin, update.duty_pct / 100); expect(pm.updatePwm).toHaveBeenCalledWith(13, 0.075); }); it('falls back to channel when gpio is -1', () => { const update = { channel: 2, duty: 50, duty_pct: 50, gpio: -1 }; const targetPin = (update.gpio !== undefined && update.gpio >= 0) ? update.gpio : update.channel; pm.updatePwm(targetPin, update.duty_pct / 100); expect(pm.updatePwm).toHaveBeenCalledWith(2, 0.5); }); it('falls back to channel when gpio is undefined', () => { const update = { channel: 3, duty: 100, duty_pct: 100 } as any; const targetPin = (update.gpio !== undefined && update.gpio >= 0) ? update.gpio : update.channel; pm.updatePwm(targetPin, update.duty_pct / 100); expect(pm.updatePwm).toHaveBeenCalledWith(3, 1.0); }); it('normalizes duty_pct to 0.0–1.0 (divides by 100)', () => { const update = { channel: 0, duty: 25, duty_pct: 25, gpio: 5 }; const targetPin = (update.gpio !== undefined && update.gpio >= 0) ? update.gpio : update.channel; pm.updatePwm(targetPin, update.duty_pct / 100); expect(pm.updatePwm).toHaveBeenCalledWith(5, 0.25); }); }); // ───────────────────────────────────────────────────────────────────────────── // 7. Servo angle mapping — pulse-width based for ESP32 // ───────────────────────────────────────────────────────────────────────────── describe('Servo angle mapping (pulse-width)', () => { const logic = () => PartSimulationRegistry.get('servo')!; it('maps real servo duty cycles to correct angles', () => { const shim = makeEsp32Shim(); const el = makeElement() as any; logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-map'); const cb = shim._getPwmCallback(); // Servo pulse widths at 50Hz (20ms period): // 544µs = 2.72% duty → 0° // 1472µs = 7.36% duty → 90° // 2400µs = 12.00% duty → 180° const testCases = [ { pulseUs: 544, expectedAngle: 0 }, { pulseUs: 1008, expectedAngle: 45 }, { pulseUs: 1472, expectedAngle: 90 }, { pulseUs: 1936, expectedAngle: 135 }, { pulseUs: 2400, expectedAngle: 180 }, ]; for (const { pulseUs, expectedAngle } of testCases) { const dutyCycle = pulseUs / 20000; // fraction of 20ms period cb!(13, dutyCycle); expect(el.angle).toBe(expectedAngle); } }); }); // ───────────────────────────────────────────────────────────────────────────── // 8. Potentiometer — setAdcVoltage on ESP32 // ───────────────────────────────────────────────────────────────────────────── describe('Potentiometer — ESP32 ADC path', () => { it('setAdcVoltage delegates to Esp32BridgeShim.setAdcVoltage', () => { const shim = makeEsp32Shim(); const result = setAdcVoltage(shim as any, 34, 1.65); expect(result).toBe(true); expect(shim.setAdcVoltage).toHaveBeenCalledWith(34, 1.65); }); it('setAdcVoltage returns false for non-ADC ESP32 pins', () => { const shim = makeEsp32Shim(); // GPIO 13 is not an ADC pin on ESP32 const result = setAdcVoltage(shim as any, 13, 1.65); expect(result).toBe(false); }); it('setAdcVoltage works for AVR (pin 14-19)', () => { const avrSim = makeAVRSim() as any; avrSim.getADC = () => ({ channelValues: new Array(6).fill(0) }); const result = setAdcVoltage(avrSim as any, 14, 2.5); expect(result).toBe(true); }); }); // ───────────────────────────────────────────────────────────────────────────── // 9. ESP32 ADC channel mapping (GPIO → ADC1 channel) // ───────────────────────────────────────────────────────────────────────────── describe('ESP32 ADC channel mapping', () => { it('maps GPIO 36-39 → ADC1 CH0-3', () => { const shim = makeEsp32Shim(); setAdcVoltage(shim as any, 36, 1.0); setAdcVoltage(shim as any, 37, 1.0); setAdcVoltage(shim as any, 38, 1.0); setAdcVoltage(shim as any, 39, 1.0); const calls = shim._getAdcCalls(); expect(calls.map(c => c.channel)).toEqual([0, 1, 2, 3]); }); it('maps GPIO 32-35 → ADC1 CH4-7', () => { const shim = makeEsp32Shim(); setAdcVoltage(shim as any, 32, 1.0); setAdcVoltage(shim as any, 33, 1.0); setAdcVoltage(shim as any, 34, 1.0); setAdcVoltage(shim as any, 35, 1.0); const calls = shim._getAdcCalls(); expect(calls.map(c => c.channel)).toEqual([4, 5, 6, 7]); }); it('converts voltage to millivolts correctly', () => { const shim = makeEsp32Shim(); setAdcVoltage(shim as any, 34, 1.65); const calls = shim._getAdcCalls(); expect(calls[0].millivolts).toBe(1650); }); it('rejects non-ADC GPIOs (0-31)', () => { const shim = makeEsp32Shim(); const result = setAdcVoltage(shim as any, 13, 1.0); expect(result).toBe(false); expect(shim._getAdcCalls()).toHaveLength(0); }); }); // ───────────────────────────────────────────────────────────────────────────── // 10. LEDC 0x5000 marker decoding — channel extraction fix // ───────────────────────────────────────────────────────────────────────────── describe('LEDC 0x5000 marker decoding', () => { // QEMU fires: qemu_set_irq(ledc_sync, 0x5000 | (ledn << 8) | intensity) // Worker must extract: ledc_ch = (direction >> 8) & 0x0F (NOT & 0xFF) function decodeLedc(direction: number) { const marker = direction & 0xF000; if (marker !== 0x5000) return null; const ledc_ch = (direction >> 8) & 0x0F; // correct: strips marker bits const intensity = direction & 0xFF; return { ledc_ch, intensity }; } function decodeLedcBroken(direction: number) { const marker = direction & 0xF000; if (marker !== 0x5000) return null; const ledc_ch = (direction >> 8) & 0xFF; // BUG: includes marker bits const intensity = direction & 0xFF; return { ledc_ch, intensity }; } it('HS channel 0 (ledn=0): direction=0x500B → ch=0, not ch=80', () => { const direction = 0x5000 | (0 << 8) | 11; // 0x500B const correct = decodeLedc(direction)!; const broken = decodeLedcBroken(direction)!; expect(correct.ledc_ch).toBe(0); // correct expect(broken.ledc_ch).toBe(80); // BUG: 0x50 = 80 expect(correct.intensity).toBe(11); }); it('LS channel 0 (ledn=8): direction=0x5811 → ch=8, not ch=88', () => { const direction = 0x5000 | (8 << 8) | 17; // 0x5811 const correct = decodeLedc(direction)!; const broken = decodeLedcBroken(direction)!; expect(correct.ledc_ch).toBe(8); // correct expect(broken.ledc_ch).toBe(88); // BUG: 0x58 = 88 expect(correct.intensity).toBe(17); }); it('HS channel 7 (ledn=7): direction=0x5732 → ch=7', () => { const direction = 0x5000 | (7 << 8) | 50; // 0x5732 expect(decodeLedc(direction)!.ledc_ch).toBe(7); expect(decodeLedc(direction)!.intensity).toBe(50); }); it('LS channel 7 (ledn=15): direction=0x5F64 → ch=15', () => { const direction = 0x5000 | (15 << 8) | 100; // 0x5F64 expect(decodeLedc(direction)!.ledc_ch).toBe(15); expect(decodeLedc(direction)!.intensity).toBe(100); }); it('all 16 channels decode correctly', () => { for (let ledn = 0; ledn < 16; ledn++) { const direction = 0x5000 | (ledn << 8) | 42; const result = decodeLedc(direction)!; expect(result.ledc_ch).toBe(ledn); expect(result.intensity).toBe(42); } }); }); // ───────────────────────────────────────────────────────────────────────────── // 11. GPIO out_sel scanning — LEDC→GPIO mapping // ───────────────────────────────────────────────────────────────────────────── describe('GPIO out_sel scanning for LEDC mapping', () => { // Simulates what the LEDC poll thread does: read gpio_out_sel[40] and // scan for LEDC signal values (72-87) to build _ledc_gpio_map function scanOutSel(outSel: number[]): Map { const ledcGpioMap = new Map(); for (let gpioPin = 0; gpioPin < outSel.length; gpioPin++) { const signal = outSel[gpioPin] & 0xFF; if (signal >= 72 && signal <= 87) { const ledcCh = signal - 72; ledcGpioMap.set(ledcCh, gpioPin); } } return ledcGpioMap; } it('detects LEDC HS ch0 (signal=72) on GPIO 13', () => { const outSel = new Array(40).fill(256); // 256 = no function outSel[13] = 72; // LEDC HS ch0 → GPIO 13 const map = scanOutSel(outSel); expect(map.get(0)).toBe(13); expect(map.size).toBe(1); }); it('detects LEDC LS ch0 (signal=80) on GPIO 2', () => { const outSel = new Array(40).fill(256); outSel[2] = 80; // LEDC LS ch0 → GPIO 2 const map = scanOutSel(outSel); expect(map.get(8)).toBe(2); // ch8 = LS ch0 }); it('detects multiple LEDC channels', () => { const outSel = new Array(40).fill(256); outSel[13] = 72; // HS ch0 → GPIO 13 outSel[12] = 73; // HS ch1 → GPIO 12 outSel[14] = 80; // LS ch0 → GPIO 14 const map = scanOutSel(outSel); expect(map.get(0)).toBe(13); expect(map.get(1)).toBe(12); expect(map.get(8)).toBe(14); expect(map.size).toBe(3); }); it('ignores non-LEDC signals (< 72 or > 87)', () => { const outSel = new Array(40).fill(256); outSel[5] = 71; // signal 71 = not LEDC outSel[6] = 88; // signal 88 = not LEDC outSel[7] = 0; // signal 0 = GPIO matrix simple const map = scanOutSel(outSel); expect(map.size).toBe(0); }); it('explains why 0x2000 marker was broken for LEDC signals', () => { // QEMU fires: 0x2000 | ((signal & 0xFF) << 8) | (gpio & 0xFF) // For signal=72 (0x48), gpio=13: direction = 0x2000 | 0x4800 | 0x0D = 0x680D // marker = direction & 0xF000 = 0x6000 ≠ 0x2000 → NEVER MATCHED! const signal = 72; const gpio = 13; const direction = 0x2000 | ((signal & 0xFF) << 8) | (gpio & 0xFF); expect(direction).toBe(0x680D); expect(direction & 0xF000).toBe(0x6000); // NOT 0x2000! expect(direction & 0xF000).not.toBe(0x2000); // confirms the bug }); }); // ───────────────────────────────────────────────────────────────────────────── // 12. End-to-end: LEDC update with correct GPIO routes to servo // ───────────────────────────────────────────────────────────────────────────── describe('End-to-end: LEDC → servo angle', () => { const logic = () => PartSimulationRegistry.get('servo')!; it('ledc_update with gpio=13 → updatePwm(13, duty) → servo moves', () => { const shim = makeEsp32Shim(); const el = makeElement() as any; logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-e2e'); // Simulate what useSimulatorStore.onLedcUpdate does: const update = { channel: 0, duty: 7.36, duty_pct: 7.36, gpio: 13 }; const targetPin = (update.gpio >= 0) ? update.gpio : update.channel; const dutyCycleFraction = update.duty_pct / 100; // This is what the store calls: shim.pinManager.updatePwm(targetPin, dutyCycleFraction); // The servo's onPwmChange callback should have been triggered const cb = shim._getPwmCallback(); expect(cb).not.toBeNull(); // Manually invoke the callback (simulating PinManager dispatching) cb!(13, dutyCycleFraction); // 7.36% duty = 1472µs pulse → ~90° expect(el.angle).toBeGreaterThanOrEqual(88); expect(el.angle).toBeLessThanOrEqual(92); }); it('ledc_update with WRONG ch=80 and gpio=-1 would NOT reach servo on pin 13', () => { // This demonstrates the bug that was fixed: // ch=80 (from broken & 0xFF) with gpio=-1 → updatePwm(80, duty) // But servo listens on pin 13 → callback never fires const shim = makeEsp32Shim(); const el = makeElement() as any; logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-bug-demo'); const cb = shim._getPwmCallback(); // With the bug: updatePwm would be called with pin=80 (wrong) // The servo registered on pin 13, so this would NOT trigger it // (PinManager only dispatches to callbacks registered for that pin) expect(cb).not.toBeNull(); // Calling with wrong pin does nothing (servo registered on 13, not 80) cb!(80, 0.075); // wrong pin // angle still 0 since the real PinManager wouldn't route pin 80 to pin 13's callback // (In our mock, the callback is directly invoked, but in production it wouldn't fire) }); }); // ───────────────────────────────────────────────────────────────────────────── // 13. LEDC polling — data type and internal config // ───────────────────────────────────────────────────────────────────────────── describe('LEDC polling — data format', () => { it('duty values from QEMU are floats representing percentages (0-100)', () => { // Simulates what the LEDC polling thread reads from QEMU // QEMU stores: duty[ch] = 100.0 * raw_duty / (16 * (2^duty_res - 1)) // For a servo at 50Hz, 13-bit resolution, 1500µs pulse: // raw_duty = 1500/20000 * 8192 = 614.4 // duty_pct = 100 * 614.4 / (16 * 8191) ≈ 0.469... but QEMU formula differs // What matters: duty is a float percentage const dutyPct = 7.5; // 7.5% = 1500µs at 50Hz = ~90° // Frontend receives duty_pct, divides by 100 const dutyCycleFraction = dutyPct / 100; // 0.075 // Servo maps pulse width: const MIN_DC = 544 / 20000; // 0.0272 const MAX_DC = 2400 / 20000; // 0.12 const angle = Math.round( ((dutyCycleFraction - MIN_DC) / (MAX_DC - MIN_DC)) * 180 ); // 7.5% duty ≈ 93° (close to 90°) expect(angle).toBeGreaterThanOrEqual(88); expect(angle).toBeLessThanOrEqual(95); }); it('LEDC internal config ID is 6 (QEMU_INTERNAL_LEDC_CHANNEL_DUTY)', () => { // Verifies the constant matches QEMU's definition // #define QEMU_INTERNAL_LEDC_CHANNEL_DUTY 6 const QEMU_INTERNAL_LEDC_CHANNEL_DUTY = 6; expect(QEMU_INTERNAL_LEDC_CHANNEL_DUTY).toBe(6); }); it('deduplication: identical duty values are not re-emitted', () => { // Simulates the _last_duty tracking in _ledc_poll_thread const lastDuty = [0.0, 0.0, 0.0]; const emitted: { ch: number; duty: number }[] = []; function pollOnce(duties: number[]) { for (let ch = 0; ch < duties.length; ch++) { const duty = duties[ch]; if (Math.abs(duty - lastDuty[ch]) < 0.01) continue; lastDuty[ch] = duty; if (duty > 0) emitted.push({ ch, duty }); } } // First poll: duty = 7.5 → emitted pollOnce([7.5, 0, 0]); expect(emitted).toHaveLength(1); expect(emitted[0]).toEqual({ ch: 0, duty: 7.5 }); // Second poll: same duty → NOT emitted (deduplication) pollOnce([7.5, 0, 0]); expect(emitted).toHaveLength(1); // still 1 // Third poll: duty changed → emitted pollOnce([12.0, 0, 0]); expect(emitted).toHaveLength(2); expect(emitted[1]).toEqual({ ch: 0, duty: 12.0 }); }); });