Chris Tate 5 hónapja
szülő
commit
1336489352

+ 3 - 0
.github/workflows/ci.yml

@@ -41,5 +41,8 @@ jobs:
       - name: Type check
         run: pnpm type-check
 
+      - name: Test
+        run: pnpm test
+
       - name: Build
         run: pnpm build

+ 8 - 1
package.json

@@ -17,14 +17,21 @@
     "format": "prettier --write \"**/*.{ts,tsx}\"",
     "type-check": "turbo run check-types",
     "check-types": "turbo run check-types",
+    "test": "vitest run",
+    "test:watch": "vitest",
+    "test:coverage": "vitest run --coverage",
     "prepare": "husky"
   },
   "devDependencies": {
+    "@testing-library/dom": "^10.4.1",
+    "@testing-library/react": "^16.3.1",
     "husky": "^9.1.7",
+    "jsdom": "^27.4.0",
     "lint-staged": "^16.2.7",
     "prettier": "^3.7.4",
     "turbo": "^2.7.4",
-    "typescript": "5.9.2"
+    "typescript": "5.9.2",
+    "vitest": "^4.0.17"
   },
   "packageManager": "pnpm@9.0.0",
   "engines": {

+ 284 - 0
packages/core/src/actions.test.ts

@@ -0,0 +1,284 @@
+import { describe, it, expect, vi } from "vitest";
+import {
+  resolveAction,
+  executeAction,
+  interpolateString,
+  action,
+} from "./actions";
+
+describe("interpolateString", () => {
+  it("interpolates ${path} expressions", () => {
+    const data = { user: { name: "Alice" }, count: 5 };
+
+    expect(interpolateString("Hello ${/user/name}!", data)).toBe(
+      "Hello Alice!",
+    );
+    expect(interpolateString("${/user/name} has ${/count} items", data)).toBe(
+      "Alice has 5 items",
+    );
+  });
+
+  it("returns string unchanged when no variables", () => {
+    expect(interpolateString("No vars here", {})).toBe("No vars here");
+  });
+
+  it("replaces missing values with empty string", () => {
+    expect(interpolateString("Hello ${/missing}!", {})).toBe("Hello !");
+  });
+
+  it("handles multiple occurrences of same variable", () => {
+    const data = { name: "Bob" };
+    expect(interpolateString("${/name} says ${/name}", data)).toBe(
+      "Bob says Bob",
+    );
+  });
+});
+
+describe("resolveAction", () => {
+  it("resolves literal params", () => {
+    const resolved = resolveAction(
+      {
+        name: "navigate",
+        params: { url: "/home", count: 5 },
+      },
+      {},
+    );
+
+    expect(resolved.name).toBe("navigate");
+    expect(resolved.params.url).toBe("/home");
+    expect(resolved.params.count).toBe(5);
+  });
+
+  it("resolves dynamic path params", () => {
+    const data = { userId: 123, settings: { theme: "dark" } };
+    const resolved = resolveAction(
+      {
+        name: "updateUser",
+        params: {
+          id: { path: "/userId" },
+          theme: { path: "/settings/theme" },
+        },
+      },
+      data,
+    );
+
+    expect(resolved.params.id).toBe(123);
+    expect(resolved.params.theme).toBe("dark");
+  });
+
+  it("interpolates confirmation messages", () => {
+    const data = { user: { name: "Alice" } };
+    const resolved = resolveAction(
+      {
+        name: "delete",
+        confirm: {
+          title: "Delete ${/user/name}",
+          message: "Are you sure you want to delete ${/user/name}?",
+        },
+      },
+      data,
+    );
+
+    expect(resolved.confirm?.title).toBe("Delete Alice");
+    expect(resolved.confirm?.message).toBe(
+      "Are you sure you want to delete Alice?",
+    );
+  });
+
+  it("preserves onSuccess and onError handlers", () => {
+    const resolved = resolveAction(
+      {
+        name: "save",
+        onSuccess: { navigate: "/success" },
+        onError: { set: { error: "$error.message" } },
+      },
+      {},
+    );
+
+    expect(resolved.onSuccess).toEqual({ navigate: "/success" });
+    expect(resolved.onError).toEqual({ set: { error: "$error.message" } });
+  });
+});
+
+describe("executeAction", () => {
+  it("calls the handler with resolved params", async () => {
+    const handler = vi.fn().mockResolvedValue(undefined);
+
+    await executeAction({
+      action: { name: "test", params: { value: 42 } },
+      handler,
+      setData: vi.fn(),
+    });
+
+    expect(handler).toHaveBeenCalledWith({ value: 42 });
+  });
+
+  it("handles onSuccess with navigate", async () => {
+    const navigate = vi.fn();
+
+    await executeAction({
+      action: {
+        name: "test",
+        params: {},
+        onSuccess: { navigate: "/success" },
+      },
+      handler: vi.fn().mockResolvedValue(undefined),
+      setData: vi.fn(),
+      navigate,
+    });
+
+    expect(navigate).toHaveBeenCalledWith("/success");
+  });
+
+  it("handles onSuccess with set", async () => {
+    const setData = vi.fn();
+
+    await executeAction({
+      action: {
+        name: "test",
+        params: {},
+        onSuccess: { set: { saved: true, message: "Done" } },
+      },
+      handler: vi.fn().mockResolvedValue(undefined),
+      setData,
+    });
+
+    expect(setData).toHaveBeenCalledWith("saved", true);
+    expect(setData).toHaveBeenCalledWith("message", "Done");
+  });
+
+  it("handles onSuccess with action", async () => {
+    const executeActionFn = vi.fn();
+
+    await executeAction({
+      action: {
+        name: "test",
+        params: {},
+        onSuccess: { action: "followUp" },
+      },
+      handler: vi.fn().mockResolvedValue(undefined),
+      setData: vi.fn(),
+      executeAction: executeActionFn,
+    });
+
+    expect(executeActionFn).toHaveBeenCalledWith("followUp");
+  });
+
+  it("handles onError with set", async () => {
+    const setData = vi.fn();
+    const error = new Error("Something went wrong");
+
+    await executeAction({
+      action: {
+        name: "test",
+        params: {},
+        onError: { set: { error: "$error.message" } },
+      },
+      handler: vi.fn().mockRejectedValue(error),
+      setData,
+    });
+
+    expect(setData).toHaveBeenCalledWith("error", "Something went wrong");
+  });
+
+  it("handles onError with action", async () => {
+    const executeActionFn = vi.fn();
+    const error = new Error("Failed");
+
+    await executeAction({
+      action: {
+        name: "test",
+        params: {},
+        onError: { action: "handleError" },
+      },
+      handler: vi.fn().mockRejectedValue(error),
+      setData: vi.fn(),
+      executeAction: executeActionFn,
+    });
+
+    expect(executeActionFn).toHaveBeenCalledWith("handleError");
+  });
+
+  it("re-throws error when no onError handler", async () => {
+    const error = new Error("Unhandled");
+
+    await expect(
+      executeAction({
+        action: { name: "test", params: {} },
+        handler: vi.fn().mockRejectedValue(error),
+        setData: vi.fn(),
+      }),
+    ).rejects.toThrow("Unhandled");
+  });
+});
+
+describe("action helper", () => {
+  describe("simple", () => {
+    it("creates action with name only", () => {
+      const a = action.simple("navigate");
+
+      expect(a.name).toBe("navigate");
+      expect(a.params).toBeUndefined();
+    });
+
+    it("creates action with params", () => {
+      const a = action.simple("navigate", { url: "/home" });
+
+      expect(a.name).toBe("navigate");
+      expect(a.params).toEqual({ url: "/home" });
+    });
+  });
+
+  describe("withConfirm", () => {
+    it("creates action with confirmation", () => {
+      const a = action.withConfirm("delete", {
+        title: "Confirm",
+        message: "Are you sure?",
+      });
+
+      expect(a.name).toBe("delete");
+      expect(a.confirm).toEqual({
+        title: "Confirm",
+        message: "Are you sure?",
+      });
+    });
+
+    it("creates action with confirmation and params", () => {
+      const a = action.withConfirm(
+        "delete",
+        { title: "Delete", message: "Delete item?" },
+        { id: 123 },
+      );
+
+      expect(a.name).toBe("delete");
+      expect(a.params).toEqual({ id: 123 });
+      expect(a.confirm?.title).toBe("Delete");
+    });
+  });
+
+  describe("withSuccess", () => {
+    it("creates action with navigate success handler", () => {
+      const a = action.withSuccess("save", { navigate: "/success" });
+
+      expect(a.name).toBe("save");
+      expect(a.onSuccess).toEqual({ navigate: "/success" });
+    });
+
+    it("creates action with set success handler", () => {
+      const a = action.withSuccess("save", { set: { saved: true } });
+
+      expect(a.onSuccess).toEqual({ set: { saved: true } });
+    });
+
+    it("creates action with success handler and params", () => {
+      const a = action.withSuccess(
+        "save",
+        { navigate: "/done" },
+        { data: "test" },
+      );
+
+      expect(a.params).toEqual({ data: "test" });
+      expect(a.onSuccess).toEqual({ navigate: "/done" });
+    });
+  });
+});

+ 198 - 0
packages/core/src/catalog.test.ts

@@ -0,0 +1,198 @@
+import { describe, it, expect } from "vitest";
+import { z } from "zod";
+import { createCatalog, generateCatalogPrompt } from "./catalog";
+
+describe("createCatalog", () => {
+  it("creates catalog with components", () => {
+    const catalog = createCatalog({
+      components: {
+        text: {
+          props: z.object({ content: z.string() }),
+          description: "Display text",
+        },
+        button: {
+          props: z.object({ label: z.string() }),
+          description: "A clickable button",
+        },
+      },
+    });
+
+    expect(catalog.componentNames).toHaveLength(2);
+    expect(catalog.hasComponent("text")).toBe(true);
+    expect(catalog.hasComponent("button")).toBe(true);
+    expect(catalog.hasComponent("unknown")).toBe(false);
+  });
+
+  it("creates catalog with actions", () => {
+    const catalog = createCatalog({
+      components: {
+        button: { props: z.object({ label: z.string() }) },
+      },
+      actions: {
+        navigate: { description: "Navigate to URL" },
+        submit: { description: "Submit form" },
+      },
+    });
+
+    expect(catalog.actionNames).toHaveLength(2);
+    expect(catalog.hasAction("navigate")).toBe(true);
+    expect(catalog.hasAction("submit")).toBe(true);
+    expect(catalog.hasAction("unknown")).toBe(false);
+  });
+
+  it("creates catalog with custom validation functions", () => {
+    const catalog = createCatalog({
+      components: {
+        input: { props: z.object({ value: z.string() }) },
+      },
+      functions: {
+        customValidator: {
+          validate: (value) => typeof value === "string" && value.length > 0,
+          description: "Custom validation",
+        },
+      },
+    });
+
+    expect(catalog.functionNames).toHaveLength(1);
+    expect(catalog.hasFunction("customValidator")).toBe(true);
+  });
+
+  it("validates elements correctly", () => {
+    const catalog = createCatalog({
+      components: {
+        text: {
+          props: z.object({ content: z.string() }),
+        },
+      },
+    });
+
+    const validElement = {
+      key: "1",
+      type: "text",
+      props: { content: "Hello" },
+    };
+    const invalidElement = {
+      key: "1",
+      type: "text",
+      props: { content: 123 },
+    };
+
+    expect(catalog.validateElement(validElement).success).toBe(true);
+    expect(catalog.validateElement(invalidElement).success).toBe(false);
+  });
+
+  it("validates UI trees", () => {
+    const catalog = createCatalog({
+      components: {
+        text: { props: z.object({ content: z.string() }) },
+      },
+    });
+
+    const validTree = {
+      root: "1",
+      elements: {
+        "1": { key: "1", type: "text", props: { content: "Hello" } },
+      },
+    };
+
+    expect(catalog.validateTree(validTree).success).toBe(true);
+  });
+
+  it("uses default name when not provided", () => {
+    const catalog = createCatalog({
+      components: {
+        text: { props: z.object({ content: z.string() }) },
+      },
+    });
+
+    expect(catalog.name).toBe("unnamed");
+  });
+
+  it("uses provided name", () => {
+    const catalog = createCatalog({
+      name: "MyCatalog",
+      components: {
+        text: { props: z.object({ content: z.string() }) },
+      },
+    });
+
+    expect(catalog.name).toBe("MyCatalog");
+  });
+});
+
+describe("generateCatalogPrompt", () => {
+  it("generates prompt containing catalog name", () => {
+    const catalog = createCatalog({
+      name: "TestCatalog",
+      components: {
+        text: {
+          props: z.object({ content: z.string() }),
+          description: "Display text content",
+        },
+      },
+    });
+
+    const prompt = generateCatalogPrompt(catalog);
+
+    expect(prompt).toContain("TestCatalog");
+  });
+
+  it("includes component descriptions", () => {
+    const catalog = createCatalog({
+      components: {
+        text: {
+          props: z.object({ content: z.string() }),
+          description: "Display text content",
+        },
+      },
+    });
+
+    const prompt = generateCatalogPrompt(catalog);
+
+    expect(prompt).toContain("text");
+    expect(prompt).toContain("Display text content");
+  });
+
+  it("includes action descriptions", () => {
+    const catalog = createCatalog({
+      components: {
+        button: { props: z.object({ label: z.string() }) },
+      },
+      actions: {
+        alert: { description: "Show alert message" },
+      },
+    });
+
+    const prompt = generateCatalogPrompt(catalog);
+
+    expect(prompt).toContain("alert");
+    expect(prompt).toContain("Show alert message");
+  });
+
+  it("includes visibility documentation", () => {
+    const catalog = createCatalog({
+      components: {
+        text: { props: z.object({ content: z.string() }) },
+      },
+    });
+
+    const prompt = generateCatalogPrompt(catalog);
+
+    expect(prompt).toContain("Visibility");
+    expect(prompt).toContain("visible");
+  });
+
+  it("includes validation documentation", () => {
+    const catalog = createCatalog({
+      components: {
+        text: { props: z.object({ content: z.string() }) },
+      },
+    });
+
+    const prompt = generateCatalogPrompt(catalog);
+
+    expect(prompt).toContain("Validation");
+    expect(prompt).toContain("required");
+    expect(prompt).toContain("email");
+  });
+});

+ 125 - 0
packages/core/src/types.test.ts

@@ -0,0 +1,125 @@
+import { describe, it, expect } from "vitest";
+import { resolveDynamicValue, getByPath, setByPath } from "./types";
+
+describe("getByPath", () => {
+  it("gets nested values with JSON pointer paths", () => {
+    const data = { user: { name: "John", scores: [10, 20, 30] } };
+
+    expect(getByPath(data, "/user/name")).toBe("John");
+    expect(getByPath(data, "/user/scores/0")).toBe(10);
+    expect(getByPath(data, "/user/scores/1")).toBe(20);
+  });
+
+  it("returns root object for empty or root path", () => {
+    const data = { value: 42 };
+
+    expect(getByPath(data, "/")).toBe(data);
+    expect(getByPath(data, "")).toBe(data);
+  });
+
+  it("returns undefined for missing paths", () => {
+    const data = { user: { name: "John" } };
+
+    expect(getByPath(data, "/user/missing")).toBeUndefined();
+    expect(getByPath(data, "/nonexistent/path")).toBeUndefined();
+  });
+
+  it("handles paths without leading slash", () => {
+    const data = { user: { name: "John" } };
+
+    expect(getByPath(data, "user/name")).toBe("John");
+  });
+
+  it("returns undefined when traversing through non-object", () => {
+    const data = { value: "string" };
+
+    expect(getByPath(data, "/value/nested")).toBeUndefined();
+  });
+
+  it("handles null values in path", () => {
+    const data = { user: null };
+
+    expect(getByPath(data, "/user/name")).toBeUndefined();
+  });
+});
+
+describe("setByPath", () => {
+  it("sets value at existing path", () => {
+    const data: Record<string, unknown> = { user: { name: "John" } };
+
+    setByPath(data, "/user/name", "Alice");
+
+    expect((data.user as Record<string, unknown>).name).toBe("Alice");
+  });
+
+  it("creates intermediate objects for deep paths", () => {
+    const data: Record<string, unknown> = {};
+
+    setByPath(data, "/a/b/c", "deep");
+
+    expect(
+      ((data.a as Record<string, unknown>).b as Record<string, unknown>).c,
+    ).toBe("deep");
+  });
+
+  it("handles paths without leading slash", () => {
+    const data: Record<string, unknown> = {};
+
+    setByPath(data, "user/name", "John");
+
+    expect((data.user as Record<string, unknown>).name).toBe("John");
+  });
+
+  it("overwrites existing values", () => {
+    const data: Record<string, unknown> = { count: 5 };
+
+    setByPath(data, "/count", 10);
+
+    expect(data.count).toBe(10);
+  });
+
+  it("handles array-like paths", () => {
+    const data: Record<string, unknown> = { items: {} };
+
+    setByPath(data, "/items/0", "first");
+
+    expect((data.items as Record<string, unknown>)["0"]).toBe("first");
+  });
+});
+
+describe("resolveDynamicValue", () => {
+  it("resolves literal string values", () => {
+    expect(resolveDynamicValue("hello", {})).toBe("hello");
+  });
+
+  it("resolves literal number values", () => {
+    expect(resolveDynamicValue(42, {})).toBe(42);
+  });
+
+  it("resolves literal boolean values", () => {
+    expect(resolveDynamicValue(true, {})).toBe(true);
+    expect(resolveDynamicValue(false, {})).toBe(false);
+  });
+
+  it("resolves path references", () => {
+    const data = { user: { name: "Alice" } };
+
+    expect(resolveDynamicValue({ path: "/user/name" }, data)).toBe("Alice");
+  });
+
+  it("returns undefined for missing path references", () => {
+    const data = { user: { name: "Alice" } };
+
+    expect(resolveDynamicValue({ path: "/missing" }, data)).toBeUndefined();
+  });
+
+  it("returns undefined for null value", () => {
+    expect(resolveDynamicValue(null as unknown as string, {})).toBeUndefined();
+  });
+
+  it("returns undefined for undefined value", () => {
+    expect(
+      resolveDynamicValue(undefined as unknown as string, {}),
+    ).toBeUndefined();
+  });
+});

+ 403 - 0
packages/core/src/validation.test.ts

@@ -0,0 +1,403 @@
+import { describe, it, expect } from "vitest";
+import {
+  builtInValidationFunctions,
+  runValidationCheck,
+  runValidation,
+  check,
+} from "./validation";
+
+describe("builtInValidationFunctions", () => {
+  describe("required", () => {
+    it("passes for non-empty values", () => {
+      expect(builtInValidationFunctions.required("hello")).toBe(true);
+      expect(builtInValidationFunctions.required(0)).toBe(true);
+      expect(builtInValidationFunctions.required(false)).toBe(true);
+      expect(builtInValidationFunctions.required(["item"])).toBe(true);
+      expect(builtInValidationFunctions.required({ key: "value" })).toBe(true);
+    });
+
+    it("fails for empty values", () => {
+      expect(builtInValidationFunctions.required("")).toBe(false);
+      expect(builtInValidationFunctions.required("   ")).toBe(false);
+      expect(builtInValidationFunctions.required(null)).toBe(false);
+      expect(builtInValidationFunctions.required(undefined)).toBe(false);
+      expect(builtInValidationFunctions.required([])).toBe(false);
+    });
+  });
+
+  describe("email", () => {
+    it("passes for valid emails", () => {
+      expect(builtInValidationFunctions.email("test@example.com")).toBe(true);
+      expect(builtInValidationFunctions.email("user.name@domain.co")).toBe(
+        true,
+      );
+      expect(builtInValidationFunctions.email("a@b.c")).toBe(true);
+    });
+
+    it("fails for invalid emails", () => {
+      expect(builtInValidationFunctions.email("invalid")).toBe(false);
+      expect(builtInValidationFunctions.email("missing@domain")).toBe(false);
+      expect(builtInValidationFunctions.email("@domain.com")).toBe(false);
+      expect(builtInValidationFunctions.email("user@")).toBe(false);
+      expect(builtInValidationFunctions.email(123)).toBe(false);
+    });
+  });
+
+  describe("minLength", () => {
+    it("passes when string meets minimum length", () => {
+      expect(builtInValidationFunctions.minLength("hello", { min: 3 })).toBe(
+        true,
+      );
+      expect(builtInValidationFunctions.minLength("abc", { min: 3 })).toBe(
+        true,
+      );
+      expect(builtInValidationFunctions.minLength("abcdef", { min: 3 })).toBe(
+        true,
+      );
+    });
+
+    it("fails when string is too short", () => {
+      expect(builtInValidationFunctions.minLength("hi", { min: 3 })).toBe(
+        false,
+      );
+      expect(builtInValidationFunctions.minLength("", { min: 1 })).toBe(false);
+    });
+
+    it("fails for non-strings", () => {
+      expect(builtInValidationFunctions.minLength(123, { min: 1 })).toBe(false);
+    });
+
+    it("fails when min is not provided", () => {
+      expect(builtInValidationFunctions.minLength("hello", {})).toBe(false);
+    });
+  });
+
+  describe("maxLength", () => {
+    it("passes when string meets maximum length", () => {
+      expect(builtInValidationFunctions.maxLength("hi", { max: 5 })).toBe(true);
+      expect(builtInValidationFunctions.maxLength("hello", { max: 5 })).toBe(
+        true,
+      );
+    });
+
+    it("fails when string exceeds maximum", () => {
+      expect(builtInValidationFunctions.maxLength("hello!", { max: 5 })).toBe(
+        false,
+      );
+    });
+  });
+
+  describe("pattern", () => {
+    it("passes when string matches pattern", () => {
+      expect(
+        builtInValidationFunctions.pattern("abc123", {
+          pattern: "^[a-z0-9]+$",
+        }),
+      ).toBe(true);
+    });
+
+    it("fails when string does not match pattern", () => {
+      expect(
+        builtInValidationFunctions.pattern("ABC", { pattern: "^[a-z]+$" }),
+      ).toBe(false);
+    });
+
+    it("fails for invalid regex pattern", () => {
+      expect(
+        builtInValidationFunctions.pattern("test", { pattern: "[invalid" }),
+      ).toBe(false);
+    });
+  });
+
+  describe("min", () => {
+    it("passes when number meets minimum", () => {
+      expect(builtInValidationFunctions.min(5, { min: 3 })).toBe(true);
+      expect(builtInValidationFunctions.min(3, { min: 3 })).toBe(true);
+    });
+
+    it("fails when number is below minimum", () => {
+      expect(builtInValidationFunctions.min(2, { min: 3 })).toBe(false);
+    });
+
+    it("fails for non-numbers", () => {
+      expect(builtInValidationFunctions.min("5", { min: 3 })).toBe(false);
+    });
+  });
+
+  describe("max", () => {
+    it("passes when number meets maximum", () => {
+      expect(builtInValidationFunctions.max(3, { max: 5 })).toBe(true);
+      expect(builtInValidationFunctions.max(5, { max: 5 })).toBe(true);
+    });
+
+    it("fails when number exceeds maximum", () => {
+      expect(builtInValidationFunctions.max(6, { max: 5 })).toBe(false);
+    });
+  });
+
+  describe("numeric", () => {
+    it("passes for numbers", () => {
+      expect(builtInValidationFunctions.numeric(42)).toBe(true);
+      expect(builtInValidationFunctions.numeric(3.14)).toBe(true);
+      expect(builtInValidationFunctions.numeric(0)).toBe(true);
+    });
+
+    it("passes for numeric strings", () => {
+      expect(builtInValidationFunctions.numeric("42")).toBe(true);
+      expect(builtInValidationFunctions.numeric("3.14")).toBe(true);
+    });
+
+    it("fails for non-numeric values", () => {
+      expect(builtInValidationFunctions.numeric("abc")).toBe(false);
+      expect(builtInValidationFunctions.numeric(NaN)).toBe(false);
+      expect(builtInValidationFunctions.numeric(null)).toBe(false);
+    });
+  });
+
+  describe("url", () => {
+    it("passes for valid URLs", () => {
+      expect(builtInValidationFunctions.url("https://example.com")).toBe(true);
+      expect(builtInValidationFunctions.url("http://localhost:3000")).toBe(
+        true,
+      );
+      expect(
+        builtInValidationFunctions.url("https://example.com/path?query=1"),
+      ).toBe(true);
+    });
+
+    it("fails for invalid URLs", () => {
+      expect(builtInValidationFunctions.url("not-a-url")).toBe(false);
+      expect(builtInValidationFunctions.url("example.com")).toBe(false);
+    });
+  });
+
+  describe("matches", () => {
+    it("passes when values match", () => {
+      expect(
+        builtInValidationFunctions.matches("password", { other: "password" }),
+      ).toBe(true);
+      expect(builtInValidationFunctions.matches(123, { other: 123 })).toBe(
+        true,
+      );
+    });
+
+    it("fails when values do not match", () => {
+      expect(
+        builtInValidationFunctions.matches("password", { other: "different" }),
+      ).toBe(false);
+    });
+  });
+});
+
+describe("runValidationCheck", () => {
+  it("runs a validation check and returns result", () => {
+    const result = runValidationCheck(
+      { fn: "required", message: "Required" },
+      { value: "hello", dataModel: {} },
+    );
+
+    expect(result.fn).toBe("required");
+    expect(result.valid).toBe(true);
+    expect(result.message).toBe("Required");
+  });
+
+  it("resolves dynamic args from dataModel", () => {
+    const result = runValidationCheck(
+      {
+        fn: "minLength",
+        args: { min: { path: "/minLen" } },
+        message: "Too short",
+      },
+      { value: "hi", dataModel: { minLen: 5 } },
+    );
+
+    expect(result.valid).toBe(false);
+  });
+
+  it("returns valid for unknown functions with warning", () => {
+    const result = runValidationCheck(
+      { fn: "unknownFunction", message: "Unknown" },
+      { value: "test", dataModel: {} },
+    );
+
+    expect(result.valid).toBe(true);
+  });
+
+  it("uses custom validation functions", () => {
+    const customFunctions = {
+      startsWithA: (value: unknown) =>
+        typeof value === "string" && value.startsWith("A"),
+    };
+
+    const result = runValidationCheck(
+      { fn: "startsWithA", message: "Must start with A" },
+      { value: "Apple", dataModel: {}, customFunctions },
+    );
+
+    expect(result.valid).toBe(true);
+  });
+});
+
+describe("runValidation", () => {
+  it("runs all validation checks", () => {
+    const result = runValidation(
+      {
+        checks: [
+          { fn: "required", message: "Required" },
+          { fn: "email", message: "Invalid email" },
+        ],
+      },
+      { value: "test@example.com", dataModel: {} },
+    );
+
+    expect(result.valid).toBe(true);
+    expect(result.errors).toHaveLength(0);
+    expect(result.checks).toHaveLength(2);
+  });
+
+  it("collects all errors", () => {
+    const result = runValidation(
+      {
+        checks: [
+          { fn: "required", message: "Required" },
+          { fn: "email", message: "Invalid email" },
+        ],
+      },
+      { value: "", dataModel: {} },
+    );
+
+    expect(result.valid).toBe(false);
+    expect(result.errors).toContain("Required");
+    expect(result.errors).toContain("Invalid email");
+  });
+
+  it("skips validation when enabled condition is false", () => {
+    const result = runValidation(
+      {
+        checks: [{ fn: "required", message: "Required" }],
+        enabled: { eq: [1, 2] }, // Always false
+      },
+      { value: "", dataModel: {} },
+    );
+
+    expect(result.valid).toBe(true);
+    expect(result.checks).toHaveLength(0);
+  });
+
+  it("runs validation when enabled condition is true", () => {
+    const result = runValidation(
+      {
+        checks: [{ fn: "required", message: "Required" }],
+        enabled: { eq: [1, 1] }, // Always true
+      },
+      { value: "", dataModel: {} },
+    );
+
+    expect(result.valid).toBe(false);
+  });
+
+  it("returns valid when no checks defined", () => {
+    const result = runValidation({}, { value: "", dataModel: {} });
+
+    expect(result.valid).toBe(true);
+    expect(result.checks).toHaveLength(0);
+  });
+});
+
+describe("check helper", () => {
+  describe("required", () => {
+    it("creates required check with default message", () => {
+      const c = check.required();
+
+      expect(c.fn).toBe("required");
+      expect(c.message).toBe("This field is required");
+    });
+
+    it("creates required check with custom message", () => {
+      const c = check.required("Custom message");
+
+      expect(c.message).toBe("Custom message");
+    });
+  });
+
+  describe("email", () => {
+    it("creates email check with default message", () => {
+      const c = check.email();
+
+      expect(c.fn).toBe("email");
+      expect(c.message).toBe("Invalid email address");
+    });
+  });
+
+  describe("minLength", () => {
+    it("creates minLength check with args", () => {
+      const c = check.minLength(5, "Too short");
+
+      expect(c.fn).toBe("minLength");
+      expect(c.args).toEqual({ min: 5 });
+      expect(c.message).toBe("Too short");
+    });
+
+    it("uses default message", () => {
+      const c = check.minLength(3);
+
+      expect(c.message).toBe("Must be at least 3 characters");
+    });
+  });
+
+  describe("maxLength", () => {
+    it("creates maxLength check with args", () => {
+      const c = check.maxLength(100);
+
+      expect(c.fn).toBe("maxLength");
+      expect(c.args).toEqual({ max: 100 });
+    });
+  });
+
+  describe("pattern", () => {
+    it("creates pattern check", () => {
+      const c = check.pattern("^[a-z]+$", "Letters only");
+
+      expect(c.fn).toBe("pattern");
+      expect(c.args).toEqual({ pattern: "^[a-z]+$" });
+      expect(c.message).toBe("Letters only");
+    });
+  });
+
+  describe("min", () => {
+    it("creates min check", () => {
+      const c = check.min(0, "Must be positive");
+
+      expect(c.fn).toBe("min");
+      expect(c.args).toEqual({ min: 0 });
+    });
+  });
+
+  describe("max", () => {
+    it("creates max check", () => {
+      const c = check.max(100);
+
+      expect(c.fn).toBe("max");
+      expect(c.args).toEqual({ max: 100 });
+    });
+  });
+
+  describe("url", () => {
+    it("creates url check", () => {
+      const c = check.url("Must be a URL");
+
+      expect(c.fn).toBe("url");
+      expect(c.message).toBe("Must be a URL");
+    });
+  });
+
+  describe("matches", () => {
+    it("creates matches check with path reference", () => {
+      const c = check.matches("/password", "Passwords must match");
+
+      expect(c.fn).toBe("matches");
+      expect(c.args).toEqual({ other: { path: "/password" } });
+      expect(c.message).toBe("Passwords must match");
+    });
+  });
+});

+ 274 - 0
packages/core/src/visibility.test.ts

@@ -0,0 +1,274 @@
+import { describe, it, expect } from "vitest";
+import {
+  evaluateLogicExpression,
+  evaluateVisibility,
+  visibility,
+} from "./visibility";
+
+describe("evaluateLogicExpression", () => {
+  const emptyContext = { dataModel: {} };
+
+  describe("comparison operators", () => {
+    it("evaluates eq expression", () => {
+      expect(evaluateLogicExpression({ eq: [1, 1] }, emptyContext)).toBe(true);
+      expect(evaluateLogicExpression({ eq: [1, 2] }, emptyContext)).toBe(false);
+      expect(evaluateLogicExpression({ eq: ["a", "a"] }, emptyContext)).toBe(
+        true,
+      );
+    });
+
+    it("evaluates neq expression", () => {
+      expect(evaluateLogicExpression({ neq: [1, 2] }, emptyContext)).toBe(true);
+      expect(evaluateLogicExpression({ neq: [1, 1] }, emptyContext)).toBe(
+        false,
+      );
+    });
+
+    it("evaluates gt expression", () => {
+      expect(evaluateLogicExpression({ gt: [5, 3] }, emptyContext)).toBe(true);
+      expect(evaluateLogicExpression({ gt: [3, 5] }, emptyContext)).toBe(false);
+      expect(evaluateLogicExpression({ gt: [5, 5] }, emptyContext)).toBe(false);
+    });
+
+    it("evaluates gte expression", () => {
+      expect(evaluateLogicExpression({ gte: [5, 5] }, emptyContext)).toBe(true);
+      expect(evaluateLogicExpression({ gte: [6, 5] }, emptyContext)).toBe(true);
+      expect(evaluateLogicExpression({ gte: [4, 5] }, emptyContext)).toBe(
+        false,
+      );
+    });
+
+    it("evaluates lt expression", () => {
+      expect(evaluateLogicExpression({ lt: [3, 5] }, emptyContext)).toBe(true);
+      expect(evaluateLogicExpression({ lt: [5, 3] }, emptyContext)).toBe(false);
+      expect(evaluateLogicExpression({ lt: [5, 5] }, emptyContext)).toBe(false);
+    });
+
+    it("evaluates lte expression", () => {
+      expect(evaluateLogicExpression({ lte: [5, 5] }, emptyContext)).toBe(true);
+      expect(evaluateLogicExpression({ lte: [4, 5] }, emptyContext)).toBe(true);
+      expect(evaluateLogicExpression({ lte: [6, 5] }, emptyContext)).toBe(
+        false,
+      );
+    });
+  });
+
+  describe("boolean operators", () => {
+    it("evaluates and expression", () => {
+      expect(
+        evaluateLogicExpression(
+          { and: [{ eq: [1, 1] }, { eq: [2, 2] }] },
+          emptyContext,
+        ),
+      ).toBe(true);
+      expect(
+        evaluateLogicExpression(
+          { and: [{ eq: [1, 1] }, { eq: [1, 2] }] },
+          emptyContext,
+        ),
+      ).toBe(false);
+      expect(
+        evaluateLogicExpression(
+          { and: [{ eq: [1, 2] }, { eq: [1, 2] }] },
+          emptyContext,
+        ),
+      ).toBe(false);
+    });
+
+    it("evaluates or expression", () => {
+      expect(
+        evaluateLogicExpression(
+          { or: [{ eq: [1, 2] }, { eq: [2, 2] }] },
+          emptyContext,
+        ),
+      ).toBe(true);
+      expect(
+        evaluateLogicExpression(
+          { or: [{ eq: [1, 1] }, { eq: [1, 2] }] },
+          emptyContext,
+        ),
+      ).toBe(true);
+      expect(
+        evaluateLogicExpression(
+          { or: [{ eq: [1, 2] }, { eq: [3, 4] }] },
+          emptyContext,
+        ),
+      ).toBe(false);
+    });
+
+    it("evaluates not expression", () => {
+      expect(
+        evaluateLogicExpression({ not: { eq: [1, 2] } }, emptyContext),
+      ).toBe(true);
+      expect(
+        evaluateLogicExpression({ not: { eq: [1, 1] } }, emptyContext),
+      ).toBe(false);
+    });
+  });
+
+  describe("path expressions", () => {
+    it("evaluates truthy path values", () => {
+      const ctx = { dataModel: { isAdmin: true, count: 5, name: "John" } };
+
+      expect(evaluateLogicExpression({ path: "/isAdmin" }, ctx)).toBe(true);
+      expect(evaluateLogicExpression({ path: "/count" }, ctx)).toBe(true);
+      expect(evaluateLogicExpression({ path: "/name" }, ctx)).toBe(true);
+    });
+
+    it("evaluates falsy path values", () => {
+      const ctx = {
+        dataModel: { isAdmin: false, count: 0, name: "", nothing: null },
+      };
+
+      expect(evaluateLogicExpression({ path: "/isAdmin" }, ctx)).toBe(false);
+      expect(evaluateLogicExpression({ path: "/count" }, ctx)).toBe(false);
+      expect(evaluateLogicExpression({ path: "/name" }, ctx)).toBe(false);
+      expect(evaluateLogicExpression({ path: "/nothing" }, ctx)).toBe(false);
+    });
+
+    it("evaluates missing paths as false", () => {
+      expect(
+        evaluateLogicExpression({ path: "/nonexistent" }, emptyContext),
+      ).toBe(false);
+    });
+  });
+
+  describe("with dynamic path references", () => {
+    it("resolves path references in comparisons", () => {
+      const ctx = { dataModel: { count: 5, limit: 10 } };
+
+      expect(
+        evaluateLogicExpression(
+          { lt: [{ path: "/count" }, { path: "/limit" }] },
+          ctx,
+        ),
+      ).toBe(true);
+      expect(
+        evaluateLogicExpression({ eq: [{ path: "/count" }, 5] }, ctx),
+      ).toBe(true);
+    });
+  });
+});
+
+describe("evaluateVisibility", () => {
+  it("returns true for undefined condition", () => {
+    expect(evaluateVisibility(undefined, { dataModel: {} })).toBe(true);
+  });
+
+  it("evaluates boolean literals", () => {
+    expect(evaluateVisibility(true, { dataModel: {} })).toBe(true);
+    expect(evaluateVisibility(false, { dataModel: {} })).toBe(false);
+  });
+
+  it("evaluates path conditions", () => {
+    expect(
+      evaluateVisibility(
+        { path: "/visible" },
+        { dataModel: { visible: true } },
+      ),
+    ).toBe(true);
+    expect(
+      evaluateVisibility(
+        { path: "/visible" },
+        { dataModel: { visible: false } },
+      ),
+    ).toBe(false);
+  });
+
+  it("evaluates auth signedIn condition", () => {
+    expect(
+      evaluateVisibility(
+        { auth: "signedIn" },
+        { dataModel: {}, authState: { isSignedIn: true } },
+      ),
+    ).toBe(true);
+    expect(
+      evaluateVisibility(
+        { auth: "signedIn" },
+        { dataModel: {}, authState: { isSignedIn: false } },
+      ),
+    ).toBe(false);
+    expect(evaluateVisibility({ auth: "signedIn" }, { dataModel: {} })).toBe(
+      false,
+    );
+  });
+
+  it("evaluates auth signedOut condition", () => {
+    expect(
+      evaluateVisibility(
+        { auth: "signedOut" },
+        { dataModel: {}, authState: { isSignedIn: false } },
+      ),
+    ).toBe(true);
+    expect(
+      evaluateVisibility(
+        { auth: "signedOut" },
+        { dataModel: {}, authState: { isSignedIn: true } },
+      ),
+    ).toBe(false);
+  });
+
+  it("evaluates logic expressions", () => {
+    expect(evaluateVisibility({ eq: [1, 1] }, { dataModel: {} })).toBe(true);
+    expect(
+      evaluateVisibility(
+        { and: [{ eq: [1, 1] }, { eq: [2, 2] }] },
+        { dataModel: {} },
+      ),
+    ).toBe(true);
+  });
+});
+
+describe("visibility helper", () => {
+  it("creates always condition", () => {
+    expect(visibility.always).toBe(true);
+  });
+
+  it("creates never condition", () => {
+    expect(visibility.never).toBe(false);
+  });
+
+  it("creates when (path) condition", () => {
+    expect(visibility.when("/user/isAdmin")).toEqual({ path: "/user/isAdmin" });
+  });
+
+  it("creates signedIn condition", () => {
+    expect(visibility.signedIn).toEqual({ auth: "signedIn" });
+  });
+
+  it("creates signedOut condition", () => {
+    expect(visibility.signedOut).toEqual({ auth: "signedOut" });
+  });
+
+  it("creates and condition", () => {
+    const cond1 = { eq: [1, 1] as [number, number] };
+    const cond2 = { eq: [2, 2] as [number, number] };
+    expect(visibility.and(cond1, cond2)).toEqual({ and: [cond1, cond2] });
+  });
+
+  it("creates or condition", () => {
+    const cond1 = { eq: [1, 1] as [number, number] };
+    const cond2 = { eq: [2, 2] as [number, number] };
+    expect(visibility.or(cond1, cond2)).toEqual({ or: [cond1, cond2] });
+  });
+
+  it("creates not condition", () => {
+    const cond = { eq: [1, 2] as [number, number] };
+    expect(visibility.not(cond)).toEqual({ not: cond });
+  });
+
+  it("creates eq condition", () => {
+    expect(visibility.eq(1, 1)).toEqual({ eq: [1, 1] });
+    expect(visibility.eq({ path: "/a" }, { path: "/b" })).toEqual({
+      eq: [{ path: "/a" }, { path: "/b" }],
+    });
+  });
+
+  it("creates comparison conditions", () => {
+    expect(visibility.neq(1, 2)).toEqual({ neq: [1, 2] });
+    expect(visibility.gt(5, 3)).toEqual({ gt: [5, 3] });
+    expect(visibility.gte(5, 5)).toEqual({ gte: [5, 5] });
+    expect(visibility.lt(3, 5)).toEqual({ lt: [3, 5] });
+    expect(visibility.lte(5, 5)).toEqual({ lte: [5, 5] });
+  });
+});

+ 191 - 0
packages/react/src/contexts/data.test.tsx

@@ -0,0 +1,191 @@
+import { describe, it, expect, vi } from "vitest";
+import React from "react";
+import { renderHook, act } from "@testing-library/react";
+import { DataProvider, useData, useDataValue, useDataBinding } from "./data";
+
+describe("DataProvider", () => {
+  it("provides initial data to children", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <DataProvider initialData={{ user: { name: "John" } }}>
+        {children}
+      </DataProvider>
+    );
+
+    const { result } = renderHook(() => useData(), { wrapper });
+
+    expect(result.current.data).toEqual({ user: { name: "John" } });
+  });
+
+  it("provides empty object when no initial data", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <DataProvider>{children}</DataProvider>
+    );
+
+    const { result } = renderHook(() => useData(), { wrapper });
+
+    expect(result.current.data).toEqual({});
+  });
+
+  it("provides auth state to children", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <DataProvider authState={{ isSignedIn: true, user: { id: "123" } }}>
+        {children}
+      </DataProvider>
+    );
+
+    const { result } = renderHook(() => useData(), { wrapper });
+
+    expect(result.current.authState).toEqual({
+      isSignedIn: true,
+      user: { id: "123" },
+    });
+  });
+});
+
+describe("useData", () => {
+  it("provides get function to retrieve values", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <DataProvider initialData={{ user: { name: "John" } }}>
+        {children}
+      </DataProvider>
+    );
+
+    const { result } = renderHook(() => useData(), { wrapper });
+
+    expect(result.current.get("/user/name")).toBe("John");
+  });
+
+  it("allows setting data at path with set function", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <DataProvider initialData={{}}>{children}</DataProvider>
+    );
+
+    const { result } = renderHook(() => useData(), { wrapper });
+
+    act(() => {
+      result.current.set("/user/name", "Alice");
+    });
+
+    expect((result.current.data.user as Record<string, unknown>).name).toBe(
+      "Alice",
+    );
+  });
+
+  it("calls onDataChange callback when data changes", () => {
+    const onDataChange = vi.fn();
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <DataProvider initialData={{}} onDataChange={onDataChange}>
+        {children}
+      </DataProvider>
+    );
+
+    const { result } = renderHook(() => useData(), { wrapper });
+
+    act(() => {
+      result.current.set("/count", 42);
+    });
+
+    expect(onDataChange).toHaveBeenCalledWith("/count", 42);
+  });
+
+  it("allows updating multiple values with update function", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <DataProvider initialData={{}}>{children}</DataProvider>
+    );
+
+    const { result } = renderHook(() => useData(), { wrapper });
+
+    act(() => {
+      result.current.update({
+        "/name": "John",
+        "/age": 30,
+      });
+    });
+
+    expect(result.current.data.name).toBe("John");
+    expect(result.current.data.age).toBe(30);
+  });
+});
+
+describe("useDataValue", () => {
+  it("returns value at specified path", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <DataProvider initialData={{ user: { name: "John", age: 30 } }}>
+        {children}
+      </DataProvider>
+    );
+
+    const { result } = renderHook(() => useDataValue("/user/name"), {
+      wrapper,
+    });
+
+    expect(result.current).toBe("John");
+  });
+
+  it("returns undefined for missing path", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <DataProvider initialData={{}}>{children}</DataProvider>
+    );
+
+    const { result } = renderHook(() => useDataValue("/missing"), { wrapper });
+
+    expect(result.current).toBeUndefined();
+  });
+
+  it("updates when data changes", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <DataProvider initialData={{ count: 0 }}>{children}</DataProvider>
+    );
+
+    // Use a single hook that returns both values
+    const { result, rerender } = renderHook(
+      () => ({
+        data: useData(),
+        value: useDataValue<number>("/count"),
+      }),
+      { wrapper },
+    );
+
+    expect(result.current.value).toBe(0);
+
+    act(() => {
+      result.current.data.set("/count", 5);
+    });
+
+    rerender();
+    expect(result.current.value).toBe(5);
+  });
+});
+
+describe("useDataBinding", () => {
+  it("returns tuple with value and setter for path", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <DataProvider initialData={{ name: "John" }}>{children}</DataProvider>
+    );
+
+    const { result } = renderHook(() => useDataBinding("/name"), { wrapper });
+
+    const [value, setValue] = result.current;
+    expect(value).toBe("John");
+    expect(typeof setValue).toBe("function");
+  });
+
+  it("setter updates the value", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <DataProvider initialData={{ name: "John" }}>{children}</DataProvider>
+    );
+
+    const { result, rerender } = renderHook(() => useDataBinding("/name"), {
+      wrapper,
+    });
+
+    act(() => {
+      const [, setValue] = result.current;
+      setValue("Alice");
+    });
+
+    rerender();
+    const [value] = result.current;
+    expect(value).toBe("Alice");
+  });
+});

