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