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

355 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
});
});