From ae32caf453ae8a6e5572f54b7038ff258bf085d3 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 23 Mar 2026 11:18:16 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=20ops=20validation=20with=20Typ?= =?UTF-8?q?eBox=20schemas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a `validate` module that uses @sinclair/typebox to enforce correct op structure and numeric ranges (u8/u16/u32) before rendering. Exports `validate`, `assert`, and a `validated` Term wrapper. Adds the `./validate` subpath export. --- deno.json | 6 +- deno.lock | 16 ++++- test/validate.test.ts | 109 ++++++++++++++++++++++++++++++++ validate.ts | 143 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 test/validate.test.ts create mode 100644 validate.ts diff --git a/deno.json b/deno.json index 0bc1f62..c7b9eda 100644 --- a/deno.json +++ b/deno.json @@ -11,9 +11,13 @@ "imports": { "@std/testing": "jsr:@std/testing@1", "@std/expect": "jsr:@std/expect@1", + "@sinclair/typebox": "npm:@sinclair/typebox@^0.34", "dnt": "jsr:@deno/dnt@0.42.3" }, - "exports": "./mod.ts", + "exports": { + ".": "./mod.ts", + "./validate": "./validate.ts" + }, "publish": { "include": ["*.ts", "clayterm.wasm"] }, diff --git a/deno.lock b/deno.lock index b253942..f7961a2 100644 --- a/deno.lock +++ b/deno.lock @@ -14,7 +14,10 @@ "jsr:@std/path@^1.1.4": "1.1.4", "jsr:@std/testing@1": "1.0.17", "jsr:@ts-morph/bootstrap@0.27": "0.27.0", - "jsr:@ts-morph/common@0.27": "0.27.0" + "jsr:@ts-morph/common@0.27": "0.27.0", + "npm:@sinclair/typebox@*": "0.34.48", + "npm:@sinclair/typebox@0.34": "0.34.48", + "npm:valrs@*": "0.1.0" }, "jsr": { "@david/code-block-writer@13.0.3": { @@ -83,11 +86,20 @@ ] } }, + "npm": { + "@sinclair/typebox@0.34.48": { + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==" + }, + "valrs@0.1.0": { + "integrity": "sha512-BqVkjx3qhsRLHerblLDoqEx0OEx7ms0DB6LPv40oWkMfFKUVKrqVuklaGdrPrHyubC5hSHYfEtUiQXrCkC6xHQ==" + } + }, "workspace": { "dependencies": [ "jsr:@deno/dnt@0.42.3", "jsr:@std/expect@1", - "jsr:@std/testing@1" + "jsr:@std/testing@1", + "npm:@sinclair/typebox@0.34" ] } } diff --git a/test/validate.test.ts b/test/validate.test.ts new file mode 100644 index 0000000..920cda4 --- /dev/null +++ b/test/validate.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it } from "./suite.ts"; +import { createTerm, type Term } from "../term.ts"; +import { close, grow, open, rgba, text } from "../ops.ts"; +import { assert, validate, validated } from "../validate.ts"; +import { print } from "./print.ts"; + +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + +describe("validate", () => { + it("accepts valid ops", () => { + expect(validate([ + open("root", { layout: { width: grow(), height: grow() } }), + text("hello"), + close(), + ])).toBe(true); + }); + + it("accepts empty array", () => { + expect(validate([])).toBe(true); + }); + + it("rejects ops with wrong id", () => { + expect(validate([{ id: 0xff }])).toBe(false); + }); + + it("rejects open element missing name", () => { + expect(validate([{ id: 0x02 }])).toBe(false); + }); + + it("rejects text missing content", () => { + expect(validate([{ id: 0x03 }])).toBe(false); + }); + + it("rejects non-array", () => { + expect(validate("garbage")).toBe(false); + }); + + it("rejects null", () => { + expect(validate(null)).toBe(false); + }); + + it("assert throws TypeError on bad input", () => { + expect(() => assert([{ id: 0x02 }])).toThrow(TypeError); + }); + + it("rejects padding > 255 (u8 overflow)", () => { + expect(validate([ + open("x", { layout: { padding: { left: 300 } } }), + close(), + ])).toBe(false); + }); + + it("rejects fractional padding", () => { + expect(validate([ + open("x", { layout: { padding: { left: 1.5 } } }), + close(), + ])).toBe(false); + }); + + it("rejects fontSize > 255", () => { + expect(validate([text("hi", { fontSize: 256 })])).toBe(false); + }); + + it("rejects gap > 65535 (u16 overflow)", () => { + expect(validate([ + open("x", { layout: { gap: 70000 } }), + close(), + ])).toBe(false); + }); + + it("rejects negative border width", () => { + expect(validate([ + open("x", { border: { color: 0xFF0000, left: -1 } }), + close(), + ])).toBe(false); + }); + + it("rejects fractional color", () => { + expect(validate([text("hi", { color: 1.5 })])).toBe(false); + }); +}); + +describe("validated", () => { + let term: Term; + + beforeEach(async () => { + term = validated(await createTerm({ width: 40, height: 10 })); + }); + + it("renders valid ops normally", () => { + const out = print( + decode(term.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("Hello, World!"), + close(), + ])), + 40, + 10, + ); + expect(out).toContain("Hello, World!"); + }); + + it("throws on invalid ops", () => { + // deno-lint-ignore no-explicit-any + expect(() => term.render([{ id: 0xff }] as any)).toThrow(TypeError); + }); +}); diff --git a/validate.ts b/validate.ts new file mode 100644 index 0000000..faca885 --- /dev/null +++ b/validate.ts @@ -0,0 +1,143 @@ +import { Type } from "@sinclair/typebox"; +import { TypeCompiler } from "@sinclair/typebox/compiler"; +import type { Op } from "./ops.ts"; +import type { Term } from "./term.ts"; + +/* ── Range helpers (match bit-packing in pack()) ──────────────────── */ + +const u8 = Type.Integer({ minimum: 0, maximum: 255 }); +const u16 = Type.Integer({ minimum: 0, maximum: 65535 }); +const u32 = Type.Integer({ minimum: 0, maximum: 0xFFFFFFFF }); + +/* ── Sizing axis (discriminated union) ────────────────────────────── */ + +const Fit = Type.Object({ + type: Type.Literal("fit"), + min: Type.Optional(Type.Number()), + max: Type.Optional(Type.Number()), +}); + +const Grow = Type.Object({ + type: Type.Literal("grow"), + min: Type.Optional(Type.Number()), + max: Type.Optional(Type.Number()), +}); + +const Percent = Type.Object({ + type: Type.Literal("percent"), + value: Type.Number(), +}); + +const Fixed = Type.Object({ + type: Type.Literal("fixed"), + value: Type.Number(), +}); + +const SizingAxis = Type.Union([Fit, Grow, Percent, Fixed]); + +/* ── Sub-objects ──────────────────────────────────────────────────── */ + +const Padding = Type.Object({ + left: Type.Optional(u8), + right: Type.Optional(u8), + top: Type.Optional(u8), + bottom: Type.Optional(u8), +}); + +const Layout = Type.Object({ + width: Type.Optional(SizingAxis), + height: Type.Optional(SizingAxis), + padding: Type.Optional(Padding), + gap: Type.Optional(u16), + direction: Type.Optional( + Type.Union([Type.Literal("ltr"), Type.Literal("ttb")]), + ), + alignX: Type.Optional(u8), + alignY: Type.Optional(u8), +}); + +const CornerRadius = Type.Object({ + tl: Type.Optional(u8), + tr: Type.Optional(u8), + bl: Type.Optional(u8), + br: Type.Optional(u8), +}); + +const Border = Type.Object({ + color: u32, + left: Type.Optional(u8), + right: Type.Optional(u8), + top: Type.Optional(u8), + bottom: Type.Optional(u8), +}); + +const Clip = Type.Object({ + horizontal: Type.Optional(Type.Boolean()), + vertical: Type.Optional(Type.Boolean()), +}); + +const Floating = Type.Object({ + x: Type.Optional(Type.Number()), + y: Type.Optional(Type.Number()), + parent: Type.Optional(Type.Integer({ minimum: 0 })), + attachTo: Type.Optional(u8), + attachPoints: Type.Optional(u8), + zIndex: Type.Optional(u16), +}); + +/* ── Op types (discriminated on `id`) ─────────────────────────────── */ + +const CloseElement = Type.Object({ id: Type.Literal(0x04) }); + +const OpenElement = Type.Object({ + id: Type.Literal(0x02), + name: Type.String(), + layout: Type.Optional(Layout), + bg: Type.Optional(u32), + cornerRadius: Type.Optional(CornerRadius), + border: Type.Optional(Border), + clip: Type.Optional(Clip), + floating: Type.Optional(Floating), +}); + +const TextOp = Type.Object({ + id: Type.Literal(0x03), + content: Type.String(), + color: Type.Optional(u32), + fontSize: Type.Optional(u8), + fontId: Type.Optional(u8), + wrap: Type.Optional(u8), + attrs: Type.Optional(u8), +}); + +const Ops = Type.Array(Type.Union([OpenElement, TextOp, CloseElement])); + +/* ── Compiled validator ───────────────────────────────────────────── */ + +const compiled = TypeCompiler.Compile(Ops); + +export function validate(ops: unknown): ops is Op[] { + return compiled.Check(ops); +} + +export function assert(ops: unknown): asserts ops is Op[] { + if (!compiled.Check(ops)) { + const errors = [...compiled.Errors(ops)]; + const msg = errors + .slice(0, 5) + .map((e) => `${e.path}: ${e.message}`) + .join("\n"); + throw new TypeError(`Invalid ops:\n${msg}`); + } +} + +/* ── Term wrapper ─────────────────────────────────────────────────── */ + +export function validated(term: Term): Term { + return { + render(ops: Op[]): Uint8Array { + assert(ops); + return term.render(ops); + }, + }; +} -- 2.51.2