3a4fa1c932
保留逐字 margin/jiya.bias 字体后备方案;补齐 demo 重排、量测边界说明及共享规则 1.0.1 反例向量。 验证:完整构建通过,Node 376 项、WKWebView 64 项及原生 demo 9 项通过;Firefox 仍暂缓。
829 lines
28 KiB
JavaScript
829 lines
28 KiB
JavaScript
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('<div id="root"><p lang="zh-Hant">原文,內容。</p></div>');
|
||
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('<div id="root"><p lang="zh-Hant">原文,內容。</p></div>');
|
||
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('<div id="root"><p lang="zh-Hant">嵌套,渲染。</p></div>');
|
||
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('<div id="root"><p lang="zh-Hant">重建,觀察。</p></div>');
|
||
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('<div id="root"><p lang="zh-Hant">(版面回調</p></div>');
|
||
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('<div id="root"><p lang="zh-Hant">作者,屬性。</p></div>');
|
||
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('<div id="root"><p lang="zh-Hans">前文<strong>粗体文字</strong>,后续</p></div>');
|
||
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('<div id="root"><p lang="zh-Hant">(混合記錄</p></div>');
|
||
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) => `<p>(${index}</p>`).join("");
|
||
const { document } = setupDom(`<div id="root" lang="zh-Hant">${html}</div>`);
|
||
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) => `<p>(${index}</p>`).join("");
|
||
const { document } = setupDom(`<div id="root" lang="zh-Hant">${html}</div>`);
|
||
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('<div id="root"><p lang="zh-Hant">外來,產物。</p></div>');
|
||
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('<div id="root"><p lang="zh-Hant">內容,變更。</p></div>');
|
||
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('<div id="root"><p lang="zh-Hant">(生命週期</p></div>');
|
||
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) => `<p>(${index}</p>`).join("");
|
||
const { document } = setupDom(`<div id="root" lang="zh-Hant">${html}</div>`);
|
||
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('<main><p id="a">原文,甲。</p><p id="b">原文,乙。</p></main>');
|
||
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('<main><p id="a">甲文,內容。</p><p id="b">乙文,內容。</p></main>');
|
||
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('<main><p id="a">甲文,內容。</p><p id="b">乙文,內容。</p></main>');
|
||
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(); }
|
||
});
|