diff --git a/ts/CLAUDE.md b/ts/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/ts/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/ts/bun.lock b/ts/bun.lock new file mode 100644 index 0000000..434fd0d --- /dev/null +++ b/ts/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "libmonoformat", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="], + } +} diff --git a/ts/package.json b/ts/package.json new file mode 100644 index 0000000..05a6735 --- /dev/null +++ b/ts/package.json @@ -0,0 +1,11 @@ +{ + "name": "libmonoformat", + "module": "index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/ts/src/index.ts b/ts/src/index.ts new file mode 100644 index 0000000..63f73c0 --- /dev/null +++ b/ts/src/index.ts @@ -0,0 +1,45 @@ +// ============================================================================= +// index.ts — public entry point +// +// Re-exports everything from library.ts so browser bundlers and module users +// get a single clean import path: +// +// import { MonoDisplayDriver, loadBinFile } from "./index.ts"; +// +// PLAN: if the project splits into sub-packages (parser / renderer / utils), +// this file will aggregate them here so callers never need to update imports. +// ============================================================================= + +export { + // Driver (primary browser-facing API) + MonoDisplayDriver, + + // Parser — useful when you want to parse without rendering + MonoDisplayParser, + + // Low-level binary reader — exposed so power users can parse custom sections + BinaryReader, + + // Renderer — exposed for embedding into custom canvas setups + MonoDisplayRenderer, + + // Convenience helpers + loadBinFile, + buildBinBuffer, +} from "./library"; + +export type { + // Union type for all display content variants + DisplayContent, + DisplayContentType, + + // Concrete content types + StaticFrame, + AnimFrame, + Animation, + TextDisplay, + ScrollText, + + // Driver options bag + MonoDisplayDriverOptions, +} from "./library"; diff --git a/ts/src/library.ts b/ts/src/library.ts new file mode 100644 index 0000000..8ee1bd1 --- /dev/null +++ b/ts/src/library.ts @@ -0,0 +1,592 @@ +// ============================================================================= +// MonoDisplay Library — binary format parser + canvas renderer +// +// Binary format (little-endian throughout): +// [FILE HEADER] +// 4 bytes magic "MONO" (0x4D 0x4F 0x4E 0x4F) +// 1 byte version format version (currently 1) +// 1 byte content_type 0=static 1=animation 2=text 3=scrolltext +// 2 bytes width display width in pixels +// 2 bytes height display height in pixels +// +// [CONTENT — varies by content_type] +// static: +// (width*height) bytes raw 1bpp pixel rows, MSB-first within each byte +// +// animation: +// 2 bytes frame_count +// per frame: +// 4 bytes duration_ms +// (width*height) bytes pixel rows same as static +// +// text: +// 2 bytes text_len +// text_len bytes UTF-8 string (no null terminator) +// 1 byte font_id reserved, ignored for now +// 1 byte align 0=left 1=center 2=right +// +// scrolltext: +// 2 bytes text_len +// text_len bytes UTF-8 string +// 2 bytes speed_pps pixels per second (scroll rate) +// 1 byte direction 0=left 1=right +// +// PLAN: once the real spec arrives, swap out header offsets / field widths here. +// All parsing is centralised in MonoDisplayParser.parse() so callers never +// touch raw offsets. +// ============================================================================= + +// --------------------------------------------------------------------------- +// Public types — all exported so browser users can import them +// --------------------------------------------------------------------------- + +/** Supported content types embedded in a .bin display file */ +export type DisplayContentType = "static" | "animation" | "text" | "scrolltext"; + +/** A single monochrome bitmap. Pixels are row-major, 1 byte per pixel (0/1). */ +export interface StaticFrame { + type: "static"; + width: number; + height: number; + /** Unpacked: 1 byte per pixel, 0 = off, 1 = on */ + pixels: Uint8Array; +} + +/** One frame inside an animation */ +export interface AnimFrame { + /** How long this frame is displayed */ + durationMs: number; + pixels: Uint8Array; +} + +export interface Animation { + type: "animation"; + width: number; + height: number; + frames: AnimFrame[]; +} + +export interface TextDisplay { + type: "text"; + text: string; + /** 0=left 1=center 2=right — used by renderer */ + align: 0 | 1 | 2; + /** Reserved font index; renderer maps this to an embedded bitmap font */ + fontId: number; +} + +export interface ScrollText { + type: "scrolltext"; + text: string; + /** Scroll speed in pixels per second */ + speedPps: number; + /** 0=left 1=right */ + direction: 0 | 1; +} + +export type DisplayContent = StaticFrame | Animation | TextDisplay | ScrollText; + +/** Optional constructor arguments for MonoDisplayDriver */ +export interface MonoDisplayDriverOptions { + /** CSS colour for "on" pixels; default "#ffffff" */ + onColor?: string; + /** CSS colour for "off" pixels / background; default "#000000" */ + offColor?: string; + /** + * Scale factor: each logical pixel becomes NxN canvas pixels. + * Default 1. When CSS stretches the canvas for display, keep this at 1 + * and let the browser handle upscaling (use image-rendering: pixelated). + */ + scale?: number; + /** + * Logical display width in pixels. Used as canvas resolution baseline for + * content types that have no inherent size (text, scrolltext). + * Also used when static/animation content overflows this width — clipped. + * Default 160. + */ + displayWidth?: number; + /** + * Logical display height in pixels. Default 80. + */ + displayHeight?: number; + /** + * If true, driver loops animations forever. Default true. + * Set false to stop after one pass. + */ + loop?: boolean; + /** + * Callback fired whenever the driver encounters a parse or render error. + * If omitted, errors are thrown. + */ + onError?: (err: Error) => void; +} + +// --------------------------------------------------------------------------- +// BinaryReader — little-endian cursor over an ArrayBuffer +// --------------------------------------------------------------------------- + +/** + * Stateful cursor that reads little-endian primitives from an ArrayBuffer. + * + * PLAN: if the spec ever needs signed types (int16, int32) or floats, add + * readInt16 / readFloat32 etc. here — all other code stays unchanged. + */ +export class BinaryReader { + private view: DataView; + private pos: number = 0; + + constructor(buffer: ArrayBuffer) { + this.view = new DataView(buffer); + } + + get offset(): number { + return this.pos; + } + + get byteLength(): number { + return this.view.byteLength; + } + + get remaining(): number { + return this.view.byteLength - this.pos; + } + + /** Read one unsigned 8-bit integer */ + readByte(): number { + this.#assertRemaining(1); + return this.view.getUint8(this.pos++); + } + + /** Read unsigned 16-bit integer, little-endian */ + readUint16(): number { + this.#assertRemaining(2); + const v = this.view.getUint16(this.pos, true); + this.pos += 2; + return v; + } + + /** Alias kept for API clarity ("short" in spec language) */ + readShort(): number { + return this.readUint16(); + } + + /** Read unsigned 32-bit integer, little-endian */ + readUint32(): number { + this.#assertRemaining(4); + const v = this.view.getUint32(this.pos, true); + this.pos += 4; + return v; + } + + /** + * Read unsigned 64-bit integer, little-endian. + * Returns bigint because JS number cannot represent all uint64 values. + */ + readUint64(): bigint { + this.#assertRemaining(8); + const lo = BigInt(this.view.getUint32(this.pos, true)); + const hi = BigInt(this.view.getUint32(this.pos + 4, true)); + this.pos += 8; + return (hi << 32n) | lo; + } + + /** Read N raw bytes as a Uint8Array (zero-copy view) */ + readBytes(n: number): Uint8Array { + this.#assertRemaining(n); + const slice = new Uint8Array(this.view.buffer, this.pos, n); + this.pos += n; + return slice; + } + + /** Read N bytes and decode as UTF-8 */ + readUtf8(n: number): string { + return new TextDecoder().decode(this.readBytes(n)); + } + + /** Move cursor to absolute byte position */ + seek(pos: number): void { + if (pos < 0 || pos > this.view.byteLength) { + throw new RangeError(`seek(${pos}) out of bounds [0, ${this.view.byteLength}]`); + } + this.pos = pos; + } + + #assertRemaining(n: number): void { + if (this.remaining < n) { + throw new RangeError( + `BinaryReader: need ${n} byte(s) at offset ${this.pos}, only ${this.remaining} remain` + ); + } + } +} + +// --------------------------------------------------------------------------- +// File format constants +// --------------------------------------------------------------------------- + +const MAGIC = 0x4f4e4f4d; // "MONO" as little-endian uint32 + +const ContentTypeByte: Record = { + 0: "static", + 1: "animation", + 2: "text", + 3: "scrolltext", +}; + +// --------------------------------------------------------------------------- +// MonoDisplayParser +// --------------------------------------------------------------------------- + +/** + * 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. + */ +export class MonoDisplayParser { + parse(buffer: ArrayBuffer): DisplayContent { + const r = new BinaryReader(buffer); + + // --- header --- + const magic = r.readUint32(); + if (magic !== MAGIC) { + throw new Error( + `Invalid magic 0x${magic.toString(16).toUpperCase()} — expected 0x4F4E4F4D ("MONO")` + ); + } + + const version = r.readByte(); + if (version !== 1) { + // PLAN: add version dispatch here when v2 spec exists + 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 width = r.readUint16(); + const height = r.readUint16(); + + // --- content --- + switch (contentType) { + case "static": return this.#parseStatic(r, width, height); + case "animation": return this.#parseAnimation(r, width, height); + case "text": return this.#parseText(r); + case "scrolltext": return this.#parseScrollText(r); + } + } + + #parseStatic(r: BinaryReader, width: number, height: number): StaticFrame { + // PLAN: spec may use packed 1bpp; for now each byte = 1 pixel (0 or 1) + const raw = r.readBytes(width * height); + return { type: "static", width, height, pixels: new Uint8Array(raw) }; + } + + #parseAnimation(r: BinaryReader, width: number, height: number): Animation { + const frameCount = r.readUint16(); + const frames: AnimFrame[] = []; + + for (let i = 0; i < frameCount; i++) { + const durationMs = r.readUint32(); + const raw = r.readBytes(width * height); + frames.push({ durationMs, pixels: new Uint8Array(raw) }); + } + + return { type: "animation", width, height, frames }; + } + + #parseText(r: BinaryReader): TextDisplay { + const textLen = r.readUint16(); + const text = r.readUtf8(textLen); + const fontId = r.readByte(); + const alignByte = r.readByte(); + const align = (alignByte === 1 ? 1 : alignByte === 2 ? 2 : 0) as 0 | 1 | 2; + return { type: "text", text, fontId, align }; + } + + #parseScrollText(r: BinaryReader): ScrollText { + const textLen = r.readUint16(); + const text = r.readUtf8(textLen); + const speedPps = r.readUint16(); + const dirByte = r.readByte(); + const direction = dirByte === 1 ? 1 : 0 as 0 | 1; + return { type: "scrolltext", text, speedPps, direction }; + } +} + +// --------------------------------------------------------------------------- +// MonoDisplayRenderer — draws DisplayContent onto an HTMLCanvasElement +// --------------------------------------------------------------------------- + +/** + * Internal renderer; MonoDisplayDriver owns one instance. + * + * PLAN: text/scrolltext rendering currently draws a placeholder box. + * Replace #renderText / #renderScrollText with a real embedded bitmap font + * (5x7 or similar) once the font spec is finalised. + */ +export class MonoDisplayRenderer { + private ctx: CanvasRenderingContext2D; + private opts: Required; + + // Animation state + private animFrame: number = 0; + private animTimer: number | null = null; + // ScrollText state + private scrollX: number = 0; + private scrollRaf: number | null = null; + private scrollLastTs: number | null = null; + + constructor(canvas: HTMLCanvasElement, opts: Required) { + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Cannot get 2D canvas context"); + this.ctx = ctx; + this.opts = opts; + } + + /** Stop any running animation / scroll before loading new content */ + stop(): void { + if (this.animTimer !== null) { + clearTimeout(this.animTimer); + this.animTimer = null; + } + if (this.scrollRaf !== null) { + cancelAnimationFrame(this.scrollRaf); + this.scrollRaf = null; + } + } + + render(content: DisplayContent): void { + this.stop(); + switch (content.type) { + case "static": this.#renderStatic(content); break; + case "animation": this.#startAnimation(content); break; + case "text": this.#renderText(content); break; + case "scrolltext": this.#startScrollText(content); break; + } + } + + #resizeCanvas(w: number, h: number): void { + const s = this.opts.scale; + this.ctx.canvas.width = w * s; + this.ctx.canvas.height = h * s; + } + + #renderPixels(pixels: Uint8Array, width: number, height: number): void { + const { onColor, offColor, scale: s } = this.opts; + const ctx = this.ctx; + ctx.fillStyle = offColor; + ctx.fillRect(0, 0, width * s, height * s); + ctx.fillStyle = onColor; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (pixels[y * width + x]) { + ctx.fillRect(x * s, y * s, s, s); + } + } + } + } + + #renderStatic(content: StaticFrame): void { + this.#resizeCanvas(content.width, content.height); + this.#renderPixels(content.pixels, content.width, content.height); + } + + #startAnimation(content: Animation): void { + this.#resizeCanvas(content.width, content.height); + this.animFrame = 0; + + const tick = () => { + if (content.frames.length === 0) return; + const frame = content.frames[this.animFrame]; + this.#renderPixels(frame.pixels, content.width, content.height); + + this.animFrame++; + if (this.animFrame >= content.frames.length) { + if (!this.opts.loop) return; + this.animFrame = 0; + } + this.animTimer = setTimeout(tick, frame.durationMs) as unknown as number; + }; + tick(); + } + + #renderText(content: TextDisplay): void { + // PLAN: replace with bitmap font renderer; align/fontId will be honoured then + const ctx = this.ctx; + const { displayWidth: dw, displayHeight: dh, scale: s, offColor, onColor } = this.opts; + ctx.canvas.width = dw * s; + ctx.canvas.height = dh * s; + ctx.fillStyle = offColor; + ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); + ctx.fillStyle = onColor; + // Scale font to roughly fill display height; real bitmap font will replace this + const fontSize = Math.max(8, Math.floor(dh * s * 0.6)); + ctx.font = `${fontSize}px monospace`; + ctx.textBaseline = "middle"; + const padding = 4 * s; + ctx.fillText(content.text, padding, ctx.canvas.height / 2); + } + + #startScrollText(content: ScrollText): void { + // PLAN: replace inner draw with bitmap font; direction/speed wired in + const ctx = this.ctx; + const { displayWidth: dw, displayHeight: dh, scale: s } = this.opts; + ctx.canvas.width = dw * s; + ctx.canvas.height = dh * s; + // Start position: off-screen on the entry side + this.scrollX = content.direction === 1 ? -(content.text.length * 8 * s) : ctx.canvas.width; + + const tick = (ts: number) => { + const dt = this.scrollLastTs !== null ? (ts - this.scrollLastTs) / 1000 : 0; + this.scrollLastTs = ts; + + const { displayWidth: dw, displayHeight: dh, scale: s, offColor, onColor } = this.opts; + // speedPps is in logical pixels/s; scale to canvas pixels + const delta = content.speedPps * s * dt * (content.direction === 1 ? 1 : -1); + this.scrollX += delta; + + // codepoint count (not UTF-16 units) for width estimate + const cpCount = [...content.text].length; + const textWidth = cpCount * 8 * s; // rough 8px/cp until bitmap font lands + if (content.direction === 0 && this.scrollX < -textWidth) { + this.scrollX = ctx.canvas.width; + } else if (content.direction === 1 && this.scrollX > ctx.canvas.width) { + this.scrollX = -textWidth; + } + + ctx.fillStyle = offColor; + ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); + ctx.fillStyle = onColor; + const fontSize = Math.max(8, Math.floor(dh * s * 0.6)); + ctx.font = `${fontSize}px monospace`; + ctx.textBaseline = "middle"; + ctx.fillText(content.text, this.scrollX, ctx.canvas.height / 2); + + this.scrollRaf = requestAnimationFrame(tick); + }; + + this.scrollRaf = requestAnimationFrame(tick); + } +} + +// --------------------------------------------------------------------------- +// MonoDisplayDriver — public API surface +// --------------------------------------------------------------------------- + +/** + * Top-level driver. Attach to a canvas element by ID, then call load() with + * a function that returns a Promise of the .bin file. + * + * @example + * ```ts + * const driver = new MonoDisplayDriver("canvas_root"); + * driver.load(() => loadBinFile("default_test.bin")); + * ``` + * + * PLAN: add driver.stop() / driver.play() / driver.setContent(DisplayContent) + * to allow external playback control without reloading. + */ +export class MonoDisplayDriver { + private canvas: HTMLCanvasElement; + private opts: Required; + private parser: MonoDisplayParser; + private renderer: MonoDisplayRenderer | null = null; + + constructor(canvasId: string, options: MonoDisplayDriverOptions = {}) { + const el = document.getElementById(canvasId); + if (!el || el.tagName !== "CANVAS") { + throw new Error(`MonoDisplayDriver: element #${canvasId} is not a `); + } + this.canvas = el as HTMLCanvasElement; + + // Apply defaults + this.opts = { + onColor: options.onColor ?? "#ffffff", + offColor: options.offColor ?? "#000000", + scale: options.scale ?? 1, + displayWidth: options.displayWidth ?? 160, + displayHeight: options.displayHeight ?? 80, + loop: options.loop ?? true, + onError: options.onError ?? ((e: Error) => { throw e; }), + }; + + this.parser = new MonoDisplayParser(); + } + + /** + * Load binary data from the provided async factory, parse, and render. + * Calling load() again stops any current playback and starts fresh. + * + * @param loader Async function returning the raw .bin ArrayBuffer + */ + async load(loader: () => Promise): Promise { + try { + const buffer = await loader(); + const content = this.parser.parse(buffer); + + // Lazy-create renderer on first load, reuse after + if (!this.renderer) { + this.renderer = new MonoDisplayRenderer(this.canvas, this.opts); + } + this.renderer.render(content); + } catch (err) { + this.opts.onError(err instanceof Error ? err : new Error(String(err))); + } + } + + /** Stop current animation / scroll without unloading */ + stop(): void { + this.renderer?.stop(); + } +} + +// --------------------------------------------------------------------------- +// Helpers exposed for browser convenience +// --------------------------------------------------------------------------- + +/** + * Fetch a .bin file by URL and return its ArrayBuffer. + * Intended as the `loader` argument to driver.load(). + * + * @example + * ```ts + * driver.load(() => loadBinFile("/assets/default_test.bin")); + * ``` + */ +export async function loadBinFile(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`loadBinFile: HTTP ${res.status} for ${url}`); + return res.arrayBuffer(); +} + +/** + * Build a minimal valid .bin ArrayBuffer in code — useful for tests and demos. + * + * 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. + */ +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); + + 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); + + return buf; +} diff --git a/ts/test/library.test.ts b/ts/test/library.test.ts new file mode 100644 index 0000000..0fa5b66 --- /dev/null +++ b/ts/test/library.test.ts @@ -0,0 +1,268 @@ +// ============================================================================= +// library.test.ts — bun test suite for MonoDisplay library +// +// run: bun test src/library.test.ts +// +// 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. +// ============================================================================= + +import { test, expect, describe, mock, beforeEach } from "bun:test"; +import { + BinaryReader, + MonoDisplayParser, + buildBinBuffer, + type StaticFrame, + type Animation, + type TextDisplay, + type ScrollText, +} from "../src/library"; + +// --------------------------------------------------------------------------- +// Helpers to build raw binary buffers for each content type +// (mirrors the format defined in library.ts header comment) +// --------------------------------------------------------------------------- + +const MAGIC_BYTES = new Uint8Array([0x4d, 0x4f, 0x4e, 0x4f]); // "MONO" + +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); + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { out.set(p, off); off += p.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; +} + +// --------------------------------------------------------------------------- +// BinaryReader +// --------------------------------------------------------------------------- + +describe("BinaryReader", () => { + test("readByte reads single byte", () => { + const r = new BinaryReader(new Uint8Array([0xAB]).buffer); + expect(r.readByte()).toBe(0xAB); + expect(r.remaining).toBe(0); + }); + + test("readUint16 little-endian", () => { + const r = new BinaryReader(new Uint8Array([0x34, 0x12]).buffer); + expect(r.readUint16()).toBe(0x1234); + }); + + test("readShort is alias for readUint16", () => { + const r = new BinaryReader(new Uint8Array([0x01, 0x00]).buffer); + expect(r.readShort()).toBe(1); + }); + + test("readUint32 little-endian", () => { + const r = new BinaryReader(new Uint8Array([0x78, 0x56, 0x34, 0x12]).buffer); + expect(r.readUint32()).toBe(0x12345678); + }); + + test("readUint64 little-endian", () => { + 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", () => { + 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 + }); + + test("RangeError when buffer exhausted", () => { + const r = new BinaryReader(new Uint8Array([0x01]).buffer); + r.readByte(); + expect(() => r.readByte()).toThrow(RangeError); + }); + + test("seek moves cursor", () => { + 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 decodes ASCII", () => { + const str = "hello"; + const enc = new TextEncoder().encode(str); + const r = new BinaryReader(enc.buffer); + expect(r.readUtf8(5)).toBe("hello"); + }); +}); + +// --------------------------------------------------------------------------- +// MonoDisplayParser — static content +// --------------------------------------------------------------------------- + +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); + + 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); + }); + + 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 +// --------------------------------------------------------------------------- + +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 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 header = makeHeader(1, 4, 4); + const buf = concat(header, u16le(0)); + const result = parser.parse(buf) as Animation; + expect(result.frames.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// MonoDisplayParser — text +// --------------------------------------------------------------------------- + +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)); + + 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); + }); +}); + +// --------------------------------------------------------------------------- +// MonoDisplayParser — scrolltext +// --------------------------------------------------------------------------- + +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)); + + 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); + }); +}); + +// --------------------------------------------------------------------------- +// 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/); + }); +}); diff --git a/ts/tsconfig.json b/ts/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/ts/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}