|
|
|
|
@ -1,14 +1,13 @@ |
|
|
|
|
// =============================================================================
|
|
|
|
|
// library.test.ts — bun test suite for MonoDisplay library
|
|
|
|
|
//
|
|
|
|
|
// run: bun test src/library.test.ts
|
|
|
|
|
// run: bun test
|
|
|
|
|
//
|
|
|
|
|
// Tests are grouped by component. Canvas-dependent renderer tests use a
|
|
|
|
|
// minimal mock so they run in Bun (no DOM). Parser + BinaryReader tests are
|
|
|
|
|
// pure TypeScript with no mocks needed.
|
|
|
|
|
// Tests cover BinaryReader primitives and MonoDisplayParser with the real
|
|
|
|
|
// binary format: magic 0xAF7E2B63, section-based layout.
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
import { test, expect, describe, mock, beforeEach } from "bun:test"; |
|
|
|
|
import { test, expect, describe } from "bun:test"; |
|
|
|
|
import { |
|
|
|
|
BinaryReader, |
|
|
|
|
MonoDisplayParser, |
|
|
|
|
@ -20,40 +19,43 @@ import { |
|
|
|
|
} from "../src/library"; |
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Helpers to build raw binary buffers for each content type
|
|
|
|
|
// (mirrors the format defined in library.ts header comment)
|
|
|
|
|
// Low-level buffer helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const MAGIC_BYTES = new Uint8Array([0x4d, 0x4f, 0x4e, 0x4f]); // "MONO"
|
|
|
|
|
// Real magic: 0xAF 0x7E 0x2B 0x63
|
|
|
|
|
const MAGIC_BYTES = new Uint8Array([0xAF, 0x7E, 0x2B, 0x63]); |
|
|
|
|
|
|
|
|
|
function makeHeader(contentType: number, width: number, height: number): Uint8Array { |
|
|
|
|
const h = new Uint8Array(10); |
|
|
|
|
h.set(MAGIC_BYTES, 0); |
|
|
|
|
h[4] = 1; // version
|
|
|
|
|
h[5] = contentType; |
|
|
|
|
new DataView(h.buffer).setUint16(6, width, true); |
|
|
|
|
new DataView(h.buffer).setUint16(8, height, true); |
|
|
|
|
return h; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function concat(...parts: Uint8Array[]): ArrayBuffer { |
|
|
|
|
const total = parts.reduce((s, p) => s + p.byteLength, 0); |
|
|
|
|
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 p of parts) { out.set(p, off); off += p.byteLength; } |
|
|
|
|
for (const a of arrays) { out.set(a, off); off += a.byteLength; } |
|
|
|
|
return out.buffer; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function u16le(n: number): Uint8Array { |
|
|
|
|
const b = new Uint8Array(2); |
|
|
|
|
new DataView(b.buffer).setUint16(0, n, true); |
|
|
|
|
return b; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function u32le(n: number): Uint8Array { |
|
|
|
|
const b = new Uint8Array(4); |
|
|
|
|
new DataView(b.buffer).setUint32(0, n, true); |
|
|
|
|
return b; |
|
|
|
|
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); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
@ -61,50 +63,54 @@ function u32le(n: number): Uint8Array { |
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe("BinaryReader", () => { |
|
|
|
|
test("readByte reads single byte", () => { |
|
|
|
|
test("readByte", () => { |
|
|
|
|
const r = new BinaryReader(new Uint8Array([0xAB]).buffer); |
|
|
|
|
expect(r.readByte()).toBe(0xAB); |
|
|
|
|
expect(r.remaining).toBe(0); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
test("readUint16 little-endian", () => { |
|
|
|
|
test("readUint16 LE", () => { |
|
|
|
|
const r = new BinaryReader(new Uint8Array([0x34, 0x12]).buffer); |
|
|
|
|
expect(r.readUint16()).toBe(0x1234); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
test("readShort is alias for readUint16", () => { |
|
|
|
|
test("readShort alias", () => { |
|
|
|
|
const r = new BinaryReader(new Uint8Array([0x01, 0x00]).buffer); |
|
|
|
|
expect(r.readShort()).toBe(1); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
test("readUint32 little-endian", () => { |
|
|
|
|
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 little-endian", () => { |
|
|
|
|
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 correctly", () => { |
|
|
|
|
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); // 3 bytes remain, need 4
|
|
|
|
|
r.readByte(); expect(r.offset).toBe(1); |
|
|
|
|
r.readUint16(); expect(r.offset).toBe(3); |
|
|
|
|
expect(() => r.readUint32()).toThrow(RangeError); // only 3 remain
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
test("RangeError when buffer exhausted", () => { |
|
|
|
|
test("RangeError on exhaustion", () => { |
|
|
|
|
const r = new BinaryReader(new Uint8Array([0x01]).buffer); |
|
|
|
|
r.readByte(); |
|
|
|
|
expect(() => r.readByte()).toThrow(RangeError); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
test("seek moves cursor", () => { |
|
|
|
|
test("seek", () => { |
|
|
|
|
const r = new BinaryReader(new Uint8Array([10, 20, 30]).buffer); |
|
|
|
|
r.seek(2); |
|
|
|
|
expect(r.readByte()).toBe(30); |
|
|
|
|
@ -115,25 +121,68 @@ describe("BinaryReader", () => { |
|
|
|
|
expect(() => r.seek(5)).toThrow(RangeError); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
test("readUtf8 decodes ASCII", () => { |
|
|
|
|
const str = "hello"; |
|
|
|
|
const enc = new TextEncoder().encode(str); |
|
|
|
|
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 content
|
|
|
|
|
// MonoDisplayParser — static section
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe("MonoDisplayParser — static", () => { |
|
|
|
|
const parser = new MonoDisplayParser(); |
|
|
|
|
|
|
|
|
|
test("parses 2x2 static frame", () => { |
|
|
|
|
const header = makeHeader(0, 2, 2); |
|
|
|
|
const pixels = new Uint8Array([1, 0, 0, 1]); // diagonal
|
|
|
|
|
const buf = concat(header, pixels); |
|
|
|
|
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"); |
|
|
|
|
@ -142,37 +191,24 @@ describe("MonoDisplayParser — static", () => { |
|
|
|
|
expect(result.pixels[0]).toBe(1); |
|
|
|
|
expect(result.pixels[3]).toBe(1); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
test("rejects wrong magic", () => { |
|
|
|
|
const bad = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF, 1, 0, 4, 0, 4, 0]); |
|
|
|
|
expect(() => parser.parse(bad.buffer)).toThrow(/magic/i); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
test("rejects unsupported version", () => { |
|
|
|
|
const buf = makeHeader(0, 1, 1); |
|
|
|
|
buf[4] = 99; // version 99
|
|
|
|
|
expect(() => parser.parse(concat(buf, new Uint8Array([0])))).toThrow(/version/i); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
test("rejects unknown content type", () => { |
|
|
|
|
const buf = makeHeader(0xFF, 1, 1); |
|
|
|
|
expect(() => parser.parse(concat(buf, new Uint8Array([0])))).toThrow(/content type/i); |
|
|
|
|
}); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// MonoDisplayParser — animation
|
|
|
|
|
// MonoDisplayParser — animation section
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe("MonoDisplayParser — animation", () => { |
|
|
|
|
const parser = new MonoDisplayParser(); |
|
|
|
|
|
|
|
|
|
test("parses 2-frame animation", () => { |
|
|
|
|
const header = makeHeader(1, 2, 2); // type=1 animation
|
|
|
|
|
const frameCount = u16le(2); |
|
|
|
|
const frame1 = concat(u32le(100), new Uint8Array([1, 1, 1, 1])); |
|
|
|
|
const frame2 = concat(u32le(200), new Uint8Array([0, 0, 0, 0])); |
|
|
|
|
const buf = concat(header, frameCount, new Uint8Array(frame1), new Uint8Array(frame2)); |
|
|
|
|
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"); |
|
|
|
|
@ -182,31 +218,24 @@ describe("MonoDisplayParser — animation", () => { |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
test("empty animation (0 frames) is valid", () => { |
|
|
|
|
const header = makeHeader(1, 4, 4); |
|
|
|
|
const buf = concat(header, u16le(0)); |
|
|
|
|
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
|
|
|
|
|
// MonoDisplayParser — text section
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe("MonoDisplayParser — text", () => { |
|
|
|
|
const parser = new MonoDisplayParser(); |
|
|
|
|
|
|
|
|
|
test("parses text content", () => { |
|
|
|
|
const header = makeHeader(2, 0, 0); // w/h unused for text
|
|
|
|
|
const str = "HELLO"; |
|
|
|
|
const enc = new TextEncoder().encode(str); |
|
|
|
|
const payload = concat( |
|
|
|
|
u16le(enc.byteLength), |
|
|
|
|
enc, |
|
|
|
|
new Uint8Array([0]), // fontId
|
|
|
|
|
new Uint8Array([1]), // align = center
|
|
|
|
|
); |
|
|
|
|
const buf = concat(header, new Uint8Array(payload)); |
|
|
|
|
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"); |
|
|
|
|
@ -214,26 +243,29 @@ describe("MonoDisplayParser — text", () => { |
|
|
|
|
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
|
|
|
|
|
// MonoDisplayParser — scrolltext section
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe("MonoDisplayParser — scrolltext", () => { |
|
|
|
|
const parser = new MonoDisplayParser(); |
|
|
|
|
|
|
|
|
|
test("parses scrolltext content", () => { |
|
|
|
|
const header = makeHeader(3, 0, 0); |
|
|
|
|
const str = "SCROLL ME"; |
|
|
|
|
const enc = new TextEncoder().encode(str); |
|
|
|
|
const payload = concat( |
|
|
|
|
u16le(enc.byteLength), |
|
|
|
|
enc, |
|
|
|
|
u16le(60), // speedPps = 60
|
|
|
|
|
new Uint8Array([0]), // direction = left
|
|
|
|
|
); |
|
|
|
|
const buf = concat(header, new Uint8Array(payload)); |
|
|
|
|
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"); |
|
|
|
|
@ -241,6 +273,40 @@ describe("MonoDisplayParser — scrolltext", () => { |
|
|
|
|
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); |
|
|
|
|
}); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|