feat: updates to file format

pull/1/head
flop 4 weeks ago
parent 4d9bb7d26a
commit 30afeb4dad
  1. 9
      ts/.claude/settings.local.json
  2. 57
      ts/index.html
  3. 161
      ts/src/library.ts
  4. 266
      ts/test/library.test.ts

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(bun test:*)",
"Bash(bun build:*)",
"Bash(bun run:*)"
]
}
}

@ -111,75 +111,60 @@
// Demo helpers — build synthetic .bin ArrayBuffers for all 4 content types // 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) { 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); const out = new Uint8Array(total);
let off = 0; 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; 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 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; } 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() { function makeCheckerboard() {
const W = 160, H = 80; const W = 160, H = 80;
const pixels = new Uint8Array(W * H); const pixels = new Uint8Array(W * H);
for (let y = 0; y < H; y++) for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) for (let x = 0; x < W; x++)
pixels[y * W + x] = (x + y) % 2; 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() { function makeAnimation() {
const W = 160, H = 80; const W = 160, H = 80;
function frame(fill, durationMs) { const f = (fill, ms) => new Uint8Array(concat(u32le(ms), new Uint8Array(W * H).fill(fill)));
return concat(u32le(durationMs), new Uint8Array(W * H).fill(fill)); return makeFile(0x02, new Uint8Array(concat(u16le(W), u16le(H), u16le(2), f(1, 200), f(0, 200))));
}
return concat(makeHeader(1, W, H), u16le(2), frame(1, 200), frame(0, 200));
} }
// Text content type // Text section (type 0x03)
function makeTextBin(text, align /* 0=left 1=center 2=right */) { function makeTextBin(text, align /* 0=left 1=center 2=right */) {
const enc = new TextEncoder().encode(text); const enc = new TextEncoder().encode(text);
return concat( return makeFile(0x03, new Uint8Array(concat(u16le(enc.byteLength), enc, u8(0), u8(align ?? 1))));
makeHeader(2, 0, 0),
u16le(enc.byteLength), enc,
new Uint8Array([0, align ?? 1]) // fontId=0, align
);
} }
// Scrolltext content type // Scrolltext section (type 0x04)
function makeScrollTextBin(text, speedPps, direction /* 0=left 1=right */) { function makeScrollTextBin(text, speedPps, direction /* 0=left 1=right */) {
const enc = new TextEncoder().encode(text); const enc = new TextEncoder().encode(text);
return concat( return makeFile(0x04, new Uint8Array(concat(u16le(enc.byteLength), enc, u16le(speedPps ?? 60), u8(direction ?? 0))));
makeHeader(3, 0, 0),
u16le(enc.byteLength), enc,
u16le(speedPps ?? 60),
new Uint8Array([direction ?? 0])
);
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Driver — 160×80, 25fps, green-on-dark // Driver — 160×80, 25fps, green-on-dark
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const { MonoDisplayDriver } = window.MonoDisplay;
const driver = new MonoDisplayDriver("canvas_root", { const driver = new MonoDisplayDriver("canvas_root", {
onColor: "#33ff66", onColor: "#33ff66",
offColor: "#0a0a0a", offColor: "#0a0a0a",

@ -212,6 +212,16 @@ export class BinaryReader {
return new TextDecoder().decode(this.readBytes(n)); 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 */ /** Move cursor to absolute byte position */
seek(pos: number): void { seek(pos: number): void {
if (pos < 0 || pos > this.view.byteLength) { 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<number, DisplayContentType> = { /**
0: "static", * File header layout (12 bytes total):
1: "animation", *
2: "text", * offset size field
3: "scrolltext", *
* 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<number, DisplayContentType> = {
0x01: "static",
0x02: "animation",
0x03: "text",
0x04: "scrolltext",
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -249,40 +285,72 @@ const ContentTypeByte: Record<number, DisplayContentType> = {
/** /**
* Parses a .bin ArrayBuffer into a strongly-typed DisplayContent value. * Parses a .bin ArrayBuffer into a strongly-typed DisplayContent value.
* *
* PLAN: when the real spec lands, update ONLY the parse* private methods. * Returns the first recognised section's content. Files with numSections=0
* The public surface (parse DisplayContent) stays identical. * 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 { export class MonoDisplayParser {
parse(buffer: ArrayBuffer): DisplayContent { parse(buffer: ArrayBuffer): DisplayContent {
const r = new BinaryReader(buffer); const r = new BinaryReader(buffer);
// --- header --- // --- file header (12 bytes) ---
const magic = r.readUint32(); const magic = r.readUint32();
if (magic !== MAGIC) { if (magic !== MAGIC) {
throw new Error( 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(); const version = r.readUint32(); // PLAN: confirm uint32 vs uint8+3pad in spec
if (version !== 1) { if (version === 0 || version > 1) {
// PLAN: add version dispatch here when v2 spec exists
throw new Error(`Unsupported format version ${version}`); throw new Error(`Unsupported format version ${version}`);
} }
const contentTypeByte = r.readByte(); const numSections = r.readUint16();
const contentType = ContentTypeByte[contentTypeByte]; r.readUint16(); // reserved — ignored
if (!contentType) {
throw new Error(`Unknown content type byte 0x${contentTypeByte.toString(16)}`); 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(); throw new Error("No recognised sections found in file");
const height = r.readUint16(); }
// --- content --- #parseSection(r: BinaryReader, contentType: DisplayContentType): DisplayContent {
switch (contentType) { switch (contentType) {
case "static": return this.#parseStatic(r, width, height); case "static": {
case "animation": return this.#parseAnimation(r, width, height); 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 "text": return this.#parseText(r);
case "scrolltext": return this.#parseScrollText(r); case "scrolltext": return this.#parseScrollText(r);
} }
@ -589,29 +657,46 @@ export async function loadBinFile(url: string): Promise<ArrayBuffer> {
} }
/** /**
* 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, * PLAN: expand into a full BinaryWriter for all section types once spec is complete.
* so integration tests can construct every content type without raw byte arrays. * Currently only "static" section is encoded; others throw.
*/ */
export function buildBinBuffer(content: DisplayContent): ArrayBuffer { 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") { if (content.type !== "static") {
throw new Error(`buildBinBuffer: encoding "${content.type}" not yet implemented`); throw new Error(`buildBinBuffer: encoding "${content.type}" not yet implemented`);
} }
const headerSize = 4 + 1 + 1 + 2 + 2; // magic + version + type + w + h // Section data: width(u16) + height(u16) + pixels[w*h]
const pixelSize = content.width * content.height; const sectionDataSize = 2 + 2 + content.width * content.height;
const buf = new ArrayBuffer(headerSize + pixelSize);
const view = new DataView(buf);
view.setUint32(0, MAGIC, true); // File header: magic(4) + version(4) + numSections(2) + reserved(2) = 12
view.setUint8(4, 1); // version // Section header: type(1) + size(3) = 4
view.setUint8(5, 0); // content type = static const totalSize = 12 + 4 + sectionDataSize;
view.setUint16(6, content.width, true);
view.setUint16(8, content.height, true); const buf = new ArrayBuffer(totalSize);
new Uint8Array(buf, headerSize).set(content.pixels); 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; return buf;
} }

@ -1,14 +1,13 @@
// ============================================================================= // =============================================================================
// library.test.ts — bun test suite for MonoDisplay library // 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 // Tests cover BinaryReader primitives and MonoDisplayParser with the real
// minimal mock so they run in Bun (no DOM). Parser + BinaryReader tests are // binary format: magic 0xAF7E2B63, section-based layout.
// pure TypeScript with no mocks needed.
// ============================================================================= // =============================================================================
import { test, expect, describe, mock, beforeEach } from "bun:test"; import { test, expect, describe } from "bun:test";
import { import {
BinaryReader, BinaryReader,
MonoDisplayParser, MonoDisplayParser,
@ -20,40 +19,43 @@ import {
} from "../src/library"; } from "../src/library";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers to build raw binary buffers for each content type // Low-level buffer helpers
// (mirrors the format defined in library.ts header comment)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
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 { function concat(...parts: (Uint8Array | ArrayBuffer)[]): ArrayBuffer {
const h = new Uint8Array(10); const arrays = parts.map(p => p instanceof ArrayBuffer ? new Uint8Array(p) : p);
h.set(MAGIC_BYTES, 0); const total = arrays.reduce((s, a) => s + a.byteLength, 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);
const out = new Uint8Array(total); const out = new Uint8Array(total);
let off = 0; 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; return out.buffer;
} }
function u16le(n: number): Uint8Array { function u8(n: number) { return new Uint8Array([n]); }
const b = new Uint8Array(2); function u16le(n: number) { const b = new Uint8Array(2); new DataView(b.buffer).setUint16(0, n, true); return b; }
new DataView(b.buffer).setUint16(0, n, true); function u24le(n: number) { return new Uint8Array([n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF]); }
return b; function u32le(n: number) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n, true); return b; }
}
/**
function u32le(n: number): Uint8Array { * Build a well-formed file buffer with a single section.
const b = new Uint8Array(4); * sectionType: 0x01=static 0x02=animation 0x03=text 0x04=scrolltext
new DataView(b.buffer).setUint32(0, n, true); * sectionData: raw section payload (after section header)
return b; */
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", () => { describe("BinaryReader", () => {
test("readByte reads single byte", () => { test("readByte", () => {
const r = new BinaryReader(new Uint8Array([0xAB]).buffer); const r = new BinaryReader(new Uint8Array([0xAB]).buffer);
expect(r.readByte()).toBe(0xAB); expect(r.readByte()).toBe(0xAB);
expect(r.remaining).toBe(0); expect(r.remaining).toBe(0);
}); });
test("readUint16 little-endian", () => { test("readUint16 LE", () => {
const r = new BinaryReader(new Uint8Array([0x34, 0x12]).buffer); const r = new BinaryReader(new Uint8Array([0x34, 0x12]).buffer);
expect(r.readUint16()).toBe(0x1234); expect(r.readUint16()).toBe(0x1234);
}); });
test("readShort is alias for readUint16", () => { test("readShort alias", () => {
const r = new BinaryReader(new Uint8Array([0x01, 0x00]).buffer); const r = new BinaryReader(new Uint8Array([0x01, 0x00]).buffer);
expect(r.readShort()).toBe(1); 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); const r = new BinaryReader(new Uint8Array([0x78, 0x56, 0x34, 0x12]).buffer);
expect(r.readUint32()).toBe(0x12345678); expect(r.readUint32()).toBe(0x12345678);
}); });
test("readUint64 little-endian", () => { test("readUint64 LE", () => {
const bytes = new Uint8Array(8); const bytes = new Uint8Array(8);
new DataView(bytes.buffer).setBigUint64(0, 0x0102030405060708n, true); new DataView(bytes.buffer).setBigUint64(0, 0x0102030405060708n, true);
const r = new BinaryReader(bytes.buffer); const r = new BinaryReader(bytes.buffer);
expect(r.readUint64()).toBe(0x0102030405060708n); 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); const r = new BinaryReader(new Uint8Array([1, 2, 3, 4, 5, 6]).buffer);
r.readByte(); r.readByte(); expect(r.offset).toBe(1);
expect(r.offset).toBe(1); r.readUint16(); expect(r.offset).toBe(3);
r.readUint16(); expect(() => r.readUint32()).toThrow(RangeError); // only 3 remain
expect(r.offset).toBe(3);
expect(() => r.readUint32()).toThrow(RangeError); // 3 bytes remain, need 4
}); });
test("RangeError when buffer exhausted", () => { test("RangeError on exhaustion", () => {
const r = new BinaryReader(new Uint8Array([0x01]).buffer); const r = new BinaryReader(new Uint8Array([0x01]).buffer);
r.readByte(); r.readByte();
expect(() => r.readByte()).toThrow(RangeError); expect(() => r.readByte()).toThrow(RangeError);
}); });
test("seek moves cursor", () => { test("seek", () => {
const r = new BinaryReader(new Uint8Array([10, 20, 30]).buffer); const r = new BinaryReader(new Uint8Array([10, 20, 30]).buffer);
r.seek(2); r.seek(2);
expect(r.readByte()).toBe(30); expect(r.readByte()).toBe(30);
@ -115,25 +121,68 @@ describe("BinaryReader", () => {
expect(() => r.seek(5)).toThrow(RangeError); expect(() => r.seek(5)).toThrow(RangeError);
}); });
test("readUtf8 decodes ASCII", () => { test("readUtf8 ASCII", () => {
const str = "hello"; const enc = new TextEncoder().encode("hello");
const enc = new TextEncoder().encode(str);
const r = new BinaryReader(enc.buffer); const r = new BinaryReader(enc.buffer);
expect(r.readUtf8(5)).toBe("hello"); 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", () => { describe("MonoDisplayParser — static", () => {
const parser = new MonoDisplayParser(); const parser = new MonoDisplayParser();
test("parses 2x2 static frame", () => { test("parses 2×2 static frame", () => {
const header = makeHeader(0, 2, 2); const data = concat(u16le(2), u16le(2), new Uint8Array([1, 0, 0, 1]));
const pixels = new Uint8Array([1, 0, 0, 1]); // diagonal const buf = makeFile(0x01, new Uint8Array(data));
const buf = concat(header, pixels);
const result = parser.parse(buf) as StaticFrame; const result = parser.parse(buf) as StaticFrame;
expect(result.type).toBe("static"); expect(result.type).toBe("static");
@ -142,37 +191,24 @@ describe("MonoDisplayParser — static", () => {
expect(result.pixels[0]).toBe(1); expect(result.pixels[0]).toBe(1);
expect(result.pixels[3]).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", () => { describe("MonoDisplayParser — animation", () => {
const parser = new MonoDisplayParser(); const parser = new MonoDisplayParser();
test("parses 2-frame animation", () => { test("parses 2-frame animation", () => {
const header = makeHeader(1, 2, 2); // type=1 animation const pixels = new Uint8Array(4); // 2×2
const frameCount = u16le(2); const data = concat(
const frame1 = concat(u32le(100), new Uint8Array([1, 1, 1, 1])); u16le(2), u16le(2), // width, height
const frame2 = concat(u32le(200), new Uint8Array([0, 0, 0, 0])); u16le(2), // frameCount
const buf = concat(header, frameCount, new Uint8Array(frame1), new Uint8Array(frame2)); 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; const result = parser.parse(buf) as Animation;
expect(result.type).toBe("animation"); expect(result.type).toBe("animation");
@ -182,31 +218,24 @@ describe("MonoDisplayParser — animation", () => {
}); });
test("empty animation (0 frames) is valid", () => { test("empty animation (0 frames) is valid", () => {
const header = makeHeader(1, 4, 4); const data = concat(u16le(4), u16le(4), u16le(0));
const buf = concat(header, u16le(0)); const buf = makeFile(0x02, new Uint8Array(data));
const result = parser.parse(buf) as Animation; const result = parser.parse(buf) as Animation;
expect(result.frames.length).toBe(0); expect(result.frames.length).toBe(0);
}); });
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// MonoDisplayParser — text // MonoDisplayParser — text section
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("MonoDisplayParser — text", () => { describe("MonoDisplayParser — text", () => {
const parser = new MonoDisplayParser(); const parser = new MonoDisplayParser();
test("parses text content", () => { test("parses text with align=center", () => {
const header = makeHeader(2, 0, 0); // w/h unused for text const enc = new TextEncoder().encode("HELLO");
const str = "HELLO"; const data = concat(u16le(enc.byteLength), enc, u8(0), u8(1)); // fontId=0 align=1
const enc = new TextEncoder().encode(str); const buf = makeFile(0x03, new Uint8Array(data));
const payload = concat(
u16le(enc.byteLength),
enc,
new Uint8Array([0]), // fontId
new Uint8Array([1]), // align = center
);
const buf = concat(header, new Uint8Array(payload));
const result = parser.parse(buf) as TextDisplay; const result = parser.parse(buf) as TextDisplay;
expect(result.type).toBe("text"); expect(result.type).toBe("text");
@ -214,26 +243,29 @@ describe("MonoDisplayParser — text", () => {
expect(result.align).toBe(1); expect(result.align).toBe(1);
expect(result.fontId).toBe(0); 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", () => { describe("MonoDisplayParser — scrolltext", () => {
const parser = new MonoDisplayParser(); const parser = new MonoDisplayParser();
test("parses scrolltext content", () => { test("parses scrolltext left-direction", () => {
const header = makeHeader(3, 0, 0); const enc = new TextEncoder().encode("SCROLL ME");
const str = "SCROLL ME"; const data = concat(u16le(enc.byteLength), enc, u16le(60), u8(0));
const enc = new TextEncoder().encode(str); const buf = makeFile(0x04, new Uint8Array(data));
const payload = concat(
u16le(enc.byteLength),
enc,
u16le(60), // speedPps = 60
new Uint8Array([0]), // direction = left
);
const buf = concat(header, new Uint8Array(payload));
const result = parser.parse(buf) as ScrollText; const result = parser.parse(buf) as ScrollText;
expect(result.type).toBe("scrolltext"); expect(result.type).toBe("scrolltext");
@ -241,6 +273,40 @@ describe("MonoDisplayParser — scrolltext", () => {
expect(result.speedPps).toBe(60); expect(result.speedPps).toBe(60);
expect(result.direction).toBe(0); 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);
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

Loading…
Cancel
Save