+ 105 - 0
packages/react/src/contexts/visibility.test.tsx

@@ -0,0 +1,105 @@
+import { describe, it, expect } from "vitest";
+import React from "react";
+import { renderHook } from "@testing-library/react";
+import { VisibilityProvider, useVisibility, useIsVisible } from "./visibility";
+import { DataProvider } from "./data";
+
+const createWrapper =
+  (data: Record<string, unknown> = {}, authState?: { isSignedIn: boolean }) =>
+  ({ children }: { children: React.ReactNode }) => (
+    <DataProvider initialData={data} authState={authState}>
+      <VisibilityProvider>{children}</VisibilityProvider>
+    </DataProvider>
+  );
+
+describe("useVisibility", () => {
+  it("provides isVisible function", () => {
+    const { result } = renderHook(() => useVisibility(), {
+      wrapper: createWrapper(),
+    });
+
+    expect(typeof result.current.isVisible).toBe("function");
+  });
+
+  it("provides visibility context", () => {
+    const { result } = renderHook(() => useVisibility(), {
+      wrapper: createWrapper({ test: true }),
+    });
+
+    expect(result.current.ctx.dataModel).toEqual({ test: true });
+  });
+});
+
+describe("useIsVisible", () => {
+  it("returns true for undefined condition", () => {
+    const { result } = renderHook(() => useIsVisible(undefined), {
+      wrapper: createWrapper(),
+    });
+
+    expect(result.current).toBe(true);
+  });
+
+  it("returns true for true condition", () => {
+    const { result } = renderHook(() => useIsVisible(true), {
+      wrapper: createWrapper(),
+    });
+
+    expect(result.current).toBe(true);
+  });
+
+  it("returns false for false condition", () => {
+    const { result } = renderHook(() => useIsVisible(false), {
+      wrapper: createWrapper(),
+    });
+
+    expect(result.current).toBe(false);
+  });
+
+  it("evaluates path conditions against data", () => {
+    const { result: trueResult } = renderHook(
+      () => useIsVisible({ path: "/isVisible" }),
+      { wrapper: createWrapper({ isVisible: true }) },
+    );
+    expect(trueResult.current).toBe(true);
+
+    const { result: falseResult } = renderHook(
+      () => useIsVisible({ path: "/isVisible" }),
+      { wrapper: createWrapper({ isVisible: false }) },
+    );
+    expect(falseResult.current).toBe(false);
+  });
+
+  it("evaluates auth conditions", () => {
+    const { result: signedInResult } = renderHook(
+      () => useIsVisible({ auth: "signedIn" }),
+      { wrapper: createWrapper({}, { isSignedIn: true }) },
+    );
+    expect(signedInResult.current).toBe(true);
+
+    const { result: signedOutResult } = renderHook(
+      () => useIsVisible({ auth: "signedOut" }),
+      { wrapper: createWrapper({}, { isSignedIn: false }) },
+    );
+    expect(signedOutResult.current).toBe(true);
+  });
+
+  it("evaluates logic expressions", () => {
+    const { result } = renderHook(() => useIsVisible({ eq: [1, 1] }), {
+      wrapper: createWrapper(),
+    });
+
+    expect(result.current).toBe(true);
+  });
+
+  it("evaluates complex conditions with data", () => {
+    const { result } = renderHook(
+      () =>
+        useIsVisible({
+          and: [{ path: "/user/isAdmin" }, { eq: [{ path: "/count" }, 5] }],
+        }),
+      { wrapper: createWrapper({ user: { isAdmin: true }, count: 5 }) },
+    );
+
+    expect(result.current).toBe(true);
+  });
+});

