fix(web): 修復排版邊界與增量生命週期
This commit is contained in:
@@ -691,7 +691,7 @@ test("moved IVS ownership: anchor and text-move throws roll back exactly", () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("moved IVS ownership: partial roots and detached links restore as one unit", () =>
|
||||
test("moved IVS ownership: partial roots restore as one unit; deleted carrier stays deleted", () =>
|
||||
{
|
||||
const original = '<strong id="partial-base">\u{20000}</strong>'
|
||||
+ '<em id="partial-selector">\u{E0100}A</em>';
|
||||
@@ -746,10 +746,14 @@ test("moved IVS ownership: partial roots and detached links restore as one unit"
|
||||
}
|
||||
|
||||
jz.revert(revertRoot);
|
||||
assert.equal(paragraph.innerHTML, original, `${kind} restores exact author DOM`);
|
||||
// 移除整個載體即刪除其原文;不能因回退另一端而復活已刪 base/VS。
|
||||
const expected = kind === "detached-gap"
|
||||
? '<strong id="partial-base"></strong><em id="partial-selector">A</em>' : original;
|
||||
assert.equal(paragraph.innerHTML, expected, `${kind} restores or preserves exact author DOM`);
|
||||
assert.equal(paragraph.querySelector("#partial-base"), base, `${kind} base identity`);
|
||||
assert.equal(paragraph.querySelector("#partial-selector"), selectorParent, `${kind} parent identity`);
|
||||
assert.equal(selectorParent.textContent, "\u{E0100}A", `${kind} selector original parent`);
|
||||
assert.equal(selectorParent.textContent, kind === "detached-gap" ? "A" : "\u{E0100}A",
|
||||
`${kind} selector original parent`);
|
||||
assert.equal(countTag(paragraph, "jz-hws"), 0, `${kind} removes linked gap`);
|
||||
assert.equal(
|
||||
Array.from(outside.childNodes).filter((node) => node.nodeType === 8).length,
|
||||
|
||||
@@ -774,7 +774,10 @@ test("CSS:聚珍不內建 font-family(字型交消費端),半形顯式
|
||||
const cssPath = fileURLToPath(new URL("../dist/juzhen.css", import.meta.url));
|
||||
const css = readFileSync(cssPath, "utf8");
|
||||
assert.equal(/font-family/.test(css), false, "聚珍 CSS 不應內建任何 font-family");
|
||||
assert.match(css, /font-feature-settings:\s*"locl"\s+1,\s*"halt"\s+1/, "半形 feature 應顯式保留 locl");
|
||||
assert.match(css, /font-feature-settings:\s*var\(--jz-half-features,\s*"locl"\s+1,\s*"halt"\s+1\)/,
|
||||
"半形 feature 的預設值應顯式保留 locl");
|
||||
assert.match(css, /--jz-half-features:\s*"locl"\s+1,\s*"halt"\s+1/,
|
||||
"halt 模式應顯式保留 locl");
|
||||
});
|
||||
|
||||
// ----- charClass:自定義字符分類規則集(向下兼容+擴充)-----
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { setupDom } from "./helpers.mjs";
|
||||
import { createJuzhen } from "../dist/juzhen.mjs";
|
||||
|
||||
function layoutHarness(document)
|
||||
{
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
const originalRects = window.Element.prototype.getClientRects;
|
||||
const observers = [];
|
||||
let measurements = 0;
|
||||
let resolveFonts;
|
||||
const ready = new Promise((resolve) => { resolveFonts = resolve; });
|
||||
Object.defineProperty(document, "fonts", { configurable: true, value: { ready } });
|
||||
globalThis.ResizeObserver = class
|
||||
{
|
||||
constructor(callback) { this.callback = callback; this.disconnected = false; observers.push(this); }
|
||||
observe() {}
|
||||
disconnect() { this.disconnected = true; }
|
||||
fire() { this.callback([], this); }
|
||||
};
|
||||
window.Element.prototype.getClientRects = function ()
|
||||
{
|
||||
measurements += 1;
|
||||
return [ { top: 0, bottom: 16, left: 0, right: 16, width: 16, height: 16 } ];
|
||||
};
|
||||
return {
|
||||
observers,
|
||||
resolveFonts,
|
||||
count: () => measurements,
|
||||
restore()
|
||||
{
|
||||
window.Element.prototype.getClientRects = originalRects;
|
||||
if (originalResizeObserver === undefined) { delete globalThis.ResizeObserver; }
|
||||
else { globalThis.ResizeObserver = originalResizeObserver; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const signal of [ "resize", "fonts" ])
|
||||
{
|
||||
test("layout lifecycle:重排後舊 " + signal + " 回調不得量測新產物", async () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p lang="zh-Hant">「甲乙</p>');
|
||||
const root = document.querySelector("p");
|
||||
const layout = layoutHarness(document);
|
||||
const jz = createJuzhen({ spacing: false, orphan: false, jinze: false, longWord: false });
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
jz.rerender(root);
|
||||
assert.equal(layout.observers.length, 2);
|
||||
assert.equal(layout.observers[0].disconnected, true);
|
||||
const before = layout.count();
|
||||
if (signal === "resize")
|
||||
{
|
||||
layout.observers[0].fire();
|
||||
assert.equal(layout.count(), before, "已卸載回調不碰目前產物");
|
||||
layout.observers[1].fire();
|
||||
assert.equal(layout.count(), before + 1, "目前回調正常執行");
|
||||
}
|
||||
else
|
||||
{
|
||||
layout.resolveFonts();
|
||||
await Promise.resolve();
|
||||
assert.equal(layout.count(), before + 1, "只執行目前安裝的字型回調");
|
||||
}
|
||||
}
|
||||
finally { jz.revert(root); jz.disconnect(); layout.restore(); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("layout lifecycle:回退後字型 Promise 不再查詢根子樹", async () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p lang="zh-Hant">「甲乙</p>');
|
||||
const root = document.querySelector("p");
|
||||
const layout = layoutHarness(document);
|
||||
const jz = createJuzhen({ spacing: false, orphan: false, jinze: false, longWord: false });
|
||||
const nativeQuery = root.querySelectorAll;
|
||||
let queries = 0;
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
jz.revert(root);
|
||||
root.querySelectorAll = function (...args) { queries += 1; return nativeQuery.apply(this, args); };
|
||||
layout.resolveFonts();
|
||||
await Promise.resolve();
|
||||
assert.equal(queries, 0, "卸載後不再進入 layout 工作");
|
||||
assert.equal(root.innerHTML, "「甲乙");
|
||||
}
|
||||
finally { root.querySelectorAll = nativeQuery; jz.disconnect(); layout.restore(); dom.window.close(); }
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { setupDom } from "./helpers.mjs";
|
||||
import { createJuzhen } from "../dist/juzhen.mjs";
|
||||
|
||||
for (const minLength of [ 6.5, Infinity, NaN, -Infinity, 1e21 ])
|
||||
{
|
||||
test("longWord:無效計數 " + minLength + " 回退六字,不靜默停用", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p>configuration</p>');
|
||||
const jz = createJuzhen({ jiya: false, spacing: false, jinze: false, orphan: false,
|
||||
longWord: { minLength } });
|
||||
try
|
||||
{
|
||||
jz.render(document.body);
|
||||
assert.equal(jz.options.longWord.minLength, 6);
|
||||
assert.equal(document.querySelectorAll('[data-jz="longWord"]').length, 1);
|
||||
assert.equal(document.body.textContent, "configuration");
|
||||
jz.revert(document.body);
|
||||
assert.equal(document.querySelector("p").innerHTML, "configuration");
|
||||
}
|
||||
finally { jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const chars of [ Infinity, NaN, -Infinity ])
|
||||
{
|
||||
test("orphan:非有限計數 " + chars + " 回退兩字", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p>甲乙丙丁</p>');
|
||||
const jz = createJuzhen({ jiya: false, spacing: false, jinze: false, longWord: false,
|
||||
orphan: { chars } });
|
||||
try
|
||||
{
|
||||
jz.render(document.body);
|
||||
assert.equal(jz.options.orphan.chars, 2);
|
||||
assert.equal(document.querySelector("jz-orphan").textContent, "丙丁");
|
||||
jz.revert(document.body);
|
||||
assert.equal(document.querySelector("p").innerHTML, "甲乙丙丁");
|
||||
}
|
||||
finally { jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("有效計數:longWord 整數閾值及 orphan 小數向下取整維持既有行為", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p>abcdef abcdefg甲乙丙丁</p>');
|
||||
const jz = createJuzhen({ jiya: false, spacing: false, jinze: false,
|
||||
longWord: { minLength: 7 }, orphan: { chars: 2.5 } });
|
||||
try
|
||||
{
|
||||
jz.render(document.body);
|
||||
assert.equal(jz.options.orphan.chars, 2);
|
||||
assert.deepEqual(Array.from(document.querySelectorAll('[data-jz="longWord"]'), (el) => el.textContent), [ "abcdefg" ]);
|
||||
assert.equal(document.querySelector("jz-orphan").textContent, "丙丁");
|
||||
}
|
||||
finally { jz.revert(document.body); jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { setupDom } from "./helpers.mjs";
|
||||
import { createJuzhen } from "../dist/juzhen.mjs";
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
for (const scenario of [ "move-added-block", "detached-target-record" ])
|
||||
{
|
||||
test("observer scope:" + scenario + " 不排版已移出觀察根的作者內容", async () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<main id="root"></main><aside id="outside"></aside>');
|
||||
const root = document.getElementById("root");
|
||||
const outside = document.getElementById("outside");
|
||||
const p = document.createElement("p");
|
||||
p.innerHTML = "新增ABC,作者文字。";
|
||||
const jz = createJuzhen();
|
||||
try
|
||||
{
|
||||
if (scenario === "detached-target-record") { root.appendChild(p); }
|
||||
jz.observe(root);
|
||||
if (scenario === "move-added-block") { root.appendChild(p); }
|
||||
outside.appendChild(p);
|
||||
if (scenario === "detached-target-record") { p.setAttribute("lang", "zh-Hans"); }
|
||||
const original = p.outerHTML;
|
||||
await tick();
|
||||
assert.equal(p.outerHTML, original, "已移出內容不能因尚未派發的記錄被新排版");
|
||||
const active = document.createElement("p");
|
||||
active.textContent = "根內ABC,仍處理。";
|
||||
root.appendChild(active);
|
||||
await tick();
|
||||
assert.ok(active.querySelector("jz-char"), "觀察根內後續更新保持正常");
|
||||
}
|
||||
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const change of [ "text", "attribute", "new-block" ])
|
||||
{
|
||||
test("observer detached root:" + change + " 在未掛入 document 的根仍增量更新", async () =>
|
||||
{
|
||||
const { dom, document } = setupDom();
|
||||
const root = document.createElement("main");
|
||||
root.innerHTML = '<p lang="zh-Hant">原文,甲乙。</p>';
|
||||
const jz = createJuzhen();
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
jz.observe(root);
|
||||
const p = root.querySelector("p");
|
||||
if (change === "text") { p.textContent = "新文ABC,丙丁。"; }
|
||||
else if (change === "attribute") { p.setAttribute("data-jz-level", "text"); }
|
||||
else
|
||||
{
|
||||
const added = document.createElement("p");
|
||||
added.textContent = "追加ABC,戊己。";
|
||||
root.appendChild(added);
|
||||
}
|
||||
await tick();
|
||||
assert.equal(root.isConnected, false);
|
||||
if (change === "attribute")
|
||||
{
|
||||
assert.equal(p.getAttribute("data-jz-block-level"), "text");
|
||||
assert.equal(p.querySelector("jz-orphan"), null);
|
||||
}
|
||||
else
|
||||
{
|
||||
const target = change === "new-block" ? root.lastElementChild : p;
|
||||
assert.ok(target.querySelector("jz-hws"));
|
||||
assert.ok(target.querySelector("jz-char"));
|
||||
}
|
||||
const currentText = root.textContent;
|
||||
jz.revert(root);
|
||||
assert.equal(root.querySelector("[data-jz]"), null);
|
||||
assert.equal(root.textContent, currentText);
|
||||
}
|
||||
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
@@ -730,3 +730,91 @@ test("observer provenance: transition inference and queue consumption scale line
|
||||
}
|
||||
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(); }
|
||||
});
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { setupDom } from "./helpers.mjs";
|
||||
import { createJuzhen } from "../dist/juzhen.mjs";
|
||||
|
||||
test("pill:塊本身與其後代塊的內部不進入 standalone pass", () =>
|
||||
{
|
||||
for (const html of [
|
||||
'<main><div class="pill"><p>甲ABC乙,丙。</p></div><p>正常ABC,文字。</p></main>',
|
||||
'<main><p class="pill">甲ABC乙,丙。</p><p>正常ABC,文字。</p></main>',
|
||||
])
|
||||
{
|
||||
const { dom, document } = setupDom(html);
|
||||
const original = document.body.innerHTML;
|
||||
const pill = document.querySelector(".pill");
|
||||
const before = pill.innerHTML;
|
||||
const jz = createJuzhen({ pillSelector: ".pill" });
|
||||
try
|
||||
{
|
||||
jz.render(document.body);
|
||||
assert.equal(pill.innerHTML, before, "pill 內部保持原樣");
|
||||
assert.ok(document.querySelector("main > p:not(.pill) jz-hws"), "正常兄弟塊仍處理");
|
||||
jz.revert(document.body);
|
||||
assert.equal(pill.innerHTML, before);
|
||||
}
|
||||
finally { jz.disconnect(); dom.window.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
const exclusions = [
|
||||
[ "avoid", "data-jz-skip" ],
|
||||
[ "text", 'data-jz-level="text"' ],
|
||||
[ "off", 'data-juzhen-off="orphan jinze"' ],
|
||||
[ "predicate", 'class="skip-text"' ],
|
||||
];
|
||||
const skippedText = (node) => node.nodeType === 3
|
||||
&& node.parentElement?.classList.contains("skip-text");
|
||||
|
||||
test("jinze:排除或關閉段落功能的兩側不建立禁斷綁定", () =>
|
||||
{
|
||||
for (const [ label, attr ] of exclusions)
|
||||
{
|
||||
for (const side of [ "before-close", "after-open" ])
|
||||
{
|
||||
const protectedHtml = '<span ' + attr + '>自管</span>';
|
||||
const html = side === "before-close"
|
||||
? '<p>' + protectedHtml + ',正常文字。</p>'
|
||||
: '<p>正常文字(' + protectedHtml + '</p>';
|
||||
const { dom, document } = setupDom(html);
|
||||
const original = document.body.innerHTML;
|
||||
const protectedEl = document.querySelector("span");
|
||||
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false,
|
||||
isSkipped: skippedText });
|
||||
try
|
||||
{
|
||||
jz.render(document.body);
|
||||
assert.equal(protectedEl.closest("jz-jinze"), null, label + "/" + side);
|
||||
assert.equal(protectedEl.innerHTML, "自管");
|
||||
jz.revert(document.body);
|
||||
assert.equal(document.body.innerHTML, original);
|
||||
}
|
||||
finally { jz.disconnect(); dom.window.close(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("jinze:透明元素內的排除內容不可被整元素退化綁定帶入", () =>
|
||||
{
|
||||
const html = '<p><a href="#">甲<span data-jz-skip>自管</span>乙</a>,正常。</p>';
|
||||
const { dom, document } = setupDom(html);
|
||||
const original = document.body.innerHTML;
|
||||
const protectedEl = document.querySelector("[data-jz-skip]");
|
||||
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false });
|
||||
try
|
||||
{
|
||||
jz.render(document.body);
|
||||
assert.equal(protectedEl.closest("jz-jinze"), null);
|
||||
jz.revert(document.body);
|
||||
assert.equal(document.body.innerHTML, original);
|
||||
}
|
||||
finally { jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
|
||||
test("jinze:註解與空 Text 不阻斷禁則,回退保留註解身份和次序", () =>
|
||||
{
|
||||
const html = '<p>甲<!--anchor-->,乙。</p>';
|
||||
const { dom, document } = setupDom(html);
|
||||
const original = document.body.innerHTML;
|
||||
const p = document.querySelector("p");
|
||||
const anchor = p.childNodes[1];
|
||||
const empty = document.createTextNode("");
|
||||
p.insertBefore(empty, anchor);
|
||||
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false });
|
||||
try
|
||||
{
|
||||
jz.render(p);
|
||||
const group = p.querySelector("jz-jinze");
|
||||
assert.equal(group.textContent, "甲,");
|
||||
assert.ok(group.contains(anchor));
|
||||
assert.ok(group.contains(empty));
|
||||
jz.revert(p);
|
||||
assert.equal(p.childNodes[1], anchor);
|
||||
assert.equal(anchor.previousSibling.textContent, "甲");
|
||||
assert.equal(anchor.nextSibling.textContent, ",乙。");
|
||||
assert.equal(document.body.innerHTML, original);
|
||||
}
|
||||
finally { jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
|
||||
test("jinze:行內 render 根不把外部兄弟搬入生成群組", () =>
|
||||
{
|
||||
const html = '<p>甲<strong id="root">,乙。</strong>後文</p>';
|
||||
const { dom, document } = setupDom(html);
|
||||
const original = document.body.innerHTML;
|
||||
const root = document.getElementById("root");
|
||||
const parent = root.parentNode;
|
||||
const before = root.previousSibling;
|
||||
const after = root.nextSibling;
|
||||
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false });
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
assert.equal(root.parentNode, parent);
|
||||
assert.equal(root.previousSibling, before);
|
||||
assert.equal(root.nextSibling, after);
|
||||
assert.equal(parent.querySelectorAll(":scope > jz-jinze").length, 0);
|
||||
jz.revert(root);
|
||||
assert.equal(document.body.innerHTML, original);
|
||||
}
|
||||
finally { jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
|
||||
test("orphan:排除/text/off 的尾部內容不計數,也不包裹", () =>
|
||||
{
|
||||
for (const [ label, attr ] of exclusions)
|
||||
{
|
||||
const html = '<p>正常文字<span ' + attr + '>自管文字</span></p>';
|
||||
const { dom, document } = setupDom(html);
|
||||
const original = document.body.innerHTML;
|
||||
const jz = createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false,
|
||||
isSkipped: skippedText });
|
||||
try
|
||||
{
|
||||
jz.render(document.body);
|
||||
assert.equal(document.querySelector("jz-orphan"), null, label + " 尾部是綁定邊界");
|
||||
jz.revert(document.body);
|
||||
assert.equal(document.body.innerHTML, original);
|
||||
}
|
||||
finally { jz.disconnect(); dom.window.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
test("orphan:只綁最後排除邊界之後的合資格末尾", () =>
|
||||
{
|
||||
for (const [ label, attr ] of exclusions)
|
||||
{
|
||||
for (const tail of [ "末", "最後" ])
|
||||
{
|
||||
const html = '<p>前文<span ' + attr + '>自管文字</span>' + tail + '</p>';
|
||||
const { dom, document } = setupDom(html);
|
||||
const original = document.body.innerHTML;
|
||||
const jz = createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false,
|
||||
isSkipped: skippedText });
|
||||
try
|
||||
{
|
||||
jz.render(document.body);
|
||||
const orphan = document.querySelector("jz-orphan");
|
||||
assert.equal(orphan?.textContent ?? null, tail === "末" ? null : tail, label + "/" + tail);
|
||||
assert.equal(document.querySelector("span").closest("jz-orphan"), null);
|
||||
jz.revert(document.body);
|
||||
assert.equal(document.body.innerHTML, original);
|
||||
}
|
||||
finally { jz.disconnect(); dom.window.close(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("orphan:不可切容器不以整綁繞過排除邊界,可切容器只切合資格末尾", () =>
|
||||
{
|
||||
for (const tag of [ 'span', 'a href="#"' ])
|
||||
{
|
||||
const closeTag = tag.split(" ")[0];
|
||||
const html = '<p>前文<' + tag + '><span data-jz-skip>自管</span>最後</' + closeTag + '></p>';
|
||||
const { dom, document } = setupDom(html);
|
||||
const original = document.body.innerHTML;
|
||||
const protectedEl = document.querySelector("[data-jz-skip]");
|
||||
const jz = createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false });
|
||||
try
|
||||
{
|
||||
jz.render(document.body);
|
||||
assert.equal(protectedEl.closest("jz-orphan"), null);
|
||||
assert.equal(document.querySelector("jz-orphan")?.textContent ?? null,
|
||||
closeTag === "span" ? "最後" : null);
|
||||
jz.revert(document.body);
|
||||
assert.equal(document.body.innerHTML, original);
|
||||
}
|
||||
finally { jz.disconnect(); dom.window.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
test("jinze:完整跨節點 base/VS/surrogate 原子一起綁到閉標點", () =>
|
||||
{
|
||||
for (const parts of [ [ "𠀀", "\u{E0100}" ], [ "\uD840", "\uDC00", "\uDB40", "\uDD00" ] ])
|
||||
{
|
||||
const { dom, document } = setupDom("<p></p>");
|
||||
const p = document.querySelector("p");
|
||||
p.append("前文");
|
||||
const authors = parts.map((part) =>
|
||||
{
|
||||
const span = document.createElement("span");
|
||||
span.textContent = part;
|
||||
p.appendChild(span);
|
||||
return span;
|
||||
});
|
||||
p.append(",");
|
||||
const original = p.innerHTML;
|
||||
const jz = createJuzhen({ spacing: false, orphan: false, longWord: false });
|
||||
try
|
||||
{
|
||||
jz.render(p);
|
||||
assert.equal(p.querySelector("jz-jinze").textContent, "𠀀\u{E0100},");
|
||||
const rendered = p.innerHTML;
|
||||
jz.render(p);
|
||||
assert.equal(p.innerHTML, rendered);
|
||||
jz.revert(p);
|
||||
assert.equal(p.innerHTML, original);
|
||||
assert.deepEqual(Array.from(p.querySelectorAll("span")), authors);
|
||||
}
|
||||
finally { jz.disconnect(); dom.window.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
test("orphan:split surrogate 的補充平面字計為一個實義字", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p>前文<strong>\uD840</strong><em>\uDC00</em>末</p>');
|
||||
const p = document.querySelector("p");
|
||||
const original = p.innerHTML;
|
||||
const jz = createJuzhen({ spacing: false, jiya: false, jinze: false, longWord: false });
|
||||
try
|
||||
{
|
||||
jz.render(p);
|
||||
assert.equal(p.querySelector("jz-orphan").textContent, "𠀀末");
|
||||
jz.revert(p);
|
||||
assert.equal(p.innerHTML, original);
|
||||
}
|
||||
finally { jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { setupDom } from "./helpers.mjs";
|
||||
import { createJuzhen } from "../dist/juzhen.mjs";
|
||||
const ONLY_JIYA = { spacing: false, jinze: false, orphan: false, longWord: false };
|
||||
|
||||
for (const selector of [ "\uFE00", "\u{E0100}" ])
|
||||
{
|
||||
for (const full of [ false, true ])
|
||||
{
|
||||
test("jiya atom:跨透明節點 VS " + selector.codePointAt(0).toString(16) + " full=" + full, () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p lang="zh-Hant">甲<strong id="base">,</strong><em id="vs">' + selector + '</em>「乙丙」</p>');
|
||||
const root = document.querySelector("p");
|
||||
const original = root.innerHTML;
|
||||
const text = root.textContent;
|
||||
const base = document.getElementById("base");
|
||||
const vs = document.getElementById("vs");
|
||||
let clicks = 0;
|
||||
vs.addEventListener("click", () => { clicks += 1; });
|
||||
const jz = createJuzhen(full ? {} : ONLY_JIYA);
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
const comma = root.querySelector("jz-char.bd-pause");
|
||||
assert.equal(comma.textContent, "," + selector);
|
||||
assert.equal(comma.querySelector("jz-inner").textContent, "," + selector);
|
||||
assert.equal(comma.classList.contains("jz-half"), true);
|
||||
assert.equal(root.querySelector("jz-char.bd-open").classList.contains("jz-half"), true);
|
||||
assert.equal(document.getElementById("base"), base);
|
||||
assert.equal(document.getElementById("vs"), vs);
|
||||
vs.dispatchEvent(new dom.window.Event("click"));
|
||||
assert.equal(clicks, 1);
|
||||
assert.equal(root.textContent, text);
|
||||
const rendered = root.innerHTML;
|
||||
jz.render(root);
|
||||
assert.equal(root.innerHTML, rendered, "重複 render 不增殖");
|
||||
jz.revert(root);
|
||||
assert.equal(root.innerHTML, original);
|
||||
}
|
||||
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("jiya atom:astral base 與 VS 的四個 surrogate 半字合成一個原子", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p lang="zh-Hant"><strong></strong><em></em><i></i><b></b>「甲</p>');
|
||||
const root = document.querySelector("p");
|
||||
[ "\uD83D", "\uDE00", "\uDB40", "\uDD00" ].forEach((text, i) => { root.children[i].textContent = text; });
|
||||
const original = root.innerHTML;
|
||||
const jz = createJuzhen({ ...ONLY_JIYA, charClass: { biaodian: { pause: "😀" } } });
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
const chars = root.querySelectorAll("jz-char");
|
||||
assert.equal(chars.length, 2);
|
||||
assert.equal(chars[0].textContent, "😀\u{E0100}");
|
||||
assert.equal(chars[0].classList.contains("jz-half"), true);
|
||||
jz.revert(root);
|
||||
assert.equal(root.innerHTML, original);
|
||||
}
|
||||
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
|
||||
test("jiya 分類:astral 字集不包含原文中孤立的 surrogate 半字", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p></p>');
|
||||
const root = document.querySelector("p");
|
||||
root.textContent = "\uD83DA\uDE00";
|
||||
const original = root.textContent;
|
||||
const jz = createJuzhen({ ...ONLY_JIYA, charClass: { biaodian: { pause: "😀" } } });
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
assert.equal(root.querySelector("jz-char"), null);
|
||||
assert.equal(root.textContent, original);
|
||||
}
|
||||
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
|
||||
for (const excluded of [ false, true ])
|
||||
{
|
||||
test("jiya 邊界:空 Text excluded=" + excluded, () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p lang="zh-Hant">,<strong></strong>「</p>');
|
||||
const root = document.querySelector("p");
|
||||
const empty = document.createTextNode("");
|
||||
root.querySelector("strong").appendChild(empty);
|
||||
const jz = createJuzhen({ ...ONLY_JIYA, isSkipped: (node) => excluded && node === empty });
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
assert.equal(root.querySelectorAll("jz-char.jz-half").length, excluded ? 0 : 2);
|
||||
}
|
||||
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const side of [ "base", "vs" ])
|
||||
{
|
||||
test("jiya 跨原子局部回退:" + side + " 端保持原文及另一端位置", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p lang="zh-Hant"><strong id="base">,</strong><em id="vs">\uFE00</em>「</p>');
|
||||
const root = document.querySelector("p");
|
||||
const jz = createJuzhen(ONLY_JIYA);
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
jz.revert(document.getElementById(side));
|
||||
assert.equal(document.getElementById("base").textContent, ",");
|
||||
assert.equal(document.getElementById("vs").textContent, "\uFE00");
|
||||
assert.equal(root.textContent, ",\uFE00「");
|
||||
jz.revert(root);
|
||||
assert.equal(root.innerHTML, '<strong id="base">,</strong><em id="vs">\uFE00</em>「');
|
||||
}
|
||||
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const mutation of [ "replace-source", "move-carrier", "move-author-text", "foreign-revert" ])
|
||||
{
|
||||
test("jiya 原子回退:" + mutation + " 保留作者外部修改/實例所有權", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<main><p lang="zh-Hant"><strong id="base">,</strong><em id="vs">\uFE00</em>「</p><aside></aside></main>');
|
||||
const root = document.querySelector("p");
|
||||
const jz = createJuzhen(ONLY_JIYA);
|
||||
const other = createJuzhen(ONLY_JIYA);
|
||||
const aside = document.querySelector("aside");
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
const comma = root.querySelector("jz-char.bd-pause");
|
||||
const selector = comma.querySelector("jz-inner").lastChild;
|
||||
if (mutation === "replace-source") { document.getElementById("vs").textContent = "作者新文"; }
|
||||
else if (mutation === "move-carrier") { aside.appendChild(comma); }
|
||||
else if (mutation === "move-author-text") { aside.appendChild(selector); }
|
||||
else
|
||||
{
|
||||
const html = root.innerHTML;
|
||||
other.revert(root);
|
||||
assert.equal(root.innerHTML, html);
|
||||
}
|
||||
jz.revert(root);
|
||||
assert.equal(document.getElementById("base").textContent, ",");
|
||||
if (mutation === "replace-source")
|
||||
{
|
||||
assert.equal(document.getElementById("vs").textContent, "作者新文");
|
||||
assert.equal(root.textContent, ",作者新文「");
|
||||
}
|
||||
else if (mutation === "move-author-text") { assert.equal(aside.textContent, "\uFE00"); }
|
||||
else
|
||||
{
|
||||
assert.equal(document.getElementById("vs").textContent, "\uFE00");
|
||||
assert.equal(root.querySelector("[data-jz]"), null);
|
||||
assert.equal(aside.querySelector("[data-jz]"), null);
|
||||
}
|
||||
}
|
||||
finally { jz.revert(document.body); jz.disconnect(); other.disconnect(); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const mutation of [ "clone-outer", "clone-inner", "clone-base", "remove-outer" ])
|
||||
{
|
||||
test("jiya 原子失效:" + mutation + " 不重複或復活已刪內容", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p lang="zh-Hant"><strong id="base">,</strong><em id="vs">\uFE00</em>「</p>');
|
||||
const root = document.querySelector("p");
|
||||
const jz = createJuzhen(ONLY_JIYA);
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
const outer = root.querySelector("jz-char.bd-pause");
|
||||
const inner = outer.querySelector("jz-inner");
|
||||
const base = document.getElementById("base");
|
||||
if (mutation === "clone-outer") { outer.replaceWith(outer.cloneNode(true)); }
|
||||
else if (mutation === "clone-inner") { inner.replaceWith(inner.cloneNode(true)); }
|
||||
else if (mutation === "clone-base") { base.replaceWith(base.cloneNode(true)); }
|
||||
else { outer.remove(); }
|
||||
const before = root.textContent;
|
||||
jz.revert(root);
|
||||
assert.equal(root.textContent, before, "回退不能從失效的私有來源補回舊內容");
|
||||
if (mutation !== "clone-inner")
|
||||
{
|
||||
const holder = document.createElement("aside");
|
||||
holder.appendChild(outer);
|
||||
jz.revert(holder);
|
||||
assert.equal(holder.firstChild, outer, "失效舊產物撤銷所有權,重插入也不重新取得");
|
||||
}
|
||||
}
|
||||
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("jiya 原子失效:來源父被替換不把 VS 放回已脫離的作者舊父", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p lang="zh-Hant"><strong>,</strong><em id="vs">\uFE00</em>「</p>');
|
||||
const root = document.querySelector("p");
|
||||
const source = document.getElementById("vs");
|
||||
const jz = createJuzhen(ONLY_JIYA);
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
source.replaceWith(source.cloneNode(true));
|
||||
jz.revert(root);
|
||||
assert.equal(source.textContent, "");
|
||||
assert.equal(root.textContent, ",「");
|
||||
}
|
||||
finally { jz.revert(root); jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
|
||||
test("jiya 原子生命週期:完整 render 根脫離文檔仍精確回退", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p lang="zh-Hant"><strong>,</strong><em>\uFE00</em>「</p>');
|
||||
const root = document.querySelector("p");
|
||||
const original = root.innerHTML;
|
||||
const jz = createJuzhen(ONLY_JIYA);
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
root.remove();
|
||||
jz.revert(root);
|
||||
assert.equal(root.innerHTML, original);
|
||||
}
|
||||
finally { jz.disconnect(); dom.window.close(); }
|
||||
});
|
||||
@@ -129,6 +129,58 @@ test("所有權:子根 render 後對祖先 revert 完整清理", () =>
|
||||
assert.equal(document.body.innerHTML, before);
|
||||
});
|
||||
|
||||
test("所有權:局部 revert 完整撤回祖根與獨立子根的混合產物", () =>
|
||||
{
|
||||
const { document } = setupDom('<main><section id="part">'
|
||||
+ '<p id="a">甲文,內容。</p><p id="b">乙文,內容。</p></section></main>');
|
||||
const main = document.querySelector("main");
|
||||
const part = document.getElementById("part");
|
||||
const a = document.getElementById("a");
|
||||
const b = document.getElementById("b");
|
||||
const before = part.innerHTML;
|
||||
const jz = createJuzhen({ jiya: { halfWidth: "margin" }, justifyAtoms: false });
|
||||
jz.render(main);
|
||||
jz.render(b);
|
||||
assert.ok(a.querySelector("jz-char"));
|
||||
assert.ok(b.querySelector("jz-char"));
|
||||
assert.equal(b.getAttribute("data-jz-halfwidth"), "margin");
|
||||
|
||||
jz.revert(part);
|
||||
assert.equal(part.innerHTML, before, "祖根寫入的兄弟產物與子根屬性皆撤回");
|
||||
assert.equal(main.getAttribute("data-jz-halfwidth"), "margin", "請求範圍外祖根屬性保留");
|
||||
jz.render(part);
|
||||
assert.ok(a.querySelector("jz-char"), "還原後可再次渲染");
|
||||
jz.revert(main);
|
||||
assert.equal(part.innerHTML, before);
|
||||
});
|
||||
|
||||
test("所有權:混合根局部 revert 保留作者與其他實例的產物", () =>
|
||||
{
|
||||
const { document } = setupDom('<main><section id="part">'
|
||||
+ '<p id="a" data-jz-block-level="author-level">甲文,內容。</p>'
|
||||
+ '<p id="b">乙文,內容。</p><p id="foreign">他文,內容。</p>'
|
||||
+ '<span data-jz-skip><jz-hws data-jz="spacing" data-jz-kind="wrap">作者</jz-hws></span>'
|
||||
+ '</section></main>');
|
||||
const part = document.getElementById("part");
|
||||
const foreign = document.getElementById("foreign");
|
||||
const peer = createJuzhen();
|
||||
peer.render(foreign);
|
||||
const before = part.innerHTML;
|
||||
const foreignBefore = foreign.innerHTML;
|
||||
const author = part.querySelector("span > jz-hws");
|
||||
const local = createJuzhen({ scope: { avoid: "#foreign" } });
|
||||
local.render(document.querySelector("main"));
|
||||
local.render(document.getElementById("b"));
|
||||
|
||||
local.revert(part);
|
||||
assert.equal(part.innerHTML, before, "本實例全清且作者/peer 產物完整保留");
|
||||
assert.equal(foreign.innerHTML, foreignBefore);
|
||||
assert.equal(part.querySelector("span > jz-hws"), author, "作者元素身份保留");
|
||||
assert.equal(document.getElementById("a").getAttribute("data-jz-block-level"), "author-level");
|
||||
assert.equal(foreign.getAttribute("data-jz-block-level"), "paragraph");
|
||||
peer.revert(foreign);
|
||||
});
|
||||
|
||||
test("所有權:本實例完整還原 wrapper 與 splitoff", () =>
|
||||
{
|
||||
const html = '<p lang="zh-Hans">前文<strong>粗体文字</strong>,后续</p>';
|
||||
|
||||
@@ -298,6 +298,20 @@ test("shared rules: canonical character classes equal the Web source constants",
|
||||
assert.equal(canonical.overrideSemantics.reclassificationAllowed, false);
|
||||
});
|
||||
|
||||
test("shared rules: explicit script identifiers match whole subtags", () =>
|
||||
{
|
||||
for (const [ lang, expected ] of [
|
||||
[ "zh-CN-x-shant", "zh-Hans" ],
|
||||
[ "zh-TW-x-hansford", "zh-Hant" ],
|
||||
[ "ZH-CN-X-EHANT", "zh-Hans" ],
|
||||
[ "zh-Hant-CN", "zh-Hant" ],
|
||||
[ "zh-Hans-TW", "zh-Hans" ],
|
||||
])
|
||||
{
|
||||
assert.equal(adapter.classify(lang), expected, lang);
|
||||
}
|
||||
});
|
||||
|
||||
test("shared rules: locale defaults and all language vectors match the Web adapter", () =>
|
||||
{
|
||||
const config = { default: "zh-Hant", policy: {} };
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { createJuzhen } from "../dist/juzhen.mjs";
|
||||
import { setupDom } from "./helpers.mjs";
|
||||
|
||||
const TEXT_ONLY = { jiya: false, jinze: false, orphan: false, longWord: false };
|
||||
|
||||
for (const cp of [ 0xE000, 0xE001, 0xE002, 0xE003 ])
|
||||
{
|
||||
test(`spacing source: U+${cp.toString(16).toUpperCase()} remains author text`, () =>
|
||||
{
|
||||
const text = "漢" + String.fromCodePoint(cp) + "A中";
|
||||
const { dom, document } = setupDom("<p></p>");
|
||||
const paragraph = document.querySelector("p");
|
||||
paragraph.textContent = text;
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
for (let cycle = 0; cycle < 3; cycle += 1)
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.textContent, text);
|
||||
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "A" ]);
|
||||
const rendered = paragraph.innerHTML;
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.innerHTML, rendered, "repeat render stays idempotent");
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.textContent, text);
|
||||
assert.equal(paragraph.querySelector("[data-jz]"), null);
|
||||
}
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const fakeIndex of [ "0", "9" ])
|
||||
{
|
||||
test(`spacing source: fake compound placeholder ${fakeIndex} does not collide with GPT-4`, () =>
|
||||
{
|
||||
const text = "漢\uE001" + fakeIndex + "\uE002 GPT-4中";
|
||||
const { dom, document } = setupDom("<p></p>");
|
||||
const paragraph = document.querySelector("p");
|
||||
paragraph.textContent = text;
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.textContent, text);
|
||||
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "4" ]);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.textContent, text);
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("spacing source: reserved characters across transparent nodes preserve IVS and space normalization", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p><strong>漢\uE000\uE001</strong>'
|
||||
+ '<em>0\uE002\uE003\uE004</em> GPT-4<span>\u{20000}</span>'
|
||||
+ '<i>\u{E0100}</i> A<span>甲</span> <b>乙</b></p>');
|
||||
const paragraph = document.querySelector("p");
|
||||
const source = paragraph.textContent;
|
||||
const expected = source.replace("\u{E0100} A", "\u{E0100}A").replace("甲 乙", "甲乙");
|
||||
const authors = Array.from(paragraph.querySelectorAll("strong,em,span,i,b"));
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
for (let cycle = 0; cycle < 3; cycle += 1)
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.textContent, expected, "only contracted author spaces are removed");
|
||||
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent),
|
||||
[ "4", "\u{20000}\u{E0100}", "A" ]);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.textContent, expected);
|
||||
assert.equal(paragraph.querySelector("[data-jz]"), null);
|
||||
assert.deepEqual(Array.from(paragraph.querySelectorAll("strong,em,span,i,b")), authors);
|
||||
}
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
|
||||
test("spacing source: candidate markers respect custom CJK and western classes", () =>
|
||||
{
|
||||
const { dom, document } = setupDom("<p>漢A中</p>");
|
||||
const paragraph = document.querySelector("p");
|
||||
const jz = createJuzhen({ ...TEXT_ONLY, charClass: {
|
||||
cjk: "\\uE000-\\uE003", western: "\\uE004-\\uE007",
|
||||
} });
|
||||
try
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.textContent, "漢A中");
|
||||
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "漢", "A" ]);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.textContent, "漢A中");
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
|
||||
test("spacing source: supplementary markers keep source PUA and DOM atom offsets intact", () =>
|
||||
{
|
||||
const { dom, document } = setupDom("<p>漢\u{F0000}A中</p>");
|
||||
const paragraph = document.querySelector("p");
|
||||
const text = paragraph.textContent;
|
||||
const jz = createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "\\uE000-\\uF8FF" } });
|
||||
try
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.textContent, text);
|
||||
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "A" ]);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.textContent, text);
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
|
||||
test("spacing source: exhausted marker candidates fail before author text playback", () =>
|
||||
{
|
||||
const { dom, document } = setupDom("<p>漢 A中</p>");
|
||||
const paragraph = document.querySelector("p");
|
||||
const before = paragraph.innerHTML;
|
||||
const jz = createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "\\u0000-\\u{10FFFF}" } });
|
||||
try
|
||||
{
|
||||
assert.throws(() => jz.render(paragraph), /已佔用全部間隙標記候選/);
|
||||
assert.equal(paragraph.innerHTML, before, "no source atom or author space was replayed");
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, before);
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
|
||||
for (const selector of [ "code", "code span" ])
|
||||
{
|
||||
test(`spacing root: directly rendering ${selector} preserves pill contents`, () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p>外<code><span>漢A</span> 字B</code>文</p>');
|
||||
const root = document.querySelector(selector);
|
||||
const before = document.body.innerHTML;
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
assert.equal(root.querySelector("jz-hws"), null);
|
||||
jz.render(root);
|
||||
jz.revert(root);
|
||||
assert.equal(document.body.innerHTML, before);
|
||||
}
|
||||
finally { jz.revert(root); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("spacing pill: a single author space across transparent nodes normalizes on both sides", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p><span>漢</span><em> </em><code>x</code>'
|
||||
+ '<i> </i><strong>文</strong></p>');
|
||||
const paragraph = document.querySelector("p");
|
||||
const authors = Array.from(paragraph.querySelectorAll("span,em,code,i,strong"));
|
||||
const expected = paragraph.innerHTML.replace("<em> </em>", "<em></em>").replace("<i> </i>", "<i></i>");
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
for (let cycle = 0; cycle < 3; cycle += 1)
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.textContent, "漢x文");
|
||||
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "漢", "x" ]);
|
||||
const rendered = paragraph.innerHTML;
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.innerHTML, rendered);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, expected);
|
||||
assert.deepEqual(Array.from(paragraph.querySelectorAll("span,em,code,i,strong")), authors);
|
||||
}
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
|
||||
for (const base of [ "漢", "\u{20000}" ])
|
||||
{
|
||||
for (const withSpace of [ false, true ])
|
||||
{
|
||||
test(`spacing pill: split ${base.codePointAt(0).toString(16)} IVS with space=${withSpace} stays one atom`, () =>
|
||||
{
|
||||
const vs = "\u{E0100}";
|
||||
const space = withSpace ? " " : "";
|
||||
const { dom, document } = setupDom(`<p><strong>${base}</strong><em>${vs}</em>`
|
||||
+ `<span>${space}</span><code>x</code><i>${space}</i><b>${base}</b><u>${vs}</u></p>`);
|
||||
const paragraph = document.querySelector("p");
|
||||
const expected = paragraph.innerHTML.replace("<span> </span>", "<span></span>").replace("<i> </i>", "<i></i>");
|
||||
const authors = Array.from(paragraph.children);
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
for (let cycle = 0; cycle < 3; cycle += 1)
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.textContent, base + vs + "x" + base + vs);
|
||||
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ base + vs, "x" ]);
|
||||
const rendered = paragraph.innerHTML;
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.innerHTML, rendered);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, expected, "cross-node selector returns to its author element");
|
||||
assert.deepEqual(Array.from(paragraph.children), authors);
|
||||
}
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const whitespace of [ " ", "\n", "\t", " \n" ])
|
||||
{
|
||||
test(`spacing pill: whitespace ${JSON.stringify(whitespace)} is a boundary, not a single removable space`, () =>
|
||||
{
|
||||
const { dom, document } = setupDom(`<p><span>漢</span><i>${whitespace}</i>`
|
||||
+ `<code>x</code><em>${whitespace}</em><span>文</span></p>`);
|
||||
const paragraph = document.querySelector("p");
|
||||
const before = paragraph.innerHTML;
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.querySelector("jz-hws"), null);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, before);
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const barrier of [
|
||||
'<mark data-juzhen-off="spacing"></mark>',
|
||||
'<code data-juzhen-off="spacing"></code>',
|
||||
'<span data-jz-skip></span>',
|
||||
"<br>",
|
||||
"<x-opaque></x-opaque>",
|
||||
])
|
||||
{
|
||||
test(`spacing pill: empty ${barrier} stops lookup beyond one space on both sides`, () =>
|
||||
{
|
||||
const { dom, document } = setupDom(`<p><span>漢</span>${barrier}<i> </i>`
|
||||
+ `<code>x</code><em> </em>${barrier}<span>文</span></p>`);
|
||||
const paragraph = document.querySelector("p");
|
||||
const before = paragraph.innerHTML;
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.querySelector("jz-hws"), null);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, before);
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("spacing pill: out-of-scope neighbors stop space normalization", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p><span>漢</span><i class="prose"> </i>'
|
||||
+ '<code class="prose">x</code><em class="prose"> </em><span>文</span></p>');
|
||||
const paragraph = document.querySelector("p");
|
||||
const before = paragraph.innerHTML;
|
||||
const jz = createJuzhen({ ...TEXT_ONLY, scope: { include: ".prose" } });
|
||||
try
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.querySelector("jz-hws"), null);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, before);
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
|
||||
test("spacing pill: local render cannot move an outside IVS base or delete an inside trailing space", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p><strong>\u{20000}</strong><span id="root">'
|
||||
+ '<em>\u{E0100}</em><code>x</code><i> </i></span><b>文</b></p>');
|
||||
const root = document.getElementById("root");
|
||||
const before = document.body.innerHTML;
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
assert.equal(document.body.innerHTML, before);
|
||||
jz.render(root);
|
||||
jz.revert(root);
|
||||
assert.equal(document.body.innerHTML, before);
|
||||
}
|
||||
finally { jz.revert(root); dom.window.close(); }
|
||||
});
|
||||
|
||||
test("spacing pill: a local transparent root encloses only its own neighbor", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p>外<span id="root"><strong>漢</strong>'
|
||||
+ '<code>x</code></span> 文</p>');
|
||||
const root = document.getElementById("root");
|
||||
const before = document.body.innerHTML;
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
assert.deepEqual(Array.from(root.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "漢" ]);
|
||||
assert.equal(document.querySelector("p").lastChild.data, " 文", "root-external space remains author text");
|
||||
jz.revert(root);
|
||||
assert.equal(document.body.innerHTML, before);
|
||||
}
|
||||
finally { jz.revert(root); dom.window.close(); }
|
||||
});
|
||||
|
||||
function appendTextChunks(parent, chunks)
|
||||
{
|
||||
const tags = [ "strong", "em", "i", "span", "b", "u" ];
|
||||
return chunks.map((chunk, index) =>
|
||||
{
|
||||
const element = parent.ownerDocument.createElement(tags[index % tags.length]);
|
||||
element.textContent = chunk;
|
||||
parent.appendChild(element);
|
||||
return element;
|
||||
});
|
||||
}
|
||||
|
||||
const SPLIT_CODEPOINTS = [
|
||||
[ "\uD840", "\uDC00" ],
|
||||
[ "\uD840", "\uDC00", "\uDB40", "\uDD00" ],
|
||||
[ "\uD840", "\uDC00\uDB40", "\uDD00" ],
|
||||
[ "\u{20000}", "\uDB40", "\uDD00" ],
|
||||
[ "漢", "\uDB40", "\uDD00" ],
|
||||
];
|
||||
|
||||
for (const [index, chunks] of SPLIT_CODEPOINTS.entries())
|
||||
{
|
||||
test(`spacing source: split surrogate codepoint case ${index} maps to one complete gap`, () =>
|
||||
{
|
||||
const { dom, document } = setupDom("<p></p>");
|
||||
const paragraph = document.querySelector("p");
|
||||
const authors = appendTextChunks(paragraph, [ ...chunks, "A" ]);
|
||||
const before = paragraph.innerHTML;
|
||||
const atom = chunks.join("");
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
for (let cycle = 0; cycle < 3; cycle += 1)
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.textContent, atom + "A");
|
||||
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ atom ]);
|
||||
const rendered = paragraph.innerHTML;
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.innerHTML, rendered);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, before);
|
||||
assert.deepEqual(Array.from(paragraph.children), authors);
|
||||
}
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
|
||||
for (const withSpace of [ false, true ])
|
||||
{
|
||||
test(`spacing pill: split surrogate case ${index}, space=${withSpace} remains complete on both sides`, () =>
|
||||
{
|
||||
const { dom, document } = setupDom("<p></p>");
|
||||
const paragraph = document.querySelector("p");
|
||||
appendTextChunks(paragraph, chunks);
|
||||
if (withSpace) { appendTextChunks(paragraph, [ " " ]); }
|
||||
const code = document.createElement("code");
|
||||
code.textContent = "x";
|
||||
paragraph.appendChild(code);
|
||||
if (withSpace) { appendTextChunks(paragraph, [ " " ]); }
|
||||
appendTextChunks(paragraph, chunks);
|
||||
const authors = Array.from(paragraph.children);
|
||||
const before = paragraph.innerHTML.replaceAll("<strong> </strong>", "<strong></strong>");
|
||||
const atom = chunks.join("");
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
for (let cycle = 0; cycle < 3; cycle += 1)
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.textContent, atom + "x" + atom);
|
||||
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ atom, "x" ]);
|
||||
const rendered = paragraph.innerHTML;
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.innerHTML, rendered);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, before);
|
||||
assert.deepEqual(Array.from(paragraph.children), authors);
|
||||
}
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const barrier of [
|
||||
'<mark data-juzhen-off="spacing"></mark>',
|
||||
'<code data-juzhen-off="spacing"></code>',
|
||||
'<span data-jz-skip></span>',
|
||||
"<br>",
|
||||
"<x-opaque></x-opaque>",
|
||||
])
|
||||
{
|
||||
test(`spacing surrogate: ${barrier} prevents joining adjacent high and low halves`, () =>
|
||||
{
|
||||
const { dom, document } = setupDom("<p></p>");
|
||||
const paragraph = document.querySelector("p");
|
||||
appendTextChunks(paragraph, [ "\uD840" ]);
|
||||
paragraph.insertAdjacentHTML("beforeend", barrier);
|
||||
appendTextChunks(paragraph, [ "\uDC00" ]);
|
||||
const code = document.createElement("code");
|
||||
code.textContent = "x";
|
||||
paragraph.appendChild(code);
|
||||
appendTextChunks(paragraph, [ "\uD840" ]);
|
||||
paragraph.insertAdjacentHTML("beforeend", barrier);
|
||||
appendTextChunks(paragraph, [ "\uDC00", "A" ]);
|
||||
const before = paragraph.innerHTML;
|
||||
const text = paragraph.textContent;
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.textContent, text);
|
||||
assert.equal(paragraph.querySelector("jz-hws"), null);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, before);
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("spacing surrogate: actual isolated UTF-16 halves stay author text", () =>
|
||||
{
|
||||
const { dom, document } = setupDom("<p></p>");
|
||||
const paragraph = document.querySelector("p");
|
||||
appendTextChunks(paragraph, [ "\uD840", "A" ]);
|
||||
const code = document.createElement("code");
|
||||
code.textContent = "x";
|
||||
paragraph.appendChild(code);
|
||||
appendTextChunks(paragraph, [ "\uDC00" ]);
|
||||
const text = paragraph.textContent;
|
||||
const before = paragraph.innerHTML;
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.textContent, text);
|
||||
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "A" ]);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, before);
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
|
||||
test("spacing surrogate: a local root cannot join either outside surrogate half", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p><strong></strong><span id="root">'
|
||||
+ '<em></em><code>x</code><i></i></span><b></b></p>');
|
||||
document.querySelector("strong").textContent = "\uD840";
|
||||
document.querySelector("em").textContent = "\uDC00";
|
||||
document.querySelector("i").textContent = "\uD840";
|
||||
document.querySelector("b").textContent = "\uDC00";
|
||||
const root = document.getElementById("root");
|
||||
const before = document.body.innerHTML;
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try
|
||||
{
|
||||
jz.render(root);
|
||||
assert.equal(document.body.innerHTML, before);
|
||||
jz.revert(root);
|
||||
assert.equal(document.body.innerHTML, before);
|
||||
}
|
||||
finally { jz.revert(root); dom.window.close(); }
|
||||
});
|
||||
|
||||
test("spacing surrogate: an excluded Text is a run boundary even when empty", () =>
|
||||
{
|
||||
const { dom, document } = setupDom("<p></p>");
|
||||
const paragraph = document.querySelector("p");
|
||||
appendTextChunks(paragraph, [ "\uD840" ]);
|
||||
const excluded = document.createTextNode("");
|
||||
paragraph.appendChild(excluded);
|
||||
appendTextChunks(paragraph, [ "\uDC00", "A" ]);
|
||||
const before = paragraph.innerHTML;
|
||||
const jz = createJuzhen({ ...TEXT_ONLY, isSkipped: (node) => node === excluded });
|
||||
try
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.querySelector("jz-hws"), null);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, before);
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
|
||||
test("spacing surrogate: complete codepoints across source scope boundaries are not joined", () =>
|
||||
{
|
||||
const { dom, document } = setupDom('<p><strong></strong><em class="prose"></em>'
|
||||
+ '<code class="prose">x</code><i class="prose"></i><b></b></p>');
|
||||
document.querySelector("strong").textContent = "\uD840";
|
||||
document.querySelector("em").textContent = "\uDC00";
|
||||
document.querySelector("i").textContent = "\uD840";
|
||||
document.querySelector("b").textContent = "\uDC00";
|
||||
const paragraph = document.querySelector("p");
|
||||
const before = paragraph.innerHTML;
|
||||
const jz = createJuzhen({ ...TEXT_ONLY, scope: { include: ".prose" } });
|
||||
try
|
||||
{
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.querySelector("jz-hws"), null);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, before);
|
||||
}
|
||||
finally { jz.revert(paragraph); dom.window.close(); }
|
||||
});
|
||||
@@ -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