diff --git a/docs/guide/layout.md b/docs/guide/layout.md index 29c24b8a..64c693b9 100644 --- a/docs/guide/layout.md +++ b/docs/guide/layout.md @@ -259,6 +259,46 @@ Conservative fallback: - `button` `id`-only changes do not trigger relayout; label/padding changes do. - If any committed node is outside signature coverage, relayout is forced conservatively. +## ZRDL Binary Format Invariants + +These invariants describe current builder + engine behavior for ZRDL bytes. + +### 4-byte alignment rules + +- Header size is fixed at 64 bytes; when `cmdCount > 0`, `cmdOffset` is 64. +- `totalSize`, `cmdBytes`, `stringsBytesLen`, and `blobsBytesLen` are 4-byte aligned. +- Section offsets (`cmdOffset`, `stringsSpanOffset`, `stringsBytesOffset`, `blobsSpanOffset`, `blobsBytesOffset`) are 4-byte aligned. +- Command records start on 4-byte boundaries. Command `size` must be 4-byte aligned; any command padding bytes are zeroed. +- `addBlob(...)` requires `bytes.byteLength % 4 === 0`. String entries are not individually aligned; the strings section as a whole is padded to 4-byte alignment. + +### String interning guarantees + +- Interning is by exact string value within one builder epoch (from construction/reset to the next `reset()`). +- Repeated equal strings across `drawText(...)` and `addTextRunBlob(...)` reuse one `string_index` and one string-table entry. +- New string indices are assigned in first-seen order. +- `reset()` clears interning state (and command/blob/string sections), so indices are rebuilt on the next frame. + +### Limit and overflow behavior + +- Builder caps (`maxDrawlistBytes`, `maxCmdCount`, `maxStrings`, `maxStringBytes`, `maxBlobs`, `maxBlobBytes`) fail with `ZRDL_TOO_LARGE` when exceeded. +- Builder failures are sticky: first error is retained and later commands no-op until `reset()`. +- Engine validation enforces runtime limits (`dl_max_total_bytes`, `dl_max_cmds`, `dl_max_strings`, `dl_max_blobs`, `dl_max_clip_depth`, `dl_max_text_run_segments`) and returns `ZR_ERR_LIMIT` when exceeded. +- Offset/length arithmetic that overflows during validation is treated as invalid format (`ZR_ERR_FORMAT`), not wraparound. + +### Encoded string cache semantics (including reset) + +- Encoded string caching is optional: `encodedStringCacheCap = 0` disables it. +- On a cache miss with caching enabled, if cache size is already `>= cap`, the cache is cleared, then the new entry is inserted. +- `reset()` does not clear the encoded-string cache. It clears per-drawlist state only, so cached encodings can persist across frames while the same builder instance is reused. +- v1 caches only strings with `text.length <= 96`; v2 has no length filter for cache eligibility. + +### v1 vs v2 differences (cursor command) + +- v1 and v2 share the same header shape and opcodes `1..6`. +- v2 sets header `version = 2` and adds `OP_SET_CURSOR` (opcode `7`), encoded as a 20-byte command (`8` byte header + `12` byte payload). +- `SET_CURSOR` payload fields are `x:int32`, `y:int32`, `shape:u8`, `visible:u8`, `blink:u8`, `reserved0:u8`. `x` and `y` allow `-1` (leave unchanged). +- v1 command validation/execution does not allow `OP_SET_CURSOR`; it is rejected as unsupported. + ## Borders `ui.box` can draw a border around its content: diff --git a/packages/core/src/drawlist/__tests__/builder.alignment.test.ts b/packages/core/src/drawlist/__tests__/builder.alignment.test.ts new file mode 100644 index 00000000..4f76ffe5 --- /dev/null +++ b/packages/core/src/drawlist/__tests__/builder.alignment.test.ts @@ -0,0 +1,291 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import { createDrawlistBuilderV1 } from "../builder_v1.js"; +import type { DrawlistBuildResult } from "../types.js"; + +const HEADER = { + TOTAL_SIZE: 12, + CMD_OFFSET: 16, + CMD_BYTES: 20, + CMD_COUNT: 24, + STRINGS_SPAN_OFFSET: 28, + STRINGS_COUNT: 32, + STRINGS_BYTES_OFFSET: 36, + STRINGS_BYTES_LEN: 40, + BLOBS_SPAN_OFFSET: 44, + BLOBS_COUNT: 48, + BLOBS_BYTES_OFFSET: 52, + BLOBS_BYTES_LEN: 56, + SIZE: 64, +} as const; + +const CMD = { + SIZE: 4, + HEADER_SIZE: 8, +} as const; + +const SPAN_SIZE = 8; + +type ParsedHeader = Readonly<{ + totalSize: number; + cmdOffset: number; + cmdBytes: number; + cmdCount: number; + stringsSpanOffset: number; + stringsCount: number; + stringsBytesOffset: number; + stringsBytesLen: number; + blobsSpanOffset: number; + blobsCount: number; + blobsBytesOffset: number; + blobsBytesLen: number; +}>; + +function align4(n: number): number { + return (n + 3) & ~3; +} + +function toView(bytes: Uint8Array): DataView { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); +} + +function parseHeader(bytes: Uint8Array): ParsedHeader { + const dv = toView(bytes); + return { + totalSize: dv.getUint32(HEADER.TOTAL_SIZE, true), + cmdOffset: dv.getUint32(HEADER.CMD_OFFSET, true), + cmdBytes: dv.getUint32(HEADER.CMD_BYTES, true), + cmdCount: dv.getUint32(HEADER.CMD_COUNT, true), + stringsSpanOffset: dv.getUint32(HEADER.STRINGS_SPAN_OFFSET, true), + stringsCount: dv.getUint32(HEADER.STRINGS_COUNT, true), + stringsBytesOffset: dv.getUint32(HEADER.STRINGS_BYTES_OFFSET, true), + stringsBytesLen: dv.getUint32(HEADER.STRINGS_BYTES_LEN, true), + blobsSpanOffset: dv.getUint32(HEADER.BLOBS_SPAN_OFFSET, true), + blobsCount: dv.getUint32(HEADER.BLOBS_COUNT, true), + blobsBytesOffset: dv.getUint32(HEADER.BLOBS_BYTES_OFFSET, true), + blobsBytesLen: dv.getUint32(HEADER.BLOBS_BYTES_LEN, true), + }; +} + +function expectOk(result: DrawlistBuildResult): Uint8Array { + assert.equal(result.ok, true); + if (!result.ok) throw new Error("expected build() to succeed"); + return result.bytes; +} + +function readStringSpan(dv: DataView, stringsSpanOffset: number, index: number): { off: number; len: number } { + const spanOff = stringsSpanOffset + index * SPAN_SIZE; + return { + off: dv.getUint32(spanOff, true), + len: dv.getUint32(spanOff + 4, true), + }; +} + +function commandStarts(bytes: Uint8Array, h: ParsedHeader): readonly number[] { + const dv = toView(bytes); + if (h.cmdCount === 0) return []; + + let cursor = h.cmdOffset; + const starts: number[] = []; + for (let i = 0; i < h.cmdCount; i++) { + starts.push(cursor); + const size = dv.getUint32(cursor + CMD.SIZE, true); + assert.equal(size >= CMD.HEADER_SIZE, true, `command ${i} has invalid size`); + cursor += align4(size); + } + assert.equal(cursor, h.cmdOffset + h.cmdBytes); + return starts; +} + +describe("DrawlistBuilderV1 - alignment and padding", () => { + test("empty drawlist has aligned total size and zero section offsets", () => { + const bytes = expectOk(createDrawlistBuilderV1().build()); + const h = parseHeader(bytes); + + assert.equal(h.totalSize, HEADER.SIZE); + assert.equal((h.totalSize & 3) === 0, true); + assert.equal(h.cmdOffset, 0); + assert.equal(h.cmdBytes, 0); + assert.equal(h.cmdCount, 0); + assert.equal(h.stringsSpanOffset, 0); + assert.equal(h.stringsBytesOffset, 0); + assert.equal(h.blobsSpanOffset, 0); + assert.equal(h.blobsBytesOffset, 0); + }); + + test("near-empty clear drawlist keeps command start and section layout aligned", () => { + const b = createDrawlistBuilderV1(); + b.clear(); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + + assert.equal(h.cmdOffset, HEADER.SIZE); + assert.equal((h.cmdOffset & 3) === 0, true); + assert.equal((h.cmdBytes & 3) === 0, true); + assert.equal(h.cmdCount, 1); + assert.equal(h.stringsCount, 0); + assert.equal(h.blobsCount, 0); + }); + + test("all command starts are 4-byte aligned in a mixed stream", () => { + const b = createDrawlistBuilderV1(); + b.clear(); + b.fillRect(0, 0, 3, 2); + b.drawText(1, 1, "abc"); + b.pushClip(0, 0, 3, 2); + b.popClip(); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + const starts = commandStarts(bytes, h); + + assert.equal(starts.length, h.cmdCount); + for (const start of starts) { + assert.equal((start & 3) === 0, true); + } + }); + + test("walking command sizes lands exactly on cmdOffset + cmdBytes", () => { + const b = createDrawlistBuilderV1(); + const blobIndex = b.addBlob(new Uint8Array([1, 2, 3, 4])); + assert.equal(blobIndex, 0); + b.clear(); + b.drawText(0, 0, "x"); + b.drawTextRun(2, 1, 0); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + const starts = commandStarts(bytes, h); + + assert.equal(starts.length, 3); + assert.equal(starts[0], HEADER.SIZE); + }); + + test("section offsets are aligned and ordered when strings and blobs exist", () => { + const b = createDrawlistBuilderV1(); + b.drawText(0, 0, "abc"); + const blobIndex = b.addBlob(new Uint8Array([9, 8, 7, 6])); + assert.equal(blobIndex, 0); + b.drawTextRun(1, 0, 0); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + + assert.equal((h.cmdOffset & 3) === 0, true); + assert.equal((h.stringsSpanOffset & 3) === 0, true); + assert.equal((h.stringsBytesOffset & 3) === 0, true); + assert.equal((h.blobsSpanOffset & 3) === 0, true); + assert.equal((h.blobsBytesOffset & 3) === 0, true); + + assert.equal(h.stringsSpanOffset, HEADER.SIZE + h.cmdBytes); + assert.equal(h.stringsBytesOffset, h.stringsSpanOffset + h.stringsCount * SPAN_SIZE); + assert.equal(h.blobsSpanOffset, h.stringsBytesOffset + h.stringsBytesLen); + assert.equal(h.blobsBytesOffset, h.blobsSpanOffset + h.blobsCount * SPAN_SIZE); + }); + + test("odd-length text: 1-byte string gets 3 zero padding bytes", () => { + const b = createDrawlistBuilderV1(); + b.drawText(0, 0, "a"); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + const dv = toView(bytes); + const span = readStringSpan(dv, h.stringsSpanOffset, 0); + + assert.equal(span.off, 0); + assert.equal(span.len, 1); + assert.equal(h.stringsBytesLen, 4); + assert.equal(bytes[h.stringsBytesOffset], 0x61); + assert.equal(bytes[h.stringsBytesOffset + 1], 0); + assert.equal(bytes[h.stringsBytesOffset + 2], 0); + assert.equal(bytes[h.stringsBytesOffset + 3], 0); + }); + + test("odd-length text: 2-byte string gets 2 zero padding bytes", () => { + const b = createDrawlistBuilderV1(); + b.drawText(0, 0, "ab"); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + const dv = toView(bytes); + const span = readStringSpan(dv, h.stringsSpanOffset, 0); + + assert.equal(span.off, 0); + assert.equal(span.len, 2); + assert.equal(h.stringsBytesLen, 4); + assert.equal(bytes[h.stringsBytesOffset], 0x61); + assert.equal(bytes[h.stringsBytesOffset + 1], 0x62); + assert.equal(bytes[h.stringsBytesOffset + 2], 0); + assert.equal(bytes[h.stringsBytesOffset + 3], 0); + }); + + test("odd-length text: 3-byte string gets 1 zero padding byte", () => { + const b = createDrawlistBuilderV1(); + b.drawText(0, 0, "abc"); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + const dv = toView(bytes); + const span = readStringSpan(dv, h.stringsSpanOffset, 0); + + assert.equal(span.off, 0); + assert.equal(span.len, 3); + assert.equal(h.stringsBytesLen, 4); + assert.equal(bytes[h.stringsBytesOffset], 0x61); + assert.equal(bytes[h.stringsBytesOffset + 1], 0x62); + assert.equal(bytes[h.stringsBytesOffset + 2], 0x63); + assert.equal(bytes[h.stringsBytesOffset + 3], 0); + }); + + test("empty string still has aligned string section with zero raw bytes", () => { + const b = createDrawlistBuilderV1(); + b.drawText(0, 0, ""); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + const dv = toView(bytes); + const span = readStringSpan(dv, h.stringsSpanOffset, 0); + + assert.equal(h.stringsCount, 1); + assert.equal((h.stringsSpanOffset & 3) === 0, true); + assert.equal((h.stringsBytesOffset & 3) === 0, true); + assert.equal(h.stringsBytesOffset, h.stringsSpanOffset + SPAN_SIZE); + assert.equal(span.off, 0); + assert.equal(span.len, 0); + assert.equal(h.stringsBytesLen, 0); + }); + + test("multiple odd-length strings keep contiguous raw spans and aligned tail padding", () => { + const b = createDrawlistBuilderV1(); + b.drawText(0, 0, "a"); + b.drawText(0, 1, "bb"); + b.drawText(0, 2, "ccc"); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + const dv = toView(bytes); + const s0 = readStringSpan(dv, h.stringsSpanOffset, 0); + const s1 = readStringSpan(dv, h.stringsSpanOffset, 1); + const s2 = readStringSpan(dv, h.stringsSpanOffset, 2); + + assert.equal(s0.off, 0); + assert.equal(s0.len, 1); + assert.equal(s1.off, 1); + assert.equal(s1.len, 2); + assert.equal(s2.off, 3); + assert.equal(s2.len, 3); + + assert.equal(h.stringsBytesLen, 8); + assert.equal(bytes[h.stringsBytesOffset + 6], 0); + assert.equal(bytes[h.stringsBytesOffset + 7], 0); + }); + + test("reuseOutputBuffer keeps odd-string padding zeroed across reset/build cycles", () => { + const b = createDrawlistBuilderV1({ reuseOutputBuffer: true }); + + b.drawText(0, 0, "abcd"); + expectOk(b.build()); + + b.reset(); + b.drawText(0, 0, "a"); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + + assert.equal(h.stringsBytesLen, 4); + assert.equal(bytes[h.stringsBytesOffset], 0x61); + assert.equal(bytes[h.stringsBytesOffset + 1], 0); + assert.equal(bytes[h.stringsBytesOffset + 2], 0); + assert.equal(bytes[h.stringsBytesOffset + 3], 0); + }); +}); diff --git a/packages/core/src/drawlist/__tests__/builder.limits.test.ts b/packages/core/src/drawlist/__tests__/builder.limits.test.ts new file mode 100644 index 00000000..b8b4ddc8 --- /dev/null +++ b/packages/core/src/drawlist/__tests__/builder.limits.test.ts @@ -0,0 +1,238 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import { createDrawlistBuilderV1 } from "../builder_v1.js"; +import type { DrawlistBuildErrorCode, DrawlistBuildResult } from "../types.js"; + +const HEADER = { + TOTAL_SIZE: 12, + CMD_OFFSET: 16, + CMD_BYTES: 20, + CMD_COUNT: 24, + STRINGS_SPAN_OFFSET: 28, + STRINGS_COUNT: 32, + STRINGS_BYTES_OFFSET: 36, + STRINGS_BYTES_LEN: 40, + BLOBS_SPAN_OFFSET: 44, + BLOBS_COUNT: 48, + BLOBS_BYTES_OFFSET: 52, + BLOBS_BYTES_LEN: 56, + SIZE: 64, +} as const; + +const SPAN_SIZE = 8; +const CMD_SIZE_CLEAR = 8; +const CMD_SIZE_DRAW_TEXT = 8 + 40; + +type ParsedHeader = Readonly<{ + totalSize: number; + cmdOffset: number; + cmdBytes: number; + cmdCount: number; + stringsSpanOffset: number; + stringsCount: number; + stringsBytesOffset: number; + stringsBytesLen: number; + blobsSpanOffset: number; + blobsCount: number; + blobsBytesOffset: number; + blobsBytesLen: number; +}>; + +function align4(n: number): number { + return (n + 3) & ~3; +} + +function toView(bytes: Uint8Array): DataView { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); +} + +function parseHeader(bytes: Uint8Array): ParsedHeader { + const dv = toView(bytes); + return { + totalSize: dv.getUint32(HEADER.TOTAL_SIZE, true), + cmdOffset: dv.getUint32(HEADER.CMD_OFFSET, true), + cmdBytes: dv.getUint32(HEADER.CMD_BYTES, true), + cmdCount: dv.getUint32(HEADER.CMD_COUNT, true), + stringsSpanOffset: dv.getUint32(HEADER.STRINGS_SPAN_OFFSET, true), + stringsCount: dv.getUint32(HEADER.STRINGS_COUNT, true), + stringsBytesOffset: dv.getUint32(HEADER.STRINGS_BYTES_OFFSET, true), + stringsBytesLen: dv.getUint32(HEADER.STRINGS_BYTES_LEN, true), + blobsSpanOffset: dv.getUint32(HEADER.BLOBS_SPAN_OFFSET, true), + blobsCount: dv.getUint32(HEADER.BLOBS_COUNT, true), + blobsBytesOffset: dv.getUint32(HEADER.BLOBS_BYTES_OFFSET, true), + blobsBytesLen: dv.getUint32(HEADER.BLOBS_BYTES_LEN, true), + }; +} + +function expectOk(result: DrawlistBuildResult): Uint8Array { + assert.equal(result.ok, true); + if (!result.ok) throw new Error("expected build() to succeed"); + return result.bytes; +} + +function expectError(result: DrawlistBuildResult, code: DrawlistBuildErrorCode): void { + assert.equal(result.ok, false); + if (result.ok) throw new Error("expected build() to fail"); + assert.equal(result.error.code, code); +} + +describe("DrawlistBuilderV1 - limits boundaries", () => { + test("maxCmdCount: exactly at limit succeeds", () => { + const b = createDrawlistBuilderV1({ maxCmdCount: 2 }); + b.clear(); + b.clear(); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + + assert.equal(h.cmdCount, 2); + assert.equal(h.cmdBytes, 16); + }); + + test("maxCmdCount: overflow fails", () => { + const b = createDrawlistBuilderV1({ maxCmdCount: 2 }); + b.clear(); + b.clear(); + b.clear(); + + expectError(b.build(), "ZRDL_TOO_LARGE"); + }); + + test("maxStrings: exactly at limit with unique strings succeeds", () => { + const b = createDrawlistBuilderV1({ maxStrings: 2 }); + b.drawText(0, 0, "a"); + b.drawText(0, 1, "b"); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + + assert.equal(h.stringsCount, 2); + assert.equal(h.cmdCount, 2); + }); + + test("maxStrings: interned duplicates do not consume extra slots", () => { + const b = createDrawlistBuilderV1({ maxStrings: 1 }); + b.drawText(0, 0, "same"); + b.drawText(2, 0, "same"); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + + assert.equal(h.stringsCount, 1); + assert.equal(h.cmdCount, 2); + }); + + test("maxStrings: overflow on next unique string fails", () => { + const b = createDrawlistBuilderV1({ maxStrings: 1 }); + b.drawText(0, 0, "a"); + b.drawText(0, 1, "b"); + + expectError(b.build(), "ZRDL_TOO_LARGE"); + }); + + test("maxStringBytes: exactly-at-limit ASCII payload succeeds", () => { + const b = createDrawlistBuilderV1({ maxStringBytes: 3 }); + b.drawText(0, 0, "abc"); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + const dv = toView(bytes); + const spanLen = dv.getUint32(h.stringsSpanOffset + 4, true); + + assert.equal(h.stringsCount, 1); + assert.equal(spanLen, 3); + assert.equal(h.stringsBytesLen, 4); + }); + + test("maxStringBytes: exactly-at-limit UTF-8 payload succeeds", () => { + const text = "éa"; + const utf8Len = new TextEncoder().encode(text).byteLength; + const b = createDrawlistBuilderV1({ maxStringBytes: utf8Len }); + b.drawText(0, 0, text); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + const dv = toView(bytes); + const spanLen = dv.getUint32(h.stringsSpanOffset + 4, true); + + assert.equal(utf8Len, 3); + assert.equal(spanLen, utf8Len); + assert.equal(h.stringsBytesLen, 4); + }); + + test("maxStringBytes: overflow fails", () => { + const b = createDrawlistBuilderV1({ maxStringBytes: 3 }); + b.drawText(0, 0, "abcd"); + + expectError(b.build(), "ZRDL_TOO_LARGE"); + }); + + test("maxDrawlistBytes: exactly-at-limit clear-only payload succeeds", () => { + const exactLimit = HEADER.SIZE + CMD_SIZE_CLEAR; + const b = createDrawlistBuilderV1({ maxDrawlistBytes: exactLimit }); + b.clear(); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + + assert.equal(h.totalSize, exactLimit); + assert.equal(h.cmdBytes, CMD_SIZE_CLEAR); + }); + + test("maxDrawlistBytes: one byte below minimum clear payload fails", () => { + const b = createDrawlistBuilderV1({ maxDrawlistBytes: HEADER.SIZE + CMD_SIZE_CLEAR - 1 }); + b.clear(); + + expectError(b.build(), "ZRDL_TOO_LARGE"); + }); + + test("maxDrawlistBytes: exact text drawlist boundary succeeds", () => { + const textBytes = 3; + const exactLimit = HEADER.SIZE + CMD_SIZE_DRAW_TEXT + SPAN_SIZE + align4(textBytes); + const b = createDrawlistBuilderV1({ maxDrawlistBytes: exactLimit }); + b.drawText(0, 0, "abc"); + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + + assert.equal(h.totalSize, exactLimit); + assert.equal(h.cmdBytes, CMD_SIZE_DRAW_TEXT); + assert.equal(h.stringsBytesLen, 4); + }); + + test("maxDrawlistBytes: one byte below text drawlist boundary fails", () => { + const textBytes = 3; + const exactLimit = HEADER.SIZE + CMD_SIZE_DRAW_TEXT + SPAN_SIZE + align4(textBytes); + const b = createDrawlistBuilderV1({ maxDrawlistBytes: exactLimit - 1 }); + b.drawText(0, 0, "abc"); + + expectError(b.build(), "ZRDL_TOO_LARGE"); + }); + + test("zero limit values are rejected for each configured cap", () => { + const cases: readonly Readonly<{ opts: Parameters[0] }>[] = [ + { opts: { maxDrawlistBytes: 0 } }, + { opts: { maxCmdCount: 0 } }, + { opts: { maxStringBytes: 0 } }, + { opts: { maxStrings: 0 } }, + ]; + + for (const { opts } of cases) { + const b = createDrawlistBuilderV1(opts); + expectError(b.build(), "ZRDL_BAD_PARAMS"); + } + }); + + test("large-limit smoke: realistic batch stays within limits", () => { + const b = createDrawlistBuilderV1({ + maxDrawlistBytes: 1_000_000, + maxCmdCount: 10_000, + maxStringBytes: 100_000, + maxStrings: 10_000, + }); + + for (let i = 0; i < 64; i++) { + b.drawText(i, 0, `row-${i}`); + } + + const bytes = expectOk(b.build()); + const h = parseHeader(bytes); + + assert.equal(h.cmdCount, 64); + assert.equal(h.stringsCount, 64); + assert.equal(h.totalSize <= 1_000_000, true); + assert.equal((h.totalSize & 3) === 0, true); + }); +}); diff --git a/packages/core/src/drawlist/__tests__/builder.reset.test.ts b/packages/core/src/drawlist/__tests__/builder.reset.test.ts new file mode 100644 index 00000000..2b53c3ab --- /dev/null +++ b/packages/core/src/drawlist/__tests__/builder.reset.test.ts @@ -0,0 +1,281 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import { createDrawlistBuilderV1, createDrawlistBuilderV2 } from "../../index.js"; + +const HEADER_SIZE = 64; +const INT32_MAX = 2147483647; + +const OP_CLEAR = 1; +const OP_FILL_RECT = 2; +const OP_DRAW_TEXT = 3; +const OP_SET_CURSOR = 7; + +const decoder = new TextDecoder(); + +type Header = Readonly<{ + totalSize: number; + cmdOffset: number; + cmdBytes: number; + cmdCount: number; + stringsSpanOffset: number; + stringsCount: number; + stringsBytesOffset: number; + stringsBytesLen: number; + blobsSpanOffset: number; + blobsCount: number; + blobsBytesOffset: number; + blobsBytesLen: number; +}>; + +type CmdHeader = Readonly<{ + off: number; + opcode: number; + flags: number; + size: number; + payloadOff: number; +}>; + +function u16(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint16(off, true); +} + +function u32(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint32(off, true); +} + +function i32(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getInt32(off, true); +} + +function align4(n: number): number { + return (n + 3) & ~3; +} + +function readHeader(bytes: Uint8Array): Header { + return { + totalSize: u32(bytes, 12), + cmdOffset: u32(bytes, 16), + cmdBytes: u32(bytes, 20), + cmdCount: u32(bytes, 24), + stringsSpanOffset: u32(bytes, 28), + stringsCount: u32(bytes, 32), + stringsBytesOffset: u32(bytes, 36), + stringsBytesLen: u32(bytes, 40), + blobsSpanOffset: u32(bytes, 44), + blobsCount: u32(bytes, 48), + blobsBytesOffset: u32(bytes, 52), + blobsBytesLen: u32(bytes, 56), + }; +} + +function parseCommands(bytes: Uint8Array): readonly CmdHeader[] { + const h = readHeader(bytes); + if (h.cmdCount === 0) return []; + + const out: CmdHeader[] = []; + let off = h.cmdOffset; + for (let i = 0; i < h.cmdCount; i++) { + const size = u32(bytes, off + 4); + out.push({ + off, + opcode: u16(bytes, off), + flags: u16(bytes, off + 2), + size, + payloadOff: off + 8, + }); + off += size; + } + assert.equal(off, h.cmdOffset + h.cmdBytes); + return out; +} + +function decodeString(bytes: Uint8Array, h: Header, stringIndex: number): string { + const spanOff = h.stringsSpanOffset + stringIndex * 8; + const off = u32(bytes, spanOff); + const len = u32(bytes, spanOff + 4); + const start = h.stringsBytesOffset + off; + return decoder.decode(bytes.subarray(start, start + len)); +} + +describe("DrawlistBuilder reset behavior", () => { + test("v1 reset clears prior commands/strings/blobs for next frame", () => { + const b = createDrawlistBuilderV1(); + const blobIndex = b.addTextRunBlob([{ text: "A" }, { text: "B" }]); + assert.equal(blobIndex, 0); + if (blobIndex === null) return; + + b.drawTextRun(1, 2, blobIndex); + b.drawText(0, 0, "frame0"); + const first = b.build(); + assert.equal(first.ok, true); + if (!first.ok) return; + + const h1 = readHeader(first.bytes); + assert.equal(h1.cmdCount, 2); + assert.equal(h1.stringsCount, 3); + assert.equal(h1.blobsCount, 1); + + b.reset(); + b.clear(); + const second = b.build(); + assert.equal(second.ok, true); + if (!second.ok) return; + + const h2 = readHeader(second.bytes); + assert.equal(h2.cmdCount, 1); + assert.equal(h2.cmdBytes, 8); + assert.equal(h2.stringsCount, 0); + assert.equal(h2.blobsCount, 0); + assert.equal(h2.totalSize, 72); + }); + + test("v2 reset drops cursor and string state before next frame", () => { + const b = createDrawlistBuilderV2(); + b.setCursor({ x: 12, y: 4, shape: 1, visible: true, blink: false }); + b.drawText(0, 0, "persist"); + const first = b.build(); + assert.equal(first.ok, true); + if (!first.ok) return; + + const h1 = readHeader(first.bytes); + assert.equal(h1.cmdCount, 2); + assert.equal(h1.stringsCount, 1); + + b.reset(); + b.fillRect(0, 0, 1, 1); + const second = b.build(); + assert.equal(second.ok, true); + if (!second.ok) return; + + const h2 = readHeader(second.bytes); + assert.equal(h2.cmdCount, 1); + assert.equal(h2.stringsCount, 0); + assert.equal(h2.blobsCount, 0); + + const cmds = parseCommands(second.bytes); + const cmd = cmds[0]; + if (!cmd) return; + assert.equal(cmd.opcode, OP_FILL_RECT); + }); + + test("v1 reset clears sticky failure state and restores successful builds", () => { + const b = createDrawlistBuilderV1({ maxStrings: 1 }); + b.drawText(0, 0, "a"); + b.drawText(0, 1, "b"); + const failed = b.build(); + assert.equal(failed.ok, false); + if (failed.ok) return; + assert.equal(failed.error.code, "ZRDL_TOO_LARGE"); + + b.reset(); + b.drawText(0, 0, "ok"); + const recovered = b.build(); + assert.equal(recovered.ok, true); + if (!recovered.ok) return; + assert.equal(readHeader(recovered.bytes).stringsCount, 1); + }); + + test("v2 reset clears sticky failure state and allows cursor commands again", () => { + const b = createDrawlistBuilderV2({ maxCmdCount: 1 }); + b.setCursor({ x: 1, y: 1, shape: 0, visible: true, blink: true }); + b.setCursor({ x: 2, y: 2, shape: 1, visible: true, blink: false }); + const failed = b.build(); + assert.equal(failed.ok, false); + if (failed.ok) return; + assert.equal(failed.error.code, "ZRDL_TOO_LARGE"); + + b.reset(); + b.setCursor({ x: 3, y: 4, shape: 2, visible: false, blink: true }); + const recovered = b.build(); + assert.equal(recovered.ok, true); + if (!recovered.ok) return; + + const cmd = parseCommands(recovered.bytes)[0]; + if (!cmd) return; + assert.equal(cmd.opcode, OP_SET_CURSOR); + assert.equal(i32(recovered.bytes, cmd.payloadOff + 0), 3); + assert.equal(i32(recovered.bytes, cmd.payloadOff + 4), 4); + }); + + test("v1 reset reuse remains stable across many frames", () => { + const b = createDrawlistBuilderV1(); + + for (let frame = 0; frame < 128; frame++) { + const text = `f${frame}`; + b.reset(); + b.clear(); + b.drawText(frame % 7, frame % 5, text, { bold: (frame & 1) === 1 }); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const h = readHeader(res.bytes); + assert.equal(h.cmdCount, 2); + assert.equal(h.cmdBytes, 56); + assert.equal(h.stringsCount, 1); + assert.equal(h.blobsCount, 0); + assert.equal(h.stringsBytesLen, align4(text.length)); + assert.equal(h.totalSize, HEADER_SIZE + 56 + 8 + align4(text.length)); + + const cmds = parseCommands(res.bytes); + const clear = cmds[0]; + const draw = cmds[1]; + if (!clear || !draw) return; + assert.equal(clear.opcode, OP_CLEAR); + assert.equal(draw.opcode, OP_DRAW_TEXT); + assert.equal(draw.flags, 0); + assert.equal(draw.size, 48); + + const stringIndex = u32(res.bytes, draw.payloadOff + 8); + const byteLen = u32(res.bytes, draw.payloadOff + 16); + assert.equal(stringIndex, 0); + assert.equal(byteLen, text.length); + assert.equal(decodeString(res.bytes, h, 0), text); + } + }); + + test("v2 reset reuse across many frames keeps cursor correctness stable", () => { + const b = createDrawlistBuilderV2(); + + for (let frame = 0; frame < 128; frame++) { + const origin = frame % 2 === 0; + const x = origin ? 0 : INT32_MAX - frame; + const y = origin ? 0 : INT32_MAX - frame; + const shape = origin ? 0 : 2; + const blink = origin; + + b.reset(); + b.clear(); + b.setCursor({ x, y, shape, visible: true, blink }); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const h = readHeader(res.bytes); + assert.equal(h.cmdCount, 2); + assert.equal(h.cmdBytes, 28); + assert.equal(h.stringsCount, 0); + assert.equal(h.blobsCount, 0); + assert.equal(h.totalSize, 92); + + const cmds = parseCommands(res.bytes); + const clear = cmds[0]; + const cursor = cmds[1]; + if (!clear || !cursor) return; + + assert.equal(clear.opcode, OP_CLEAR); + assert.equal(cursor.opcode, OP_SET_CURSOR); + assert.equal(cursor.size, 20); + assert.equal(i32(res.bytes, cursor.payloadOff + 0), x); + assert.equal(i32(res.bytes, cursor.payloadOff + 4), y); + assert.equal(res.bytes[cursor.payloadOff + 8], shape); + assert.equal(res.bytes[cursor.payloadOff + 9], 1); + assert.equal(res.bytes[cursor.payloadOff + 10], blink ? 1 : 0); + assert.equal(res.bytes[cursor.payloadOff + 11], 0); + } + }); +}); diff --git a/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts b/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts new file mode 100644 index 00000000..4d82730d --- /dev/null +++ b/packages/core/src/drawlist/__tests__/builder.round-trip.test.ts @@ -0,0 +1,587 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import { + ZRDL_MAGIC, + ZR_DRAWLIST_VERSION_V1, + ZR_DRAWLIST_VERSION_V2, + createDrawlistBuilderV1, + createDrawlistBuilderV2, +} from "../../index.js"; + +const HEADER_SIZE = 64; +const INT32_MAX = 2147483647; + +const OP_CLEAR = 1; +const OP_FILL_RECT = 2; +const OP_DRAW_TEXT = 3; +const OP_PUSH_CLIP = 4; +const OP_POP_CLIP = 5; +const OP_DRAW_TEXT_RUN = 6; +const OP_SET_CURSOR = 7; + +const decoder = new TextDecoder(); + +type Header = Readonly<{ + magic: number; + version: number; + headerSize: number; + totalSize: number; + cmdOffset: number; + cmdBytes: number; + cmdCount: number; + stringsSpanOffset: number; + stringsCount: number; + stringsBytesOffset: number; + stringsBytesLen: number; + blobsSpanOffset: number; + blobsCount: number; + blobsBytesOffset: number; + blobsBytesLen: number; + reserved0: number; +}>; + +type CmdHeader = Readonly<{ + off: number; + opcode: number; + flags: number; + size: number; + payloadOff: number; +}>; + +type PackedStyle = Readonly<{ fg: number; bg: number; attrs: number; reserved0: number }>; + +function u8(bytes: Uint8Array, off: number): number { + return bytes[off] ?? 0; +} + +function u16(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint16(off, true); +} + +function u32(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint32(off, true); +} + +function i32(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getInt32(off, true); +} + +function align4(n: number): number { + return (n + 3) & ~3; +} + +function readHeader(bytes: Uint8Array): Header { + return { + magic: u32(bytes, 0), + version: u32(bytes, 4), + headerSize: u32(bytes, 8), + totalSize: u32(bytes, 12), + cmdOffset: u32(bytes, 16), + cmdBytes: u32(bytes, 20), + cmdCount: u32(bytes, 24), + stringsSpanOffset: u32(bytes, 28), + stringsCount: u32(bytes, 32), + stringsBytesOffset: u32(bytes, 36), + stringsBytesLen: u32(bytes, 40), + blobsSpanOffset: u32(bytes, 44), + blobsCount: u32(bytes, 48), + blobsBytesOffset: u32(bytes, 52), + blobsBytesLen: u32(bytes, 56), + reserved0: u32(bytes, 60), + }; +} + +function assertAligned4(label: string, value: number): void { + assert.equal(value % 4, 0, `${label} must be 4-byte aligned`); +} + +function assertHeaderLayout(bytes: Uint8Array, h: Header): void { + assert.equal(h.headerSize, HEADER_SIZE); + assert.equal(h.totalSize, bytes.byteLength); + assert.equal(h.reserved0, 0); + + let cursor = HEADER_SIZE; + + if (h.cmdCount === 0) { + assert.equal(h.cmdOffset, 0); + assert.equal(h.cmdBytes, 0); + } else { + assert.equal(h.cmdOffset, cursor); + assertAligned4("cmdOffset", h.cmdOffset); + assertAligned4("cmdBytes", h.cmdBytes); + cursor += h.cmdBytes; + } + + if (h.stringsCount === 0) { + assert.equal(h.stringsSpanOffset, 0); + assert.equal(h.stringsBytesOffset, 0); + assert.equal(h.stringsBytesLen, 0); + } else { + assert.equal(h.stringsSpanOffset, cursor); + assertAligned4("stringsSpanOffset", h.stringsSpanOffset); + cursor += h.stringsCount * 8; + + assert.equal(h.stringsBytesOffset, cursor); + assertAligned4("stringsBytesOffset", h.stringsBytesOffset); + assertAligned4("stringsBytesLen", h.stringsBytesLen); + cursor += h.stringsBytesLen; + } + + if (h.blobsCount === 0) { + assert.equal(h.blobsSpanOffset, 0); + assert.equal(h.blobsBytesOffset, 0); + assert.equal(h.blobsBytesLen, 0); + } else { + assert.equal(h.blobsSpanOffset, cursor); + assertAligned4("blobsSpanOffset", h.blobsSpanOffset); + cursor += h.blobsCount * 8; + + assert.equal(h.blobsBytesOffset, cursor); + assertAligned4("blobsBytesOffset", h.blobsBytesOffset); + assertAligned4("blobsBytesLen", h.blobsBytesLen); + cursor += h.blobsBytesLen; + } + + assert.equal(cursor, h.totalSize); +} + +function parseCommands(bytes: Uint8Array): readonly CmdHeader[] { + const h = readHeader(bytes); + if (h.cmdCount === 0) return []; + + const out: CmdHeader[] = []; + let off = h.cmdOffset; + + for (let i = 0; i < h.cmdCount; i++) { + const size = u32(bytes, off + 4); + out.push({ + off, + opcode: u16(bytes, off), + flags: u16(bytes, off + 2), + size, + payloadOff: off + 8, + }); + off += size; + } + + assert.equal(off, h.cmdOffset + h.cmdBytes); + return out; +} + +function readStyle(bytes: Uint8Array, off: number): PackedStyle { + return { + fg: u32(bytes, off), + bg: u32(bytes, off + 4), + attrs: u32(bytes, off + 8), + reserved0: u32(bytes, off + 12), + }; +} + +function decodeStringSlice( + bytes: Uint8Array, + h: Header, + stringIndex: number, + byteOff: number, + byteLen: number, +): string { + const spanOff = h.stringsSpanOffset + stringIndex * 8; + const strOff = u32(bytes, spanOff); + const strLen = u32(bytes, spanOff + 4); + assert.equal(byteOff + byteLen <= strLen, true); + + const start = h.stringsBytesOffset + strOff + byteOff; + return decoder.decode(bytes.subarray(start, start + byteLen)); +} + +function readSetCursorCommand(bytes: Uint8Array, cmd: CmdHeader) { + assert.equal(cmd.opcode, OP_SET_CURSOR); + assert.equal(cmd.size, 20); + return { + x: i32(bytes, cmd.payloadOff), + y: i32(bytes, cmd.payloadOff + 4), + shape: u8(bytes, cmd.payloadOff + 8), + visible: u8(bytes, cmd.payloadOff + 9), + blink: u8(bytes, cmd.payloadOff + 10), + reserved0: u8(bytes, cmd.payloadOff + 11), + }; +} + +function simulateV1CommandReader( + bytes: Uint8Array, +): Readonly<{ ok: true } | { ok: false; unsupportedOpcode: number }> { + const cmds = parseCommands(bytes); + for (const cmd of cmds) { + switch (cmd.opcode) { + case OP_CLEAR: + case OP_FILL_RECT: + case OP_DRAW_TEXT: + case OP_PUSH_CLIP: + case OP_POP_CLIP: + case OP_DRAW_TEXT_RUN: + break; + default: + return { ok: false, unsupportedOpcode: cmd.opcode }; + } + } + return { ok: true }; +} + +describe("DrawlistBuilder round-trip binary readback", () => { + test("v1 header magic/version/counts/offsets/byte sizes are exact for mixed commands", () => { + const b = createDrawlistBuilderV1(); + b.clear(); + b.fillRect(1, 2, 3, 4, { + fg: { r: 0x11, g: 0x22, b: 0x33 }, + bg: { r: 0x44, g: 0x55, b: 0x66 }, + bold: true, + italic: true, + }); + b.pushClip(0, 0, 10, 10); + b.drawText(7, 8, "hey", { underline: true, dim: true }); + b.popClip(); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const h = readHeader(res.bytes); + assert.equal(h.magic, ZRDL_MAGIC); + assert.equal(h.version, ZR_DRAWLIST_VERSION_V1); + assert.equal(h.cmdOffset, 64); + assert.equal(h.cmdBytes, 128); + assert.equal(h.cmdCount, 5); + assert.equal(h.stringsSpanOffset, 192); + assert.equal(h.stringsCount, 1); + assert.equal(h.stringsBytesOffset, 200); + assert.equal(h.stringsBytesLen, 4); + assert.equal(h.blobsSpanOffset, 0); + assert.equal(h.blobsCount, 0); + assert.equal(h.blobsBytesOffset, 0); + assert.equal(h.blobsBytesLen, 0); + assert.equal(h.totalSize, 204); + + assertHeaderLayout(res.bytes, h); + }); + + test("v1 fillRect command readback preserves geometry and packed style", () => { + const b = createDrawlistBuilderV1(); + b.fillRect(-3, 9, 11, 13, { + fg: { r: 1, g: 2, b: 3 }, + bg: { r: 4, g: 5, b: 6 }, + bold: true, + underline: true, + dim: true, + }); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const cmds = parseCommands(res.bytes); + assert.equal(cmds.length, 1); + const cmd = cmds[0]; + if (!cmd) return; + + assert.equal(cmd.opcode, OP_FILL_RECT); + assert.equal(cmd.flags, 0); + assert.equal(cmd.size, 40); + assert.equal(i32(res.bytes, cmd.payloadOff + 0), -3); + assert.equal(i32(res.bytes, cmd.payloadOff + 4), 9); + assert.equal(i32(res.bytes, cmd.payloadOff + 8), 11); + assert.equal(i32(res.bytes, cmd.payloadOff + 12), 13); + + const style = readStyle(res.bytes, cmd.payloadOff + 16); + assert.equal(style.fg, 0x0001_0203); + assert.equal(style.bg, 0x0004_0506); + assert.equal(style.attrs, (1 << 0) | (1 << 2) | (1 << 4)); + assert.equal(style.reserved0, 0); + }); + + test("v1 drawText command readback resolves string span and style fields", () => { + const b = createDrawlistBuilderV1(); + b.drawText(7, 9, "hello", { + fg: { r: 255, g: 128, b: 1 }, + bg: { r: 2, g: 3, b: 4 }, + italic: true, + inverse: true, + }); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const h = readHeader(res.bytes); + const cmds = parseCommands(res.bytes); + assert.equal(cmds.length, 1); + + const cmd = cmds[0]; + if (!cmd) return; + assert.equal(cmd.opcode, OP_DRAW_TEXT); + assert.equal(cmd.size, 48); + + const x = i32(res.bytes, cmd.payloadOff + 0); + const y = i32(res.bytes, cmd.payloadOff + 4); + const stringIndex = u32(res.bytes, cmd.payloadOff + 8); + const byteOff = u32(res.bytes, cmd.payloadOff + 12); + const byteLen = u32(res.bytes, cmd.payloadOff + 16); + const style = readStyle(res.bytes, cmd.payloadOff + 20); + const reserved0 = u32(res.bytes, cmd.payloadOff + 36); + + assert.equal(x, 7); + assert.equal(y, 9); + assert.equal(stringIndex, 0); + assert.equal(byteOff, 0); + assert.equal(byteLen, 5); + assert.equal(style.fg, 0x00ff_8001); + assert.equal(style.bg, 0x0002_0304); + assert.equal(style.attrs, (1 << 1) | (1 << 3)); + assert.equal(style.reserved0, 0); + assert.equal(reserved0, 0); + assert.equal(decodeStringSlice(res.bytes, h, stringIndex, byteOff, byteLen), "hello"); + }); + + test("v1 clip push/pop commands round-trip with exact payload sizes", () => { + const b = createDrawlistBuilderV1(); + b.pushClip(2, 3, 4, 5); + b.popClip(); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const cmds = parseCommands(res.bytes); + assert.equal(cmds.length, 2); + const push = cmds[0]; + const pop = cmds[1]; + if (!push || !pop) return; + + assert.equal(push.opcode, OP_PUSH_CLIP); + assert.equal(push.size, 24); + assert.equal(i32(res.bytes, push.payloadOff + 0), 2); + assert.equal(i32(res.bytes, push.payloadOff + 4), 3); + assert.equal(i32(res.bytes, push.payloadOff + 8), 4); + assert.equal(i32(res.bytes, push.payloadOff + 12), 5); + + assert.equal(pop.opcode, OP_POP_CLIP); + assert.equal(pop.size, 8); + }); + + test("v1 repeated text uses interned string indices deterministically", () => { + const b = createDrawlistBuilderV1(); + b.drawText(0, 0, "same"); + b.drawText(0, 1, "same"); + b.drawText(0, 2, "other"); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const h = readHeader(res.bytes); + assert.equal(h.stringsCount, 2); + assert.equal(h.cmdCount, 3); + assert.equal(h.cmdBytes, 144); + assert.equal(h.stringsSpanOffset, 208); + assert.equal(h.stringsBytesOffset, 224); + assert.equal(h.stringsBytesLen, 12); + assert.equal(h.totalSize, 236); + assertHeaderLayout(res.bytes, h); + + const cmds = parseCommands(res.bytes); + const c0 = cmds[0]; + const c1 = cmds[1]; + const c2 = cmds[2]; + if (!c0 || !c1 || !c2) return; + + const idx0 = u32(res.bytes, c0.payloadOff + 8); + const idx1 = u32(res.bytes, c1.payloadOff + 8); + const idx2 = u32(res.bytes, c2.payloadOff + 8); + assert.equal(idx0, 0); + assert.equal(idx1, 0); + assert.equal(idx2, 1); + assert.equal(decodeStringSlice(res.bytes, h, idx0, 0, 4), "same"); + assert.equal(decodeStringSlice(res.bytes, h, idx2, 0, 5), "other"); + }); + + test("v2 header uses version 2 and correct cmd byte/count totals", () => { + const b = createDrawlistBuilderV2(); + b.clear(); + b.setCursor({ x: 10, y: 5, shape: 1, visible: true, blink: false }); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const h = readHeader(res.bytes); + assert.equal(h.magic, ZRDL_MAGIC); + assert.equal(h.version, ZR_DRAWLIST_VERSION_V2); + assert.equal(h.cmdOffset, 64); + assert.equal(h.cmdBytes, 28); + assert.equal(h.cmdCount, 2); + assert.equal(h.totalSize, 92); + assertHeaderLayout(res.bytes, h); + }); + + test("v2 setCursor readback preserves payload fields and reserved byte", () => { + const b = createDrawlistBuilderV2(); + b.setCursor({ x: -1, y: 123, shape: 2, visible: false, blink: true }); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const cmds = parseCommands(res.bytes); + assert.equal(cmds.length, 1); + const cmd = cmds[0]; + if (!cmd) return; + const cursor = readSetCursorCommand(res.bytes, cmd); + + assert.equal(cursor.x, -1); + assert.equal(cursor.y, 123); + assert.equal(cursor.shape, 2); + assert.equal(cursor.visible, 0); + assert.equal(cursor.blink, 1); + assert.equal(cursor.reserved0, 0); + }); + + test("v2 multiple cursor commands are emitted in-order", () => { + const b = createDrawlistBuilderV2(); + b.setCursor({ x: 1, y: 2, shape: 0, visible: true, blink: true }); + b.setCursor({ x: 3, y: 4, shape: 1, visible: true, blink: false }); + b.hideCursor(); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const h = readHeader(res.bytes); + assert.equal(h.cmdCount, 3); + assert.equal(h.cmdBytes, 60); + + const cmds = parseCommands(res.bytes); + const c0 = cmds[0]; + const c1 = cmds[1]; + const c2 = cmds[2]; + if (!c0 || !c1 || !c2) return; + + const s0 = readSetCursorCommand(res.bytes, c0); + const s1 = readSetCursorCommand(res.bytes, c1); + const s2 = readSetCursorCommand(res.bytes, c2); + + assert.equal(s0.x, 1); + assert.equal(s0.y, 2); + assert.equal(s0.shape, 0); + assert.equal(s0.visible, 1); + assert.equal(s0.blink, 1); + + assert.equal(s1.x, 3); + assert.equal(s1.y, 4); + assert.equal(s1.shape, 1); + assert.equal(s1.visible, 1); + assert.equal(s1.blink, 0); + + assert.equal(s2.x, -1); + assert.equal(s2.y, -1); + assert.equal(s2.shape, 0); + assert.equal(s2.visible, 0); + assert.equal(s2.blink, 0); + }); + + test("v2 cursor edge position (0,0) round-trips exactly", () => { + const b = createDrawlistBuilderV2(); + b.setCursor({ x: 0, y: 0, shape: 0, visible: true, blink: true }); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const cmd = parseCommands(res.bytes)[0]; + if (!cmd) return; + const cursor = readSetCursorCommand(res.bytes, cmd); + assert.equal(cursor.x, 0); + assert.equal(cursor.y, 0); + assert.equal(cursor.shape, 0); + assert.equal(cursor.visible, 1); + assert.equal(cursor.blink, 1); + }); + + test("v2 cursor edge position (large int32) round-trips exactly", () => { + const b = createDrawlistBuilderV2(); + b.setCursor({ x: INT32_MAX, y: INT32_MAX, shape: 2, visible: true, blink: false }); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const cmd = parseCommands(res.bytes)[0]; + if (!cmd) return; + const cursor = readSetCursorCommand(res.bytes, cmd); + assert.equal(cursor.x, INT32_MAX); + assert.equal(cursor.y, INT32_MAX); + assert.equal(cursor.shape, 2); + assert.equal(cursor.visible, 1); + assert.equal(cursor.blink, 0); + }); + + test("backward-compat expectation: v1 command reader accepts v1 opcode set in v2 frame", () => { + const b = createDrawlistBuilderV2(); + b.clear(); + b.fillRect(0, 0, 5, 6); + b.drawText(2, 3, "compat"); + b.pushClip(0, 0, 10, 10); + b.popClip(); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const legacy = simulateV1CommandReader(res.bytes); + assert.equal(legacy.ok, true); + }); + + test("backward-compat expectation: v1 command reader rejects SET_CURSOR opcode", () => { + const b = createDrawlistBuilderV2(); + b.clear(); + b.setCursor({ x: 2, y: 2, shape: 0, visible: true, blink: true }); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const legacy = simulateV1CommandReader(res.bytes); + assert.equal(legacy.ok, false); + if (legacy.ok) return; + assert.equal(legacy.unsupportedOpcode, OP_SET_CURSOR); + }); + + test("v2 mixed frame keeps aligned sections and expected total byte size", () => { + const b = createDrawlistBuilderV2(); + b.clear(); + b.pushClip(0, 0, 80, 24); + b.fillRect(1, 1, 5, 2, { bg: { r: 7, g: 8, b: 9 }, inverse: true }); + b.drawText(2, 2, "rt"); + b.setCursor({ x: 2, y: 2, shape: 1, visible: true, blink: false }); + b.popClip(); + + const res = b.build(); + assert.equal(res.ok, true); + if (!res.ok) return; + + const h = readHeader(res.bytes); + assert.equal(h.version, ZR_DRAWLIST_VERSION_V2); + assert.equal(h.cmdCount, 6); + assert.equal( + h.cmdBytes, + 8 + // clear + 24 + // push clip + 40 + // fill rect + 48 + // draw text + 20 + // set cursor + 8, // pop clip + ); + assert.equal(h.stringsCount, 1); + assert.equal(h.stringsBytesLen, align4(2)); + assertHeaderLayout(res.bytes, h); + }); +}); diff --git a/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts b/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts new file mode 100644 index 00000000..85236acf --- /dev/null +++ b/packages/core/src/drawlist/__tests__/builder.string-cache.test.ts @@ -0,0 +1,384 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import { createDrawlistBuilderV1, createDrawlistBuilderV2 } from "../../index.js"; + +const OP_DRAW_TEXT = 3; + +type BuildResult = + | Readonly<{ ok: true; bytes: Uint8Array }> + | Readonly<{ ok: false; error: Readonly<{ code: string; detail: string }> }>; + +type BuilderLike = Readonly<{ + drawText(x: number, y: number, text: string, style?: unknown): void; + build(): BuildResult; + reset(): void; +}>; + +type BuilderOpts = Readonly<{ + encodedStringCacheCap?: number; +}>; + +const FACTORIES: readonly Readonly<{ + name: string; + create(opts?: BuilderOpts): BuilderLike; +}>[] = [ + { name: "v1", create: (opts?: BuilderOpts) => createDrawlistBuilderV1(opts) }, + { name: "v2", create: (opts?: BuilderOpts) => createDrawlistBuilderV2(opts) }, +]; + +function u16(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint16(off, true); +} + +function u32(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint32(off, true); +} + +type DrawTextEntry = Readonly<{ stringIndex: number; byteLen: number }>; + +function readDrawTextEntries(bytes: Uint8Array): DrawTextEntry[] { + const cmdOffset = u32(bytes, 16); + const cmdBytes = u32(bytes, 20); + const cmdCount = u32(bytes, 24); + const out: DrawTextEntry[] = []; + + let off = cmdOffset; + for (let i = 0; i < cmdCount; i++) { + const opcode = u16(bytes, off + 0); + const size = u32(bytes, off + 4); + if (opcode === OP_DRAW_TEXT) { + out.push({ + stringIndex: u32(bytes, off + 16), + byteLen: u32(bytes, off + 24), + }); + } + off += size; + } + + assert.equal(off, cmdOffset + cmdBytes, "command stream should end at cmdOffset + cmdBytes"); + return out; +} + +function readInternedStrings(bytes: Uint8Array): string[] { + const spanOffset = u32(bytes, 28); + const count = u32(bytes, 32); + const stringsBytesOffset = u32(bytes, 36); + const decoder = new TextDecoder(); + + const out: string[] = []; + for (let i = 0; i < count; i++) { + const off = u32(bytes, spanOffset + i * 8 + 0); + const len = u32(bytes, spanOffset + i * 8 + 4); + out.push( + decoder.decode(bytes.subarray(stringsBytesOffset + off, stringsBytesOffset + off + len)), + ); + } + return out; +} + +function buildOk(builder: BuilderLike, label: string): Uint8Array { + const res = builder.build(); + if (!res.ok) { + throw new Error(`${label}: build should succeed (${res.error.code}: ${res.error.detail})`); + } + return res.bytes; +} + +function encodeCallCount(calls: readonly string[], value: string): number { + let count = 0; + for (const call of calls) { + if (call === value) count++; + } + return count; +} + +function withTextEncoderSpy(run: (calls: string[]) => T): T { + const OriginalTextEncoder = globalThis.TextEncoder; + assert.equal(typeof OriginalTextEncoder, "function", "TextEncoder should exist in the test runtime"); + + const calls: string[] = []; + class SpyTextEncoder { + private readonly encoder = new OriginalTextEncoder(); + + encode(input: string): Uint8Array { + calls.push(input); + return this.encoder.encode(input); + } + } + + (globalThis as { TextEncoder: typeof TextEncoder }).TextEncoder = + SpyTextEncoder as unknown as typeof TextEncoder; + + try { + return run(calls); + } finally { + (globalThis as { TextEncoder: typeof TextEncoder }).TextEncoder = OriginalTextEncoder; + } +} + +describe("drawlist encoded string cache", () => { + test("cap=0 fallback re-encodes the same string every frame", () => { + const text = "cache-é"; + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 0 }); + + b.drawText(0, 0, text); + buildOk(b, `${factory.name} frame 1`); + b.reset(); + + b.drawText(0, 0, text); + buildOk(b, `${factory.name} frame 2`); + b.reset(); + + b.drawText(0, 0, text); + buildOk(b, `${factory.name} frame 3`); + + assert.equal( + encodeCallCount(calls, text), + 3, + `${factory.name}: cap=0 should not cache encoded bytes`, + ); + }); + } + }); + + test("cap>0 cache hit avoids re-encode across frames", () => { + const text = "hit-é"; + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 8 }); + + b.drawText(0, 0, text); + buildOk(b, `${factory.name} frame 1`); + b.reset(); + + b.drawText(0, 0, text); + buildOk(b, `${factory.name} frame 2`); + b.reset(); + + b.drawText(0, 0, text); + buildOk(b, `${factory.name} frame 3`); + + assert.equal( + encodeCallCount(calls, text), + 1, + `${factory.name}: cached string should encode once`, + ); + }); + } + }); + + test("cache hit still produces correct command byte_len and decoded string data", () => { + const text = "roundtrip 😀 e\u0301 漢字"; + const expectedLen = new TextEncoder().encode(text).byteLength; + + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 4 }); + + b.drawText(0, 0, text); + const frame1 = buildOk(b, `${factory.name} frame 1`); + b.reset(); + + b.drawText(0, 0, text); + const frame2 = buildOk(b, `${factory.name} frame 2`); + + const f1Entry = readDrawTextEntries(frame1)[0]; + const f2Entry = readDrawTextEntries(frame2)[0]; + + assert.equal(f1Entry?.byteLen, expectedLen, `${factory.name}: frame1 byte_len`); + assert.equal(f2Entry?.byteLen, expectedLen, `${factory.name}: frame2 byte_len`); + assert.deepEqual(readInternedStrings(frame1), [text], `${factory.name}: frame1 decode`); + assert.deepEqual(readInternedStrings(frame2), [text], `${factory.name}: frame2 decode`); + assert.equal(encodeCallCount(calls, text), 1, `${factory.name}: one encode with hit`); + }); + } + }); + + test("eviction at capacity=1 causes prior entry misses", () => { + const a = "A-é"; + const bText = "B-é"; + + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 1 }); + + b.drawText(0, 0, a); + buildOk(b, `${factory.name} frame A1`); + b.reset(); + + b.drawText(0, 0, bText); + buildOk(b, `${factory.name} frame B`); + b.reset(); + + b.drawText(0, 0, a); + buildOk(b, `${factory.name} frame A2`); + + assert.equal(encodeCallCount(calls, a), 2, `${factory.name}: A re-encoded after eviction`); + assert.equal(encodeCallCount(calls, bText), 1, `${factory.name}: B encoded once`); + }); + } + }); + + test("capacity=2 keeps existing entries until a third unique string is inserted", () => { + const a = "a-é"; + const bText = "b-é"; + + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 2 }); + + b.drawText(0, 0, a); + buildOk(b, `${factory.name} frame a`); + b.reset(); + + b.drawText(0, 0, bText); + buildOk(b, `${factory.name} frame b`); + b.reset(); + + b.drawText(0, 0, a); + buildOk(b, `${factory.name} frame a hit`); + + assert.equal(encodeCallCount(calls, a), 1, `${factory.name}: A should hit cache`); + assert.equal(encodeCallCount(calls, bText), 1, `${factory.name}: B encoded once`); + }); + } + }); + + test("capacity=2 insertion of third unique string clears cache and evicts old entries", () => { + const a = "aa-é"; + const bText = "bb-é"; + const c = "cc-é"; + + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 2 }); + + b.drawText(0, 0, a); + b.drawText(0, 1, bText); + buildOk(b, `${factory.name} frame ab`); + b.reset(); + + b.drawText(0, 0, c); + buildOk(b, `${factory.name} frame c`); + b.reset(); + + b.drawText(0, 0, bText); + buildOk(b, `${factory.name} frame b again`); + + assert.equal(encodeCallCount(calls, a), 1, `${factory.name}: A encoded only in first frame`); + assert.equal(encodeCallCount(calls, c), 1, `${factory.name}: C encoded once`); + assert.equal(encodeCallCount(calls, bText), 2, `${factory.name}: B should miss after clear`); + }); + } + }); + + test("post-eviction re-encode still emits correct UTF-8 bytes", () => { + const a = "post-evict 😀 e\u0301"; + const bText = "other-é"; + const expectedLen = new TextEncoder().encode(a).byteLength; + + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 1 }); + + b.drawText(0, 0, a); + buildOk(b, `${factory.name} frame a1`); + b.reset(); + + b.drawText(0, 0, bText); + buildOk(b, `${factory.name} frame b`); + b.reset(); + + b.drawText(0, 0, a); + const frameA2 = buildOk(b, `${factory.name} frame a2`); + + assert.equal(encodeCallCount(calls, a), 2, `${factory.name}: A should be re-encoded`); + assert.equal(readDrawTextEntries(frameA2)[0]?.byteLen, expectedLen, `${factory.name}: byte_len`); + assert.deepEqual(readInternedStrings(frameA2), [a], `${factory.name}: decoded string`); + }); + } + }); + + test("reset/new frame semantics: string index restarts at 0 even when cache hits", () => { + const text = "index-reset-é"; + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 8 }); + + b.drawText(0, 0, text); + const frame1 = buildOk(b, `${factory.name} frame 1`); + b.reset(); + + b.drawText(0, 0, text); + const frame2 = buildOk(b, `${factory.name} frame 2`); + + assert.equal(readDrawTextEntries(frame1)[0]?.stringIndex, 0, `${factory.name}: frame1 index`); + assert.equal(readDrawTextEntries(frame2)[0]?.stringIndex, 0, `${factory.name}: frame2 index`); + assert.equal(encodeCallCount(calls, text), 1, `${factory.name}: cache hit across frames`); + }); + } + }); + + test("reset/new frame has no stale string table data", () => { + const first = "first-é"; + const second = "second-é"; + + for (const factory of FACTORIES) { + const b = factory.create({ encodedStringCacheCap: 8 }); + + b.drawText(0, 0, first); + const frame1 = buildOk(b, `${factory.name} frame 1`); + b.reset(); + + b.drawText(0, 0, second); + const frame2 = buildOk(b, `${factory.name} frame 2`); + + assert.deepEqual(readInternedStrings(frame1), [first], `${factory.name}: frame1 strings`); + assert.deepEqual(readInternedStrings(frame2), [second], `${factory.name}: frame2 strings`); + } + }); + + test("within a frame, duplicate strings dedupe independently of cache state", () => { + const text = "intra-frame-é"; + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 0 }); + b.drawText(0, 0, text); + b.drawText(0, 1, text); + const frame = buildOk(b, `${factory.name} intra-frame`); + + const entries = readDrawTextEntries(frame); + assert.equal(entries.length, 2, `${factory.name}: two drawText commands`); + assert.equal(entries[0]?.stringIndex, 0, `${factory.name}: first index`); + assert.equal(entries[1]?.stringIndex, 0, `${factory.name}: duplicate index`); + assert.equal(encodeCallCount(calls, text), 1, `${factory.name}: one encode within frame`); + }); + } + }); + + test("cap=0 fallback still preserves correct decoded output across frame changes", () => { + const first = "cap0-first-é"; + const second = "cap0-second-é"; + + for (const factory of FACTORIES) { + withTextEncoderSpy((calls) => { + const b = factory.create({ encodedStringCacheCap: 0 }); + + b.drawText(0, 0, first); + const frame1 = buildOk(b, `${factory.name} cap0 frame 1`); + b.reset(); + + b.drawText(0, 0, second); + const frame2 = buildOk(b, `${factory.name} cap0 frame 2`); + + assert.deepEqual(readInternedStrings(frame1), [first], `${factory.name}: frame1 decode`); + assert.deepEqual(readInternedStrings(frame2), [second], `${factory.name}: frame2 decode`); + assert.equal(encodeCallCount(calls, first), 1, `${factory.name}: first encoded once`); + assert.equal(encodeCallCount(calls, second), 1, `${factory.name}: second encoded once`); + }); + } + }); +}); diff --git a/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts b/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts new file mode 100644 index 00000000..c598d7fd --- /dev/null +++ b/packages/core/src/drawlist/__tests__/builder.string-intern.test.ts @@ -0,0 +1,292 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import { createDrawlistBuilderV1, createDrawlistBuilderV2 } from "../../index.js"; + +const OP_DRAW_TEXT = 3; + +type BuildResult = + | Readonly<{ ok: true; bytes: Uint8Array }> + | Readonly<{ ok: false; error: Readonly<{ code: string; detail: string }> }>; + +type BuilderLike = Readonly<{ + drawText(x: number, y: number, text: string, style?: unknown): void; + build(): BuildResult; + reset(): void; +}>; + +type BuilderOpts = Readonly<{ + maxStrings?: number; + maxStringBytes?: number; + encodedStringCacheCap?: number; +}>; + +const FACTORIES: readonly Readonly<{ + name: string; + create(opts?: BuilderOpts): BuilderLike; +}>[] = [ + { name: "v1", create: (opts?: BuilderOpts) => createDrawlistBuilderV1(opts) }, + { name: "v2", create: (opts?: BuilderOpts) => createDrawlistBuilderV2(opts) }, +]; + +function u16(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint16(off, true); +} + +function u32(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint32(off, true); +} + +type DrawTextEntry = Readonly<{ stringIndex: number; byteLen: number }>; + +function readDrawTextEntries(bytes: Uint8Array): DrawTextEntry[] { + const cmdOffset = u32(bytes, 16); + const cmdBytes = u32(bytes, 20); + const cmdCount = u32(bytes, 24); + const out: DrawTextEntry[] = []; + + let off = cmdOffset; + for (let i = 0; i < cmdCount; i++) { + const opcode = u16(bytes, off + 0); + const size = u32(bytes, off + 4); + if (opcode === OP_DRAW_TEXT) { + out.push({ + stringIndex: u32(bytes, off + 16), + byteLen: u32(bytes, off + 24), + }); + } + off += size; + } + + assert.equal(off, cmdOffset + cmdBytes, "command stream should end at cmdOffset + cmdBytes"); + return out; +} + +type StringSpan = Readonly<{ off: number; len: number }>; + +function readStringSpans(bytes: Uint8Array): StringSpan[] { + const spanOffset = u32(bytes, 28); + const count = u32(bytes, 32); + const spans: StringSpan[] = []; + for (let i = 0; i < count; i++) { + spans.push({ + off: u32(bytes, spanOffset + i * 8 + 0), + len: u32(bytes, spanOffset + i * 8 + 4), + }); + } + return spans; +} + +function readInternedStrings(bytes: Uint8Array): string[] { + const stringsBytesOffset = u32(bytes, 36); + const spans = readStringSpans(bytes); + const decoder = new TextDecoder(); + return spans.map((span) => + decoder.decode(bytes.subarray(stringsBytesOffset + span.off, stringsBytesOffset + span.off + span.len)), + ); +} + +function buildOk(builder: BuilderLike, label: string): Uint8Array { + const res = builder.build(); + if (!res.ok) { + throw new Error(`${label}: build should succeed (${res.error.code}: ${res.error.detail})`); + } + return res.bytes; +} + +describe("drawlist string interning", () => { + test("duplicate strings share the same string table index", () => { + for (const factory of FACTORIES) { + const b = factory.create(); + b.drawText(0, 0, "dup"); + b.drawText(0, 1, "dup"); + + const bytes = buildOk(b, `${factory.name} duplicate strings`); + const drawText = readDrawTextEntries(bytes); + const strings = readInternedStrings(bytes); + + assert.equal(drawText.length, 2, `${factory.name}: expected 2 drawText commands`); + assert.equal(drawText[0]?.stringIndex, 0, `${factory.name}: first string index`); + assert.equal(drawText[1]?.stringIndex, 0, `${factory.name}: duplicate string index`); + assert.deepEqual(strings, ["dup"], `${factory.name}: string table should dedupe`); + } + }); + + test("distinct strings get distinct indices", () => { + for (const factory of FACTORIES) { + const b = factory.create(); + b.drawText(0, 0, "alpha"); + b.drawText(0, 1, "beta"); + + const bytes = buildOk(b, `${factory.name} distinct strings`); + const drawText = readDrawTextEntries(bytes); + const strings = readInternedStrings(bytes); + + assert.equal(drawText[0]?.stringIndex, 0, `${factory.name}: alpha index`); + assert.equal(drawText[1]?.stringIndex, 1, `${factory.name}: beta index`); + assert.deepEqual(strings, ["alpha", "beta"], `${factory.name}: expected two strings`); + } + }); + + test("interning is based on text value only (style and coordinates do not matter)", () => { + for (const factory of FACTORIES) { + const b = factory.create(); + b.drawText(10, 20, "same", { bold: true }); + b.drawText(-1, 999, "same", { underline: true, fg: { r: 1, g: 2, b: 3 } }); + + const bytes = buildOk(b, `${factory.name} value-based interning`); + const drawText = readDrawTextEntries(bytes); + assert.equal(drawText[0]?.stringIndex, 0, `${factory.name}: first index`); + assert.equal(drawText[1]?.stringIndex, 0, `${factory.name}: second index`); + assert.deepEqual(readInternedStrings(bytes), ["same"], `${factory.name}: one interned string`); + } + }); + + test("empty string interns once with zero byte length", () => { + for (const factory of FACTORIES) { + const b = factory.create(); + b.drawText(0, 0, ""); + b.drawText(0, 1, ""); + + const bytes = buildOk(b, `${factory.name} empty string`); + const drawText = readDrawTextEntries(bytes); + const spans = readStringSpans(bytes); + const strings = readInternedStrings(bytes); + + assert.equal(drawText[0]?.stringIndex, 0, `${factory.name}: first empty index`); + assert.equal(drawText[1]?.stringIndex, 0, `${factory.name}: second empty index`); + assert.equal(drawText[0]?.byteLen, 0, `${factory.name}: empty byte len in command`); + assert.equal(spans[0]?.len, 0, `${factory.name}: empty span len`); + assert.equal(u32(bytes, 40), 0, `${factory.name}: aligned strings_bytes_len`); + assert.deepEqual(strings, [""], `${factory.name}: one empty string in table`); + } + }); + + test("very long strings (10k+) are interned and round-trip correctly", () => { + const longText = "L".repeat(10_123); + for (const factory of FACTORIES) { + const b = factory.create(); + b.drawText(0, 0, longText); + + const bytes = buildOk(b, `${factory.name} long string`); + const drawText = readDrawTextEntries(bytes); + const spans = readStringSpans(bytes); + const strings = readInternedStrings(bytes); + + assert.equal(drawText[0]?.stringIndex, 0, `${factory.name}: long string index`); + assert.equal(drawText[0]?.byteLen, longText.length, `${factory.name}: long byte len`); + assert.equal(spans[0]?.len, longText.length, `${factory.name}: long span len`); + assert.equal(strings[0], longText, `${factory.name}: long round-trip text`); + } + }); + + test("unicode string with emoji/combining marks/CJK round-trips with correct UTF-8 length", () => { + const text = "emoji😀 + combining e\u0301 + CJK漢字"; + const expectedByteLen = new TextEncoder().encode(text).byteLength; + + for (const factory of FACTORIES) { + const b = factory.create(); + b.drawText(0, 0, text); + + const bytes = buildOk(b, `${factory.name} unicode round-trip`); + const drawText = readDrawTextEntries(bytes); + const strings = readInternedStrings(bytes); + + assert.equal(drawText[0]?.byteLen, expectedByteLen, `${factory.name}: utf8 byte len`); + assert.equal(strings[0], text, `${factory.name}: unicode round-trip`); + } + }); + + test("normalization variants are treated as distinct keys", () => { + const nfc = "\u00E9"; + const nfd = "e\u0301"; + + for (const factory of FACTORIES) { + const b = factory.create(); + b.drawText(0, 0, nfc); + b.drawText(0, 1, nfd); + + const bytes = buildOk(b, `${factory.name} unicode normalization`); + const drawText = readDrawTextEntries(bytes); + const strings = readInternedStrings(bytes); + + assert.equal(drawText[0]?.stringIndex, 0, `${factory.name}: nfc index`); + assert.equal(drawText[1]?.stringIndex, 1, `${factory.name}: nfd index`); + assert.deepEqual(strings, [nfc, nfd], `${factory.name}: both forms are preserved`); + } + }); + + test("string table decode round-trips unique values in first-seen order", () => { + const input = ["", "hello", "😀", "漢字", "hello", "world", "😀", "e\u0301"]; + const expectedUnique = ["", "hello", "😀", "漢字", "world", "e\u0301"]; + const expectedIndices = [0, 1, 2, 3, 1, 4, 2, 5]; + + for (const factory of FACTORIES) { + const b = factory.create(); + for (let i = 0; i < input.length; i++) { + b.drawText(0, i, input[i] ?? ""); + } + + const bytes = buildOk(b, `${factory.name} round-trip decode`); + const drawText = readDrawTextEntries(bytes); + const actualIndices = drawText.map((entry) => entry.stringIndex); + + assert.deepEqual(actualIndices, expectedIndices, `${factory.name}: index assignment`); + assert.deepEqual(readInternedStrings(bytes), expectedUnique, `${factory.name}: unique decode`); + } + }); + + test("many unique strings produce sequential indices and full string table", () => { + const unique = Array.from({ length: 256 }, (_, i) => `u-${i.toString().padStart(3, "0")}`); + + for (const factory of FACTORIES) { + const b = factory.create(); + for (let i = 0; i < unique.length; i++) { + b.drawText(0, i, unique[i] ?? ""); + } + + const bytes = buildOk(b, `${factory.name} many unique strings`); + const drawText = readDrawTextEntries(bytes); + assert.equal(drawText.length, unique.length, `${factory.name}: drawText count`); + for (let i = 0; i < drawText.length; i++) { + assert.equal(drawText[i]?.stringIndex, i, `${factory.name}: index ${i}`); + } + assert.deepEqual(readInternedStrings(bytes), unique, `${factory.name}: decoded string table`); + } + }); + + test("reset starts a new frame with a fresh string table and reindexed strings", () => { + for (const factory of FACTORIES) { + const b = factory.create(); + b.drawText(0, 0, "first"); + b.drawText(0, 1, "second"); + const frame1 = buildOk(b, `${factory.name} frame 1`); + + b.reset(); + b.drawText(0, 0, "second"); + const frame2 = buildOk(b, `${factory.name} frame 2`); + + const frame1Indices = readDrawTextEntries(frame1).map((entry) => entry.stringIndex); + const frame2Indices = readDrawTextEntries(frame2).map((entry) => entry.stringIndex); + + assert.deepEqual(frame1Indices, [0, 1], `${factory.name}: frame 1 indices`); + assert.deepEqual(frame2Indices, [0], `${factory.name}: frame 2 indices restart`); + assert.deepEqual(readInternedStrings(frame2), ["second"], `${factory.name}: no stale strings`); + } + }); + + test("maxStrings cap rejects too many unique interned strings", () => { + for (const factory of FACTORIES) { + const b = factory.create({ maxStrings: 3 }); + b.drawText(0, 0, "a"); + b.drawText(0, 1, "b"); + b.drawText(0, 2, "c"); + b.drawText(0, 3, "d"); + + const res = b.build(); + assert.equal(res.ok, false, `${factory.name}: should fail when maxStrings exceeded`); + if (res.ok) continue; + assert.equal(res.error.code, "ZRDL_TOO_LARGE", `${factory.name}: error code`); + } + }); +});