diff --git a/ts/.claude/settings.local.json b/ts/.claude/settings.local.json new file mode 100644 index 0000000..8475835 --- /dev/null +++ b/ts/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(bun test:*)", + "Bash(bun build:*)", + "Bash(bun run:*)" + ] + } +} diff --git a/ts/index.html b/ts/index.html index 73bd9a8..2d9cd35 100644 --- a/ts/index.html +++ b/ts/index.html @@ -111,75 +111,60 @@ // Demo helpers — build synthetic .bin ArrayBuffers for all 4 content types // ------------------------------------------------------------------------- - const MAGIC = new Uint8Array([0x4d, 0x4f, 0x4e, 0x4f]); // "MONO" - - function makeHeader(contentType, width, height) { - const h = new Uint8Array(10); - h.set(MAGIC, 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) { - const total = parts.reduce((s, p) => s + p.byteLength, 0); + 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(new Uint8Array(p), off); off += p.byteLength; } + for (const a of arrays) { out.set(a, off); off += a.byteLength; } return out.buffer; } + function u8(n) { return new Uint8Array([n]); } function u16le(n) { const b = new Uint8Array(2); new DataView(b.buffer).setUint16(0, n, true); return b; } + function u24le(n) { return new Uint8Array([n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF]); } function u32le(n) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n, true); return b; } - // 160×80 checkerboard — static + /** Wrap section payload in a valid 1-section file envelope */ + function makeFile(sectionType, sectionData) { + const fileHdr = concat(MAGIC, u32le(1), u16le(1), u16le(0)); // magic+ver+numSec+reserved + const secHdr = concat(u8(sectionType), u24le(sectionData.byteLength)); + return concat(fileHdr, secHdr, sectionData); + } + + // 160×80 checkerboard — static section (type 0x01) function makeCheckerboard() { const W = 160, H = 80; const pixels = new Uint8Array(W * H); for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) pixels[y * W + x] = (x + y) % 2; - return concat(makeHeader(0, W, H), pixels); + return makeFile(0x01, new Uint8Array(concat(u16le(W), u16le(H), pixels))); } - // 160×80 two-frame blink — animation + // 160×80 two-frame blink — animation section (type 0x02) function makeAnimation() { const W = 160, H = 80; - function frame(fill, durationMs) { - return concat(u32le(durationMs), new Uint8Array(W * H).fill(fill)); - } - return concat(makeHeader(1, W, H), u16le(2), frame(1, 200), frame(0, 200)); + const f = (fill, ms) => new Uint8Array(concat(u32le(ms), new Uint8Array(W * H).fill(fill))); + return makeFile(0x02, new Uint8Array(concat(u16le(W), u16le(H), u16le(2), f(1, 200), f(0, 200)))); } - // Text content type + // Text section (type 0x03) function makeTextBin(text, align /* 0=left 1=center 2=right */) { const enc = new TextEncoder().encode(text); - return concat( - makeHeader(2, 0, 0), - u16le(enc.byteLength), enc, - new Uint8Array([0, align ?? 1]) // fontId=0, align - ); + return makeFile(0x03, new Uint8Array(concat(u16le(enc.byteLength), enc, u8(0), u8(align ?? 1)))); } - // Scrolltext content type + // Scrolltext section (type 0x04) function makeScrollTextBin(text, speedPps, direction /* 0=left 1=right */) { const enc = new TextEncoder().encode(text); - return concat( - makeHeader(3, 0, 0), - u16le(enc.byteLength), enc, - u16le(speedPps ?? 60), - new Uint8Array([direction ?? 0]) - ); + return makeFile(0x04, new Uint8Array(concat(u16le(enc.byteLength), enc, u16le(speedPps ?? 60), u8(direction ?? 0)))); } // ------------------------------------------------------------------------- // Driver — 160×80, 25fps, green-on-dark // ------------------------------------------------------------------------- - const { MonoDisplayDriver } = window.MonoDisplay; - const driver = new MonoDisplayDriver("canvas_root", { onColor: "#33ff66", offColor: "#0a0a0a", diff --git a/ts/src/library.ts b/ts/src/library.ts index 0e6121b..bcffb12 100644 --- a/ts/src/library.ts +++ b/ts/src/library.ts @@ -212,6 +212,16 @@ export class BinaryReader { return new TextDecoder().decode(this.readBytes(n)); } + /** Read unsigned 24-bit integer, little-endian (3-byte field in section headers) */ + readUint24(): number { + this.#assertRemaining(3); + const lo = this.view.getUint8(this.pos); + const mid = this.view.getUint8(this.pos + 1); + const hi = this.view.getUint8(this.pos + 2); + this.pos += 3; + return lo | (mid << 8) | (hi << 16); + } + /** Move cursor to absolute byte position */ seek(pos: number): void { if (pos < 0 || pos > this.view.byteLength) { @@ -230,16 +240,42 @@ export class BinaryReader { } // --------------------------------------------------------------------------- -// File format constants +// File format constants (real spec) // --------------------------------------------------------------------------- -const MAGIC = 0x4f4e4f4d; // "MONO" as little-endian uint32 +// File magic: 0xAF 0x7E 0x2B 0x63 (read as uint32 LE = 0x632B7EAF) +const MAGIC = 0x632B7EAF; -const ContentTypeByte: Record = { - 0: "static", - 1: "animation", - 2: "text", - 3: "scrolltext", +/** + * File header layout (12 bytes total): + * + * offset size field + * ────── ──── ───────────────────────────────────────────── + * 0 4 Magic (0xAF 0x7E 0x2B 0x63) + * 4 4 Version (uint32 LE; 0=illegal, 1=current, 2+=reserved) + * PLAN: spec shows full 4-byte row — confirm if uint8+padding or uint32 + * 8 2 Number of sections (uint16 LE) + * 10 2 Reserved (must be 0) + * + * Section header layout (4 bytes): + * + * offset size field + * ────── ──── ───────────────────────────────────────────── + * 0 1 Section type (uint8) + * 1 3 Section data size in bytes (uint24 LE), excludes header + * + * Section types (placeholder values — PLAN: confirm with full spec): + * 0x01 static frame width(u16) height(u16) pixels[w*h] + * 0x02 animation width(u16) height(u16) frameCount(u16) frames… + * 0x03 text textLen(u16) utf8… fontId(u8) align(u8) + * 0x04 scrolltext textLen(u16) utf8… speedPps(u16) direction(u8) + */ + +const SectionType: Record = { + 0x01: "static", + 0x02: "animation", + 0x03: "text", + 0x04: "scrolltext", }; // --------------------------------------------------------------------------- @@ -249,40 +285,72 @@ const ContentTypeByte: Record = { /** * Parses a .bin ArrayBuffer into a strongly-typed DisplayContent value. * - * PLAN: when the real spec lands, update ONLY the parse* private methods. - * The public surface (parse → DisplayContent) stays identical. + * Returns the first recognised section's content. Files with numSections=0 + * throw. Multiple sections: only the first supported type is returned for now. + * PLAN: expose all sections as DisplayContent[] once the full spec is clear. */ export class MonoDisplayParser { parse(buffer: ArrayBuffer): DisplayContent { const r = new BinaryReader(buffer); - // --- header --- + // --- file header (12 bytes) --- const magic = r.readUint32(); if (magic !== MAGIC) { throw new Error( - `Invalid magic 0x${magic.toString(16).toUpperCase()} — expected 0x4F4E4F4D ("MONO")` + `Invalid magic 0x${magic.toString(16).toUpperCase()} — expected 0x632B7EAF` ); } - const version = r.readByte(); - if (version !== 1) { - // PLAN: add version dispatch here when v2 spec exists + const version = r.readUint32(); // PLAN: confirm uint32 vs uint8+3pad in spec + if (version === 0 || version > 1) { throw new Error(`Unsupported format version ${version}`); } - const contentTypeByte = r.readByte(); - const contentType = ContentTypeByte[contentTypeByte]; - if (!contentType) { - throw new Error(`Unknown content type byte 0x${contentTypeByte.toString(16)}`); + const numSections = r.readUint16(); + r.readUint16(); // reserved — ignored + + if (numSections === 0) { + throw new Error("File contains 0 sections — nothing to render"); + } + + // --- section loop --- + for (let i = 0; i < numSections; i++) { + // section header (4 bytes) + const sectionType = r.readByte(); + const sectionSize = r.readUint24(); + const sectionStart = r.offset; + + const contentType = SectionType[sectionType]; + if (!contentType) { + // Unknown section — skip it and continue to next + r.seek(sectionStart + sectionSize); + continue; + } + + // Parse this section; width/height read from section data for bitmap types + const content = this.#parseSection(r, contentType); + + // Seek past any remaining section bytes (handles padding / future fields) + r.seek(sectionStart + sectionSize); + + return content; // return first recognised section } - const width = r.readUint16(); - const height = r.readUint16(); + throw new Error("No recognised sections found in file"); + } - // --- content --- + #parseSection(r: BinaryReader, contentType: DisplayContentType): DisplayContent { switch (contentType) { - case "static": return this.#parseStatic(r, width, height); - case "animation": return this.#parseAnimation(r, width, height); + case "static": { + const width = r.readUint16(); + const height = r.readUint16(); + return this.#parseStatic(r, width, height); + } + case "animation": { + const width = r.readUint16(); + const height = r.readUint16(); + return this.#parseAnimation(r, width, height); + } case "text": return this.#parseText(r); case "scrolltext": return this.#parseScrollText(r); } @@ -589,29 +657,46 @@ export async function loadBinFile(url: string): Promise { } /** - * Build a minimal valid .bin ArrayBuffer in code — useful for tests and demos. + * Build a valid .bin ArrayBuffer for testing and demos. + * Encodes one section using the real file format. * - * PLAN: expand this into a full BinaryWriter helper once the spec is final, - * so integration tests can construct every content type without raw byte arrays. + * PLAN: expand into a full BinaryWriter for all section types once spec is complete. + * Currently only "static" section is encoded; others throw. */ export function buildBinBuffer(content: DisplayContent): ArrayBuffer { - // PLAN: implement encoding for all four content types - // For now only "static" is written out (enough to round-trip in tests) if (content.type !== "static") { throw new Error(`buildBinBuffer: encoding "${content.type}" not yet implemented`); } - const headerSize = 4 + 1 + 1 + 2 + 2; // magic + version + type + w + h - const pixelSize = content.width * content.height; - const buf = new ArrayBuffer(headerSize + pixelSize); - const view = new DataView(buf); + // Section data: width(u16) + height(u16) + pixels[w*h] + const sectionDataSize = 2 + 2 + content.width * content.height; - view.setUint32(0, MAGIC, true); - view.setUint8(4, 1); // version - view.setUint8(5, 0); // content type = static - view.setUint16(6, content.width, true); - view.setUint16(8, content.height, true); - new Uint8Array(buf, headerSize).set(content.pixels); + // File header: magic(4) + version(4) + numSections(2) + reserved(2) = 12 + // Section header: type(1) + size(3) = 4 + const totalSize = 12 + 4 + sectionDataSize; + + const buf = new ArrayBuffer(totalSize); + const view = new DataView(buf); + let off = 0; + + // File header + view.setUint32(off, MAGIC, true); off += 4; // magic + view.setUint32(off, 1, true); off += 4; // version = 1 + view.setUint16(off, 1, true); off += 2; // numSections = 1 + view.setUint16(off, 0, true); off += 2; // reserved = 0 + + // Section header + view.setUint8(off, 0x01); off += 1; // section type = static + // uint24 LE for section size + view.setUint8(off, sectionDataSize & 0xFF); + view.setUint8(off + 1, (sectionDataSize >> 8) & 0xFF); + view.setUint8(off + 2, (sectionDataSize >> 16) & 0xFF); + off += 3; + + // Section data + view.setUint16(off, content.width, true); off += 2; + view.setUint16(off, content.height, true); off += 2; + new Uint8Array(buf, off).set(content.pixels); return buf; } diff --git a/ts/test/library.test.ts b/ts/test/library.test.ts index 0fa5b66..b491ee2 100644 --- a/ts/test/library.test.ts +++ b/ts/test/library.test.ts @@ -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 — static content +// 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 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); + }); }); // ---------------------------------------------------------------------------