feat: implement syncElement method for rendering GDDRAM to RGBA ImageData in VirtualSSD1306

test: add tests for SSD1306 rendering path and syncElement functionality
pull/10/head
David Montero Crespo 2026-03-08 23:20:33 -03:00
parent a07d219a7d
commit 6a10675a5a
2 changed files with 284 additions and 5 deletions

View File

@ -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<typeof vi.fn> };
}
/** 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 0127, page 07
sendCommandStream(device, [
0x20, 0x00, // horizontal addressing mode
0x21, 0x00, 0x7F, // col 0127
0x22, 0x00, 0x07, // page 07
]);
// Write 0xFF to column 0 of page 0 → all 8 bits set → rows 07, 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();
});
});

View File

@ -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();
}
}
}