Files
cjk-autospace/test/observer-recovery.test.mjs
T

821 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 觸發");
assert.equal(splitoff.isConnected, false);
}
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(); }
});