import { describe, expect, it } from "vitest"; import { CODECS, canConvert, convertValue, codec, ValueCodecError, PROPERTY_TYPES, } from "./registry.js"; const selectConfig = { options: [ { id: "opt-a", name: "A", color: "blue" }, { id: "opt-b", name: "B", color: "green" }, ], }; describe("value codecs", () => { it("covers all 24 property types", () => { expect(PROPERTY_TYPES).toHaveLength(24); for (const t of PROPERTY_TYPES) expect(CODECS[t]).toBeDefined(); }); it("number round-trip + coercion", () => { const c = codec("number"); expect(c.fromWire("42")).toBe(42); expect(c.fromWire(3.14)).toBe(3.14); expect(c.fromWire(null)).toBeNull(); expect(() => c.fromWire("abc")).toThrow(ValueCodecError); expect(c.toWire(5)).toBe(5); expect(c.display(1234.5, { format: "currency", currency: "USD" })).toBe("$1,234.50"); expect(c.display(0.25, { format: "percent" })).toBe("25 %"); expect(c.compare(1, 2, {})).toBeLessThan(0); }); it("select: orphan option ids display empty, sort last", () => { const c = codec("select"); expect(c.display("opt-a", selectConfig)).toBe("A"); expect(c.display("opt-gone", selectConfig)).toBe(""); // orphan → empty, never error expect(c.compare("opt-gone", "opt-a", selectConfig)).toBeGreaterThan(0); expect(c.compare("opt-a", "opt-b", selectConfig)).toBeLessThan(0); }); it("multi_select: array normalization + membership display", () => { const c = codec("multi_select"); // fromWire is config-free (TASK-0065 R1 interface: `fromWire(json)`), so it // does SHAPE normalization only — an array of option-id strings. It cannot // drop an orphan id (no access to config.options). Orphan ids are treated // as empty where config IS available (display/compare) — the spec's // "orphan option id after option delete → treated as empty, never an // error" (same level the `select` orphan test above pins). expect(c.fromWire(["opt-a", "bogus", "opt-b"])).toEqual(["opt-a", "bogus", "opt-b"]); expect(c.fromWire("opt-a")).toEqual(["opt-a"]); expect(c.fromWire(null)).toEqual([]); // display (config-aware) drops the orphan "bogus" → "A, B", never an error. expect(c.display(["opt-a", "bogus", "opt-b"], selectConfig)).toBe("A, B"); expect(c.display(["opt-b", "opt-a"], selectConfig)).toBe("B, A"); }); it("date: ISO day-only normalization, ranges ordered", () => { const d = codec("date"); expect(d.fromWire("2025-03-01")).toBe("2025-03-01"); expect(() => d.fromWire("2025-03-01T10:00:00Z")).toThrow(ValueCodecError); const r = codec("date_range"); const v = r.fromWire({ start: "2025-03-05", end: "2025-03-01" }); expect(v).toEqual({ start: "2025-03-01", end: "2025-03-05" }); expect( r.compare( { start: "2025-01-01", end: "2025-01-02" }, { start: "2025-02-01", end: "2025-02-02" }, {}, ), ).toBeLessThan(0); }); it("checkbox: missing = false", () => { const c = codec("checkbox"); expect(c.fromWire(null)).toBe(false); expect(c.fromWire(undefined)).toBe(false); expect(c.fromWire(true)).toBe(true); }); it("unique_id display with prefix + padding", () => { const c = codec("unique_id"); expect(c.display(7, { prefix: "rec-", padding: 4 })).toBe("rec-0007"); }); it("conversion matrix: exact allowed set", () => { expect(canConvert("text", "rich_text")).toBe(true); expect(canConvert("rich_text", "text")).toBe(true); expect(canConvert("number", "text")).toBe(true); expect(canConvert("text", "number")).toBe(false); expect(canConvert("select", "status")).toBe(false); expect(canConvert("relation", "text")).toBe(false); expect(convertValue("number", "text", 42.5)).toBe("42.5"); expect(convertValue("text", "number", "42")).toBeNull(); }); it("files: normalization keeps shape", () => { const c = codec("files"); const v = c.fromWire([{ id: "f1", name: "a.png", url: "x", size: 10 }, "junk"]); expect(v).toEqual([{ id: "f1", name: "a.png", url: "x", size: 10 }]); expect(c.display(v, {})).toBe("a.png"); }); });