360 lines
12 KiB
TypeScript
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 });
|
|
});
|
|
}); |