+ 151 - 0
packages/react/src/hooks.test.ts

@@ -0,0 +1,151 @@
+import { describe, it, expect } from "vitest";
+import { flatToTree } from "./hooks";
+
+describe("flatToTree", () => {
+  it("converts array of elements to tree structure", () => {
+    const elements = [
+      { key: "container", type: "stack", props: {}, parentKey: null },
+      {
+        key: "text1",
+        type: "text",
+        props: { content: "Hello" },
+        parentKey: "container",
+      },
+      {
+        key: "text2",
+        type: "text",
+        props: { content: "World" },
+        parentKey: "container",
+      },
+    ];
+
+    const tree = flatToTree(elements);
+
+    expect(tree.root).toBe("container");
+    expect(Object.keys(tree.elements)).toHaveLength(3);
+    expect(tree.elements["container"]).toBeDefined();
+    expect(tree.elements["text1"]).toBeDefined();
+    expect(tree.elements["text2"]).toBeDefined();
+  });
+
+  it("builds parent-child relationships", () => {
+    const elements = [
+      { key: "root", type: "stack", props: {}, parentKey: null },
+      { key: "child1", type: "text", props: {}, parentKey: "root" },
+      { key: "child2", type: "text", props: {}, parentKey: "root" },
+    ];
+
+    const tree = flatToTree(elements);
+
+    expect(tree.elements["root"].children).toHaveLength(2);
+    expect(tree.elements["root"].children).toContain("child1");
+    expect(tree.elements["root"].children).toContain("child2");
+  });
+
+  it("handles single root element", () => {
+    const elements = [
+      {
+        key: "only",
+        type: "text",
+        props: { content: "Single" },
+        parentKey: null,
+      },
+    ];
+
+    const tree = flatToTree(elements);
+
+    expect(tree.root).toBe("only");
+    expect(Object.keys(tree.elements)).toHaveLength(1);
+  });
+
+  it("handles deeply nested elements", () => {
+    const elements = [
+      { key: "level0", type: "stack", props: {}, parentKey: null },
+      { key: "level1", type: "stack", props: {}, parentKey: "level0" },
+      { key: "level2", type: "stack", props: {}, parentKey: "level1" },
+      { key: "level3", type: "text", props: {}, parentKey: "level2" },
+    ];
+
+    const tree = flatToTree(elements);
+
+    expect(tree.root).toBe("level0");
+    expect(tree.elements["level0"].children).toContain("level1");
+    expect(tree.elements["level1"].children).toContain("level2");
+    expect(tree.elements["level2"].children).toContain("level3");
+  });
+
+  it("preserves element props", () => {
+    const elements = [
+      {
+        key: "btn",
+        type: "button",
+        props: { label: "Click me", variant: "primary" },
+        parentKey: null,
+      },
+    ];
+
+    const tree = flatToTree(elements);
+
+    expect(tree.elements["btn"].props).toEqual({
+      label: "Click me",
+      variant: "primary",
+    });
+  });
+
+  it("preserves visibility conditions", () => {
+    const elements = [
+      {
+        key: "conditional",
+        type: "text",
+        props: {},
+        parentKey: null,
+        visible: { path: "/isVisible" },
+      },
+    ];
+
+    const tree = flatToTree(elements);
+
+    expect(tree.elements["conditional"].visible).toEqual({
+      path: "/isVisible",
+    });
+  });
+
+  it("handles elements with undefined parentKey as root", () => {
+    const elements = [
+      { key: "root", type: "stack", props: {} } as {
+        key: string;
+        type: string;
+        props: Record<string, unknown>;
+        parentKey?: string | null;
+      },
+    ];
+
+    const tree = flatToTree(elements);
+
+    // Elements without parentKey should not become root (only null parentKey)
+    // This tests the edge case
+    expect(tree.elements["root"]).toBeDefined();
+  });
+
+  it("handles empty elements array", () => {
+    const tree = flatToTree([]);
+
+    expect(tree.root).toBe("");
+    expect(Object.keys(tree.elements)).toHaveLength(0);
+  });
+
+  it("handles multiple children correctly", () => {
+    const elements = [
+      { key: "parent", type: "grid", props: {}, parentKey: null },
+      { key: "a", type: "card", props: {}, parentKey: "parent" },
+      { key: "b", type: "card", props: {}, parentKey: "parent" },
+      { key: "c", type: "card", props: {}, parentKey: "parent" },
+      { key: "d", type: "card", props: {}, parentKey: "parent" },
+    ];
+
+    const tree = flatToTree(elements);
+
+    expect(tree.elements["parent"].children).toHaveLength(4);
+    expect(tree.elements["parent"].children).toEqual(["a", "b", "c", "d"]);
+  });
+});

