velxio/frontend/src/__tests__/ssd168x-decoder.test.ts

355 lines
12 KiB
TypeScript
Raw Normal View History

/**
* Vitest port of `test/test_epaper/test_ssd168x_protocol.py`. Ensures the
* TypeScript SSD168xDecoder produces byte-for-byte identical framebuffers
* to the Python reference.
*
* Adding a new test case here? Mirror it in the Python file too (or vice
* versa) the cross-decoder consistency assertion at the bottom hashes a
* real GxEPD2-emitted byte trace through both and asserts the same Frame.
*/
import { describe, it, expect } from 'vitest';
import {
SSD168xDecoder,
type Frame,
CMD_SW_RESET,
CMD_DRIVER_OUTPUT_CTRL,
CMD_DATA_ENTRY_MODE,
CMD_SET_RAMX_RANGE,
CMD_SET_RAMY_RANGE,
CMD_BORDER_WAVEFORM,
CMD_DISP_UPDATE_CTRL_1,
CMD_DISP_UPDATE_CTRL_2,
CMD_TEMP_SENSOR,
CMD_SET_RAMX_COUNTER,
CMD_SET_RAMY_COUNTER,
CMD_WRITE_BLACK_VRAM,
CMD_WRITE_RED_VRAM,
CMD_MASTER_ACTIVATION,
CMD_DEEP_SLEEP,
} from '../simulation/displays/SSD168xDecoder';
// ── Helpers (mirror the Python ones) ──────────────────────────────────────────
const cmd = (c: number): Array<[number, boolean]> => [[c, false]];
const data = (...bs: number[]): Array<[number, boolean]> => bs.map((b) => [b, true] as [number, boolean]);
function feedAll(d: SSD168xDecoder, ...streams: Array<Array<[number, boolean]>>) {
for (const stream of streams) {
for (const [byte, dcHigh] of stream) {
d.feed(byte, dcHigh);
}
}
}
function gxepd2Init154(d: SSD168xDecoder) {
feedAll(
d,
cmd(CMD_SW_RESET),
cmd(CMD_DRIVER_OUTPUT_CTRL),
data(0xc7, 0x00, 0x00),
cmd(CMD_DATA_ENTRY_MODE),
data(0x03),
cmd(CMD_SET_RAMX_RANGE),
data(0x00, 0x18),
cmd(CMD_SET_RAMY_RANGE),
data(0x00, 0x00, 0xc7, 0x00),
cmd(CMD_BORDER_WAVEFORM),
data(0x05),
cmd(CMD_DISP_UPDATE_CTRL_1),
data(0x00, 0x80),
cmd(CMD_TEMP_SENSOR),
data(0x80),
cmd(CMD_SET_RAMX_COUNTER),
data(0x00),
cmd(CMD_SET_RAMY_COUNTER),
data(0x00, 0x00),
);
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('SSD168xDecoder — init sequence', () => {
it('accepts the GxEPD2 init for the 1.54" panel without unknown opcodes', () => {
const d = new SSD168xDecoder({ width: 200, height: 200 });
gxepd2Init154(d);
expect(d.unknownCmds).toEqual([]);
expect(d.refreshedCount).toBe(0);
expect(d.inDeepSleep).toBe(false);
});
it('SW reset clears the BW plane', () => {
const d = new SSD168xDecoder({ width: 200, height: 200 });
feedAll(d, cmd(CMD_WRITE_BLACK_VRAM), data(0x00, 0x00, 0x00));
expect(Array.from(d.bwRam.slice(0, 25)).some((b) => b !== 0xff)).toBe(true);
feedAll(d, cmd(CMD_SW_RESET));
expect(Array.from(d.bwRam.slice(0, 25)).every((b) => b === 0xff)).toBe(true);
});
});
describe('SSD168xDecoder — RAM windowing', () => {
it('SET_RAMX_RANGE / SET_RAMY_RANGE set the active window', () => {
const d = new SSD168xDecoder({ width: 200, height: 200 });
feedAll(
d,
cmd(CMD_SET_RAMX_RANGE),
data(0x00, 0x18),
cmd(CMD_SET_RAMY_RANGE),
data(0x00, 0x00, 0xc7, 0x00),
);
// Indirectly via writePixel auto-increment behaviour at the boundary:
feedAll(d, cmd(CMD_SET_RAMX_COUNTER), data(0x00));
feedAll(d, cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00));
feedAll(d, cmd(CMD_WRITE_BLACK_VRAM), data(0xaa));
expect(d.bwRam[0]).toBe(0xaa);
});
it('SET_RAMX_COUNTER / SET_RAMY_COUNTER seek inside the window', () => {
const d = new SSD168xDecoder({ width: 200, height: 200 });
feedAll(
d,
cmd(CMD_SET_RAMX_COUNTER),
data(0x05),
cmd(CMD_SET_RAMY_COUNTER),
data(0x10, 0x00),
cmd(CMD_WRITE_BLACK_VRAM),
data(0xab),
);
const bpr = 25; // 200/8
expect(d.bwRam[0x10 * bpr + 0x05]).toBe(0xab);
});
});
describe('SSD168xDecoder — pixel writing & wrap', () => {
it('WRITE_BLACK_VRAM auto-increments X', () => {
const d = new SSD168xDecoder({ width: 200, height: 200 });
gxepd2Init154(d);
feedAll(d, cmd(CMD_WRITE_BLACK_VRAM), data(0x00, 0xff, 0xaa));
expect(d.bwRam[0]).toBe(0x00);
expect(d.bwRam[1]).toBe(0xff);
expect(d.bwRam[2]).toBe(0xaa);
});
it('writes wrap to the next row at xrange[1]', () => {
const d = new SSD168xDecoder({ width: 200, height: 200 });
feedAll(
d,
cmd(CMD_DATA_ENTRY_MODE),
data(0x03),
cmd(CMD_SET_RAMX_RANGE),
data(0x00, 0x01),
cmd(CMD_SET_RAMX_COUNTER),
data(0x00),
cmd(CMD_SET_RAMY_COUNTER),
data(0x00, 0x00),
);
feedAll(d, cmd(CMD_WRITE_BLACK_VRAM), data(0xaa, 0xbb, 0xcc, 0xdd));
const bpr = 25;
expect(d.bwRam[0]).toBe(0xaa);
expect(d.bwRam[1]).toBe(0xbb);
expect(d.bwRam[bpr + 0]).toBe(0xcc);
expect(d.bwRam[bpr + 1]).toBe(0xdd);
});
});
describe('SSD168xDecoder — frame latch & compose', () => {
it('MASTER_ACTIVATION fires onFlush with the latched frame', () => {
const seen: Frame[] = [];
const d = new SSD168xDecoder({
width: 200,
height: 200,
onFlush: (f) => seen.push(f),
});
gxepd2Init154(d);
feedAll(
d,
cmd(CMD_DISP_UPDATE_CTRL_2),
data(0xf7),
cmd(CMD_MASTER_ACTIVATION),
);
expect(seen.length).toBe(1);
expect(d.refreshedCount).toBe(1);
const frame = seen[0];
expect(frame.width).toBe(200);
expect(frame.height).toBe(200);
// Default RAM is 0xFF (all bits=1) → all pixels white.
for (let i = 0; i < frame.pixels.length; i++) expect(frame.pixels[i]).toBe(1);
});
it('red plane wins over black on compose', () => {
const d = new SSD168xDecoder({ width: 8, height: 2 });
// Row 0 all-black, Row 1 all-white
feedAll(d, cmd(CMD_WRITE_BLACK_VRAM), data(0x00, 0xff));
// Reset cursors then write red plane: row 0 first 4 px red, row 1 nothing
feedAll(d, cmd(CMD_SET_RAMX_COUNTER), data(0x00));
feedAll(d, cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00));
feedAll(d, cmd(CMD_WRITE_RED_VRAM), data(0xf0, 0x00));
const frame = d.composeFrame();
expect(Array.from(frame.pixels.slice(0, 4))).toEqual([2, 2, 2, 2]);
expect(Array.from(frame.pixels.slice(4, 8))).toEqual([0, 0, 0, 0]);
expect(Array.from(frame.pixels.slice(8, 16))).toEqual([1, 1, 1, 1, 1, 1, 1, 1]);
});
});
describe('SSD168xDecoder — deep sleep & unknown opcodes', () => {
it('DEEP_SLEEP with data 0x01 sets the in_deep_sleep flag', () => {
const d = new SSD168xDecoder({ width: 200, height: 200 });
feedAll(d, cmd(CMD_DEEP_SLEEP), data(0x01));
expect(d.inDeepSleep).toBe(true);
});
it('unknown opcodes are logged not raised', () => {
const d = new SSD168xDecoder({ width: 200, height: 200 });
feedAll(d, cmd(0xab), data(0x01, 0x02));
expect(d.unknownCmds).toContain(0xab);
});
});
describe('SSD168xDecoder — end-to-end hello world', () => {
it('writes a single black pixel at (0,0) and the frame reflects it', () => {
const seen: Frame[] = [];
const d = new SSD168xDecoder({
width: 200,
height: 200,
onFlush: (f) => seen.push(f),
});
gxepd2Init154(d);
feedAll(d, cmd(CMD_SET_RAMX_COUNTER), data(0x00));
feedAll(d, cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00));
feedAll(d, cmd(CMD_WRITE_BLACK_VRAM), data(0x7f));
feedAll(
d,
cmd(CMD_DISP_UPDATE_CTRL_2),
data(0xf7),
cmd(CMD_MASTER_ACTIVATION),
);
expect(seen.length).toBe(1);
expect(seen[0].pixels[0]).toBe(0); // top-left black
expect(seen[0].pixels[1]).toBe(1); // (1,0) white
});
});
describe('SSD168xDecoder — tri-colour B/W/R pipeline', () => {
// Mirrors how GxEPD2_3C drives a real SSD1680 panel: write the BW plane
// first (cmd 0x24), reset cursors, then write the red plane (cmd 0x26),
// then activate. The composed frame must mix both planes correctly.
it('three-row pattern composes B / W / R correctly', () => {
// 16-wide × 3-high panel — easy to pin every pixel by hand.
const seen: Frame[] = [];
const d = new SSD168xDecoder({
width: 16,
height: 3,
onFlush: (f) => seen.push(f),
});
// Match the typical GxEPD2 init sub-sequence enough to set window + cursor.
feedAll(
d,
cmd(CMD_DATA_ENTRY_MODE), data(0x03),
cmd(CMD_SET_RAMX_RANGE), data(0x00, 0x01), // 2 bytes wide
cmd(CMD_SET_RAMY_RANGE), data(0x00, 0x00, 0x02, 0x00), // rows 0..2
cmd(CMD_SET_RAMX_COUNTER), data(0x00),
cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00),
);
// BW plane: row 0 all-black, row 1 all-white, row 2 mixed (left half black)
feedAll(
d,
cmd(CMD_WRITE_BLACK_VRAM),
data(
0x00, 0x00, // row 0
0xff, 0xff, // row 1
0x00, 0xff, // row 2
),
);
// Reset cursor for the red plane.
feedAll(
d,
cmd(CMD_SET_RAMX_COUNTER), data(0x00),
cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00),
);
// Red plane: row 0 nothing, row 1 first 4 px red, row 2 nothing.
feedAll(
d,
cmd(CMD_WRITE_RED_VRAM),
data(
0x00, 0x00, // row 0 — no red
0xf0, 0x00, // row 1 — first 4 px red
0x00, 0x00, // row 2 — no red
),
);
feedAll(d, cmd(CMD_MASTER_ACTIVATION));
expect(seen.length).toBe(1);
const px = seen[0].pixels;
// Row 0: all black (0)
for (let x = 0; x < 16; x++) expect(px[x]).toBe(0);
// Row 1: cols 0-3 red (red wins over white), cols 4-15 white
for (let x = 0; x < 4; x++) expect(px[16 + x]).toBe(2);
for (let x = 4; x < 16; x++) expect(px[16 + x]).toBe(1);
// Row 2: cols 0-7 black, cols 8-15 white
for (let x = 0; x < 8; x++) expect(px[32 + x]).toBe(0);
for (let x = 8; x < 16; x++) expect(px[32 + x]).toBe(1);
});
it('writing the red plane alone (BW left default white) shows red on white', () => {
const seen: Frame[] = [];
const d = new SSD168xDecoder({
width: 8,
height: 1,
onFlush: (f) => seen.push(f),
});
feedAll(
d,
cmd(CMD_DATA_ENTRY_MODE), data(0x03),
cmd(CMD_SET_RAMX_RANGE), data(0x00, 0x00),
cmd(CMD_SET_RAMY_RANGE), data(0x00, 0x00, 0x00, 0x00),
cmd(CMD_SET_RAMX_COUNTER), data(0x00),
cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00),
cmd(CMD_WRITE_RED_VRAM),
data(0xaa), // alternating red pixels (1010 1010)
cmd(CMD_MASTER_ACTIVATION),
);
const px = seen[0].pixels;
expect(Array.from(px)).toEqual([2, 1, 2, 1, 2, 1, 2, 1]);
});
});
describe('SSD168xDecoder — larger panel sizes', () => {
// Use Uint8Array.every() (single hot loop, one assertion) rather than per-pixel
// expect() — at 400×300 = 120k pixels Vitest's per-call overhead dominates.
it('initializes a 4.2" 400×300 frame as all white', () => {
const seen: Frame[] = [];
const d = new SSD168xDecoder({
width: 400,
height: 300,
onFlush: (f) => seen.push(f),
});
feedAll(d, cmd(CMD_MASTER_ACTIVATION));
expect(seen[0].width).toBe(400);
expect(seen[0].height).toBe(300);
expect(seen[0].pixels.length).toBe(400 * 300);
expect(seen[0].pixels.every((p) => p === 1)).toBe(true);
});
it('initializes a 7.5" 800×480 frame as all white', () => {
const seen: Frame[] = [];
const d = new SSD168xDecoder({
width: 800,
height: 480,
onFlush: (f) => seen.push(f),
});
feedAll(d, cmd(CMD_MASTER_ACTIVATION));
expect(seen[0].width).toBe(800);
expect(seen[0].height).toBe(480);
expect(seen[0].pixels.length).toBe(800 * 480);
expect(seen[0].pixels.every((p) => p === 1)).toBe(true);
});
});