| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497 |
- 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<string, unknown> = {},
- extra: Partial<PropResolutionContext> = {},
- ): 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<string, unknown> = {},
- ): 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");
- });
- });
|