fix(web): 修復排版邊界與增量生命週期

This commit is contained in:
2026-10-02 19:08:02 +08:00
parent 1c2fb0ed59
commit b1a16c4969
39 changed files with 4763 additions and 673 deletions
+326
View File
@@ -0,0 +1,326 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { execFileSync } from "node:child_process";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
const ONLY_SPACING = { jiya: false, jinze: false, orphan: false, longWord: false };
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
for (const emptied of [ false, true ])
{
test("revert provenance:重用已撤回容器 emptied=" + emptied + " 不循環或丟字", () =>
{
// 有界子進程使來源鏈循環的回歸失敗可終止,不能卡住整套測試。
const script = `
import assert from 'node:assert/strict';
import { setupDom } from ${JSON.stringify(new URL("./helpers.mjs", import.meta.url).href)};
import { createJuzhen } from ${JSON.stringify(new URL("../dist/juzhen.mjs", import.meta.url).href)};
const {dom,document}=setupDom('<p></p>');const root=document.querySelector('p');
for(const t of ['甲','「','禰','\\uFE00','A'])root.append(document.createTextNode(t));
const B=createJuzhen({spacing:false,orphan:false,longWord:false});
const C=createJuzhen({spacing:false,jiya:false,jinze:false,longWord:false});
const A=createJuzhen({jiya:false,jinze:false,orphan:false,longWord:false});
B.render(root);const oldJ=root.querySelector('jz-jinze');
C.render(root);const oldO=root.querySelector('jz-orphan');A.render(root);
B.revert(root);oldJ.append(oldO);C.revert(oldJ);
let request=oldJ;
if(${emptied}){root.append(oldJ.querySelector('jz-hws'));oldJ.replaceChildren();request=root;}
const text=request.textContent;A.revert(request);assert.equal(request.textContent,text);
A.disconnect();B.disconnect();C.disconnect();dom.window.close();
console.log('bounded revert PASS');
`;
const output = execFileSync(process.execPath, [ "--input-type=module", "-e", script ],
{ encoding: "utf8", timeout: 5000 });
assert.match(output, /bounded revert PASS/);
});
}
for (const [name, attrs, options] of [
[ "level", 'data-jz-level="text"', {} ],
[ "off", 'data-juzhen-off="jiya orphan jinze longWord spacing"', {} ],
[ "avoid", "data-jz-skip", {} ],
[ "lang", 'lang="zh-Hans"', {} ],
[ "pill", 'class="pill"', { pillSelector: ".pill" } ],
])
{
test("observer relocate:" + name + " 的增量結果與完整重排一致", async () =>
{
const { dom, document } = setupDom('<main><section id="from"><p>文字ABCDEF,甲乙。</p></section>'
+ '<section id="to" ' + attrs + '></section></main>');
const root = document.querySelector("main");
const paragraph = root.querySelector("p");
const jz = createJuzhen(options);
try
{
jz.render(root);
jz.observe(root);
document.getElementById("to").append(paragraph);
await tick();
jz.disconnect();
const incremental = paragraph.outerHTML;
jz.rerender(root);
assert.equal(incremental, paragraph.outerHTML);
assert.equal(paragraph.textContent, "文字ABCDEF,甲乙。");
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
}
test("observer relocate:舊 avoid 快取不帶入新父範圍", async () =>
{
const { dom, document } = setupDom('<main><section data-jz-skip><p>文字ABC,甲乙。</p></section><section id="to"></section></main>');
const root = document.querySelector("main");
const paragraph = root.querySelector("p");
const jz = createJuzhen();
try
{
jz.render(root);
jz.observe(root);
document.getElementById("to").append(paragraph);
await tick();
jz.disconnect();
const incremental = paragraph.outerHTML;
jz.rerender(root);
assert.equal(incremental, paragraph.outerHTML);
assert.ok(paragraph.querySelector("jz-char"));
}
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
});
for (const [name, marker, options] of [
[ "off", 'data-juzhen-off="spacing"', {} ],
[ "whitelist", 'data-juzhen="jiya"', {} ],
[ "blocks", 'class="disabled"', { blocks: [ { selector: ".disabled", features: [ "jiya" ] } ] } ],
])
{
test("spacing inline feature:" + name + " 保留禁用內容及作者空格", () =>
{
const { dom, document } = setupDom('<p>外ABC<span ' + marker + '>甲 ABC 乙</span>文XYZ</p>');
const root = document.querySelector("p");
const disabled = root.querySelector("span");
const original = root.textContent;
const jz = createJuzhen({ ...ONLY_SPACING, ...options });
try
{
jz.render(root);
assert.equal(disabled.innerHTML, "甲 ABC 乙");
assert.equal(root.textContent, original);
assert.equal(root.querySelectorAll("jz-hws").length, 2, "外部啟用區仍留隙");
jz.revert(root);
assert.equal(root.textContent, original);
}
finally { jz.revert(root); dom.window.close(); }
});
}
for (const marker of [ 'data-juzhen-off="spacing"', 'data-juzhen="jiya"' ])
{
test("spacing feature:禁用塊內最近子白名單可重新啟用 " + marker, () =>
{
const { dom, document } = setupDom('<p ' + marker + '>甲 ABC<span data-juzhen="spacing">乙 DEF</span>丙 GHI</p>');
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_SPACING);
try
{
jz.render(root);
assert.equal(root.textContent, "甲 ABC乙DEF丙 GHI");
assert.equal(root.querySelectorAll("jz-hws").length, 1);
assert.equal(root.querySelector("span jz-hws").textContent, "乙");
}
finally { jz.revert(root); dom.window.close(); }
});
}
for (const side of [ "left", "right" ])
{
test("spacing pill feature:" + side + " 鄰字禁用時不刪空格或造隙", () =>
{
const html = side === "left" ? '<span data-juzhen-off="spacing">甲 </span><code>x</code>'
: '<code>x</code><span data-juzhen-off="spacing"> 甲</span>';
const { dom, document } = setupDom("<p>" + html + "</p>");
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_SPACING);
try { jz.render(root); assert.equal(root.innerHTML, html); }
finally { jz.revert(root); dom.window.close(); }
});
}
for (const separator of [ '<span data-juzhen-off="spacing"></span>', '<span data-juzhen=""></span>' ])
{
test("spacing feature:空禁用元素中斷 run " + separator, () =>
{
const { dom, document } = setupDom("<p>甲" + separator + "ABC</p>");
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_SPACING);
try { jz.render(root); assert.equal(root.querySelector("jz-hws"), null); }
finally { jz.revert(root); dom.window.close(); }
});
}
for (const full of [ false, true ])
{
for (const mutation of [ "clone-gap", "clone-base", "remove-gap", "replace-source-parent" ])
{
test("spacing carrier integrity:" + mutation + " full=" + full, () =>
{
const { dom, document } = setupDom('<p><strong id="base">禰</strong><em id="vs">\u{E0100}</em>A</p>');
const root = document.querySelector("p");
const source = root.querySelector("em");
const jz = createJuzhen(full ? {} : ONLY_SPACING);
try
{
jz.render(root);
const gap = root.querySelector("jz-hws");
const base = document.getElementById("base");
if (mutation === "clone-gap") { gap.replaceWith(gap.cloneNode(true)); }
else if (mutation === "clone-base") { base.replaceWith(base.cloneNode(true)); }
else if (mutation === "remove-gap") { gap.remove(); }
else { source.replaceWith(source.cloneNode(true)); }
const before = root.textContent;
jz.revert(root);
assert.equal(root.textContent, mutation === "replace-source-parent" ? "禰A" : before);
if (mutation === "replace-source-parent") { assert.equal(source.textContent, ""); }
}
finally { jz.revert(root); dom.window.close(); }
});
}
}
for (const order of [ "binding-first", "spacing-first" ])
{
test("spacing multi-owner:" + order + " 回退受控父包裹不丟 VS", () =>
{
const { dom, document } = setupDom("<p></p>");
const root = document.querySelector("p");
for (const value of [ "「", "禰", "\uFE00", "A" ]) { root.appendChild(document.createTextNode(value)); }
const binding = createJuzhen({ spacing: false, orphan: false, longWord: false });
const spacing = createJuzhen(ONLY_SPACING);
try
{
binding.render(root);
spacing.render(root);
if (order === "binding-first") { binding.revert(root); spacing.revert(root); }
else { spacing.revert(root); binding.revert(root); }
assert.equal(root.textContent, "「禰\uFE00A");
assert.equal(root.querySelector("[data-jz]"), null);
}
finally { binding.revert(root); spacing.revert(root); dom.window.close(); }
});
}
for (const side of [ "left", "right" ])
{
test("spacing pill feature:空禁用元素在 " + side + " 阻斷鄰字查詢", () =>
{
const barrier = '<span data-juzhen-off="spacing"></span>';
const html = side === "left" ? "甲" + barrier + "<code>x</code>"
: "<code>x</code>" + barrier + "甲";
const { dom, document } = setupDom("<p>" + html + "</p>");
const root = document.querySelector("p");
const jz = createJuzhen(ONLY_SPACING);
try { jz.render(root); assert.equal(root.innerHTML, html); }
finally { jz.revert(root); dom.window.close(); }
});
}
for (const order of [ [0,1,2], [1,0,2], [0,2,1], [1,2,0], [2,0,1], [2,1,0] ])
{
test("spacing multi-owner:傳遞解包鏈 " + order.join("→") + " 保留來源原子", () =>
{
const { dom, document } = setupDom("<p></p>");
const root = document.querySelector("p");
for (const value of [ "甲", "「", "禰", "\uFE00", "A" ]) { root.appendChild(document.createTextNode(value)); }
const instances = [
createJuzhen({ spacing: false, orphan: false, longWord: false }),
createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false }),
createJuzhen(ONLY_SPACING),
];
try
{
for (const instance of instances) { instance.render(root); }
for (const index of order) { instances[index].revert(root); }
assert.equal(root.textContent, "甲「禰\uFE00A");
assert.equal(root.querySelector("[data-jz]"), null);
}
finally { for (const instance of instances) { instance.revert(root); } dom.window.close(); }
});
}
for (const order of [ "punctuation-first", "spacing-first" ])
{
test("spacing multi-owner:跨 Text 標點父解包 " + order + " 保留 VS", () =>
{
const { dom, document } = setupDom('<p><strong>,</strong><em>\uFE00</em>A</p>');
const root = document.querySelector("p");
const punctuation = createJuzhen({ spacing: false, jinze: false, orphan: false, longWord: false });
const spacing = createJuzhen({ ...ONLY_SPACING, charClass: { cjk: "," } });
try
{
punctuation.render(root);
spacing.render(root);
if (order === "punctuation-first") { punctuation.revert(root); spacing.revert(root); }
else { spacing.revert(root); punctuation.revert(root); }
assert.equal(root.textContent, ",\uFE00A");
assert.equal(root.querySelector("[data-jz]"), null);
}
finally { punctuation.revert(root); spacing.revert(root); dom.window.close(); }
});
}
for (const mode of [ "all", "spacing-only" ])
{
test("spacing include:行內 opt-in " + mode + " 只處理選中子樹", () =>
{
const html = '<p>外ABC<span class="prose">甲DEF<span class="prose">乙GHI</span>丙</span>文XYZ</p>';
const { dom, document } = setupDom(html);
const root = document.querySelector("p");
const jz = createJuzhen({ ...(mode === "all" ? {} : ONLY_SPACING), scope: { include: ".prose" } });
try
{
jz.render(root);
const gaps = Array.from(root.querySelectorAll("jz-hws"));
assert.deepEqual(gaps.map((gap) => gap.textContent), [ "甲", "F", "乙", "I" ]);
assert.equal(root.firstChild.textContent, "外ABC");
assert.equal(root.lastChild.textContent, "文XYZ");
const rendered = root.innerHTML;
jz.render(root);
assert.equal(root.innerHTML, rendered);
jz.revert(root);
assert.equal(root.outerHTML, html);
}
finally { jz.revert(root); dom.window.close(); }
});
}
for (const marker of [ 'data-juzhen-off="jiya"', 'data-juzhen="spacing"' ])
{
test("jiya feature:空禁用元素中斷標點鄰接 " + marker, () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">,<span ' + marker + '></span>「</p>');
const root = document.querySelector("p");
const jz = createJuzhen({ spacing: false, orphan: false, jinze: false, longWord: false });
try
{
jz.render(root);
assert.equal(root.querySelectorAll("jz-char").length, 2);
assert.equal(root.querySelectorAll("jz-char.jz-half").length, 0);
}
finally { jz.revert(root); dom.window.close(); }
});
}
test("jiya feature:跨 Text 原子不越空禁用元素搬移 VS", () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant"><strong>,</strong><span data-juzhen-off="jiya"></span><em>\uFE00</em>「</p>');
const root = document.querySelector("p");
const jz = createJuzhen({ spacing: false, orphan: false, jinze: false, longWord: false });
try
{
jz.render(root);
assert.equal(root.querySelector("jz-char").textContent, ",");
assert.equal(root.querySelector("em").textContent, "\uFE00");
assert.equal(root.querySelector("jz-char.jz-half"), null);
}
finally { jz.revert(root); dom.window.close(); }
});