232 lines
8.5 KiB
TypeScript
232 lines
8.5 KiB
TypeScript
import { test, expect, beforeEach } from "bun:test";
|
|
import { Window } from "happy-dom";
|
|
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
|
import { mountHtml } from "@wrnexus/test";
|
|
|
|
// Fresh DOM per test, with the runtime's globals bound.
|
|
function mount(html: string): Window {
|
|
const win = new Window() as unknown as Window & Record<string, unknown>;
|
|
win.document.body.innerHTML = `<div id="app">${html}</div>`;
|
|
(globalThis as Record<string, unknown>).window = win;
|
|
(globalThis as Record<string, unknown>).document = win.document;
|
|
(globalThis as Record<string, unknown>).NodeFilter = (
|
|
win as unknown as { NodeFilter: unknown }
|
|
).NodeFilter;
|
|
(0, eval)(REACTIVE_RUNTIME);
|
|
// Hydrate deterministically (auto-init waits on DOMContentLoaded, which the
|
|
// test window may not fire). setupScope is idempotent, so this is safe.
|
|
const w = win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void };
|
|
w.__wrnexusHydrateScopes?.(win.document);
|
|
return win as unknown as Window;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
delete (globalThis as Record<string, unknown>).window;
|
|
delete (globalThis as Record<string, unknown>).document;
|
|
});
|
|
|
|
test("hydrates {expr} mustaches from data-scope", () => {
|
|
const win = mount(`<div data-scope="count: 0"><span>{count}, {count * 2}</span></div>`);
|
|
expect(win.document.querySelector("span")!.textContent).toBe("0, 0");
|
|
});
|
|
|
|
test("@event (data-on-click) mutates a signal and re-renders", () => {
|
|
const win = mount(
|
|
`<div data-scope="count: 0"><button data-on-click="count++">{count}</button></div>`,
|
|
);
|
|
const btn = win.document.querySelector("button")!;
|
|
expect(btn.textContent).toBe("0");
|
|
btn.click();
|
|
btn.click();
|
|
expect(btn.textContent).toBe("2");
|
|
});
|
|
|
|
test("nested scopes don't clobber each other (regression)", () => {
|
|
// An empty outer scope must not touch inner scopes' values.
|
|
const win = mount(
|
|
`<div data-scope="">
|
|
<div data-scope="count: 0"><button data-on-click="count++">A{count}</button></div>
|
|
<div data-scope="count: 10"><button data-on-click="count++">B{count}</button></div>
|
|
</div>`,
|
|
);
|
|
const [a, b] = Array.from(win.document.querySelectorAll("button"));
|
|
expect(a!.textContent).toBe("A0");
|
|
expect(b!.textContent).toBe("B10");
|
|
a!.click();
|
|
expect(a!.textContent).toBe("A1");
|
|
expect(b!.textContent).toBe("B10"); // unchanged
|
|
});
|
|
|
|
test("data-text binds an element's textContent to an expression", () => {
|
|
const win = mount(
|
|
`<div data-scope="n: 3"><strong data-text="n * 3">?</strong><button data-on-click="n = 5">x</button></div>`,
|
|
);
|
|
expect(win.document.querySelector("strong")!.textContent).toBe("9");
|
|
win.document.querySelector("button")!.click();
|
|
expect(win.document.querySelector("strong")!.textContent).toBe("15");
|
|
});
|
|
|
|
test("string scope values bind via data-text", () => {
|
|
const win = mount(`<div data-scope="msg: 'hi'"><strong data-text="msg">?</strong></div>`);
|
|
expect(win.document.querySelector("strong")!.textContent).toBe("hi");
|
|
});
|
|
|
|
test("data-for renders a list of objects and reacts to array changes", () => {
|
|
const win = mount(
|
|
`<div data-scope="todos: [{text: 'a'}, {text: 'b'}]">
|
|
<ul><li data-for="t in todos" data-text="t.text"></li></ul>
|
|
<button id="add" data-on-click="todos = todos.concat([{text: 'c'}])">add</button>
|
|
<button id="clear" data-on-click="todos = []">clear</button>
|
|
</div>`,
|
|
);
|
|
const items = () => Array.from(win.document.querySelectorAll("li"), (li) => li.textContent);
|
|
expect(items()).toEqual(["a", "b"]);
|
|
(win.document.getElementById("add") as unknown as HTMLElement).click();
|
|
expect(items()).toEqual(["a", "b", "c"]);
|
|
(win.document.getElementById("clear") as unknown as HTMLElement).click();
|
|
expect(items()).toEqual([]);
|
|
});
|
|
|
|
test("data-for exposes item + index, mustaches and member access", () => {
|
|
const win = mount(
|
|
`<div data-scope="rows: [{name: 'x'}, {name: 'y'}]">
|
|
<ul><li data-for="r, i in rows">{i}:{r.name}</li></ul>
|
|
</div>`,
|
|
);
|
|
expect(Array.from(win.document.querySelectorAll("li"), (li) => li.textContent)).toEqual([
|
|
"0:x",
|
|
"1:y",
|
|
]);
|
|
});
|
|
|
|
test("keyed data-for preserves DOM identity when items reorder", () => {
|
|
const win = mount(
|
|
`<div data-scope="rows: [{id: 1, name: 'a'}, {id: 2, name: 'b'}]">
|
|
<ul><li data-for="row in rows key row.id">{row.name}</li></ul>
|
|
<button data-on-click="rows = rows.slice().reverse()">reverse</button>
|
|
</div>`,
|
|
);
|
|
|
|
const before = Array.from(win.document.querySelectorAll("li"));
|
|
expect(before.map((node) => node.textContent)).toEqual(["a", "b"]);
|
|
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
|
|
const after = Array.from(win.document.querySelectorAll("li"));
|
|
expect(after.map((node) => node.textContent)).toEqual(["b", "a"]);
|
|
expect(after[0]).toBe(before[1]);
|
|
expect(after[1]).toBe(before[0]);
|
|
});
|
|
|
|
test("expression evaluator: member access, ternary, comparison, calls", () => {
|
|
const win = mount(
|
|
`<div data-scope="user: {name: 'Ada', age: 36}, items: [1, 2, 3]">
|
|
<span id="a" data-text="user.name"></span>
|
|
<span id="b" data-text="user.age > 30 ? 'senior' : 'junior'"></span>
|
|
<span id="c" data-text="items.length"></span>
|
|
</div>`,
|
|
);
|
|
expect(win.document.getElementById("a")!.textContent).toBe("Ada");
|
|
expect(win.document.getElementById("b")!.textContent).toBe("senior");
|
|
expect(win.document.getElementById("c")!.textContent).toBe("3");
|
|
});
|
|
|
|
test("data-show toggles visibility on a reactive expression (tabs pattern)", () => {
|
|
const win = mount(
|
|
`<div data-scope="tab: 0">
|
|
<button data-on-click="tab = 1">go</button>
|
|
<section id="a" data-show="tab === 0">A</section>
|
|
<section id="b" data-show="tab === 1">B</section>
|
|
</div>`,
|
|
);
|
|
const disp = (id: string) =>
|
|
(win.document.getElementById(id) as unknown as HTMLElement).style.display;
|
|
expect(disp("a")).toBe("");
|
|
expect(disp("b")).toBe("none");
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
expect(disp("a")).toBe("none");
|
|
expect(disp("b")).toBe("");
|
|
});
|
|
|
|
test("reactive attribute bindings update input and accessibility attributes", () => {
|
|
const win = mount(
|
|
`<div data-scope="show: false">
|
|
<input id="password" type="password" data-wrn-bind-0='["type","{show ? 'text' : 'password'}"]'>
|
|
<button data-on-click="show = !show" aria-label="Show password"
|
|
data-wrn-bind-0='["aria-label","{show ? 'Hide password' : 'Show password'}"]'>Toggle</button>
|
|
</div>`,
|
|
);
|
|
const input = win.document.getElementById("password") as unknown as HTMLInputElement;
|
|
const button = win.document.querySelector("button") as unknown as HTMLElement;
|
|
expect(input.type).toBe("password");
|
|
expect(button.getAttribute("aria-label")).toBe("Show password");
|
|
button.click();
|
|
expect(input.type).toBe("text");
|
|
expect(button.getAttribute("aria-label")).toBe("Hide password");
|
|
});
|
|
|
|
test("independent signals in one scope update correctly (dependency tracking)", () => {
|
|
const win = mount(
|
|
`<div data-scope="a: 0, b: 100">
|
|
<span id="ta" data-text="a"></span>
|
|
<span id="tb" data-text="b"></span>
|
|
<button id="ba" data-on-click="a++">A</button>
|
|
<button id="bb" data-on-click="b++">B</button>
|
|
</div>`,
|
|
);
|
|
const ta = () => win.document.getElementById("ta")!.textContent;
|
|
const tb = () => win.document.getElementById("tb")!.textContent;
|
|
const click = (id: string) => (win.document.getElementById(id) as unknown as HTMLElement).click();
|
|
expect([ta(), tb()]).toEqual(["0", "100"]);
|
|
click("ba");
|
|
click("ba");
|
|
expect([ta(), tb()]).toEqual(["2", "100"]); // b untouched by a's changes
|
|
click("bb");
|
|
expect([ta(), tb()]).toEqual(["2", "101"]);
|
|
});
|
|
test("data-for preserves arrays of objects from encoded component scope", () => {
|
|
const items = [
|
|
{
|
|
question: "One",
|
|
answer: "First",
|
|
},
|
|
{
|
|
question: "Two",
|
|
answer: "Second",
|
|
},
|
|
{
|
|
question: "Three",
|
|
answer: "Third",
|
|
},
|
|
{
|
|
question: "Four",
|
|
answer: "Fourth",
|
|
},
|
|
];
|
|
|
|
const encoded = Buffer.from(
|
|
JSON.stringify({
|
|
items,
|
|
}),
|
|
"utf8",
|
|
).toString("base64");
|
|
|
|
const dom = mountHtml(`
|
|
<div data-wrn-scope="${encoded}">
|
|
<article data-for="item, index in items">
|
|
<span>{index + 1}</span>
|
|
<strong>{item.question}</strong>
|
|
</article>
|
|
</div>
|
|
`);
|
|
|
|
expect(dom.querySelectorAll("article")).toHaveLength(4);
|
|
|
|
expect(dom.querySelectorAll("strong").map((element) => element.textContent)).toEqual([
|
|
"One",
|
|
"Two",
|
|
"Three",
|
|
"Four",
|
|
]);
|
|
});
|