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('

');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('

文字ABCDEF,甲乙。

' + '
'); 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('

文字ABC,甲乙。

'); 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('

外ABC甲 ABC 乙文XYZ

'); 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('

甲 ABC乙 DEF丙 GHI

'); 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" ? '甲 x' : 'x 甲'; const { dom, document } = setupDom("

" + html + "

"); 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 [ '', '' ]) { test("spacing feature:空禁用元素中斷 run " + separator, () => { const { dom, document } = setupDom("

甲" + separator + "ABC

"); 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('

禰\u{E0100}A

'); 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("

"); 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 = ''; const html = side === "left" ? "甲" + barrier + "x" : "x" + barrier + "甲"; const { dom, document } = setupDom("

" + html + "

"); 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("

"); 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('

,\uFE00A

'); 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 = '

外ABC甲DEF乙GHI丙文XYZ

'; 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('

,「

'); 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('

,\uFE00「

'); 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(); } });