From 6a10675a5a7223c744b066f8842645f715e7cf37 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Sun, 8 Mar 2026 23:20:33 -0300 Subject: [PATCH] feat: implement syncElement method for rendering GDDRAM to RGBA ImageData in VirtualSSD1306 test: add tests for SSD1306 rendering path and syncElement functionality --- frontend/src/__tests__/ssd1306-render.test.ts | 243 ++++++++++++++++++ .../src/simulation/parts/ProtocolParts.ts | 46 +++- 2 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 frontend/src/__tests__/ssd1306-render.test.ts diff --git a/frontend/src/__tests__/ssd1306-render.test.ts b/frontend/src/__tests__/ssd1306-render.test.ts new file mode 100644 index 0000000..50dec9a --- /dev/null +++ b/frontend/src/__tests__/ssd1306-render.test.ts @@ -0,0 +1,243 @@ +/** + * ssd1306-render.test.ts + * + * Tests the SSD1306 OLED simulation's rendering path: + * - GDDRAM is filled correctly via I2C writes + * - syncElement() converts 1-bit GDDRAM → RGBA ImageData + * - element.imageData is updated and element.redraw() is called + * + * This covers the bug fix where syncElement() was calling el.buffer / + * el.renderFrame() (non-existent) instead of el.imageData / el.redraw(). + */ + +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry'; +import '../simulation/parts/ProtocolParts'; + +// ─── Polyfill ImageData for Node/Vitest (no browser) ───────────────────────── + +beforeAll(() => { + if (typeof globalThis.ImageData === 'undefined') { + class ImageDataPolyfill { + readonly width: number; + readonly height: number; + readonly data: Uint8ClampedArray; + + constructor(widthOrData: number | Uint8ClampedArray, height: number) { + if (typeof widthOrData === 'number') { + this.width = widthOrData; + this.height = height; + this.data = new Uint8ClampedArray(widthOrData * height * 4); + } else { + this.width = widthOrData.length / 4 / height; + this.height = height; + this.data = new Uint8ClampedArray(widthOrData); + } + } + } + (globalThis as any).ImageData = ImageDataPolyfill; + } +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Build a mock wokwi-ssd1306 element with the real ImageData API. */ +function makeOLEDElement() { + const imageData = new ImageData(128, 64); + const redraw = vi.fn(); + return { + imageData, + redraw, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as HTMLElement & { imageData: ImageData; redraw: ReturnType }; +} + +/** Build a minimal AVR simulator stub that supports addI2CDevice. */ +function makeSim() { + const devices: any[] = []; + return { + addI2CDevice: vi.fn((d: any) => devices.push(d)), + i2cBus: { removeDevice: vi.fn() }, + _devices: devices, + }; +} + +/** + * Simulate the Adafruit SSD1306 library's I2C init + fill sequence. + * + * The library sends: + * START → addr 0x3C write → 0x00 (cmd ctrl) → [commands…] → STOP + * START → addr 0x3C write → 0x40 (data ctrl) → [data…] → STOP + * + * In our model the I2CBusManager calls device.writeByte() for every byte + * after the address phase, starting with the control byte. + */ +function sendCommandStream(device: any, cmds: number[]) { + device.writeByte(0x00); // control byte: command stream (Co=0, D/C#=0) + for (const b of cmds) device.writeByte(b); + device.stop(); +} + +function sendDataStream(device: any, data: number[]) { + device.writeByte(0x40); // control byte: GDDRAM data + for (const b of data) device.writeByte(b); + device.stop(); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('SSD1306 — ImageData rendering (syncElement fix)', () => { + it('registers ssd1306 in PartSimulationRegistry', () => { + expect(PartSimulationRegistry.get('ssd1306')).toBeDefined(); + }); + + it('creates a VirtualSSD1306 device at address 0x3C', () => { + const el = makeOLEDElement(); + const sim = makeSim(); + PartSimulationRegistry.get('ssd1306')!.attachEvents!(el, sim as any, () => null); + expect(sim.addI2CDevice).toHaveBeenCalledOnce(); + expect(sim._devices[0].address).toBe(0x3C); + }); + + it('calls element.redraw() after a STOP', () => { + const el = makeOLEDElement(); + const sim = makeSim(); + PartSimulationRegistry.get('ssd1306')!.attachEvents!(el, sim as any, () => null); + const device = sim._devices[0]; + + // Simple data write — fill first byte + sendDataStream(device, [0xFF]); + + expect(el.redraw).toHaveBeenCalled(); + }); + + it('renders a fully-lit column 0 of page 0 (0xFF → top 8 pixels lit)', () => { + const el = makeOLEDElement(); + const sim = makeSim(); + PartSimulationRegistry.get('ssd1306')!.attachEvents!(el, sim as any, () => null); + const device = sim._devices[0]; + + // Set horizontal addressing, col 0–127, page 0–7 + sendCommandStream(device, [ + 0x20, 0x00, // horizontal addressing mode + 0x21, 0x00, 0x7F, // col 0–127 + 0x22, 0x00, 0x07, // page 0–7 + ]); + + // Write 0xFF to column 0 of page 0 → all 8 bits set → rows 0–7, col 0 lit + sendDataStream(device, [0xFF]); + + const px = el.imageData.data; // RGBA + + // Row 0, col 0 → pixel index 0 + const idx = (0 * 128 + 0) * 4; + expect(px[idx + 3]).toBe(255); // alpha = 255 (opaque) + expect(px[idx] + px[idx + 1] + px[idx + 2]).toBeGreaterThan(0); // not black + + // Row 7, col 0 → pixel index (7 * 128 + 0) * 4 + const idx7 = (7 * 128 + 0) * 4; + expect(px[idx7 + 3]).toBe(255); + expect(px[idx7] + px[idx7 + 1] + px[idx7 + 2]).toBeGreaterThan(0); + }); + + it('renders an unlit pixel as black (RGB = 0)', () => { + const el = makeOLEDElement(); + const sim = makeSim(); + PartSimulationRegistry.get('ssd1306')!.attachEvents!(el, sim as any, () => null); + const device = sim._devices[0]; + + sendCommandStream(device, [0x20, 0x00, 0x21, 0x00, 0x7F, 0x22, 0x00, 0x07]); + // 0x01 → only bit 0 set → only row 0 of page 0 is lit; row 1 is off + sendDataStream(device, [0x01]); + + const px = el.imageData.data; + + // Row 0 col 0 → lit + const idxLit = (0 * 128 + 0) * 4; + expect(px[idxLit] + px[idxLit + 1] + px[idxLit + 2]).toBeGreaterThan(0); + + // Row 1 col 0 → unlit (bit 1 of 0x01 = 0) + const idxOff = (1 * 128 + 0) * 4; + expect(px[idxOff]).toBe(0); + expect(px[idxOff + 1]).toBe(0); + expect(px[idxOff + 2]).toBe(0); + }); + + it('fills all 1024 GDDRAM bytes via horizontal addressing', () => { + const el = makeOLEDElement(); + const sim = makeSim(); + PartSimulationRegistry.get('ssd1306')!.attachEvents!(el, sim as any, () => null); + const device = sim._devices[0]; + + sendCommandStream(device, [0x20, 0x00, 0x21, 0x00, 0x7F, 0x22, 0x00, 0x07]); + + // Fill all 1024 GDDRAM bytes with a checkerboard pattern (0xAA / 0x55) + const data: number[] = []; + for (let i = 0; i < 1024; i++) data.push(i % 2 === 0 ? 0xAA : 0x55); + sendDataStream(device, data); + + // Spot-check: page 7, col 127 = index 7*128+127 = 1023 + expect(device.buffer[1023]).toBe(0x55); + + // All 128*64 pixels must have alpha=255 + const px = el.imageData.data; + let allOpaque = true; + for (let i = 3; i < px.length; i += 4) { + if (px[i] !== 255) { allOpaque = false; break; } + } + expect(allOpaque).toBe(true); + }); + + it('does not throw when element has no imageData yet (null/undefined)', () => { + const el = { + imageData: undefined, + redraw: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as HTMLElement; + + const sim = makeSim(); + PartSimulationRegistry.get('ssd1306')!.attachEvents!(el, sim as any, () => null); + const device = sim._devices[0]; + + expect(() => sendDataStream(device, [0xFF])).not.toThrow(); + }); + + it('Adafruit SSD1306 init sequence: processes multi-byte commands without crashing', () => { + const el = makeOLEDElement(); + const sim = makeSim(); + PartSimulationRegistry.get('ssd1306')!.attachEvents!(el, sim as any, () => null); + const device = sim._devices[0]; + + // Minimal Adafruit init (from Adafruit_SSD1306.cpp begin()) + const initCmds = [ + 0xAE, // Display OFF + 0xD5, 0x80, // Set display clock divide + 0xA8, 0x3F, // Set multiplex ratio (64-1) + 0xD3, 0x00, // Set display offset + 0x40, // Set start line + 0x8D, 0x14, // Charge pump ON + 0x20, 0x00, // Horizontal addressing + 0xA1, // Segment remap + 0xC8, // COM output scan direction + 0xDA, 0x12, // COM pins hardware config + 0x81, 0xCF, // Contrast + 0xD9, 0xF1, // Pre-charge period + 0xDB, 0x40, // VCOMH deselect level + 0xA4, // Display from RAM + 0xA6, // Normal display + 0x2E, // Deactivate scroll + 0xAF, // Display ON + ]; + + expect(() => { + sendCommandStream(device, initCmds); + // After init, write one page of data + sendCommandStream(device, [0x21, 0x00, 0x7F, 0x22, 0x00, 0x07]); + sendDataStream(device, new Array(1024).fill(0x00)); + }).not.toThrow(); + + expect(el.redraw).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/simulation/parts/ProtocolParts.ts b/frontend/src/simulation/parts/ProtocolParts.ts index dcf1655..847c8cc 100644 --- a/frontend/src/simulation/parts/ProtocolParts.ts +++ b/frontend/src/simulation/parts/ProtocolParts.ts @@ -166,15 +166,51 @@ class VirtualSSD1306 implements I2CDevice { this.syncElement(); } + /** + * Push the 1-bit GDDRAM buffer to the wokwi-ssd1306 web component. + * + * wokwi-ssd1306 API: + * - `element.imageData` — a 128×64 ImageData (RGBA, 4 bytes/pixel) + * - `element.redraw()` — flushes imageData to the internal canvas + * + * GDDRAM layout: 8 pages × 128 columns. + * Each byte holds 8 vertical pixels; bit 0 = topmost pixel in the page. + */ private syncElement(): void { const el = this.element as any; if (!el) return; - // wokwi-ssd1306 exposes a Uint8Array `buffer` property - if (el.buffer !== undefined) { - el.buffer = new Uint8Array(this.buffer); + + // Obtain the ImageData object (initialised by wokwi-ssd1306 constructor) + let imgData: ImageData | undefined = el.imageData; + if (!imgData || imgData.width !== 128 || imgData.height !== 64) { + try { + imgData = new ImageData(128, 64); + } catch { + return; + } } - if (typeof el.renderFrame === 'function') { - el.renderFrame(this.buffer); + + const px = imgData.data; // Uint8ClampedArray, RGBA + + for (let page = 0; page < 8; page++) { + for (let col = 0; col < 128; col++) { + const byte = this.buffer[page * 128 + col]; + for (let bit = 0; bit < 8; bit++) { + const row = page * 8 + bit; + const lit = (byte >> bit) & 1; + const idx = (row * 128 + col) * 4; + // Lit pixel: bright cyan-white typical of OLED; off pixel: full black + px[idx] = lit ? 200 : 0; // R + px[idx + 1] = lit ? 230 : 0; // G + px[idx + 2] = lit ? 255 : 0; // B + px[idx + 3] = 255; // A + } + } + } + + el.imageData = imgData; + if (typeof el.redraw === 'function') { + el.redraw(); } } }