Files
ujsx/test/mod.test.ts

360 lines
12 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { h, createRoot, createComponent, Fragment, jsx, jsxs, jsxDEV } from "../src/core/h.js";
import { isUElement, isURoot, isUPrimitive, UJSX } from "../src/core/schema.js";
import type { UNode, UElement } from "../src/core/schema.js";
import { Value } from "@alkdev/typebox/value";
import { Context } from "../src/core/context.js";
import { TransformRegistry, childCtx, ctx as transformCtx } from "../src/transform/registry.js";
import type { Direction } from "../src/core/context.js";
import { ValuePointer, selectNode, setNode } from "../src/core/pointer.js";
import { signal, computed, ReactiveRoot, reactiveComponent, reactiveElement } from "../src/core/reactive.js";
import { createPubSubEmitter } from "../src/core/events.js";
import { createRoot as createHostRoot } from "../src/host/config.js";
import type { HostConfig } from "../src/host/config.js";
describe("h()", () => {
it("creates UElement", () => {
const el = h("div", { class: "test" }, "hello");
expect(isUElement(el)).toBe(true);
if (isUElement(el)) {
expect(el.type).toBe("div");
expect(el.props.class).toBe("test");
expect(el.children).toEqual(["hello"]);
}
});
it("with null props creates empty props object", () => {
const el = h("p", null, "text");
expect(isUElement(el)).toBe(true);
if (isUElement(el)) {
expect(el.props).toEqual({});
expect(el.children).toEqual(["text"]);
}
});
it("with root type creates URoot", () => {
const root = h("root", { id: "test" }, "child1", "child2");
expect(isURoot(root)).toBe(true);
if (isURoot(root)) {
expect(root.type).toBe("root");
expect(root.props.id).toBe("test");
expect(root.children).toEqual(["child1", "child2"]);
}
});
});
describe("createRoot", () => {
it("creates URoot with id", () => {
const root = createRoot("my-root", "a", "b");
expect(isURoot(root)).toBe(true);
if (isURoot(root)) {
expect(root.type).toBe("root");
expect(root.props.id).toBe("my-root");
}
});
});
describe("Fragment", () => {
it("flattens children and removes null/false", () => {
const result = Fragment({ children: ["a", null, "b", false, "c"] as UNode[] });
expect(result).toEqual(["a", "b", "c"]);
});
});
describe("jsx runtime aliases", () => {
it("jsx/jsxs/jsxDEV are aliases for h", () => {
expect(jsx).toBe(h);
expect(jsxs).toBe(h);
expect(jsxDEV).toBe(h);
});
});
describe("createComponent", () => {
it("adds displayName and targets", () => {
const MyComp = createComponent("MyComp", (props) => h("div", null, props.text), ["markdown"]);
expect(MyComp.displayName).toBe("MyComp");
expect(MyComp.targets).toEqual(["markdown"]);
});
});
describe("type guards", () => {
it("isUElement discriminates", () => {
expect(isUElement(h("div", null))).toBe(true);
expect(isUElement("text")).toBe(false);
expect(isUElement(null)).toBe(false);
expect(isUElement(createRoot("r"))).toBe(false);
});
it("isURoot discriminates", () => {
expect(isURoot(createRoot("r"))).toBe(true);
expect(isURoot(h("div", null))).toBe(false);
});
it("isUPrimitive discriminates", () => {
expect(isUPrimitive("text")).toBe(true);
expect(isUPrimitive(42)).toBe(true);
expect(isUPrimitive(true)).toBe(true);
expect(isUPrimitive(null)).toBe(true);
expect(isUPrimitive(h("div", null))).toBe(false);
});
});
describe("UElement key field", () => {
it("h() extracts key from props and promotes to element level", () => {
const el = h("div", { key: "item-1", class: "test" }, "hello");
expect(isUElement(el)).toBe(true);
if (isUElement(el)) {
expect(el.key).toBe("item-1");
expect(el.props.key).toBeUndefined();
expect(el.props.class).toBe("test");
}
});
it("h() without key does not add key field", () => {
const el = h("div", { class: "test" }, "hello");
expect(isUElement(el)).toBe(true);
if (isUElement(el)) {
expect(el.key).toBeUndefined();
}
});
it("h() with null props has no key", () => {
const el = h("div", null, "hello");
expect(isUElement(el)).toBe(true);
if (isUElement(el)) {
expect(el.key).toBeUndefined();
}
});
it("UElement with key validates against TypeBox schema", () => {
const UElementSchema = UJSX.Import("UElement");
const el = h("li", { key: "a" }, "item");
expect(Value.Check(UElementSchema, el)).toBe(true);
});
it("UElement without key validates against TypeBox schema (backward compat)", () => {
const UElementSchema = UJSX.Import("UElement");
const el = h("li", { class: "item" }, "item");
expect(Value.Check(UElementSchema, el)).toBe(true);
});
it("URoot does not have a key field", () => {
const root = createRoot("r");
expect("key" in root).toBe(false);
});
it("h() with root type does not promote key to URoot", () => {
const root = h("root", { key: "should-not-appear", id: "test" }, "child");
expect(isURoot(root)).toBe(true);
if (isURoot(root)) {
expect("key" in root).toBe(false);
expect(root.props.key).toBeUndefined();
}
});
});
describe("Context", () => {
it("stores and updates values", () => {
const ctx = new Context({ density: "full", target: "markdown" });
expect(ctx.get().density).toBe("full");
expect(ctx.get().target).toBe("markdown");
ctx.set({ density: "compact" });
expect(ctx.get().density).toBe("compact");
expect(ctx.get().target).toBe("markdown");
});
it("fork creates independent copy", () => {
const parent = new Context({ density: "full", target: "markdown" });
const child = parent.fork({ density: "minimal" });
expect(child.get().density).toBe("minimal");
expect(parent.get().density).toBe("full");
});
});
describe("reactive pipeline", () => {
it("signal + computed", () => {
const name = signal("world");
const greeting = computed(() => `hello ${name.value}`);
expect(greeting.value).toBe("hello world");
name.value = "ujsx";
expect(greeting.value).toBe("hello ujsx");
});
it("ReactiveRoot update and subscribe", () => {
const root = new ReactiveRoot(h("div", null, "initial"));
const received: UNode[] = [];
const unsub = root.subscribe((n) => received.push(n));
root.update(() => h("div", null, "updated"));
expect(received.length).toBe(2);
unsub();
});
it("ReactiveRoot render emits events", () => {
const events: { type: string; id: string; payload: unknown }[] = [];
const root = new ReactiveRoot(h("div", null, "hello"));
const stop = root.render((e) => events.push(e));
expect(events.length).toBe(1);
expect(events[0].type).toBe("root.render");
stop();
});
it("reactiveComponent wraps component in computed signal", () => {
const MyComp = createComponent("MyComp", (props) => h("div", null, props.text as string));
const propsSignal = signal({ text: "hello" });
const reactive = reactiveComponent(MyComp, propsSignal);
expect(reactive.type).toBe("MyComp");
const node = reactive.signal.value;
expect(isUElement(node)).toBe(true);
if (isUElement(node)) {
expect(node.children[0]).toBe("hello");
}
propsSignal.value = { text: "world" };
const updated = reactive.signal.value;
expect(isUElement(updated)).toBe(true);
if (isUElement(updated)) {
expect(updated.children[0]).toBe("world");
}
});
});
describe("ValuePointer", () => {
it("stores and updates reactive values", () => {
const ptr = new ValuePointer("initial");
expect(ptr.value).toBe("initial");
ptr.value = "updated";
expect(ptr.value).toBe("updated");
});
});
describe("tree traversal", () => {
it("selectNode traverses tree by child index", () => {
const tree = h("div", null, h("p", null, "child1"), h("span", null, "child2"));
const child0 = selectNode(tree, ["0"]);
expect(isUElement(child0)).toBe(true);
if (isUElement(child0)) {
expect(child0.type).toBe("p");
}
const child1 = selectNode(tree, ["1"]);
expect(isUElement(child1)).toBe(true);
if (isUElement(child1)) {
expect(child1.type).toBe("span");
}
expect(selectNode(tree, ["9"])).toBeUndefined();
});
it("setNode updates tree immutably", () => {
const original = h("div", null, "old") as UElement;
const updated = setNode(original, ["0"], "new") as UElement;
expect(original.children[0]).toBe("old");
expect(updated.children[0]).toBe("new");
});
});
describe("TransformRegistry", () => {
it("applies bidirectional rules", () => {
const registry = new TransformRegistry<UNode, Record<string, unknown>, unknown>();
registry.register({
name: "div-ujsx-to-mdast",
direction: "ujsx→mdast" as Direction,
match: (n) => isUElement(n) && n.type === "div",
transform: (n, ctx, next) => ({
type: "paragraph",
children: (Array.isArray((n as UElement).children) ? (n as UElement).children : []).map((c: UNode, i: number) => next(c, childCtx(n, ctx, i))),
}),
priority: 1,
});
registry.register({
name: "text-ujsx-to-mdast",
direction: "ujsx→mdast" as Direction,
match: (n) => isUPrimitive(n) && typeof n === "string",
transform: (n) => ({ type: "text", value: n }),
priority: 10,
});
registry.register({
name: "paragraph-mdast-to-ujsx",
direction: "mdast→ujsx" as Direction,
match: (n) => (n as Record<string, unknown>).type === "paragraph",
transform: (n, ctx, next) => {
const mdNode = n as { children: unknown[] };
return h("div", null, ...mdNode.children.map((c, i) => next(c as UNode, childCtx(n, ctx, i))));
},
priority: 1,
});
registry.register({
name: "text-mdast-to-ujsx",
direction: "mdast→ujsx" as Direction,
match: (n) => (n as Record<string, unknown>).type === "text",
transform: (n) => (n as unknown as { value: string }).value,
priority: 10,
});
const ujsxNode = h("div", null, "hello") as UNode;
const mdastCtx = transformCtx("ujsx→mdast" as Direction);
const mdastResult = registry.transform(ujsxNode, mdastCtx);
expect((mdastResult as Record<string, unknown>).type).toBe("paragraph");
const mdastNode = { type: "paragraph", children: [{ type: "text", value: "hello" }] } as unknown as UNode;
const ujsxCtx = transformCtx("mdast→ujsx" as Direction);
const ujsxResult = registry.transform(mdastNode, ujsxCtx);
expect(isUElement(ujsxResult)).toBe(true);
if (isUElement(ujsxResult)) {
expect(ujsxResult.type).toBe("div");
}
});
});
describe("HostConfig", () => {
it("createRoot and render", () => {
const instances: { tag: string; props: Record<string, unknown> }[] = [];
const texts: string[] = [];
const events: { type: string; payload: unknown }[] = [];
const testHost: HostConfig<string, string, Record<string, unknown>> = {
name: "test",
createRootContext: () => ({}),
createInstance: (tag, props) => {
instances.push({ tag, props });
return tag;
},
createTextInstance: (text) => {
texts.push(text);
return text;
},
appendChild: () => {},
emit: (type, _id, payload) => events.push({ type, payload }),
};
const root = createHostRoot(testHost, {});
root.render(h("div", { class: "outer" }, "hello", h("span", null, "world")));
expect(instances.length).toBe(2);
expect(instances[0]!.tag).toBe("div");
expect(instances[1]!.tag).toBe("span");
expect(texts.sort()).toEqual(["hello", "world"]);
expect(events.some((e) => e.type === "root.render")).toBe(true);
});
});
describe("pubsub emitter", () => {
it("bridges to event envelope", () => {
const published: { type: string; id: string; payload: unknown }[] = [];
const mockPubSub = {
publish: <TType extends string>(type: TType, id: string, payload: unknown) => {
published.push({ type, id, payload });
},
subscribe: async function* () {},
};
const emit = createPubSubEmitter(mockPubSub);
emit("root.render", "test-1", { childCount: 3 });
emit("root.render", "test-2", { childCount: 1 });
expect(published.length).toBe(2);
expect(published[0]!.type).toBe("root.render");
expect(published[0]!.payload).toEqual({ childCount: 3 });
});
});