import { describe, it, expect, vi } from "vitest"; import { resolvePropValue, createDirectiveRegistry, type PropResolutionContext, } from "@json-render/core"; import { formatDirective } from "./format"; import { mathDirective } from "./math"; import { concatDirective } from "./concat"; import { countDirective } from "./count"; import { truncateDirective } from "./truncate"; import { pluralizeDirective } from "./pluralize"; import { joinDirective } from "./join"; import { createI18nDirective } from "./i18n"; const allDirectives = [ formatDirective, mathDirective, concatDirective, countDirective, truncateDirective, pluralizeDirective, joinDirective, ]; function makeCtx( state: Record = {}, extra: Partial = {}, ): PropResolutionContext { return { stateModel: state, directives: createDirectiveRegistry(allDirectives), ...extra, }; } // ============================================================================ // $format // ============================================================================ describe("$format", () => { it("formats a number", () => { const ctx = makeCtx(); const result = resolvePropValue({ $format: "number", value: 1234.56 }, ctx); expect(typeof result).toBe("string"); expect(result).toContain("1"); }); it("formats currency", () => { const ctx = makeCtx({ total: 42.5 }); const result = resolvePropValue( { $format: "currency", value: { $state: "/total" }, currency: "USD" }, ctx, ); expect(typeof result).toBe("string"); expect(result).toContain("42"); }); it("formats percent", () => { const ctx = makeCtx(); const result = resolvePropValue({ $format: "percent", value: 0.75 }, ctx); expect(typeof result).toBe("string"); expect(result).toContain("75"); }); it("formats a date", () => { const ctx = makeCtx(); const result = resolvePropValue( { $format: "date", value: "2024-01-15" }, ctx, ); expect(typeof result).toBe("string"); expect(result).toContain("2024"); }); it("formats a relative date with injectable now", () => { const ctx = makeCtx(); const baseDate = new Date("2024-06-15T12:00:00Z").getTime(); const result = resolvePropValue( { $format: "date", value: "2024-06-15T12:00:00Z", style: "relative", now: baseDate + 3 * 60 * 60 * 1000, }, ctx, ); expect(result).toBe("3h ago"); }); it("formats a future relative date", () => { const ctx = makeCtx(); const baseDate = new Date("2024-06-15T12:00:00Z").getTime(); const result = resolvePropValue( { $format: "date", value: "2024-06-15T12:00:00Z", style: "relative", now: baseDate - 2 * 60 * 60 * 1000, }, ctx, ); expect(result).toBe("2h from now"); }); it("returns 'just now' when date equals now", () => { const ctx = makeCtx(); const ts = new Date("2024-06-15T12:00:00Z").getTime(); const result = resolvePropValue( { $format: "date", value: "2024-06-15T12:00:00Z", style: "relative", now: ts, }, ctx, ); expect(result).toBe("just now"); }); }); // ============================================================================ // $math // ============================================================================ describe("$math", () => { it("adds two values", () => { const ctx = makeCtx({ a: 10, b: 5 }); expect( resolvePropValue( { $math: "add", a: { $state: "/a" }, b: { $state: "/b" } }, ctx, ), ).toBe(15); }); it("subtracts", () => { const ctx = makeCtx(); expect(resolvePropValue({ $math: "subtract", a: 10, b: 3 }, ctx)).toBe(7); }); it("multiplies", () => { const ctx = makeCtx(); expect(resolvePropValue({ $math: "multiply", a: 4, b: 5 }, ctx)).toBe(20); }); it("divides", () => { const ctx = makeCtx(); expect(resolvePropValue({ $math: "divide", a: 10, b: 4 }, ctx)).toBe(2.5); }); it("handles division by zero", () => { const ctx = makeCtx(); expect(resolvePropValue({ $math: "divide", a: 10, b: 0 }, ctx)).toBe(0); }); it("computes modulo", () => { const ctx = makeCtx(); expect(resolvePropValue({ $math: "mod", a: 10, b: 3 }, ctx)).toBe(1); }); it("computes min", () => { const ctx = makeCtx(); expect(resolvePropValue({ $math: "min", a: 10, b: 3 }, ctx)).toBe(3); }); it("computes max", () => { const ctx = makeCtx(); expect(resolvePropValue({ $math: "max", a: 10, b: 3 }, ctx)).toBe(10); }); it("rounds", () => { const ctx = makeCtx(); expect(resolvePropValue({ $math: "round", a: 3.7 }, ctx)).toBe(4); }); it("floors", () => { const ctx = makeCtx(); expect(resolvePropValue({ $math: "floor", a: 3.7 }, ctx)).toBe(3); }); it("ceils", () => { const ctx = makeCtx(); expect(resolvePropValue({ $math: "ceil", a: 3.2 }, ctx)).toBe(4); }); it("computes abs", () => { const ctx = makeCtx(); expect(resolvePropValue({ $math: "abs", a: -5 }, ctx)).toBe(5); }); it("defaults missing operand b to 0", () => { const ctx = makeCtx(); expect(resolvePropValue({ $math: "add", a: 5 }, ctx)).toBe(5); }); it("defaults missing operand a to 0", () => { const ctx = makeCtx(); expect(resolvePropValue({ $math: "add", b: 3 }, ctx)).toBe(3); }); it("warns when a non-numeric value is coerced to 0", () => { const ctx = makeCtx(); const spy = vi.spyOn(console, "warn").mockImplementation(() => {}); const result = resolvePropValue({ $math: "add", a: "foo", b: 3 }, ctx); expect(result).toBe(3); expect(spy).toHaveBeenCalledWith( "$math: non-numeric value coerced to 0:", "foo", ); spy.mockRestore(); }); }); // ============================================================================ // $concat // ============================================================================ describe("$concat", () => { it("concatenates strings", () => { const ctx = makeCtx({ first: "John", last: "Doe" }); expect( resolvePropValue( { $concat: [{ $state: "/first" }, " ", { $state: "/last" }] }, ctx, ), ).toBe("John Doe"); }); it("handles null values", () => { const ctx = makeCtx(); expect(resolvePropValue({ $concat: ["hello", null, "world"] }, ctx)).toBe( "helloworld", ); }); it("converts non-strings", () => { const ctx = makeCtx(); expect(resolvePropValue({ $concat: ["count: ", 42] }, ctx)).toBe( "count: 42", ); }); }); // ============================================================================ // $count // ============================================================================ describe("$count", () => { it("counts array items", () => { const ctx = makeCtx({ items: [1, 2, 3] }); expect(resolvePropValue({ $count: { $state: "/items" } }, ctx)).toBe(3); }); it("counts string length", () => { const ctx = makeCtx(); expect(resolvePropValue({ $count: "hello" }, ctx)).toBe(5); }); it("returns 0 for non-countable", () => { const ctx = makeCtx(); expect(resolvePropValue({ $count: 42 }, ctx)).toBe(0); }); it("returns 0 for empty array", () => { const ctx = makeCtx({ items: [] }); expect(resolvePropValue({ $count: { $state: "/items" } }, ctx)).toBe(0); }); }); // ============================================================================ // $truncate // ============================================================================ describe("$truncate", () => { it("truncates long text", () => { const ctx = makeCtx(); const text = "a".repeat(200); const result = resolvePropValue( { $truncate: text, length: 10, suffix: "..." }, ctx, ) as string; expect(result.length).toBe(13); expect(result).toBe("a".repeat(10) + "..."); }); it("does not truncate short text", () => { const ctx = makeCtx(); expect(resolvePropValue({ $truncate: "hello", length: 10 }, ctx)).toBe( "hello", ); }); it("uses default length and suffix", () => { const ctx = makeCtx(); const text = "a".repeat(200); const result = resolvePropValue({ $truncate: text }, ctx) as string; expect(result.length).toBe(103); // 100 + "..." }); it("resolves dynamic values", () => { const ctx = makeCtx({ body: "hello world this is a test" }); expect( resolvePropValue( { $truncate: { $state: "/body" }, length: 11, suffix: "…" }, ctx, ), ).toBe("hello world…"); }); }); // ============================================================================ // $pluralize // ============================================================================ describe("$pluralize", () => { it("handles singular", () => { const ctx = makeCtx(); expect( resolvePropValue({ $pluralize: 1, one: "item", other: "items" }, ctx), ).toBe("1 item"); }); it("handles plural", () => { const ctx = makeCtx(); expect( resolvePropValue({ $pluralize: 5, one: "item", other: "items" }, ctx), ).toBe("5 items"); }); it("handles zero with zero form", () => { const ctx = makeCtx(); expect( resolvePropValue( { $pluralize: 0, one: "item", other: "items", zero: "no items" }, ctx, ), ).toBe("no items"); }); it("handles zero without zero form", () => { const ctx = makeCtx(); expect( resolvePropValue({ $pluralize: 0, one: "item", other: "items" }, ctx), ).toBe("0 items"); }); it("resolves count from state", () => { const ctx = makeCtx({ count: 3 }); expect( resolvePropValue( { $pluralize: { $state: "/count" }, one: "file", other: "files" }, ctx, ), ).toBe("3 files"); }); it("coerces string count to number", () => { const ctx = makeCtx(); expect( resolvePropValue({ $pluralize: "3", one: "item", other: "items" }, ctx), ).toBe("3 items"); }); }); // ============================================================================ // $join // ============================================================================ describe("$join", () => { it("joins array with default separator", () => { const ctx = makeCtx({ tags: ["red", "green", "blue"] }); expect(resolvePropValue({ $join: { $state: "/tags" } }, ctx)).toBe( "red, green, blue", ); }); it("joins with custom separator", () => { const ctx = makeCtx({ tags: ["a", "b", "c"] }); expect( resolvePropValue({ $join: { $state: "/tags" }, separator: " | " }, ctx), ).toBe("a | b | c"); }); it("handles non-array values", () => { const ctx = makeCtx(); expect(resolvePropValue({ $join: "hello" }, ctx)).toBe("hello"); }); it("handles null items", () => { const ctx = makeCtx({ items: ["a", null, "b"] }); expect( resolvePropValue({ $join: { $state: "/items" }, separator: "-" }, ctx), ).toBe("a--b"); }); }); // ============================================================================ // $t (i18n) // ============================================================================ describe("createI18nDirective", () => { const tDirective = createI18nDirective({ locale: "en", messages: { en: { greeting: "Hello, {{name}}!", "checkout.submit": "Place Order", "items.count": "{{count}} items in cart", }, es: { greeting: "Hola, {{name}}!", "checkout.submit": "Realizar Pedido", }, }, fallbackLocale: "en", }); function makeI18nCtx( state: Record = {}, ): PropResolutionContext { return { stateModel: state, directives: createDirectiveRegistry([tDirective]), }; } it("translates a simple key", () => { const ctx = makeI18nCtx(); expect(resolvePropValue({ $t: "checkout.submit" }, ctx)).toBe( "Place Order", ); }); it("interpolates parameters", () => { const ctx = makeI18nCtx({ name: "Alice" }); expect( resolvePropValue( { $t: "greeting", params: { name: { $state: "/name" } } }, ctx, ), ).toBe("Hello, Alice!"); }); it("returns key for missing translations", () => { const ctx = makeI18nCtx(); expect(resolvePropValue({ $t: "missing.key" }, ctx)).toBe("missing.key"); }); it("handles multiple params", () => { const ctx = makeI18nCtx({ count: 3 }); expect( resolvePropValue( { $t: "items.count", params: { count: { $state: "/count" } } }, ctx, ), ).toBe("3 items in cart"); }); }); // ============================================================================ // Composition tests // ============================================================================ describe("directive composition", () => { it("composes $math inside $format", () => { const ctx = makeCtx({ price: 10, qty: 3 }); const result = resolvePropValue( { $format: "currency", value: { $math: "multiply", a: { $state: "/price" }, b: { $state: "/qty" }, }, currency: "USD", }, ctx, ); expect(typeof result).toBe("string"); expect(result).toContain("30"); }); it("composes $count inside $pluralize", () => { const ctx = makeCtx({ items: [1, 2, 3] }); expect( resolvePropValue( { $pluralize: { $count: { $state: "/items" } }, one: "item", other: "items", }, ctx, ), ).toBe("3 items"); }); });