1000 lines
42 KiB
JavaScript
1000 lines
42 KiB
JavaScript
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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">");
|
||
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);
|
||
});
|