// ============================================================================= // library.test.ts — bun test suite for MonoDisplay library // // run: bun test // // Tests cover BinaryReader primitives and MonoDisplayParser with the real // binary format: magic 0xAF7E2B63, section-based layout. // ============================================================================= import { test, expect, describe } from "bun:test"; import { BinaryReader, MonoDisplayParser, MonoDisplayFile, ElementType, buildBinBuffer, type StaticFrame, type Animation, type TextDisplay, type ScrollText, } from "../src/library"; // --------------------------------------------------------------------------- // Low-level buffer helpers // --------------------------------------------------------------------------- // Real magic: 0xAF 0x7E 0x2B 0x63 const MAGIC_BYTES = new Uint8Array([0xAF, 0x7E, 0x2B, 0x63]); function concat(...parts: (Uint8Array | ArrayBuffer)[]): ArrayBuffer { const arrays = parts.map(p => p instanceof ArrayBuffer ? new Uint8Array(p) : p); const total = arrays.reduce((s, a) => s + a.byteLength, 0); const out = new Uint8Array(total); let off = 0; for (const a of arrays) { out.set(a, off); off += a.byteLength; } return out.buffer; } function u8(n: number) { return new Uint8Array([n]); } function u16le(n: number) { const b = new Uint8Array(2); new DataView(b.buffer).setUint16(0, n, true); return b; } function u24le(n: number) { return new Uint8Array([n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF]); } function u32le(n: number) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n, true); return b; } /** * Build a well-formed file buffer with a single section. * sectionType: 0x01=static 0x02=animation 0x03=text 0x04=scrolltext * sectionData: raw section payload (after section header) */ function makeFile(sectionType: number, sectionData: Uint8Array): ArrayBuffer { const fileHeader = concat( MAGIC_BYTES, // magic u32le(1), // version = 1 u16le(1), // numSections = 1 u16le(0), // reserved = 0 ); const sectionHeader = concat( u8(sectionType), u24le(sectionData.byteLength), ); return concat(fileHeader, sectionHeader, sectionData); } // --------------------------------------------------------------------------- // BinaryReader // --------------------------------------------------------------------------- describe("BinaryReader", () => { test("readByte", () => { const r = new BinaryReader(new Uint8Array([0xAB]).buffer); expect(r.readByte()).toBe(0xAB); expect(r.remaining).toBe(0); }); test("readUint16 LE", () => { const r = new BinaryReader(new Uint8Array([0x34, 0x12]).buffer); expect(r.readUint16()).toBe(0x1234); }); test("readShort alias", () => { const r = new BinaryReader(new Uint8Array([0x01, 0x00]).buffer); expect(r.readShort()).toBe(1); }); test("readUint24 LE", () => { // 0x010203 LE → bytes [0x03, 0x02, 0x01] const r = new BinaryReader(new Uint8Array([0x03, 0x02, 0x01]).buffer); expect(r.readUint24()).toBe(0x010203); }); test("readUint32 LE", () => { const r = new BinaryReader(new Uint8Array([0x78, 0x56, 0x34, 0x12]).buffer); expect(r.readUint32()).toBe(0x12345678); }); test("readUint64 LE", () => { const bytes = new Uint8Array(8); new DataView(bytes.buffer).setBigUint64(0, 0x0102030405060708n, true); const r = new BinaryReader(bytes.buffer); expect(r.readUint64()).toBe(0x0102030405060708n); }); test("offset advances through mixed reads", () => { const r = new BinaryReader(new Uint8Array([1, 2, 3, 4, 5, 6]).buffer); r.readByte(); expect(r.offset).toBe(1); r.readUint16(); expect(r.offset).toBe(3); expect(() => r.readUint32()).toThrow(RangeError); // only 3 remain }); test("RangeError on exhaustion", () => { const r = new BinaryReader(new Uint8Array([0x01]).buffer); r.readByte(); expect(() => r.readByte()).toThrow(RangeError); }); test("seek", () => { const r = new BinaryReader(new Uint8Array([10, 20, 30]).buffer); r.seek(2); expect(r.readByte()).toBe(30); }); test("seek out of bounds throws", () => { const r = new BinaryReader(new Uint8Array([1, 2]).buffer); expect(() => r.seek(5)).toThrow(RangeError); }); test("readUtf8 ASCII", () => { const enc = new TextEncoder().encode("hello"); const r = new BinaryReader(enc.buffer); expect(r.readUtf8(5)).toBe("hello"); }); test("readUtf8 multi-byte codepoints", () => { const str = "日本語🚀"; const enc = new TextEncoder().encode(str); const r = new BinaryReader(enc.buffer); expect(r.readUtf8(enc.byteLength)).toBe(str); }); }); // --------------------------------------------------------------------------- // MonoDisplayParser — header validation // --------------------------------------------------------------------------- describe("MonoDisplayParser — header", () => { const parser = new MonoDisplayParser(); test("rejects wrong magic", () => { const bad = new Uint8Array(16).fill(0); bad[0] = 0xDE; bad[1] = 0xAD; bad[2] = 0xBE; bad[3] = 0xEF; expect(() => parser.parse(bad.buffer)).toThrow(/magic/i); }); test("rejects version 0 (illegal)", () => { const buf = concat(MAGIC_BYTES, u32le(0), u16le(0), u16le(0)); expect(() => parser.parse(buf)).toThrow(/version/i); }); test("rejects version > 1 (reserved)", () => { const buf = concat(MAGIC_BYTES, u32le(2), u16le(0), u16le(0)); expect(() => parser.parse(buf)).toThrow(/version/i); }); test("rejects 0 sections", () => { const buf = concat(MAGIC_BYTES, u32le(1), u16le(0), u16le(0)); // 0 sections expect(() => parser.parse(buf)).toThrow(/0 section/i); }); test("skips unknown section type, throws when no recognised section follows", () => { // Section type 0xFF is unknown const unknownData = new Uint8Array(4); const fileHeader = concat(MAGIC_BYTES, u32le(1), u16le(1), u16le(0)); const secHeader = concat(u8(0xFF), u24le(unknownData.byteLength)); const buf = concat(fileHeader, secHeader, unknownData); expect(() => parser.parse(buf)).toThrow(/no recognised/i); }); }); // --------------------------------------------------------------------------- // MonoDisplayParser — static section // --------------------------------------------------------------------------- describe("MonoDisplayParser — static", () => { const parser = new MonoDisplayParser(); test("parses 2×2 static frame", () => { const data = concat(u16le(2), u16le(2), new Uint8Array([1, 0, 0, 1])); const buf = makeFile(0x01, new Uint8Array(data)); const result = parser.parse(buf) as StaticFrame; expect(result.type).toBe("static"); expect(result.width).toBe(2); expect(result.height).toBe(2); expect(result.pixels[0]).toBe(1); expect(result.pixels[3]).toBe(1); }); }); // --------------------------------------------------------------------------- // MonoDisplayParser — animation section // --------------------------------------------------------------------------- describe("MonoDisplayParser — animation", () => { const parser = new MonoDisplayParser(); test("parses 2-frame animation", () => { const pixels = new Uint8Array(4); // 2×2 const data = concat( u16le(2), u16le(2), // width, height u16le(2), // frameCount u32le(100), pixels, // frame 1: 100ms, all-off u32le(200), new Uint8Array([1,1,1,1]), // frame 2: 200ms, all-on ); const buf = makeFile(0x02, new Uint8Array(data)); const result = parser.parse(buf) as Animation; expect(result.type).toBe("animation"); expect(result.frames.length).toBe(2); expect(result.frames[0].durationMs).toBe(100); expect(result.frames[1].durationMs).toBe(200); }); test("empty animation (0 frames) is valid", () => { const data = concat(u16le(4), u16le(4), u16le(0)); const buf = makeFile(0x02, new Uint8Array(data)); const result = parser.parse(buf) as Animation; expect(result.frames.length).toBe(0); }); }); // --------------------------------------------------------------------------- // MonoDisplayParser — text section // --------------------------------------------------------------------------- describe("MonoDisplayParser — text", () => { const parser = new MonoDisplayParser(); test("parses text with align=center", () => { const enc = new TextEncoder().encode("HELLO"); const data = concat(u16le(enc.byteLength), enc, u8(0), u8(1)); // fontId=0 align=1 const buf = makeFile(0x03, new Uint8Array(data)); const result = parser.parse(buf) as TextDisplay; expect(result.type).toBe("text"); expect(result.text).toBe("HELLO"); expect(result.align).toBe(1); expect(result.fontId).toBe(0); }); test("handles multi-byte UTF-8 text", () => { const str = "日本語"; const enc = new TextEncoder().encode(str); const data = concat(u16le(enc.byteLength), enc, u8(0), u8(0)); const buf = makeFile(0x03, new Uint8Array(data)); const result = parser.parse(buf) as TextDisplay; expect(result.text).toBe(str); }); }); // --------------------------------------------------------------------------- // MonoDisplayParser — scrolltext section // --------------------------------------------------------------------------- describe("MonoDisplayParser — scrolltext", () => { const parser = new MonoDisplayParser(); test("parses scrolltext left-direction", () => { const enc = new TextEncoder().encode("SCROLL ME"); const data = concat(u16le(enc.byteLength), enc, u16le(60), u8(0)); const buf = makeFile(0x04, new Uint8Array(data)); const result = parser.parse(buf) as ScrollText; expect(result.type).toBe("scrolltext"); expect(result.text).toBe("SCROLL ME"); expect(result.speedPps).toBe(60); expect(result.direction).toBe(0); }); test("handles multi-codepoint text incl. emoji", () => { const str = "TICKER 🚀 日本語"; const enc = new TextEncoder().encode(str); const data = concat(u16le(enc.byteLength), enc, u16le(80), u8(1)); const buf = makeFile(0x04, new Uint8Array(data)); const result = parser.parse(buf) as ScrollText; expect(result.text).toBe(str); expect(result.direction).toBe(1); }); }); // --------------------------------------------------------------------------- // MonoDisplayParser — unknown section skip // --------------------------------------------------------------------------- describe("MonoDisplayParser — section skip", () => { const parser = new MonoDisplayParser(); test("skips unknown section, parses second known section", () => { // File with 2 sections: 0xFF (unknown) then 0x01 (static 2×2) const staticData = new Uint8Array(concat(u16le(2), u16le(2), new Uint8Array([0,1,1,0]))); const unknownData = new Uint8Array(4).fill(0xAA); const fileHeader = concat(MAGIC_BYTES, u32le(1), u16le(2), u16le(0)); const sec1 = concat(u8(0xFF), u24le(unknownData.byteLength), unknownData); const sec2 = concat(u8(0x01), u24le(staticData.byteLength), staticData); const buf = concat(fileHeader, sec1, sec2); const result = parser.parse(buf) as StaticFrame; expect(result.type).toBe("static"); expect(result.pixels[1]).toBe(1); }); }); // --------------------------------------------------------------------------- // buildBinBuffer round-trip // --------------------------------------------------------------------------- describe("buildBinBuffer", () => { const parser = new MonoDisplayParser(); test("static round-trip", () => { const pixels = new Uint8Array([0, 1, 1, 0]); const original: StaticFrame = { type: "static", width: 2, height: 2, pixels }; const buf = buildBinBuffer(original); const parsed = parser.parse(buf) as StaticFrame; expect(parsed.type).toBe("static"); expect(parsed.width).toBe(2); expect(parsed.height).toBe(2); expect(Array.from(parsed.pixels)).toEqual([0, 1, 1, 0]); }); test("non-static throws (not implemented yet)", () => { const anim: Animation = { type: "animation", width: 1, height: 1, frames: [] }; expect(() => buildBinBuffer(anim)).toThrow(/not yet implemented/); }); }); // --------------------------------------------------------------------------- // MonoDisplayFile — JSON descriptor builder // --------------------------------------------------------------------------- describe("MonoDisplayFile", () => { const parser = new MonoDisplayParser(); test("single Image2D fills full display", () => { const pixels = new Uint8Array(4 * 2).fill(1); // 4×2 all-on const file = new MonoDisplayFile({ width: 4, height: 2, elements_always: [{ type: ElementType.Image2D, pixels, width: 4, height: 2 }], }); const result = parser.parse(file.toBuffer()) as StaticFrame; expect(result.type).toBe("static"); expect(result.width).toBe(4); expect(result.height).toBe(2); expect(result.pixels.every(p => p === 1)).toBe(true); }); test("xoffset / yoffset positions element", () => { // 6×2 display, 2×2 all-on block placed at xoffset=2 const block = new Uint8Array(4).fill(1); const file = new MonoDisplayFile({ width: 6, height: 2, elements_always: [{ type: ElementType.Image2D, pixels: block, width: 2, height: 2, xoffset: 2 }], }); const result = parser.parse(file.toBuffer()) as StaticFrame; // cols 0,1 off — cols 2,3 on — cols 4,5 off expect(result.pixels[0]).toBe(0); // (0,0) off expect(result.pixels[2]).toBe(1); // (2,0) on expect(result.pixels[3]).toBe(1); // (3,0) on expect(result.pixels[4]).toBe(0); // (4,0) off }); test("two overlapping elements OR-blend", () => { // 4×1 display: element A sets pixels [0,1,0,0], element B sets [0,0,0,1] const a = new Uint8Array([0, 1, 0, 0]); const b = new Uint8Array([0, 0, 0, 1]); const file = new MonoDisplayFile({ width: 4, height: 1, elements_always: [ { type: ElementType.Image2D, pixels: a, width: 4, height: 1 }, { type: ElementType.Image2D, pixels: b, width: 4, height: 1 }, ], }); const result = parser.parse(file.toBuffer()) as StaticFrame; expect(Array.from(result.pixels)).toEqual([0, 1, 0, 1]); }); test("element clipped at right/bottom edge", () => { // 4×2 display, 2×2 block placed at xoffset=3 — only 1 col fits const block = new Uint8Array(4).fill(1); const file = new MonoDisplayFile({ width: 4, height: 2, elements_always: [{ type: ElementType.Image2D, pixels: block, width: 2, height: 2, xoffset: 3 }], }); const result = parser.parse(file.toBuffer()) as StaticFrame; // col 3 on, col 4+ clipped expect(result.pixels[3]).toBe(1); // (3,0) on expect(result.width).toBe(4); // canvas didn't grow }); test("size inferred from element bounding box when omitted", () => { const pixels = new Uint8Array(3 * 2).fill(1); const file = new MonoDisplayFile({ elements_always: [{ type: ElementType.Image2D, pixels, width: 3, height: 2, xoffset: 1, yoffset: 1 }], }); const result = parser.parse(file.toBuffer()) as StaticFrame; expect(result.width).toBe(4); // 1 + 3 expect(result.height).toBe(3); // 1 + 2 }); test("empty descriptor produces 160×80 blank static frame", () => { const file = new MonoDisplayFile({}); const result = parser.parse(file.toBuffer()) as StaticFrame; expect(result.width).toBe(160); expect(result.height).toBe(80); expect(result.pixels.every(p => p === 0)).toBe(true); }); test("toBuffer round-trips through parser", () => { const pixels = new Uint8Array(120 * 80); for (let i = 0; i < pixels.length; i++) pixels[i] = i % 2; const file = new MonoDisplayFile({ width: 160, height: 80, elements_always: [{ type: ElementType.Image2D, pixels, width: 120, height: 80, xoffset: 20 }], }); const result = parser.parse(file.toBuffer()) as StaticFrame; expect(result.width).toBe(160); expect(result.pixels[20]).toBe(0); // first pixel of element (i=0 → 0) expect(result.pixels[21]).toBe(1); // second pixel (i=1 → 1) }); });