fix(web): 修復排版邊界與增量生命週期
This commit is contained in:
@@ -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(); }
|
||||
});
|
||||
Reference in New Issue
Block a user