import assert from "node:assert/strict"; import { test } from "node:test"; import { createJuzhen } from "../dist/juzhen.mjs"; import { setupDom } from "./helpers.mjs"; const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); function installCountingMutationObserver( maxCallbacks = Number.POSITIVE_INFINITY, { deferDelivery = false, wrapRecords = (records) => records } = {}, ) { const NativeMutationObserver = globalThis.MutationObserver; const instances = []; class CountingMutationObserver { constructor(callback) { this.callback = callback; this.observeCount = 0; this.disconnectCount = 0; this.callbackCount = 0; this.pendingRecords = []; this.suppressed = false; this.inner = new NativeMutationObserver((records) => { if (deferDelivery) { this.pendingRecords.push(...records); } else { this.deliver(records); } }); instances.push(this); } deliver(records) { this.callbackCount += 1; if (this.callbackCount > maxCallbacks) { this.suppressed = true; this.inner.disconnect(); return; } this.callback(wrapRecords(records), this); } observe(target, options) { this.observeCount += 1; if (!this.suppressed) { this.inner.observe(target, options); } } disconnect() { this.disconnectCount += 1; this.inner.disconnect(); } takeRecords() { return this.inner.takeRecords(); } } globalThis.MutationObserver = CountingMutationObserver; return { instances, restore() { globalThis.MutationObserver = NativeMutationObserver; }, }; } function installExpectationShiftProbe() { const nativeShift = Array.prototype.shift; let calls = 0; let work = 0; Array.prototype.shift = function () { const head = this[0]; if (head && typeof head === "object" && "target" in head && "oldValue" in head && "newValue" in head) { calls += 1; work += this.length; } return nativeShift.call(this); }; return { result: () => ({ calls, work }), restore() { Array.prototype.shift = nativeShift; }, }; } function installExpectationSpliceProbe() { const nativeSplice = Array.prototype.splice; let calls = 0; let removed = 0; Array.prototype.splice = function (...args) { const [ start, deleteCount ] = args; const head = this[0]; if (start === 0 && head && typeof head === "object" && "target" in head && "oldValue" in head && "newValue" in head) { calls += 1; removed += deleteCount; } return nativeSplice.apply(this, args); }; return { result: () => ({ calls, removed }), restore() { Array.prototype.splice = nativeSplice; }, }; } function installLayoutHarness(document, withFonts = false) { const NativeResizeObserver = globalThis.ResizeObserver; const originalGetClientRects = window.Element.prototype.getClientRects; const instances = []; let rectsEnabled = true; class CapturingResizeObserver { constructor(callback) { this.callback = callback; this.target = null; this.disconnected = false; instances.push(this); } observe(target) { this.target = target; } disconnect() { this.disconnected = true; } fire() { this.callback([ { target: this.target } ], this); } } globalThis.ResizeObserver = CapturingResizeObserver; window.Element.prototype.getClientRects = function () { if (!rectsEnabled) { return []; } return [ { bottom: 16, height: 16, left: 0, right: 16, top: 0, width: 16, x: 0, y: 0, } ]; }; if (withFonts) { Object.defineProperty(document, "fonts", { configurable: true, value: { ready: Promise.resolve() }, }); } return { instances, setRectsEnabled(value) { rectsEnabled = value; }, restore() { window.Element.prototype.getClientRects = originalGetClientRects; if (NativeResizeObserver === undefined) { delete globalThis.ResizeObserver; } else { globalThis.ResizeObserver = NativeResizeObserver; } }, }; } test("observer recovery: synchronous render failure restores later observation", async () => { const { document } = setupDom('

原文,內容。

'); const root = document.getElementById("root"); const paragraph = root.querySelector("p"); const expected = new Error("isSkipped render failure"); let throwNext = false; const jz = createJuzhen({ isSkipped() { if (!throwNext) { return false; } throwNext = false; throw expected; }, }); jz.render(root); jz.observe(root); jz.invalidate(root); throwNext = true; assert.throws(() => jz.render(root), (error) => error === expected); paragraph.textContent = "錯誤後,仍可繼續觀察。"; await tick(); await tick(); jz.disconnect(); assert.ok(paragraph.querySelector("jz-char"), "之後的 DOM 變化仍自動處理"); assert.equal(paragraph.textContent, "錯誤後,仍可繼續觀察。"); }); test("observer recovery: mutation callback failure restores a later mutation", async () => { const { document } = setupDom('

原文,內容。

'); const root = document.getElementById("root"); const paragraph = root.querySelector("p"); const expected = new Error("isSkipped mutation failure"); const reported = []; let throwNext = false; const onError = (event) => { reported.push(event.error); event.preventDefault(); }; window.addEventListener("error", onError); const jz = createJuzhen({ isSkipped() { if (!throwNext) { return false; } throwNext = false; throw expected; }, }); try { jz.render(root); jz.observe(root); jz.invalidate(root); throwNext = true; paragraph.textContent = "第一次,觸發錯誤。"; await tick(); assert.ok(reported.includes(expected), "MutationObserver 回調錯誤仍照常對外呈現"); paragraph.textContent = "第二次,恢復處理。"; await tick(); await tick(); assert.ok(paragraph.querySelector("jz-char"), "後續變化仍被觀察"); assert.equal(paragraph.textContent, "第二次,恢復處理。"); } finally { jz.disconnect(); window.removeEventListener("error", onError); } }); test("observer depth: nested render does not resume or observe partial outer changes", async () => { const { document } = setupDom('

嵌套,渲染。

'); const counting = installCountingMutationObserver(); const root = document.getElementById("root"); const paragraph = root.querySelector("p"); let reenter = false; let jz; try { jz = createJuzhen({ isSkipped() { if (reenter) { reenter = false; jz.render(paragraph); } return false; }, }); jz.render(root); jz.observe(root); assert.equal(counting.instances.length, 1); jz.invalidate(root); reenter = true; jz.rerender(root); const observer = counting.instances[0]; assert.equal(observer.disconnectCount, 1, "只有最外層 0→1 暫停會 disconnect"); assert.equal(observer.observeCount, 2, "只有最外層 1→0 恢復會 observe"); await tick(); await tick(); assert.equal(observer.callbackCount, 0, "嵌套處理不會觀察半成品或自觸發"); assert.ok(paragraph.querySelector("jz-char")); } finally { if (jz) { jz.disconnect(); } counting.restore(); } }); test("observer depth: observe replacement inside a paused render is deferred coherently", async () => { const { document } = setupDom('

重建,觀察。

'); const counting = installCountingMutationObserver(); const root = document.getElementById("root"); const paragraph = root.querySelector("p"); let replaceObserver = false; let observeCallsDuringPause = -1; let jz; try { jz = createJuzhen({ isSkipped() { if (replaceObserver) { replaceObserver = false; jz.disconnect(); jz.observe(root); observeCallsDuringPause = counting.instances.reduce( (sum, observer) => sum + observer.observeCount, 0, ); } return false; }, }); jz.render(root); jz.observe(root); jz.invalidate(root); replaceObserver = true; jz.rerender(root); assert.equal(counting.instances.length, 2, "嵌套 observe 建立新觀察者"); assert.equal(observeCallsDuringPause, 1, "暫停期內新觀察者尚未 observe"); assert.equal( counting.instances.reduce((sum, observer) => sum + observer.observeCount, 0), 2, "最外層結束後新觀察者恰恢復一次", ); paragraph.textContent = "重建後,仍能處理。"; await tick(); await tick(); assert.ok(paragraph.querySelector("jz-char")); assert.equal(counting.instances[1].callbackCount, 1, "新觀察者接手後續變化"); } finally { if (jz) { jz.disconnect(); } counting.restore(); } }); test("observer provenance: asynchronous internal layout classes do not self-trigger", async () => { const { document } = setupDom('

(版面回調

'); const counting = installCountingMutationObserver(8); const layout = installLayoutHarness(document, true); const root = document.getElementById("root"); const paragraph = root.querySelector("p"); let jz; try { jz = createJuzhen({ spacing: false, orphan: false }); jz.render(root); jz.observe(root); await tick(); await tick(); const observer = counting.instances[0]; assert.equal(layout.instances.length, 1, "未因內部 class 記錄重建 layout observer"); assert.equal(observer.callbackCount, 1, "同批 remove/add provenance 被精確消費"); assert.equal(observer.suppressed, false, "防掛上限未被觸發"); assert.ok(paragraph.querySelector("jz-char.jz-half-le"), "異步行端結果保持"); } finally { if (jz) { jz.disconnect(); } layout.restore(); counting.restore(); } }); test("observer provenance: author whitelist attributes still trigger rerender", async () => { const { document } = setupDom('

作者,屬性。

'); const counting = installCountingMutationObserver(); const root = document.getElementById("root"); const paragraph = root.querySelector("p"); const jz = createJuzhen(); try { jz.render(root); jz.observe(root); paragraph.classList.add("consumer-class"); await tick(); assert.equal(counting.instances[0].callbackCount, 1, "作者 class 觸發 observer"); assert.ok(paragraph.querySelector("jz-char")); paragraph.setAttribute("data-jz-skip", ""); await tick(); assert.equal(counting.instances[0].callbackCount, 2, "作者非 class 白名單屬性亦觸發"); assert.equal(paragraph.querySelectorAll("[data-jz]").length, 0, "skip 重分類生效"); } finally { jz.disconnect(); counting.restore(); } }); test("observer provenance: external writes on owned products are not consumed", async () => { const { document } = setupDom('

前文粗体文字,后续

'); const counting = installCountingMutationObserver(); const root = document.getElementById("root"); const jz = createJuzhen({ spacing: false, longWord: false, orphan: false }); try { jz.render(root); jz.observe(root); const atom = root.querySelector("jz-char"); atom.classList.add("consumer-class"); await tick(); assert.equal(counting.instances[0].callbackCount, 1, "owned 元素外部 class 觸發"); assert.equal(atom.isConnected, false, "外部 class 變更進入 rerender"); const nextAtom = root.querySelector("jz-char"); nextAtom.setAttribute("lang", "en"); await tick(); assert.equal(counting.instances[0].callbackCount, 2, "owned 元素外部 lang 觸發"); assert.equal(nextAtom.isConnected, false); const splitoff = root.querySelector('[data-jz-kind="splitoff"]'); splitoff.classList.add("consumer-splitoff"); await tick(); assert.equal(counting.instances[0].callbackCount, 3, "owned splitoff 外部 class 觸發"); // 外部樣式使片段無法安全併回;rerender 須保留作者元素與字序,不能丟掉 class。 assert.equal(splitoff.isConnected, true); assert.ok(splitoff.classList.contains("consumer-splitoff")); assert.equal(splitoff.hasAttribute("data-jz-kind"), false, "舊 splitoff 憑證已撤銷"); assert.equal(root.textContent, "前文粗体文字,后续"); jz.revert(root); assert.equal(splitoff.isConnected, true); assert.ok(splitoff.classList.contains("consumer-splitoff")); assert.equal(root.querySelectorAll("[data-jz]").length, 0); } finally { jz.disconnect(); counting.restore(); } }); test("observer provenance: mixed internal and external class records stay distinguishable", async () => { const { document } = setupDom('

(混合記錄

'); const counting = installCountingMutationObserver(); const layout = installLayoutHarness(document); const root = document.getElementById("root"); const jz = createJuzhen({ spacing: false, orphan: false }); try { jz.render(root); jz.observe(root); const atom = root.querySelector("jz-char"); atom.classList.add("consumer-before-internal"); layout.instances[0].fire(); await tick(); assert.equal(counting.instances[0].callbackCount, 1, "同批記錄只派發一次"); assert.equal(atom.isConnected, false, "外部 mismatch 後之內部 transition 仍精確消費"); } finally { jz.disconnect(); layout.restore(); counting.restore(); } }); test( "observer provenance: threshold compaction preserves a 1024-record tail across callbacks", { timeout: 20000 }, async () => { const size = 2048; const html = Array.from({ length: size }, (_, index) => `

(${index}

`).join(""); const { document } = setupDom(`
${html}
`); const counting = installCountingMutationObserver(4, { deferDelivery: true }); const layout = installLayoutHarness(document); const spliceProbe = installExpectationSpliceProbe(); const root = document.getElementById("root"); const jz = createJuzhen({ spacing: false, longWord: false, orphan: false }); try { jz.render(root); jz.observe(root); layout.setRectsEnabled(false); layout.instances[0].fire(); await tick(); const observer = counting.instances[0]; const records = observer.pendingRecords.splice(0); assert.equal(records.length, size, "每個 target 各排入一條 internal remove"); observer.deliver(records.slice(0, 1024)); assert.deepEqual(spliceProbe.result(), { calls: 1, removed: 1024 }); assert.equal(layout.instances.length, 1, "首批觸發 threshold compaction 而不 rerender"); observer.deliver(records.slice(1024)); assert.equal(observer.callbackCount, 2, "第二批由壓縮後 deque 繼續消費"); assert.equal(layout.instances.length, 1, "尾批全消費後 head/reset 正確"); assert.equal(root.querySelectorAll("jz-char.jz-half-le").length, 0); } finally { jz.disconnect(); spliceProbe.restore(); layout.restore(); counting.restore(); } }, ); test( "observer provenance: callback failure compacts the consumed prefix and preserves its tail", { timeout: 15000 }, async () => { const size = 1025; const html = Array.from({ length: size }, (_, index) => `

(${index}

`).join(""); const { document } = setupDom(`
${html}
`); const counting = installCountingMutationObserver(4, { deferDelivery: true }); const layout = installLayoutHarness(document); const spliceProbe = installExpectationSpliceProbe(); const root = document.getElementById("root"); const jz = createJuzhen({ spacing: false, longWord: false, orphan: false }); const expected = new Error("mid-callback classification failure"); const throwingRecord = { type: "characterData", get target() { throw expected; }, }; try { jz.render(root); jz.observe(root); layout.setRectsEnabled(false); layout.instances[0].fire(); await tick(); const observer = counting.instances[0]; const records = observer.pendingRecords.splice(0); assert.equal(records.length, size); assert.throws( () => observer.deliver([ ...records.slice(0, 1024), throwingRecord ]), (error) => error === expected, ); assert.deepEqual( spliceProbe.result(), { calls: 1, removed: 1024 }, "拋錯 callback 之 finally 立即壓縮已消費前綴", ); assert.equal(layout.instances.length, 1, "finally 壓縮已消費前綴而不 rerender"); observer.deliver([ records[1024] ]); assert.equal(layout.instances.length, 1, "下一 callback 精確消費保留之 tail"); const atom = root.querySelector("jz-char"); atom.classList.add("consumer-after-callback-error"); await tick(); assert.equal(observer.pendingRecords.length, 1, "外部 class 排入獨立 record"); observer.deliver(observer.pendingRecords.splice(0)); assert.equal(observer.callbackCount, 3, "錯誤、tail、外部 record 各交付一次"); assert.equal(atom.isConnected, false, "外部 record 未被殘留 expectation 誤吞"); } finally { jz.disconnect(); spliceProbe.restore(); layout.restore(); counting.restore(); } }, ); test("observer provenance: foreign-instance product changes still trigger", async () => { const { document } = setupDom('

外來,產物。

'); const foreign = createJuzhen(); const root = document.getElementById("root"); foreign.render(root); const atom = root.querySelector("jz-char"); const counting = installCountingMutationObserver(); const local = createJuzhen(); try { local.observe(root); atom.classList.add("foreign-change"); await tick(); assert.equal(counting.instances[0].callbackCount, 1, "外來實例無本地 provenance"); } finally { local.disconnect(); counting.restore(); } }); for (const mutationKind of [ "childList", "characterData" ]) { test(`observer provenance: owned ${mutationKind} changes still trigger`, async () => { const { document } = setupDom('

內容,變更。

'); const counting = installCountingMutationObserver(); const root = document.getElementById("root"); const jz = createJuzhen(); try { jz.render(root); jz.observe(root); const atom = root.querySelector("jz-char"); if (mutationKind === "childList") { atom.append("外"); } else { const text = document.createTreeWalker(atom, NodeFilter.SHOW_TEXT).nextNode(); text.data += "外"; } await tick(); assert.equal(counting.instances[0].callbackCount, 1); assert.equal(atom.isConnected, false, `${mutationKind} 進入 rerender`); } finally { jz.disconnect(); counting.restore(); } }); } for (const lifecycle of [ "pause", "replace", "disconnect" ]) { test(`observer provenance: ${lifecycle} clears stale expectations`, async () => { const { document } = setupDom('

(生命週期

'); const counting = installCountingMutationObserver(); const layout = installLayoutHarness(document); const root = document.getElementById("root"); const jz = createJuzhen({ spacing: false, orphan: false }); try { jz.render(root); jz.observe(root); layout.instances[0].fire(); if (lifecycle === "pause") { jz.rerender(root); } else if (lifecycle === "replace") { jz.observe(root); } else { jz.disconnect(); jz.observe(root); } const activeObserver = counting.instances[counting.instances.length - 1]; root.querySelector("jz-char").classList.add("consumer-after-lifecycle"); await tick(); assert.equal(activeObserver.callbackCount, 1, "外部 class 未被陳舊 provenance 吞掉"); } finally { jz.disconnect(); layout.restore(); counting.restore(); } }); } test("observer provenance: transition inference and queue consumption scale linearly", async (context) => { const reports = []; for (const size of [ 500, 1000, 2000 ]) { const html = Array.from({ length: size }, (_, index) => `

(${index}

`).join(""); const { document } = setupDom(`
${html}
`); let recordAccesses = 0; let recordCount = 0; const counting = installCountingMutationObserver(Number.POSITIVE_INFINITY, { wrapRecords(records) { recordCount += records.length; return records.map((record) => new Proxy({}, { get(_target, property) { recordAccesses += 1; return record[property]; }, })); }, }); const layout = installLayoutHarness(document); const shiftProbe = installExpectationShiftProbe(); const root = document.getElementById("root"); const jz = createJuzhen({ spacing: false, longWord: false, orphan: false }); try { jz.render(root); jz.observe(root); assert.equal(root.querySelectorAll("jz-char.jz-half-le").length, size); layout.instances[0].fire(); await tick(); const shift = shiftProbe.result(); assert.equal(recordCount, size * 2, "每 target 恰有 remove/add 兩條 transition"); const report = { size, recordCount, recordAccesses, queueShiftWork: shift.work }; context.diagnostic("linear probe " + JSON.stringify(report)); assert.ok( recordAccesses <= recordCount * 12 && shift.calls === 0 && shift.work === 0, "transition 推斷與 queue 消費保持線性:" + JSON.stringify(report), ); assert.equal(layout.instances.length, 1, "規模回放沒有自觸發 rerender"); reports.push(report); } finally { jz.disconnect(); shiftProbe.restore(); layout.restore(); counting.restore(); } } context.diagnostic("linear probes complete " + JSON.stringify(reports)); }); test("observer:手動局部操作先結算其他兄弟塊的待交付變更", async () => { for (const operation of [ "render", "rerender", "revert" ]) { const { dom, document } = setupDom('

原文,甲。

原文,乙。

'); const root = document.querySelector("main"); const a = document.getElementById("a"); const b = document.getElementById("b"); const jz = createJuzhen({ jinze: false, orphan: false, longWord: false }); try { jz.render(root); jz.observe(root); a.textContent = "改後,新文。"; jz[operation](b); await tick(); assert.equal(a.querySelectorAll("jz-char").length, 2, operation + " 不丟棄兄弟變更"); if (operation === "revert") { assert.equal(b.querySelector("[data-jz]"), null); } a.textContent = "再次,更新。"; await tick(); assert.equal(a.querySelectorAll("jz-char").length, 2, "結算後繼續觀察"); } finally { jz.disconnect(); dom.window.close(); } } }); test("observer:祖先分類待更新時手動 revert 子塊仍最後生效", async () => { const { dom, document } = setupDom('

甲文,內容。

乙文,內容。

'); const root = document.querySelector("main"); const a = document.getElementById("a"); const b = document.getElementById("b"); const jz = createJuzhen(); try { jz.render(root); jz.observe(root); root.setAttribute("data-jz-level", "text"); jz.revert(b); await tick(); assert.equal(a.getAttribute("data-jz-block-level"), "text"); assert.equal(a.querySelector("jz-orphan"), null, "兄弟塊採新祖先級別"); assert.equal(b.querySelector("[data-jz]"), null, "舊祖先記錄不重新 render 已回退子塊"); assert.equal(b.hasAttribute("data-jz-block-level"), false); } finally { jz.disconnect(); dom.window.close(); } }); test("observer:暫停前結算失敗仍非同步報錯,不阻擋手動操作及後續觀察", async () => { const { dom, document } = setupDom('

甲文,內容。

乙文,內容。

'); const root = document.querySelector("main"); const a = document.getElementById("a"); const b = document.getElementById("b"); const expected = new Error("pending mutation failure"); let shouldThrow = false; const errors = []; dom.window.addEventListener("error", (event) => { errors.push(event.error); event.preventDefault(); }); const jz = createJuzhen({ jinze: false, orphan: false, longWord: false, isSkipped(node) { if (shouldThrow && node === a) { shouldThrow = false; throw expected; } return false; }, }); try { jz.render(root); jz.observe(root); a.textContent = "改後,新文。"; shouldThrow = true; assert.doesNotThrow(() => jz.revert(b), "舊 observer 錯誤不變成同步 API 例外"); assert.equal(errors.length, 0); await tick(); assert.deepEqual(errors, [ expected ]); assert.equal(b.querySelector("[data-jz]"), null); a.textContent = "再次,更新。"; await tick(); assert.equal(a.querySelectorAll("jz-char").length, 2); } finally { jz.disconnect(); dom.window.close(); } });