port ujsx from Deno-only to cross-platform (Node/Bun/Deno)
Add npm project configuration (package.json, tsconfig.json, tsup, vitest) matching the taskgraph_ts conventions. All source imports changed from .ts to .js extensions for Node16 module resolution. Tests migrated from Deno.test to vitest. Fixed strict type errors (noUncheckedIndexedAccess). Preserved deno.json with sloppy-imports for dual Deno/Node compatibility. Subpath exports: schema, h, reactive, context, events, pointer, host, transform, jsx-runtime — plus barrel export at root. Build: ESM + CJS dual output via tsup. 22 tests passing.
This commit is contained in:
305
test/mod.test.ts
Normal file
305
test/mod.test.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { h, createRoot, createComponent, Fragment, jsx, jsxs, jsxDEV } from "../src/core/h.js";
|
||||
import { isUElement, isURoot, isUPrimitive } from "../src/core/schema.js";
|
||||
import type { UNode, UElement } from "../src/core/schema.js";
|
||||
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("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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user