feat(rules): 建立共享排版契约并收束 WebKit 与 Unicode 基线

This commit is contained in:
2026-09-04 21:44:11 +08:00
parent 8ee9895247
commit 993cf576c6
42 changed files with 8511 additions and 1422 deletions
+999
View File
@@ -0,0 +1,999 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createJuzhen } from "../dist/juzhen.mjs";
import { countTag, setupDom } from "./helpers.mjs";
const TEXT_ONLY = {
jiya: false,
jinze: false,
longWord: false,
orphan: false,
};
function assertNoUnpairedSurrogates(text)
{
for (let i = 0; i < text.length; i += 1)
{
const unit = text.charCodeAt(i);
if (unit >= 0xD800 && unit <= 0xDBFF)
{
assert.ok(i + 1 < text.length, "high surrogate must have a following low surrogate");
const next = text.charCodeAt(i + 1);
assert.ok(next >= 0xDC00 && next <= 0xDFFF, "high surrogate must pair with low surrogate");
i += 1;
}
else
{
assert.ok(
unit < 0xDC00 || unit > 0xDFFF,
"low surrogate must have a preceding high surrogate",
);
}
}
}
test("astral classification: Extensions B-J and compatibility supplement are built-in CJK", () =>
{
const ranges = [
[ "ext-b", 0x20000, 0x2A6DF ],
[ "ext-c", 0x2A700, 0x2B73F ],
[ "ext-d", 0x2B740, 0x2B81F ],
[ "ext-e", 0x2B820, 0x2CEAF ],
[ "ext-f", 0x2CEB0, 0x2EBEF ],
[ "ext-i", 0x2EBF0, 0x2EE5F ],
[ "compat-supplement", 0x2F800, 0x2FA1F ],
[ "ext-g", 0x30000, 0x3134F ],
[ "ext-h", 0x31350, 0x323AF ],
[ "ext-j", 0x323B0, 0x3347F ],
];
const samples = ranges.flatMap(([ id, lower, upper ]) => [
[ id + "-lower", String.fromCodePoint(lower) ],
[ id + "-upper", String.fromCodePoint(upper) ],
]);
const gaps = [
[ "before-b", 0x1FFFF ],
[ "after-b", 0x2A6E0 ],
[ "after-i", 0x2EE60 ],
[ "after-compat", 0x2FA20 ],
[ "before-g", 0x2FFFF ],
[ "after-j", 0x33480 ],
].map(([ id, value ]) => [ id, String.fromCodePoint(value) ]);
const html = samples.map(([ id, ch ]) => `<p id="${id}">${ch}A</p>`).join("")
+ gaps.map(([ id, ch ]) => `<p id="${id}">${ch}A</p>`).join("")
+ '<p id="emoji">😀A</p>';
const { document } = setupDom(html);
createJuzhen(TEXT_ONLY).render(document.body);
for (const [ id, ch ] of samples)
{
const paragraph = document.getElementById(id);
assert.equal(countTag(paragraph, "jz-hws"), 1, `${id} must space against Latin`);
assert.equal(paragraph.textContent, ch + "A");
assertNoUnpairedSurrogates(paragraph.textContent);
}
for (const [ id ] of gaps)
{
assert.equal(countTag(document.getElementById(id), "jz-hws"), 0, `${id} is an intentional gap`);
}
assert.equal(countTag(document.getElementById("emoji"), "jz-hws"), 0, "emoji is not CJK");
});
test("Safari shadow: nonzero astral CJK regex misses do not suppress spacing actions", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(
`<p id="cross">甲<strong>\u{20000}</strong>\u{E0100} A</p>`
+ `<p id="same">甲${ivs}A</p>`
+ `<p id="reverse">A甲${ivs}</p>`
+ `<p id="real-han">漢${ivs}A</p>`
+ '<p id="custom">甲<strong>😀</strong>\u{E0100}A</p>',
);
const replace = String.prototype.replace;
let simulatedMisses = 0;
String.prototype.replace = function (searchValue, replaceValue)
{
if (searchValue instanceof RegExp && searchValue.unicode
&& searchValue.source.includes("\\u{20000}"))
{
let offset = 0;
for (const ch of String(this))
{
const cp = ch.codePointAt(0);
if (offset > 0 && cp >= 0x20000 && cp <= 0x3347F)
{
simulatedMisses += 1;
return String(this);
}
offset += ch.length;
}
}
return replace.call(this, searchValue, replaceValue);
};
try
{
const representative = new RegExp("([\\u{20000}-\\u{2A6DF}])([A-Za-z])", "gu");
assert.equal("甲\u{20000}A".replace(representative, "$1$2"), "甲\u{20000}A");
const probeMisses = simulatedMisses;
createJuzhen(TEXT_ONLY).render(document.body);
createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "😀" } })
.render(document.getElementById("custom"));
assert.equal(simulatedMisses, probeMisses, "rule inputs contain no nonzero astral CJK");
}
finally { String.prototype.replace = replace; }
assert.equal(countTag(document.getElementById("cross"), "jz-hws"), 1);
assert.equal(
document.querySelector("#cross jz-hws").textContent,
ivs,
"cross-node wraps the complete variation sequence",
);
assert.equal(countTag(document.getElementById("same"), "jz-hws"), 1);
assert.equal(document.querySelector("#same jz-hws").textContent, ivs, "same-node wraps real atom");
assert.equal(countTag(document.getElementById("reverse"), "jz-hws"), 1);
assert.equal(countTag(document.getElementById("real-han"), "jz-hws"), 1, "real 漢 is not confused");
assert.equal(countTag(document.getElementById("custom"), "jz-hws"), 1, "custom astral CJK shadows");
assertNoUnpairedSurrogates(document.body.textContent);
});
test("Safari shadow differential: representative and randomized Pangu actions stay aligned", () =>
{
const cjk = "\u{20000}\u{E0100}";
const shadow = "漢";
const representatives = [
`${cjk} A`, `A ${cjk}`, `${cjk} 甲`, `...${cjk}`,
`${cjk}!A`, `A!${cjk}`, `${cjk}~A`, `${cjk}~=A`,
`${cjk}.B`, `A.${cjk}`, `A:${cjk}`, `${cjk}\"A`, `\"${cjk}`,
`${cjk}#x`, `x#${cjk}`, `A+${cjk}`, `${cjk}+A`,
`${cjk}<A`, `A<${cjk}`, `${cjk}>A`, `A>${cjk}`,
`${cjk}(A`, `)${cjk}`, `A(B`, `)A`, `%A`,
`漢${cjk}A`, `甲乙${cjk}A`, `foo-bar${cjk}`, `${cjk}GPT-4`,
];
const tokens = [
"A", "b", "1", " ", "!", "?", ";", ":", ".", "~", "~=", "+", "-", "*",
"#", "(", ")", "<", ">", "\"", "...", "%", "foo-bar", "GPT-4", "甲", "漢",
];
let state = 0xC0FFEE;
const random = () =>
{
state = (state * 1664525 + 1013904223) >>> 0;
return state;
};
const cases = representatives.slice();
for (let i = 0; i < 256; i += 1)
{
const left = tokens[random() % tokens.length];
const right = tokens[random() % tokens.length];
const mode = random() % 4;
cases.push(mode === 0 ? left + cjk + right
: mode === 1 ? cjk + left + right
: mode === 2 ? left + right + cjk
: left + cjk + "漢" + right);
}
const escapeHtml = (text) => text.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
const html = cases.map((text, index) =>
{
const bmp = text.split(cjk).join(shadow);
return `<p id="a-${index}">${escapeHtml(text)}</p>`
+ `<p id="b-${index}">${escapeHtml(bmp)}</p>`;
}).join("");
const { document } = setupDom(html);
createJuzhen(TEXT_ONLY).render(document.body);
const boundaries = (paragraph) => Array.from(paragraph.querySelectorAll("jz-hws")).map((gap) =>
{
const range = document.createRange();
range.setStart(paragraph, 0);
range.setEndAfter(gap);
let atoms = 0;
for (const ch of range.toString())
{
const cp = ch.codePointAt(0);
if (!((cp >= 0xFE00 && cp <= 0xFE0F) || (cp >= 0xE0100 && cp <= 0xE01EF)))
{
atoms += 1;
}
}
return atoms;
});
for (let index = 0; index < cases.length; index += 1)
{
const astral = document.getElementById(`a-${index}`);
const bmp = document.getElementById(`b-${index}`);
assert.deepEqual(boundaries(astral), boundaries(bmp), `gap actions ${index}`);
assert.equal(
astral.textContent.split(cjk).join(shadow),
bmp.textContent,
`space deletion and atom order ${index}`,
);
assertNoUnpairedSurrogates(astral.textContent);
}
});
test("IVS spacing: both directions and transparent inline boundaries keep base plus VS atomic", () =>
{
const ivs = "\u{20000}\u{E0100}";
const bmpVs = "中\uFE00";
const { document } = setupDom(
`<p id="ivs-left">${ivs}A</p><p id="ivs-right">A${ivs}</p>`
+ `<p id="bmp-left">${bmpVs}A</p><p id="bmp-right">A${bmpVs}</p>`
+ `<p id="inline-left"><strong>${ivs}</strong>A</p>`
+ `<p id="inline-right">A<strong>${ivs}</strong></p>`,
);
const ids = [ "ivs-left", "ivs-right", "bmp-left", "bmp-right", "inline-left", "inline-right" ];
const originals = new Map(ids.map((id) => [ id, document.getElementById(id).innerHTML ]));
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
for (const id of ids) { assert.equal(countTag(document.getElementById(id), "jz-hws"), 1, id); }
assert.equal(document.querySelector("#ivs-left jz-hws").textContent, ivs);
assert.equal(document.querySelector("#bmp-left jz-hws").textContent, bmpVs);
assert.equal(document.querySelector("#inline-left jz-hws").textContent, ivs);
assert.equal(document.getElementById("ivs-left").textContent, ivs + "A");
assert.equal(document.getElementById("ivs-right").textContent, "A" + ivs);
assertNoUnpairedSurrogates(document.body.textContent);
jz.revert(document.body);
for (const id of ids) { assert.equal(document.getElementById(id).innerHTML, originals.get(id), id); }
assertNoUnpairedSurrogates(document.body.textContent);
});
test("IVS spacing: base and selector across transparent nodes share one DOM span", () =>
{
const samples = [
[
"bmp-left",
'<strong id="author-bmp-left">中</strong>\uFE00A',
'<strong id="author-bmp-left">中</strong>\uFE00A',
],
[
"bmp-left-space",
'<strong id="author-bmp-left-space">中</strong>\uFE00 A',
'<strong id="author-bmp-left-space">中</strong>\uFE00A',
],
[
"bmp-left-partial-start",
'<strong id="author-bmp-partial-start">甲中</strong>\uFE00 A',
'<strong id="author-bmp-partial-start">甲中</strong>\uFE00A',
],
[
"bmp-left-partial-end",
'<strong id="author-bmp-partial-end-base">中</strong>'
+ '<em id="author-bmp-partial-end-vs">\uFE00x</em>',
'<strong id="author-bmp-partial-end-base">中</strong>'
+ '<em id="author-bmp-partial-end-vs">\uFE00x</em>',
],
[
"bmp-right",
'A<strong id="author-bmp-right">中</strong>\uFE00',
'A<strong id="author-bmp-right">中</strong>\uFE00',
],
[
"bmp-right-space",
'A <strong id="author-bmp-right-space">中</strong>\uFE00',
'A<strong id="author-bmp-right-space">中</strong>\uFE00',
],
[
"ivs-left",
'<strong id="author-ivs-left">\u{20000}</strong>\u{E0100}A',
'<strong id="author-ivs-left">\u{20000}</strong>\u{E0100}A',
],
[
"ivs-left-space",
'<strong id="author-ivs-left-space">\u{20000}</strong>\u{E0100} A',
'<strong id="author-ivs-left-space">\u{20000}</strong>\u{E0100}A',
],
[
"ivs-right",
'A<strong id="author-ivs-right">\u{20000}</strong>\u{E0100}',
'A<strong id="author-ivs-right">\u{20000}</strong>\u{E0100}',
],
[
"ivs-right-space",
'A <strong id="author-ivs-right-space">\u{20000}</strong>\u{E0100}',
'A<strong id="author-ivs-right-space">\u{20000}</strong>\u{E0100}',
],
];
const { document } = setupDom(samples.map(([ id, html ]) => `<p id="${id}">${html}</p>`).join(""));
const jz = createJuzhen(TEXT_ONLY);
const authors = Array.from(document.querySelectorAll('[id^="author-"]'));
const clickCounts = new Map(authors.map((el) => [ el, 0 ]));
for (const author of authors)
{
author.addEventListener("click", () => clickCounts.set(author, clickCounts.get(author) + 1));
}
const assertAuthorState = (expectedClicks) =>
{
for (const author of authors)
{
assert.equal(document.getElementById(author.id), author, `${author.id} identity`);
assert.equal(document.querySelectorAll(`[id="${author.id}"]`).length, 1, `${author.id} unique`);
author.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clickCounts.get(author), expectedClicks, `${author.id} listener`);
}
};
jz.render(document.body);
for (const [ id, , normalized ] of samples)
{
const paragraph = document.getElementById(id);
assert.equal(countTag(paragraph, "jz-hws"), 1, id);
assert.equal(paragraph.textContent, paragraph.textContent.replace(" ", ""), id);
assertNoUnpairedSurrogates(paragraph.textContent);
const gap = paragraph.querySelector("jz-hws");
if (id.includes("left"))
{
const sequence = id.startsWith("bmp") ? "中\uFE00" : "\u{20000}\u{E0100}";
assert.equal(gap.textContent, sequence, `${id} wraps the complete sequence`);
assert.equal(gap.getAttribute("data-jz-kind"), "wrap", `${id} wrapper kind`);
assert.equal(
Array.from(gap.childNodes).filter((node) => node.nodeType === 3).length,
2,
`${id} wrapper retains both Text parts`,
);
assert.equal(gap.getAttribute("data-jz-cross-node-atom"), "", `${id} cross-node flag`);
}
else { assert.equal(gap.textContent, "A", `${id} Latin carrier remains unchanged`); }
assert.equal(paragraph.textContent, setupDom(`<p>${normalized}</p>`).document.body.textContent, id);
}
assertAuthorState(1);
jz.render(document.body);
for (const [ id ] of samples)
{
assert.equal(countTag(document.getElementById(id), "jz-hws"), 1, `${id} repeated render`);
}
assertAuthorState(2);
jz.revert(document.body);
for (const [ id, , normalized ] of samples)
{
const paragraph = document.getElementById(id);
assert.equal(paragraph.innerHTML, normalized, id);
assertNoUnpairedSurrogates(paragraph.textContent);
}
assertAuthorState(3);
});
test("moved IVS ownership: repeat and peer instances never claim an existing gap", async () =>
{
const countAnchors = (root) =>
{
const walker = root.ownerDocument.createTreeWalker(
root,
root.ownerDocument.defaultView.NodeFilter.SHOW_COMMENT,
);
let count = 0;
while (walker.nextNode())
{
if (walker.currentNode.data.startsWith("jz-spacing-")) { count += 1; }
}
return count;
};
const { document } = setupDom(
'<p><strong id="repeat-author">\u{20000}</strong><em>\u{E0100}A</em></p>',
);
const paragraph = document.querySelector("p");
const author = document.getElementById("repeat-author");
const originalHtml = paragraph.innerHTML;
let clicks = 0;
author.addEventListener("click", () => { clicks += 1; });
const first = createJuzhen(TEXT_ONLY);
first.render(paragraph);
const gap = paragraph.querySelector("jz-hws");
assert.equal(countAnchors(paragraph), 2, "one original-side and one gap-side anchor");
const mutations = [];
const observer = new document.defaultView.MutationObserver((records) => mutations.push(...records));
observer.observe(paragraph, { childList: true, subtree: true, attributes: true });
first.render(paragraph);
await new Promise((resolve) => setTimeout(resolve, 0));
observer.disconnect();
assert.equal(paragraph.querySelector("jz-hws"), gap, "repeat preserves exact gap object");
assert.equal(countAnchors(paragraph), 2, "repeat creates no anchor");
const gapMutations = mutations.filter((record) => record.target === gap || gap.contains(record.target)
|| Array.from(record.addedNodes).some(
(node) => node.nodeType === 8 && node.data.startsWith("jz-spacing-"),
));
assert.equal(gapMutations.length, 0, "repeat does not mutate the gap or add anchors");
const second = createJuzhen(TEXT_ONLY);
second.render(paragraph);
second.revert(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), gap, "peer render/revert leaves first gap intact");
assert.equal(countAnchors(paragraph), 2, "peer registers no moved part");
first.revert(paragraph);
assert.equal(paragraph.innerHTML, originalHtml);
assert.equal(document.getElementById("repeat-author"), author);
author.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clicks, 1);
const reverseDom = setupDom(
'<p><strong id="reverse-author">\u{20000}</strong><em>\u{E0100}A</em></p>',
);
const reverse = reverseDom.document.querySelector("p");
const reverseHtml = reverse.innerHTML;
const a = createJuzhen(TEXT_ONLY);
const b = createJuzhen(TEXT_ONLY);
b.render(reverse);
a.render(reverse);
a.revert(reverse);
assert.equal(countTag(reverse, "jz-hws"), 1, "reverse order preserves first owner");
b.revert(reverse);
assert.equal(reverse.innerHTML, reverseHtml);
for (const attributes of [ "", ' data-jz="spacing" data-jz-kind="wrap"' ])
{
const foreignDom = setupDom(
`<p><jz-hws id="foreign"${attributes}>`
+ '<strong>\u{20000}</strong>\u{E0100}</jz-hws>A</p>',
);
const foreignParagraph = foreignDom.document.querySelector("p");
const foreignHtml = foreignParagraph.innerHTML;
createJuzhen(TEXT_ONLY).render(foreignParagraph);
assert.equal(foreignParagraph.innerHTML, foreignHtml, "foreign/forged gap is a strict no-op");
assert.equal(countAnchors(foreignParagraph), 0, "foreign/forged gap is never registered");
assert.equal(
foreignParagraph.querySelector("jz-hws").hasAttribute("data-jz-cross-node-atom"),
false,
);
}
});
test("moved IVS ownership: replaced selector parents discard stale text and rebuild", async () =>
{
const ivs = "\u{E0100}";
const methods = [ "textContent", "replaceChildren", "innerHTML" ];
for (const method of methods)
{
const { document } = setupDom(
'<p><strong id="replace-base">\u{20000}</strong>'
+ '<em id="replace-selector">\u{E0100}A</em></p>',
);
const paragraph = document.querySelector("p");
const base = document.getElementById("replace-base");
const selectorParent = document.getElementById("replace-selector");
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
const oldSelector = Array.from(paragraph.querySelector("jz-hws").childNodes)
.find((node) => node.nodeType === 3 && node.data === ivs);
assert.ok(oldSelector, `${method} captures moved selector`);
const replacement = ivs + method[0].toUpperCase();
if (method !== "innerHTML") { jz.observe(document.body); }
if (method === "textContent") { selectorParent.textContent = replacement; }
else if (method === "replaceChildren")
{
selectorParent.replaceChildren(document.createTextNode(replacement));
}
else
{
selectorParent.innerHTML = replacement;
jz.rerender(document.body);
}
if (method !== "innerHTML")
{
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
jz.disconnect();
}
assert.equal(document.getElementById("replace-base"), base, `${method} base identity`);
assert.equal(
document.getElementById("replace-selector"),
selectorParent,
`${method} selector-parent identity`,
);
assert.equal(paragraph.textContent, "\u{20000}" + replacement, `${method} exact author text`);
assert.equal(paragraph.textContent.split(ivs).length - 1, 1, `${method} has one selector`);
assert.equal(oldSelector.isConnected, false, `${method} stale selector is discarded`);
assert.equal(countTag(paragraph, "jz-hws"), 1, `${method} rebuilt gap`);
assert.equal(paragraph.querySelector("jz-hws").textContent, "\u{20000}" + ivs);
assertNoUnpairedSurrogates(paragraph.textContent);
jz.revert(document.body);
assert.equal(
paragraph.innerHTML,
'<strong id="replace-base">\u{20000}</strong>'
+ `<em id="replace-selector">${replacement}</em>`,
`${method} revert preserves only replacement`,
);
}
});
test("moved IVS ownership: empty selector parents never resurrect stale text", async () =>
{
for (const method of [ "textContent", "replaceChildren", "innerHTML" ])
{
const { document } = setupDom(
'<p><strong id="empty-base">\u{20000}</strong>'
+ '<em id="empty-selector">\u{E0100}</em>A</p>',
);
const paragraph = document.querySelector("p");
const base = document.getElementById("empty-base");
const selectorParent = document.getElementById("empty-selector");
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
const oldSelector = Array.from(paragraph.querySelector("jz-hws").childNodes)
.find((node) => node.nodeType === 3 && node.data === "\u{E0100}");
assert.ok(oldSelector, `${method} captures moved selector`);
if (method !== "innerHTML") { jz.observe(document.body); }
if (method === "textContent") { selectorParent.textContent = ""; }
else if (method === "replaceChildren") { selectorParent.replaceChildren(); }
else
{
selectorParent.innerHTML = "";
jz.rerender(document.body);
}
if (method !== "innerHTML")
{
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
jz.disconnect();
}
assert.equal(paragraph.textContent, "\u{20000}A", `${method} preserves author deletion`);
assert.equal(paragraph.textContent.includes("\u{E0100}"), false, `${method} has zero selectors`);
assert.equal(oldSelector.isConnected, false, `${method} stale selector stays discarded`);
assert.equal(document.getElementById("empty-base"), base, `${method} base identity`);
assert.equal(document.getElementById("empty-selector"), selectorParent, `${method} parent identity`);
assert.equal(selectorParent.childNodes.length, 0, `${method} original parent remains empty`);
const gap = paragraph.querySelector("jz-hws");
assert.ok(gap, `${method} rebuilds ordinary base-to-Latin gap`);
assert.equal(gap.textContent, "\u{20000}", `${method} ordinary gap wraps only base`);
assert.equal(
gap.hasAttribute("data-jz-cross-node-atom"),
false,
`${method} ordinary gap is not cross-node`,
);
assertNoUnpairedSurrogates(paragraph.textContent);
jz.revert(document.body);
assert.equal(
paragraph.innerHTML,
'<strong id="empty-base">\u{20000}</strong><em id="empty-selector"></em>A',
`${method} revert keeps selector deletion`,
);
}
});
test("moved IVS ownership: externally moved selector-only anchor restores to original parent", () =>
{
const original = '<strong id="anchor-base">\u{20000}</strong>'
+ '<em id="anchor-selector">\u{E0100}</em>A';
for (const side of [ "common", "base", "original-parent", "external-anchor" ])
{
const { document } = setupDom(`<div id="anchor-outside"></div><p>${original}</p>`);
const paragraph = document.querySelector("p");
const base = document.getElementById("anchor-base");
const selectorParent = document.getElementById("anchor-selector");
const outside = document.getElementById("anchor-outside");
let clicks = 0;
selectorParent.addEventListener("click", () => { clicks += 1; });
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
const walker = document.createTreeWalker(
paragraph,
document.defaultView.NodeFilter.SHOW_COMMENT,
);
let anchor = null;
while (walker.nextNode())
{
if (walker.currentNode.data === "jz-spacing-anchor")
{
anchor = walker.currentNode;
break;
}
}
assert.ok(anchor, `${side} original-side anchor`);
assert.equal(selectorParent.childNodes.length, 1, `${side} selector parent initially has anchor`);
outside.appendChild(anchor);
assert.equal(selectorParent.childNodes.length, 0, `${side} original parent is now empty`);
const root = side === "common" ? paragraph
: side === "base" ? base
: side === "original-parent" ? selectorParent
: outside;
jz.revert(root);
assert.equal(paragraph.innerHTML, original, `${side} exact author DOM`);
assert.equal(paragraph.querySelector("#anchor-base"), base, `${side} base identity`);
assert.equal(paragraph.querySelector("#anchor-selector"), selectorParent, `${side} parent identity`);
assert.equal(selectorParent.textContent, "\u{E0100}", `${side} selector preserved`);
assert.equal(countTag(paragraph, "jz-hws"), 0, `${side} linked gap removed`);
const outsideComments = Array.from(outside.childNodes)
.filter((node) => node.nodeType === 8 && node.data.startsWith("jz-spacing-"));
assert.equal(outsideComments.length, 0, `${side} external parent has no internal comment`);
selectorParent.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clicks, 1, `${side} listener`);
assertNoUnpairedSurrogates(paragraph.textContent);
}
});
test("moved IVS ownership: anchor and text-move throws roll back exactly", () =>
{
const fixture = '<p><strong id="throw-author">\u{20000}</strong><em>\u{E0100}A</em></p>';
const modes = [ "original-anchor", "gap-anchor", "selector" ];
for (const mode of modes)
{
for (const throwAfterNative of [ false, true ])
{
const { document } = setupDom(fixture);
const paragraph = document.querySelector("p");
const author = document.getElementById("throw-author");
const nodePrototype = document.defaultView.Node.prototype;
const insertBefore = nodePrototype.insertBefore;
const appendChild = nodePrototype.appendChild;
const error = new Error(`${mode}-${throwAfterNative ? "after" : "before"}`);
let thrown = false;
const shouldThrow = (node) => mode === "original-anchor"
? node.nodeType === 8 && node.data === "jz-spacing-anchor"
: mode === "gap-anchor"
? node.nodeType === 8 && node.data === "jz-spacing-gap-anchor"
: node.nodeType === 3 && node.data === "\u{E0100}";
nodePrototype.insertBefore = function (node, reference)
{
if (!thrown && shouldThrow(node) && mode === "original-anchor")
{
thrown = true;
if (!throwAfterNative) { throw error; }
const result = insertBefore.call(this, node, reference);
throw error;
return result;
}
return insertBefore.call(this, node, reference);
};
nodePrototype.appendChild = function (node)
{
if (!thrown && shouldThrow(node) && mode !== "original-anchor")
{
thrown = true;
if (!throwAfterNative) { throw error; }
const result = appendChild.call(this, node);
throw error;
return result;
}
return appendChild.call(this, node);
};
const jz = createJuzhen(TEXT_ONLY);
try { assert.throws(() => jz.render(paragraph), (got) => got === error); }
finally
{
nodePrototype.insertBefore = insertBefore;
nodePrototype.appendChild = appendChild;
}
assert.equal(thrown, true, `${mode} ${throwAfterNative} exercised`);
assert.equal(paragraph.innerHTML, fixture.slice(3, -4), `${mode} exact rollback`);
assert.equal(countTag(paragraph, "jz-hws"), 0);
assert.equal(
Array.from(paragraph.childNodes).filter((node) => node.nodeType === 8).length,
0,
`${mode} leaves no direct anchor`,
);
assert.equal(document.getElementById("throw-author"), author);
jz.render(paragraph);
assert.equal(countTag(paragraph, "jz-hws"), 1, `${mode} retry`);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, fixture.slice(3, -4), `${mode} retry/revert`);
}
}
});
test("moved IVS ownership: partial roots and detached links restore as one unit", () =>
{
const original = '<strong id="partial-base">\u{20000}</strong>'
+ '<em id="partial-selector">\u{E0100}A</em>';
const cases = [ "common", "anchor-side", "base-side", "detached-gap", "moved-anchor", "detached" ];
for (const kind of cases)
{
const { document } = setupDom(
`<div id="outside"></div><div id="partial-container"><p>${original}</p></div>`,
);
const paragraph = document.querySelector("p");
const container = document.getElementById("partial-container");
const base = document.getElementById("partial-base");
const selectorParent = document.getElementById("partial-selector");
const outside = document.getElementById("outside");
let clicks = 0;
base.addEventListener("click", () => { clicks += 1; });
const jz = createJuzhen(TEXT_ONLY);
jz.render(kind === "detached" ? paragraph : document.body);
const gap = paragraph.querySelector("jz-hws");
const anchorWalker = document.createTreeWalker(
paragraph,
document.defaultView.NodeFilter.SHOW_COMMENT,
);
let anchor = null;
while (anchorWalker.nextNode())
{
if (anchorWalker.currentNode.data === "jz-spacing-anchor")
{
anchor = anchorWalker.currentNode;
break;
}
}
assert.ok(anchor, `${kind} anchor exists`);
let revertRoot = paragraph;
if (kind === "anchor-side") { revertRoot = selectorParent; }
else if (kind === "base-side") { revertRoot = base; }
else if (kind === "detached-gap")
{
gap.parentNode.removeChild(gap);
revertRoot = selectorParent;
}
else if (kind === "moved-anchor")
{
outside.appendChild(anchor);
revertRoot = base;
}
else if (kind === "detached")
{
paragraph.parentNode.removeChild(paragraph);
revertRoot = container;
}
jz.revert(revertRoot);
assert.equal(paragraph.innerHTML, original, `${kind} restores 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(countTag(paragraph, "jz-hws"), 0, `${kind} removes linked gap`);
assert.equal(
Array.from(outside.childNodes).filter((node) => node.nodeType === 8).length,
0,
`${kind} never follows anchor to arbitrary parent`,
);
base.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clicks, 1, `${kind} listener`);
assertNoUnpairedSurrogates(paragraph.textContent);
if (kind === "detached")
{
container.appendChild(paragraph);
jz.render(paragraph);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, original, "detached record was cleaned before reuse");
}
}
});
test("IVS spacing survives orphan reparenting without replacing author elements", () =>
{
const samples = [
[
"ivs-forward-space",
'<strong id="orphan-author-ivs-forward">甲\u{20000}</strong>\u{E0100} A',
'<strong id="orphan-author-ivs-forward">甲\u{20000}</strong>\u{E0100}A',
"\u{20000}\u{E0100}",
true,
],
[
"bmp-forward",
'<strong id="orphan-author-bmp-forward">甲中</strong>\uFE00A',
'<strong id="orphan-author-bmp-forward">甲中</strong>\uFE00A',
"中\uFE00",
true,
],
[
"bmp-forward-nested-carrier",
'<strong id="orphan-author-bmp-nested-base">甲中</strong>'
+ '<em id="orphan-author-bmp-nested-vs">\uFE00 A</em>',
'<strong id="orphan-author-bmp-nested-base">甲中</strong>'
+ '<em id="orphan-author-bmp-nested-vs">\uFE00A</em>',
"中\uFE00",
true,
],
[
"ivs-reverse-space",
'A <strong id="orphan-author-ivs-reverse">\u{20000}</strong>\u{E0100}乙',
'A<strong id="orphan-author-ivs-reverse">\u{20000}</strong>\u{E0100}乙',
"A",
false,
],
[
"bmp-reverse",
'A<strong id="orphan-author-bmp-reverse">中</strong>\uFE00乙',
'A<strong id="orphan-author-bmp-reverse">中</strong>\uFE00乙',
"A",
false,
],
];
const { document } = setupDom(samples.map(([ id, html ]) => `<p id="${id}">${html}</p>`).join(""));
const authors = Array.from(document.querySelectorAll('[id^="orphan-author-"]'));
const clickCounts = new Map(authors.map((el) => [ el, 0 ]));
for (const author of authors)
{
author.addEventListener("click", () => clickCounts.set(author, clickCounts.get(author) + 1));
}
const jz = createJuzhen({ jiya: false, jinze: false, longWord: false });
jz.render(document.body);
for (const [ id, , normalized, carrierText, insideOrphan ] of samples)
{
const paragraph = document.getElementById(id);
const gap = paragraph.querySelector("jz-hws");
const orphan = paragraph.querySelector("jz-orphan");
assert.ok(gap, `${id} gap survives orphan`);
assert.ok(orphan, `${id} orphan is active`);
assert.equal(gap.textContent, carrierText, `${id} carrier text`);
assert.equal(orphan.contains(gap), insideOrphan, `${id} carrier placement`);
if (insideOrphan)
{
assert.equal(gap.getAttribute("data-jz-kind"), "wrap", `${id} uses atomic wrapper`);
}
assert.equal(paragraph.textContent, setupDom(`<p>${normalized}</p>`).document.body.textContent, id);
assertNoUnpairedSurrogates(paragraph.textContent);
}
for (const author of authors)
{
assert.equal(document.getElementById(author.id), author, `${author.id} identity`);
assert.equal(document.querySelectorAll(`[id="${author.id}"]`).length, 1, `${author.id} unique`);
author.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clickCounts.get(author), 1, `${author.id} listener`);
}
jz.render(document.body);
for (const [ id ] of samples)
{
assert.equal(countTag(document.getElementById(id), "jz-hws"), 1, `${id} repeated render`);
}
jz.revert(document.body);
for (const [ id, , normalized ] of samples)
{
const paragraph = document.getElementById(id);
assert.equal(paragraph.innerHTML, normalized, id);
assertNoUnpairedSurrogates(paragraph.textContent);
}
for (const author of authors)
{
assert.equal(document.getElementById(author.id), author, `${author.id} revert identity`);
author.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clickCounts.get(author), 2, `${author.id} revert listener`);
}
});
test("IVS spacing: pill neighbors use complete sequences and UTF-16 offsets", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(
`<p id="left">${ivs} <code>x</code></p>`
+ `<p id="right"><code>x</code> ${ivs}</p>`,
);
const left = document.getElementById("left");
const right = document.getElementById("right");
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
assert.equal(left.textContent, ivs + "x", "author space before pill is removed without splitting IVS");
assert.equal(right.textContent, "x" + ivs, "author space after pill is removed without splitting IVS");
assert.equal(left.querySelector("jz-hws").textContent, ivs);
assert.equal(countTag(left, "jz-hws"), 1);
assert.equal(countTag(right, "jz-hws"), 1);
assertNoUnpairedSurrogates(document.body.textContent);
jz.revert(document.body);
assert.equal(left.textContent, ivs + "x");
assert.equal(right.textContent, "x" + ivs);
assertNoUnpairedSurrogates(document.body.textContent);
});
test("IVS jinze: opening and closing punctuation bind one complete variation sequence", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(
`<p id="open" lang="zh-Hant">「${ivs}A</p>`
+ `<p id="close" lang="zh-Hant">A${ivs}」</p>`,
);
const open = document.getElementById("open");
const close = document.getElementById("close");
const originalOpen = open.innerHTML;
const originalClose = close.innerHTML;
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
jz.render(document.body);
assert.equal(open.querySelector("jz-jinze").textContent, "「" + ivs);
assert.equal(close.querySelector("jz-jinze").textContent, ivs + "」");
assert.equal(open.textContent, "「" + ivs + "A");
assert.equal(close.textContent, "A" + ivs + "」");
assertNoUnpairedSurrogates(document.body.textContent);
jz.revert(document.body);
assert.equal(open.innerHTML, originalOpen);
assert.equal(close.innerHTML, originalClose);
});
test("IVS splitoff: transparent inline splits after the full variation sequence", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(`<p lang="zh-Hant">「<strong>${ivs}A</strong></p>`);
const paragraph = document.querySelector("p");
const originalHtml = paragraph.innerHTML;
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-jinze").textContent, "「" + ivs);
const splitoff = paragraph.querySelector('strong[data-jz-kind="splitoff"]');
assert.ok(splitoff);
assert.equal(splitoff.textContent, "A");
assert.equal(paragraph.textContent, "「" + ivs + "A");
assertNoUnpairedSurrogates(paragraph.textContent);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, originalHtml);
});
test("IVS orphan: base counts once, selector counts zero, and split keeps the sequence", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(`<p id="enough">A${ivs}甲</p><p id="short">A${ivs}</p>`);
const enough = document.getElementById("enough");
const short = document.getElementById("short");
const enoughHtml = enough.innerHTML;
const shortHtml = short.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, orphan: { chars: 2 }, spacing: false });
jz.render(document.body);
assert.equal(enough.querySelector("jz-orphan").textContent, ivs + "甲");
assert.equal(short.querySelector("jz-orphan"), null, "one base plus VS is only one substantive CJK");
assertNoUnpairedSurrogates(document.body.textContent);
jz.revert(document.body);
assert.equal(enough.innerHTML, enoughHtml);
assert.equal(short.innerHTML, shortHtml);
});
test("variation selectors: punctuation suffix is atomic and selectors without a base are not CJK", () =>
{
const punctuation = setupDom('<p lang="zh-Hant">,\uFE00A</p>');
const jz = createJuzhen({ spacing: false, jinze: false, longWord: false, orphan: false });
jz.render(punctuation.document.body);
const char = punctuation.document.querySelector("jz-char.bd-pause");
assert.ok(char);
assert.equal(char.textContent, ",\uFE00");
assert.equal(countTag(punctuation.document.body, "jz-char"), 1);
jz.revert(punctuation.document.body);
assert.equal(punctuation.document.body.textContent, ",\uFE00A");
const standalone = setupDom('<p id="bmp">\uFE00A</p><p id="supp">\u{E0100}A</p>');
createJuzhen(TEXT_ONLY).render(standalone.document.body);
assert.equal(countTag(standalone.document.getElementById("bmp"), "jz-hws"), 0);
assert.equal(countTag(standalone.document.getElementById("supp"), "jz-hws"), 0);
});
test("charClass append remains additive for BMP and astral variation sequences", () =>
{
const base = setupDom('<p id="bmp">〇A</p><p id="astral">😀\u{E0100}A</p>');
createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "〇😀" } }).render(base.document.body);
assert.equal(countTag(base.document.getElementById("bmp"), "jz-hws"), 1);
assert.equal(countTag(base.document.getElementById("astral"), "jz-hws"), 1);
const punctuation = setupDom('<p lang="zh-Hant">A😀B</p>');
const jz = createJuzhen({
spacing: false,
jinze: false,
longWord: false,
orphan: false,
charClass: { biaodian: { pause: "😀" } },
});
jz.render(punctuation.document.body);
const char = punctuation.document.querySelector("jz-char.bd-pause");
assert.ok(char, "astral custom punctuation is charified once");
assert.equal(char.textContent, "😀");
assert.equal(countTag(punctuation.document.body, "jz-char"), 1);
assertNoUnpairedSurrogates(punctuation.document.body.textContent);
});