Files
cjk-autospace/test/astral-unicode.test.mjs
T

1004 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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>';
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);
// 移除整個載體即刪除其原文;不能因回退另一端而復活已刪 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, 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,
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);
});