import assert from "node:assert/strict";
import { test } from "node:test";
import { createJuzhen } from "../dist/juzhen.mjs";
import { setupDom } from "./helpers.mjs";
const TEXT_ONLY = { jiya: false, jinze: false, orphan: false, longWord: false };
for (const cp of [ 0xE000, 0xE001, 0xE002, 0xE003 ])
{
test(`spacing source: U+${cp.toString(16).toUpperCase()} remains author text`, () =>
{
const text = "漢" + String.fromCodePoint(cp) + "A中";
const { dom, document } = setupDom("
");
const paragraph = document.querySelector("p");
paragraph.textContent = text;
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "A" ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered, "repeat render stays idempotent");
jz.revert(paragraph);
assert.equal(paragraph.textContent, text);
assert.equal(paragraph.querySelector("[data-jz]"), null);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
for (const fakeIndex of [ "0", "9" ])
{
test(`spacing source: fake compound placeholder ${fakeIndex} does not collide with GPT-4`, () =>
{
const text = "漢\uE001" + fakeIndex + "\uE002 GPT-4中";
const { dom, document } = setupDom("");
const paragraph = document.querySelector("p");
paragraph.textContent = text;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "4" ]);
jz.revert(paragraph);
assert.equal(paragraph.textContent, text);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
test("spacing source: reserved characters across transparent nodes preserve IVS and space normalization", () =>
{
const { dom, document } = setupDom('漢\uE000\uE001'
+ '0\uE002\uE003\uE004 GPT-4\u{20000}'
+ '\u{E0100} A甲 乙
');
const paragraph = document.querySelector("p");
const source = paragraph.textContent;
const expected = source.replace("\u{E0100} A", "\u{E0100}A").replace("甲 乙", "甲乙");
const authors = Array.from(paragraph.querySelectorAll("strong,em,span,i,b"));
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, expected, "only contracted author spaces are removed");
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent),
[ "4", "\u{20000}\u{E0100}", "A" ]);
jz.revert(paragraph);
assert.equal(paragraph.textContent, expected);
assert.equal(paragraph.querySelector("[data-jz]"), null);
assert.deepEqual(Array.from(paragraph.querySelectorAll("strong,em,span,i,b")), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing source: candidate markers respect custom CJK and western classes", () =>
{
const { dom, document } = setupDom("漢A中
");
const paragraph = document.querySelector("p");
const jz = createJuzhen({ ...TEXT_ONLY, charClass: {
cjk: "\\uE000-\\uE003", western: "\\uE004-\\uE007",
} });
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, "漢A中");
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "漢", "A" ]);
jz.revert(paragraph);
assert.equal(paragraph.textContent, "漢A中");
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing source: supplementary markers keep source PUA and DOM atom offsets intact", () =>
{
const { dom, document } = setupDom("漢\u{F0000}A中
");
const paragraph = document.querySelector("p");
const text = paragraph.textContent;
const jz = createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "\\uE000-\\uF8FF" } });
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "A" ]);
jz.revert(paragraph);
assert.equal(paragraph.textContent, text);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing source: exhausted marker candidates fail before author text playback", () =>
{
const { dom, document } = setupDom("漢 A中
");
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "\\u0000-\\u{10FFFF}" } });
try
{
assert.throws(() => jz.render(paragraph), /已佔用全部間隙標記候選/);
assert.equal(paragraph.innerHTML, before, "no source atom or author space was replayed");
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
for (const selector of [ "code", "code span" ])
{
test(`spacing root: directly rendering ${selector} preserves pill contents`, () =>
{
const { dom, document } = setupDom('外漢A 字B文
');
const root = document.querySelector(selector);
const before = document.body.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(root);
assert.equal(root.querySelector("jz-hws"), null);
jz.render(root);
jz.revert(root);
assert.equal(document.body.innerHTML, before);
}
finally { jz.revert(root); dom.window.close(); }
});
}
test("spacing pill: a single author space across transparent nodes normalizes on both sides", () =>
{
const { dom, document } = setupDom('漢 x'
+ ' 文
');
const paragraph = document.querySelector("p");
const authors = Array.from(paragraph.querySelectorAll("span,em,code,i,strong"));
const expected = paragraph.innerHTML.replace(" ", "").replace(" ", "");
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, "漢x文");
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "漢", "x" ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, expected);
assert.deepEqual(Array.from(paragraph.querySelectorAll("span,em,code,i,strong")), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
for (const base of [ "漢", "\u{20000}" ])
{
for (const withSpace of [ false, true ])
{
test(`spacing pill: split ${base.codePointAt(0).toString(16)} IVS with space=${withSpace} stays one atom`, () =>
{
const vs = "\u{E0100}";
const space = withSpace ? " " : "";
const { dom, document } = setupDom(`${base}${vs}`
+ `${space}x${space}${base}${vs}
`);
const paragraph = document.querySelector("p");
const expected = paragraph.innerHTML.replace(" ", "").replace(" ", "");
const authors = Array.from(paragraph.children);
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, base + vs + "x" + base + vs);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ base + vs, "x" ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, expected, "cross-node selector returns to its author element");
assert.deepEqual(Array.from(paragraph.children), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
}
for (const whitespace of [ " ", "\n", "\t", " \n" ])
{
test(`spacing pill: whitespace ${JSON.stringify(whitespace)} is a boundary, not a single removable space`, () =>
{
const { dom, document } = setupDom(`漢${whitespace}`
+ `x${whitespace}文
`);
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
for (const barrier of [
'',
'',
'',
"
",
"",
])
{
test(`spacing pill: empty ${barrier} stops lookup beyond one space on both sides`, () =>
{
const { dom, document } = setupDom(`漢${barrier} `
+ `x ${barrier}文
`);
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
test("spacing pill: out-of-scope neighbors stop space normalization", () =>
{
const { dom, document } = setupDom('漢 '
+ 'x 文
');
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, scope: { include: ".prose" } });
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing pill: local render cannot move an outside IVS base or delete an inside trailing space", () =>
{
const { dom, document } = setupDom('\u{20000}'
+ '\u{E0100}x 文
');
const root = document.getElementById("root");
const before = document.body.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(root);
assert.equal(document.body.innerHTML, before);
jz.render(root);
jz.revert(root);
assert.equal(document.body.innerHTML, before);
}
finally { jz.revert(root); dom.window.close(); }
});
test("spacing pill: a local transparent root encloses only its own neighbor", () =>
{
const { dom, document } = setupDom('外漢'
+ 'x 文
');
const root = document.getElementById("root");
const before = document.body.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(root);
assert.deepEqual(Array.from(root.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "漢" ]);
assert.equal(document.querySelector("p").lastChild.data, " 文", "root-external space remains author text");
jz.revert(root);
assert.equal(document.body.innerHTML, before);
}
finally { jz.revert(root); dom.window.close(); }
});
function appendTextChunks(parent, chunks)
{
const tags = [ "strong", "em", "i", "span", "b", "u" ];
return chunks.map((chunk, index) =>
{
const element = parent.ownerDocument.createElement(tags[index % tags.length]);
element.textContent = chunk;
parent.appendChild(element);
return element;
});
}
const SPLIT_CODEPOINTS = [
[ "\uD840", "\uDC00" ],
[ "\uD840", "\uDC00", "\uDB40", "\uDD00" ],
[ "\uD840", "\uDC00\uDB40", "\uDD00" ],
[ "\u{20000}", "\uDB40", "\uDD00" ],
[ "漢", "\uDB40", "\uDD00" ],
];
for (const [index, chunks] of SPLIT_CODEPOINTS.entries())
{
test(`spacing source: split surrogate codepoint case ${index} maps to one complete gap`, () =>
{
const { dom, document } = setupDom("");
const paragraph = document.querySelector("p");
const authors = appendTextChunks(paragraph, [ ...chunks, "A" ]);
const before = paragraph.innerHTML;
const atom = chunks.join("");
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, atom + "A");
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ atom ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
assert.deepEqual(Array.from(paragraph.children), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
for (const withSpace of [ false, true ])
{
test(`spacing pill: split surrogate case ${index}, space=${withSpace} remains complete on both sides`, () =>
{
const { dom, document } = setupDom("");
const paragraph = document.querySelector("p");
appendTextChunks(paragraph, chunks);
if (withSpace) { appendTextChunks(paragraph, [ " " ]); }
const code = document.createElement("code");
code.textContent = "x";
paragraph.appendChild(code);
if (withSpace) { appendTextChunks(paragraph, [ " " ]); }
appendTextChunks(paragraph, chunks);
const authors = Array.from(paragraph.children);
const before = paragraph.innerHTML.replaceAll(" ", "");
const atom = chunks.join("");
const jz = createJuzhen(TEXT_ONLY);
try
{
for (let cycle = 0; cycle < 3; cycle += 1)
{
jz.render(paragraph);
assert.equal(paragraph.textContent, atom + "x" + atom);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ atom, "x" ]);
const rendered = paragraph.innerHTML;
jz.render(paragraph);
assert.equal(paragraph.innerHTML, rendered);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
assert.deepEqual(Array.from(paragraph.children), authors);
}
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
}
for (const barrier of [
'',
'',
'',
"
",
"",
])
{
test(`spacing surrogate: ${barrier} prevents joining adjacent high and low halves`, () =>
{
const { dom, document } = setupDom("");
const paragraph = document.querySelector("p");
appendTextChunks(paragraph, [ "\uD840" ]);
paragraph.insertAdjacentHTML("beforeend", barrier);
appendTextChunks(paragraph, [ "\uDC00" ]);
const code = document.createElement("code");
code.textContent = "x";
paragraph.appendChild(code);
appendTextChunks(paragraph, [ "\uD840" ]);
paragraph.insertAdjacentHTML("beforeend", barrier);
appendTextChunks(paragraph, [ "\uDC00", "A" ]);
const before = paragraph.innerHTML;
const text = paragraph.textContent;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
}
test("spacing surrogate: actual isolated UTF-16 halves stay author text", () =>
{
const { dom, document } = setupDom("");
const paragraph = document.querySelector("p");
appendTextChunks(paragraph, [ "\uD840", "A" ]);
const code = document.createElement("code");
code.textContent = "x";
paragraph.appendChild(code);
appendTextChunks(paragraph, [ "\uDC00" ]);
const text = paragraph.textContent;
const before = paragraph.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(paragraph);
assert.equal(paragraph.textContent, text);
assert.deepEqual(Array.from(paragraph.querySelectorAll("jz-hws"), (gap) => gap.textContent), [ "A" ]);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing surrogate: a local root cannot join either outside surrogate half", () =>
{
const { dom, document } = setupDom(''
+ 'x
');
document.querySelector("strong").textContent = "\uD840";
document.querySelector("em").textContent = "\uDC00";
document.querySelector("i").textContent = "\uD840";
document.querySelector("b").textContent = "\uDC00";
const root = document.getElementById("root");
const before = document.body.innerHTML;
const jz = createJuzhen(TEXT_ONLY);
try
{
jz.render(root);
assert.equal(document.body.innerHTML, before);
jz.revert(root);
assert.equal(document.body.innerHTML, before);
}
finally { jz.revert(root); dom.window.close(); }
});
test("spacing surrogate: an excluded Text is a run boundary even when empty", () =>
{
const { dom, document } = setupDom("");
const paragraph = document.querySelector("p");
appendTextChunks(paragraph, [ "\uD840" ]);
const excluded = document.createTextNode("");
paragraph.appendChild(excluded);
appendTextChunks(paragraph, [ "\uDC00", "A" ]);
const before = paragraph.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, isSkipped: (node) => node === excluded });
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});
test("spacing surrogate: complete codepoints across source scope boundaries are not joined", () =>
{
const { dom, document } = setupDom(''
+ 'x
');
document.querySelector("strong").textContent = "\uD840";
document.querySelector("em").textContent = "\uDC00";
document.querySelector("i").textContent = "\uD840";
document.querySelector("b").textContent = "\uDC00";
const paragraph = document.querySelector("p");
const before = paragraph.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, scope: { include: ".prose" } });
try
{
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), null);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, before);
}
finally { jz.revert(paragraph); dom.window.close(); }
});