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 ]) => `

${ch}A

`).join("") + gaps.map(([ id, ch ]) => `

${ch}A

`).join("") + '

😀A

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

甲\u{20000}\u{E0100} A

` + `

甲${ivs}A

` + `

A甲${ivs}

` + `

漢${ivs}A

` + '

甲😀\u{E0100}A

', ); 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`, `)${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 `

${escapeHtml(text)}

` + `

${escapeHtml(bmp)}

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

${ivs}A

A${ivs}

` + `

${bmpVs}A

A${bmpVs}

` + `

${ivs}A

` + `

A${ivs}

`, ); 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", '中\uFE00A', '中\uFE00A', ], [ "bmp-left-space", '中\uFE00 A', '中\uFE00A', ], [ "bmp-left-partial-start", '甲中\uFE00 A', '甲中\uFE00A', ], [ "bmp-left-partial-end", '中' + '\uFE00x', '中' + '\uFE00x', ], [ "bmp-right", 'A中\uFE00', 'A中\uFE00', ], [ "bmp-right-space", 'A 中\uFE00', 'A中\uFE00', ], [ "ivs-left", '\u{20000}\u{E0100}A', '\u{20000}\u{E0100}A', ], [ "ivs-left-space", '\u{20000}\u{E0100} A', '\u{20000}\u{E0100}A', ], [ "ivs-right", 'A\u{20000}\u{E0100}', 'A\u{20000}\u{E0100}', ], [ "ivs-right-space", 'A \u{20000}\u{E0100}', 'A\u{20000}\u{E0100}', ], ]; const { document } = setupDom(samples.map(([ id, html ]) => `

${html}

`).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(`

${normalized}

`).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( '

\u{20000}\u{E0100}A

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

\u{20000}\u{E0100}A

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

` + '\u{20000}\u{E0100}A

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

\u{20000}' + '\u{E0100}A

', ); 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, '\u{20000}' + `${replacement}`, `${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( '

\u{20000}' + '\u{E0100}A

', ); 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, '\u{20000}A', `${method} revert keeps selector deletion`, ); } }); test("moved IVS ownership: externally moved selector-only anchor restores to original parent", () => { const original = '\u{20000}' + '\u{E0100}A'; for (const side of [ "common", "base", "original-parent", "external-anchor" ]) { const { document } = setupDom(`

${original}

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

\u{20000}\u{E0100}A

'; 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 = '\u{20000}' + '\u{E0100}A'; const cases = [ "common", "anchor-side", "base-side", "detached-gap", "moved-anchor", "detached" ]; for (const kind of cases) { const { document } = setupDom( `

${original}

`, ); 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", '甲\u{20000}\u{E0100} A', '甲\u{20000}\u{E0100}A', "\u{20000}\u{E0100}", true, ], [ "bmp-forward", '甲中\uFE00A', '甲中\uFE00A', "中\uFE00", true, ], [ "bmp-forward-nested-carrier", '甲中' + '\uFE00 A', '甲中' + '\uFE00A', "中\uFE00", true, ], [ "ivs-reverse-space", 'A \u{20000}\u{E0100}乙', 'A\u{20000}\u{E0100}乙', "A", false, ], [ "bmp-reverse", 'A中\uFE00乙', 'A中\uFE00乙', "A", false, ], ]; const { document } = setupDom(samples.map(([ id, html ]) => `

${html}

`).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(`

${normalized}

`).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( `

${ivs} x

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

「${ivs}A

` + `

A${ivs}」

`, ); 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(`

「${ivs}A

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

A${ivs}甲

A${ivs}

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

,\uFE00A

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

\uFE00A

\u{E0100}A

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

〇A

😀\u{E0100}A

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

A😀B

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