+ 80 - 0
packages/react/src/renderer.test.tsx

@@ -0,0 +1,80 @@
+import { describe, it, expect } from "vitest";
+import React from "react";
+import { createRendererFromCatalog } from "./renderer";
+
+// Mock catalog object that matches the Catalog interface
+const mockCatalog = {
+  name: "test",
+  componentNames: ["text", "button"],
+  actionNames: [],
+  functionNames: [],
+  validation: "strict" as const,
+  components: {},
+  actions: {},
+  functions: {},
+  elementSchema: {} as never,
+  treeSchema: {} as never,
+  hasComponent: () => true,
+  hasAction: () => false,
+  hasFunction: () => false,
+  validateElement: () => ({ success: true }),
+  validateTree: () => ({ success: true }),
+};
+
+describe("createRendererFromCatalog", () => {
+  it("creates a React component from catalog and registry", () => {
+    const registry = {
+      text: ({ element }: { element: { props: { content: string } } }) =>
+        React.createElement("span", null, element.props.content),
+      button: ({ element }: { element: { props: { label: string } } }) =>
+        React.createElement("button", null, element.props.label),
+    };
+
+    const CatalogRenderer = createRendererFromCatalog(mockCatalog, registry);
+
+    expect(typeof CatalogRenderer).toBe("function");
+  });
+
+  it("returned component renders null for null tree", () => {
+    const registry = {
+      text: () => React.createElement("span"),
+    };
+
+    const CatalogRenderer = createRendererFromCatalog(mockCatalog, registry);
+
+    // The component should handle null tree gracefully
+    const element = React.createElement(CatalogRenderer, { tree: null });
+    expect(element).toBeDefined();
+  });
+
+  it("returned component accepts loading prop", () => {
+    const registry = {
+      text: () => React.createElement("span"),
+    };
+
+    const CatalogRenderer = createRendererFromCatalog(mockCatalog, registry);
+
+    const element = React.createElement(CatalogRenderer, {
+      tree: null,
+      loading: true,
+    });
+    expect(element.props.loading).toBe(true);
+  });
+
+  it("returned component accepts fallback prop", () => {
+    const registry = {
+      text: () => React.createElement("span"),
+    };
+
+    const Fallback = () =>
+      React.createElement("div", null, "Unknown component");
+
+    const CatalogRenderer = createRendererFromCatalog(mockCatalog, registry);
+
+    const element = React.createElement(CatalogRenderer, {
+      tree: null,
+      fallback: Fallback,
+    });
+    expect(element.props.fallback).toBe(Fallback);
+  });
+});

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 572 - 0
pnpm-lock.yaml


+ 15 - 0
vitest.config.ts

@@ -0,0 +1,15 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+  test: {
+    globals: true,
+    environment: "jsdom",
+    include: ["packages/**/*.test.ts", "packages/**/*.test.tsx"],
+    coverage: {
+      provider: "v8",
+      reporter: ["text", "json", "html"],
+      include: ["packages/*/src/**/*.{ts,tsx}"],
+      exclude: ["**/*.test.{ts,tsx}", "**/index.ts"],
+    },
+  },
+});

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott