velxio/frontend/src/__tests__/esp32-servo-pot.test.ts

732 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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.01.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<string, unknown> = {}): 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<string, number>) =>
(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.01.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<number, number> {
const ledcGpioMap = new Map<number, number>();
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 });
});
});