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

This commit is contained in:
2026-09-04 21:44:11 +08:00
parent 8ee9895247
commit 993cf576c6
42 changed files with 8511 additions and 1422 deletions
+999
View File
@@ -0,0 +1,999 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createJuzhen } from "../dist/juzhen.mjs";
import { countTag, setupDom } from "./helpers.mjs";
const TEXT_ONLY = {
jiya: false,
jinze: false,
longWord: false,
orphan: false,
};
function assertNoUnpairedSurrogates(text)
{
for (let i = 0; i < text.length; i += 1)
{
const unit = text.charCodeAt(i);
if (unit >= 0xD800 && unit <= 0xDBFF)
{
assert.ok(i + 1 < text.length, "high surrogate must have a following low surrogate");
const next = text.charCodeAt(i + 1);
assert.ok(next >= 0xDC00 && next <= 0xDFFF, "high surrogate must pair with low surrogate");
i += 1;
}
else
{
assert.ok(
unit < 0xDC00 || unit > 0xDFFF,
"low surrogate must have a preceding high surrogate",
);
}
}
}
test("astral classification: Extensions B-J and compatibility supplement are built-in CJK", () =>
{
const ranges = [
[ "ext-b", 0x20000, 0x2A6DF ],
[ "ext-c", 0x2A700, 0x2B73F ],
[ "ext-d", 0x2B740, 0x2B81F ],
[ "ext-e", 0x2B820, 0x2CEAF ],
[ "ext-f", 0x2CEB0, 0x2EBEF ],
[ "ext-i", 0x2EBF0, 0x2EE5F ],
[ "compat-supplement", 0x2F800, 0x2FA1F ],
[ "ext-g", 0x30000, 0x3134F ],
[ "ext-h", 0x31350, 0x323AF ],
[ "ext-j", 0x323B0, 0x3347F ],
];
const samples = ranges.flatMap(([ id, lower, upper ]) => [
[ id + "-lower", String.fromCodePoint(lower) ],
[ id + "-upper", String.fromCodePoint(upper) ],
]);
const gaps = [
[ "before-b", 0x1FFFF ],
[ "after-b", 0x2A6E0 ],
[ "after-i", 0x2EE60 ],
[ "after-compat", 0x2FA20 ],
[ "before-g", 0x2FFFF ],
[ "after-j", 0x33480 ],
].map(([ id, value ]) => [ id, String.fromCodePoint(value) ]);
const html = samples.map(([ id, ch ]) => `<p id="${id}">${ch}A</p>`).join("")
+ gaps.map(([ id, ch ]) => `<p id="${id}">${ch}A</p>`).join("")
+ '<p id="emoji">😀A</p>';
const { document } = setupDom(html);
createJuzhen(TEXT_ONLY).render(document.body);
for (const [ id, ch ] of samples)
{
const paragraph = document.getElementById(id);
assert.equal(countTag(paragraph, "jz-hws"), 1, `${id} must space against Latin`);
assert.equal(paragraph.textContent, ch + "A");
assertNoUnpairedSurrogates(paragraph.textContent);
}
for (const [ id ] of gaps)
{
assert.equal(countTag(document.getElementById(id), "jz-hws"), 0, `${id} is an intentional gap`);
}
assert.equal(countTag(document.getElementById("emoji"), "jz-hws"), 0, "emoji is not CJK");
});
test("Safari shadow: nonzero astral CJK regex misses do not suppress spacing actions", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(
`<p id="cross">甲<strong>\u{20000}</strong>\u{E0100} A</p>`
+ `<p id="same">甲${ivs}A</p>`
+ `<p id="reverse">A甲${ivs}</p>`
+ `<p id="real-han">漢${ivs}A</p>`
+ '<p id="custom">甲<strong>😀</strong>\u{E0100}A</p>',
);
const replace = String.prototype.replace;
let simulatedMisses = 0;
String.prototype.replace = function (searchValue, replaceValue)
{
if (searchValue instanceof RegExp && searchValue.unicode
&& searchValue.source.includes("\\u{20000}"))
{
let offset = 0;
for (const ch of String(this))
{
const cp = ch.codePointAt(0);
if (offset > 0 && cp >= 0x20000 && cp <= 0x3347F)
{
simulatedMisses += 1;
return String(this);
}
offset += ch.length;
}
}
return replace.call(this, searchValue, replaceValue);
};
try
{
const representative = new RegExp("([\\u{20000}-\\u{2A6DF}])([A-Za-z])", "gu");
assert.equal("甲\u{20000}A".replace(representative, "$1$2"), "甲\u{20000}A");
const probeMisses = simulatedMisses;
createJuzhen(TEXT_ONLY).render(document.body);
createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "😀" } })
.render(document.getElementById("custom"));
assert.equal(simulatedMisses, probeMisses, "rule inputs contain no nonzero astral CJK");
}
finally { String.prototype.replace = replace; }
assert.equal(countTag(document.getElementById("cross"), "jz-hws"), 1);
assert.equal(
document.querySelector("#cross jz-hws").textContent,
ivs,
"cross-node wraps the complete variation sequence",
);
assert.equal(countTag(document.getElementById("same"), "jz-hws"), 1);
assert.equal(document.querySelector("#same jz-hws").textContent, ivs, "same-node wraps real atom");
assert.equal(countTag(document.getElementById("reverse"), "jz-hws"), 1);
assert.equal(countTag(document.getElementById("real-han"), "jz-hws"), 1, "real 漢 is not confused");
assert.equal(countTag(document.getElementById("custom"), "jz-hws"), 1, "custom astral CJK shadows");
assertNoUnpairedSurrogates(document.body.textContent);
});
test("Safari shadow differential: representative and randomized Pangu actions stay aligned", () =>
{
const cjk = "\u{20000}\u{E0100}";
const shadow = "漢";
const representatives = [
`${cjk} A`, `A ${cjk}`, `${cjk} 甲`, `...${cjk}`,
`${cjk}!A`, `A!${cjk}`, `${cjk}~A`, `${cjk}~=A`,
`${cjk}.B`, `A.${cjk}`, `A:${cjk}`, `${cjk}\"A`, `\"${cjk}`,
`${cjk}#x`, `x#${cjk}`, `A+${cjk}`, `${cjk}+A`,
`${cjk}<A`, `A<${cjk}`, `${cjk}>A`, `A>${cjk}`,
`${cjk}(A`, `)${cjk}`, `A(B`, `)A`, `%A`,
`漢${cjk}A`, `甲乙${cjk}A`, `foo-bar${cjk}`, `${cjk}GPT-4`,
];
const tokens = [
"A", "b", "1", " ", "!", "?", ";", ":", ".", "~", "~=", "+", "-", "*",
"#", "(", ")", "<", ">", "\"", "...", "%", "foo-bar", "GPT-4", "甲", "漢",
];
let state = 0xC0FFEE;
const random = () =>
{
state = (state * 1664525 + 1013904223) >>> 0;
return state;
};
const cases = representatives.slice();
for (let i = 0; i < 256; i += 1)
{
const left = tokens[random() % tokens.length];
const right = tokens[random() % tokens.length];
const mode = random() % 4;
cases.push(mode === 0 ? left + cjk + right
: mode === 1 ? cjk + left + right
: mode === 2 ? left + right + cjk
: left + cjk + "漢" + right);
}
const escapeHtml = (text) => text.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
const html = cases.map((text, index) =>
{
const bmp = text.split(cjk).join(shadow);
return `<p id="a-${index}">${escapeHtml(text)}</p>`
+ `<p id="b-${index}">${escapeHtml(bmp)}</p>`;
}).join("");
const { document } = setupDom(html);
createJuzhen(TEXT_ONLY).render(document.body);
const boundaries = (paragraph) => Array.from(paragraph.querySelectorAll("jz-hws")).map((gap) =>
{
const range = document.createRange();
range.setStart(paragraph, 0);
range.setEndAfter(gap);
let atoms = 0;
for (const ch of range.toString())
{
const cp = ch.codePointAt(0);
if (!((cp >= 0xFE00 && cp <= 0xFE0F) || (cp >= 0xE0100 && cp <= 0xE01EF)))
{
atoms += 1;
}
}
return atoms;
});
for (let index = 0; index < cases.length; index += 1)
{
const astral = document.getElementById(`a-${index}`);
const bmp = document.getElementById(`b-${index}`);
assert.deepEqual(boundaries(astral), boundaries(bmp), `gap actions ${index}`);
assert.equal(
astral.textContent.split(cjk).join(shadow),
bmp.textContent,
`space deletion and atom order ${index}`,
);
assertNoUnpairedSurrogates(astral.textContent);
}
});
test("IVS spacing: both directions and transparent inline boundaries keep base plus VS atomic", () =>
{
const ivs = "\u{20000}\u{E0100}";
const bmpVs = "中\uFE00";
const { document } = setupDom(
`<p id="ivs-left">${ivs}A</p><p id="ivs-right">A${ivs}</p>`
+ `<p id="bmp-left">${bmpVs}A</p><p id="bmp-right">A${bmpVs}</p>`
+ `<p id="inline-left"><strong>${ivs}</strong>A</p>`
+ `<p id="inline-right">A<strong>${ivs}</strong></p>`,
);
const ids = [ "ivs-left", "ivs-right", "bmp-left", "bmp-right", "inline-left", "inline-right" ];
const originals = new Map(ids.map((id) => [ id, document.getElementById(id).innerHTML ]));
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
for (const id of ids) { assert.equal(countTag(document.getElementById(id), "jz-hws"), 1, id); }
assert.equal(document.querySelector("#ivs-left jz-hws").textContent, ivs);
assert.equal(document.querySelector("#bmp-left jz-hws").textContent, bmpVs);
assert.equal(document.querySelector("#inline-left jz-hws").textContent, ivs);
assert.equal(document.getElementById("ivs-left").textContent, ivs + "A");
assert.equal(document.getElementById("ivs-right").textContent, "A" + ivs);
assertNoUnpairedSurrogates(document.body.textContent);
jz.revert(document.body);
for (const id of ids) { assert.equal(document.getElementById(id).innerHTML, originals.get(id), id); }
assertNoUnpairedSurrogates(document.body.textContent);
});
test("IVS spacing: base and selector across transparent nodes share one DOM span", () =>
{
const samples = [
[
"bmp-left",
'<strong id="author-bmp-left">中</strong>\uFE00A',
'<strong id="author-bmp-left">中</strong>\uFE00A',
],
[
"bmp-left-space",
'<strong id="author-bmp-left-space">中</strong>\uFE00 A',
'<strong id="author-bmp-left-space">中</strong>\uFE00A',
],
[
"bmp-left-partial-start",
'<strong id="author-bmp-partial-start">甲中</strong>\uFE00 A',
'<strong id="author-bmp-partial-start">甲中</strong>\uFE00A',
],
[
"bmp-left-partial-end",
'<strong id="author-bmp-partial-end-base">中</strong>'
+ '<em id="author-bmp-partial-end-vs">\uFE00x</em>',
'<strong id="author-bmp-partial-end-base">中</strong>'
+ '<em id="author-bmp-partial-end-vs">\uFE00x</em>',
],
[
"bmp-right",
'A<strong id="author-bmp-right">中</strong>\uFE00',
'A<strong id="author-bmp-right">中</strong>\uFE00',
],
[
"bmp-right-space",
'A <strong id="author-bmp-right-space">中</strong>\uFE00',
'A<strong id="author-bmp-right-space">中</strong>\uFE00',
],
[
"ivs-left",
'<strong id="author-ivs-left">\u{20000}</strong>\u{E0100}A',
'<strong id="author-ivs-left">\u{20000}</strong>\u{E0100}A',
],
[
"ivs-left-space",
'<strong id="author-ivs-left-space">\u{20000}</strong>\u{E0100} A',
'<strong id="author-ivs-left-space">\u{20000}</strong>\u{E0100}A',
],
[
"ivs-right",
'A<strong id="author-ivs-right">\u{20000}</strong>\u{E0100}',
'A<strong id="author-ivs-right">\u{20000}</strong>\u{E0100}',
],
[
"ivs-right-space",
'A <strong id="author-ivs-right-space">\u{20000}</strong>\u{E0100}',
'A<strong id="author-ivs-right-space">\u{20000}</strong>\u{E0100}',
],
];
const { document } = setupDom(samples.map(([ id, html ]) => `<p id="${id}">${html}</p>`).join(""));
const jz = createJuzhen(TEXT_ONLY);
const authors = Array.from(document.querySelectorAll('[id^="author-"]'));
const clickCounts = new Map(authors.map((el) => [ el, 0 ]));
for (const author of authors)
{
author.addEventListener("click", () => clickCounts.set(author, clickCounts.get(author) + 1));
}
const assertAuthorState = (expectedClicks) =>
{
for (const author of authors)
{
assert.equal(document.getElementById(author.id), author, `${author.id} identity`);
assert.equal(document.querySelectorAll(`[id="${author.id}"]`).length, 1, `${author.id} unique`);
author.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clickCounts.get(author), expectedClicks, `${author.id} listener`);
}
};
jz.render(document.body);
for (const [ id, , normalized ] of samples)
{
const paragraph = document.getElementById(id);
assert.equal(countTag(paragraph, "jz-hws"), 1, id);
assert.equal(paragraph.textContent, paragraph.textContent.replace(" ", ""), id);
assertNoUnpairedSurrogates(paragraph.textContent);
const gap = paragraph.querySelector("jz-hws");
if (id.includes("left"))
{
const sequence = id.startsWith("bmp") ? "中\uFE00" : "\u{20000}\u{E0100}";
assert.equal(gap.textContent, sequence, `${id} wraps the complete sequence`);
assert.equal(gap.getAttribute("data-jz-kind"), "wrap", `${id} wrapper kind`);
assert.equal(
Array.from(gap.childNodes).filter((node) => node.nodeType === 3).length,
2,
`${id} wrapper retains both Text parts`,
);
assert.equal(gap.getAttribute("data-jz-cross-node-atom"), "", `${id} cross-node flag`);
}
else { assert.equal(gap.textContent, "A", `${id} Latin carrier remains unchanged`); }
assert.equal(paragraph.textContent, setupDom(`<p>${normalized}</p>`).document.body.textContent, id);
}
assertAuthorState(1);
jz.render(document.body);
for (const [ id ] of samples)
{
assert.equal(countTag(document.getElementById(id), "jz-hws"), 1, `${id} repeated render`);
}
assertAuthorState(2);
jz.revert(document.body);
for (const [ id, , normalized ] of samples)
{
const paragraph = document.getElementById(id);
assert.equal(paragraph.innerHTML, normalized, id);
assertNoUnpairedSurrogates(paragraph.textContent);
}
assertAuthorState(3);
});
test("moved IVS ownership: repeat and peer instances never claim an existing gap", async () =>
{
const countAnchors = (root) =>
{
const walker = root.ownerDocument.createTreeWalker(
root,
root.ownerDocument.defaultView.NodeFilter.SHOW_COMMENT,
);
let count = 0;
while (walker.nextNode())
{
if (walker.currentNode.data.startsWith("jz-spacing-")) { count += 1; }
}
return count;
};
const { document } = setupDom(
'<p><strong id="repeat-author">\u{20000}</strong><em>\u{E0100}A</em></p>',
);
const paragraph = document.querySelector("p");
const author = document.getElementById("repeat-author");
const originalHtml = paragraph.innerHTML;
let clicks = 0;
author.addEventListener("click", () => { clicks += 1; });
const first = createJuzhen(TEXT_ONLY);
first.render(paragraph);
const gap = paragraph.querySelector("jz-hws");
assert.equal(countAnchors(paragraph), 2, "one original-side and one gap-side anchor");
const mutations = [];
const observer = new document.defaultView.MutationObserver((records) => mutations.push(...records));
observer.observe(paragraph, { childList: true, subtree: true, attributes: true });
first.render(paragraph);
await new Promise((resolve) => setTimeout(resolve, 0));
observer.disconnect();
assert.equal(paragraph.querySelector("jz-hws"), gap, "repeat preserves exact gap object");
assert.equal(countAnchors(paragraph), 2, "repeat creates no anchor");
const gapMutations = mutations.filter((record) => record.target === gap || gap.contains(record.target)
|| Array.from(record.addedNodes).some(
(node) => node.nodeType === 8 && node.data.startsWith("jz-spacing-"),
));
assert.equal(gapMutations.length, 0, "repeat does not mutate the gap or add anchors");
const second = createJuzhen(TEXT_ONLY);
second.render(paragraph);
second.revert(paragraph);
assert.equal(paragraph.querySelector("jz-hws"), gap, "peer render/revert leaves first gap intact");
assert.equal(countAnchors(paragraph), 2, "peer registers no moved part");
first.revert(paragraph);
assert.equal(paragraph.innerHTML, originalHtml);
assert.equal(document.getElementById("repeat-author"), author);
author.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clicks, 1);
const reverseDom = setupDom(
'<p><strong id="reverse-author">\u{20000}</strong><em>\u{E0100}A</em></p>',
);
const reverse = reverseDom.document.querySelector("p");
const reverseHtml = reverse.innerHTML;
const a = createJuzhen(TEXT_ONLY);
const b = createJuzhen(TEXT_ONLY);
b.render(reverse);
a.render(reverse);
a.revert(reverse);
assert.equal(countTag(reverse, "jz-hws"), 1, "reverse order preserves first owner");
b.revert(reverse);
assert.equal(reverse.innerHTML, reverseHtml);
for (const attributes of [ "", ' data-jz="spacing" data-jz-kind="wrap"' ])
{
const foreignDom = setupDom(
`<p><jz-hws id="foreign"${attributes}>`
+ '<strong>\u{20000}</strong>\u{E0100}</jz-hws>A</p>',
);
const foreignParagraph = foreignDom.document.querySelector("p");
const foreignHtml = foreignParagraph.innerHTML;
createJuzhen(TEXT_ONLY).render(foreignParagraph);
assert.equal(foreignParagraph.innerHTML, foreignHtml, "foreign/forged gap is a strict no-op");
assert.equal(countAnchors(foreignParagraph), 0, "foreign/forged gap is never registered");
assert.equal(
foreignParagraph.querySelector("jz-hws").hasAttribute("data-jz-cross-node-atom"),
false,
);
}
});
test("moved IVS ownership: replaced selector parents discard stale text and rebuild", async () =>
{
const ivs = "\u{E0100}";
const methods = [ "textContent", "replaceChildren", "innerHTML" ];
for (const method of methods)
{
const { document } = setupDom(
'<p><strong id="replace-base">\u{20000}</strong>'
+ '<em id="replace-selector">\u{E0100}A</em></p>',
);
const paragraph = document.querySelector("p");
const base = document.getElementById("replace-base");
const selectorParent = document.getElementById("replace-selector");
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
const oldSelector = Array.from(paragraph.querySelector("jz-hws").childNodes)
.find((node) => node.nodeType === 3 && node.data === ivs);
assert.ok(oldSelector, `${method} captures moved selector`);
const replacement = ivs + method[0].toUpperCase();
if (method !== "innerHTML") { jz.observe(document.body); }
if (method === "textContent") { selectorParent.textContent = replacement; }
else if (method === "replaceChildren")
{
selectorParent.replaceChildren(document.createTextNode(replacement));
}
else
{
selectorParent.innerHTML = replacement;
jz.rerender(document.body);
}
if (method !== "innerHTML")
{
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
jz.disconnect();
}
assert.equal(document.getElementById("replace-base"), base, `${method} base identity`);
assert.equal(
document.getElementById("replace-selector"),
selectorParent,
`${method} selector-parent identity`,
);
assert.equal(paragraph.textContent, "\u{20000}" + replacement, `${method} exact author text`);
assert.equal(paragraph.textContent.split(ivs).length - 1, 1, `${method} has one selector`);
assert.equal(oldSelector.isConnected, false, `${method} stale selector is discarded`);
assert.equal(countTag(paragraph, "jz-hws"), 1, `${method} rebuilt gap`);
assert.equal(paragraph.querySelector("jz-hws").textContent, "\u{20000}" + ivs);
assertNoUnpairedSurrogates(paragraph.textContent);
jz.revert(document.body);
assert.equal(
paragraph.innerHTML,
'<strong id="replace-base">\u{20000}</strong>'
+ `<em id="replace-selector">${replacement}</em>`,
`${method} revert preserves only replacement`,
);
}
});
test("moved IVS ownership: empty selector parents never resurrect stale text", async () =>
{
for (const method of [ "textContent", "replaceChildren", "innerHTML" ])
{
const { document } = setupDom(
'<p><strong id="empty-base">\u{20000}</strong>'
+ '<em id="empty-selector">\u{E0100}</em>A</p>',
);
const paragraph = document.querySelector("p");
const base = document.getElementById("empty-base");
const selectorParent = document.getElementById("empty-selector");
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
const oldSelector = Array.from(paragraph.querySelector("jz-hws").childNodes)
.find((node) => node.nodeType === 3 && node.data === "\u{E0100}");
assert.ok(oldSelector, `${method} captures moved selector`);
if (method !== "innerHTML") { jz.observe(document.body); }
if (method === "textContent") { selectorParent.textContent = ""; }
else if (method === "replaceChildren") { selectorParent.replaceChildren(); }
else
{
selectorParent.innerHTML = "";
jz.rerender(document.body);
}
if (method !== "innerHTML")
{
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
jz.disconnect();
}
assert.equal(paragraph.textContent, "\u{20000}A", `${method} preserves author deletion`);
assert.equal(paragraph.textContent.includes("\u{E0100}"), false, `${method} has zero selectors`);
assert.equal(oldSelector.isConnected, false, `${method} stale selector stays discarded`);
assert.equal(document.getElementById("empty-base"), base, `${method} base identity`);
assert.equal(document.getElementById("empty-selector"), selectorParent, `${method} parent identity`);
assert.equal(selectorParent.childNodes.length, 0, `${method} original parent remains empty`);
const gap = paragraph.querySelector("jz-hws");
assert.ok(gap, `${method} rebuilds ordinary base-to-Latin gap`);
assert.equal(gap.textContent, "\u{20000}", `${method} ordinary gap wraps only base`);
assert.equal(
gap.hasAttribute("data-jz-cross-node-atom"),
false,
`${method} ordinary gap is not cross-node`,
);
assertNoUnpairedSurrogates(paragraph.textContent);
jz.revert(document.body);
assert.equal(
paragraph.innerHTML,
'<strong id="empty-base">\u{20000}</strong><em id="empty-selector"></em>A',
`${method} revert keeps selector deletion`,
);
}
});
test("moved IVS ownership: externally moved selector-only anchor restores to original parent", () =>
{
const original = '<strong id="anchor-base">\u{20000}</strong>'
+ '<em id="anchor-selector">\u{E0100}</em>A';
for (const side of [ "common", "base", "original-parent", "external-anchor" ])
{
const { document } = setupDom(`<div id="anchor-outside"></div><p>${original}</p>`);
const paragraph = document.querySelector("p");
const base = document.getElementById("anchor-base");
const selectorParent = document.getElementById("anchor-selector");
const outside = document.getElementById("anchor-outside");
let clicks = 0;
selectorParent.addEventListener("click", () => { clicks += 1; });
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
const walker = document.createTreeWalker(
paragraph,
document.defaultView.NodeFilter.SHOW_COMMENT,
);
let anchor = null;
while (walker.nextNode())
{
if (walker.currentNode.data === "jz-spacing-anchor")
{
anchor = walker.currentNode;
break;
}
}
assert.ok(anchor, `${side} original-side anchor`);
assert.equal(selectorParent.childNodes.length, 1, `${side} selector parent initially has anchor`);
outside.appendChild(anchor);
assert.equal(selectorParent.childNodes.length, 0, `${side} original parent is now empty`);
const root = side === "common" ? paragraph
: side === "base" ? base
: side === "original-parent" ? selectorParent
: outside;
jz.revert(root);
assert.equal(paragraph.innerHTML, original, `${side} exact author DOM`);
assert.equal(paragraph.querySelector("#anchor-base"), base, `${side} base identity`);
assert.equal(paragraph.querySelector("#anchor-selector"), selectorParent, `${side} parent identity`);
assert.equal(selectorParent.textContent, "\u{E0100}", `${side} selector preserved`);
assert.equal(countTag(paragraph, "jz-hws"), 0, `${side} linked gap removed`);
const outsideComments = Array.from(outside.childNodes)
.filter((node) => node.nodeType === 8 && node.data.startsWith("jz-spacing-"));
assert.equal(outsideComments.length, 0, `${side} external parent has no internal comment`);
selectorParent.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clicks, 1, `${side} listener`);
assertNoUnpairedSurrogates(paragraph.textContent);
}
});
test("moved IVS ownership: anchor and text-move throws roll back exactly", () =>
{
const fixture = '<p><strong id="throw-author">\u{20000}</strong><em>\u{E0100}A</em></p>';
const modes = [ "original-anchor", "gap-anchor", "selector" ];
for (const mode of modes)
{
for (const throwAfterNative of [ false, true ])
{
const { document } = setupDom(fixture);
const paragraph = document.querySelector("p");
const author = document.getElementById("throw-author");
const nodePrototype = document.defaultView.Node.prototype;
const insertBefore = nodePrototype.insertBefore;
const appendChild = nodePrototype.appendChild;
const error = new Error(`${mode}-${throwAfterNative ? "after" : "before"}`);
let thrown = false;
const shouldThrow = (node) => mode === "original-anchor"
? node.nodeType === 8 && node.data === "jz-spacing-anchor"
: mode === "gap-anchor"
? node.nodeType === 8 && node.data === "jz-spacing-gap-anchor"
: node.nodeType === 3 && node.data === "\u{E0100}";
nodePrototype.insertBefore = function (node, reference)
{
if (!thrown && shouldThrow(node) && mode === "original-anchor")
{
thrown = true;
if (!throwAfterNative) { throw error; }
const result = insertBefore.call(this, node, reference);
throw error;
return result;
}
return insertBefore.call(this, node, reference);
};
nodePrototype.appendChild = function (node)
{
if (!thrown && shouldThrow(node) && mode !== "original-anchor")
{
thrown = true;
if (!throwAfterNative) { throw error; }
const result = appendChild.call(this, node);
throw error;
return result;
}
return appendChild.call(this, node);
};
const jz = createJuzhen(TEXT_ONLY);
try { assert.throws(() => jz.render(paragraph), (got) => got === error); }
finally
{
nodePrototype.insertBefore = insertBefore;
nodePrototype.appendChild = appendChild;
}
assert.equal(thrown, true, `${mode} ${throwAfterNative} exercised`);
assert.equal(paragraph.innerHTML, fixture.slice(3, -4), `${mode} exact rollback`);
assert.equal(countTag(paragraph, "jz-hws"), 0);
assert.equal(
Array.from(paragraph.childNodes).filter((node) => node.nodeType === 8).length,
0,
`${mode} leaves no direct anchor`,
);
assert.equal(document.getElementById("throw-author"), author);
jz.render(paragraph);
assert.equal(countTag(paragraph, "jz-hws"), 1, `${mode} retry`);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, fixture.slice(3, -4), `${mode} retry/revert`);
}
}
});
test("moved IVS ownership: partial roots and detached links restore as one unit", () =>
{
const original = '<strong id="partial-base">\u{20000}</strong>'
+ '<em id="partial-selector">\u{E0100}A</em>';
const cases = [ "common", "anchor-side", "base-side", "detached-gap", "moved-anchor", "detached" ];
for (const kind of cases)
{
const { document } = setupDom(
`<div id="outside"></div><div id="partial-container"><p>${original}</p></div>`,
);
const paragraph = document.querySelector("p");
const container = document.getElementById("partial-container");
const base = document.getElementById("partial-base");
const selectorParent = document.getElementById("partial-selector");
const outside = document.getElementById("outside");
let clicks = 0;
base.addEventListener("click", () => { clicks += 1; });
const jz = createJuzhen(TEXT_ONLY);
jz.render(kind === "detached" ? paragraph : document.body);
const gap = paragraph.querySelector("jz-hws");
const anchorWalker = document.createTreeWalker(
paragraph,
document.defaultView.NodeFilter.SHOW_COMMENT,
);
let anchor = null;
while (anchorWalker.nextNode())
{
if (anchorWalker.currentNode.data === "jz-spacing-anchor")
{
anchor = anchorWalker.currentNode;
break;
}
}
assert.ok(anchor, `${kind} anchor exists`);
let revertRoot = paragraph;
if (kind === "anchor-side") { revertRoot = selectorParent; }
else if (kind === "base-side") { revertRoot = base; }
else if (kind === "detached-gap")
{
gap.parentNode.removeChild(gap);
revertRoot = selectorParent;
}
else if (kind === "moved-anchor")
{
outside.appendChild(anchor);
revertRoot = base;
}
else if (kind === "detached")
{
paragraph.parentNode.removeChild(paragraph);
revertRoot = container;
}
jz.revert(revertRoot);
assert.equal(paragraph.innerHTML, original, `${kind} restores exact author DOM`);
assert.equal(paragraph.querySelector("#partial-base"), base, `${kind} base identity`);
assert.equal(paragraph.querySelector("#partial-selector"), selectorParent, `${kind} parent identity`);
assert.equal(selectorParent.textContent, "\u{E0100}A", `${kind} selector original parent`);
assert.equal(countTag(paragraph, "jz-hws"), 0, `${kind} removes linked gap`);
assert.equal(
Array.from(outside.childNodes).filter((node) => node.nodeType === 8).length,
0,
`${kind} never follows anchor to arbitrary parent`,
);
base.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clicks, 1, `${kind} listener`);
assertNoUnpairedSurrogates(paragraph.textContent);
if (kind === "detached")
{
container.appendChild(paragraph);
jz.render(paragraph);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, original, "detached record was cleaned before reuse");
}
}
});
test("IVS spacing survives orphan reparenting without replacing author elements", () =>
{
const samples = [
[
"ivs-forward-space",
'<strong id="orphan-author-ivs-forward">甲\u{20000}</strong>\u{E0100} A',
'<strong id="orphan-author-ivs-forward">甲\u{20000}</strong>\u{E0100}A',
"\u{20000}\u{E0100}",
true,
],
[
"bmp-forward",
'<strong id="orphan-author-bmp-forward">甲中</strong>\uFE00A',
'<strong id="orphan-author-bmp-forward">甲中</strong>\uFE00A',
"中\uFE00",
true,
],
[
"bmp-forward-nested-carrier",
'<strong id="orphan-author-bmp-nested-base">甲中</strong>'
+ '<em id="orphan-author-bmp-nested-vs">\uFE00 A</em>',
'<strong id="orphan-author-bmp-nested-base">甲中</strong>'
+ '<em id="orphan-author-bmp-nested-vs">\uFE00A</em>',
"中\uFE00",
true,
],
[
"ivs-reverse-space",
'A <strong id="orphan-author-ivs-reverse">\u{20000}</strong>\u{E0100}乙',
'A<strong id="orphan-author-ivs-reverse">\u{20000}</strong>\u{E0100}乙',
"A",
false,
],
[
"bmp-reverse",
'A<strong id="orphan-author-bmp-reverse">中</strong>\uFE00乙',
'A<strong id="orphan-author-bmp-reverse">中</strong>\uFE00乙',
"A",
false,
],
];
const { document } = setupDom(samples.map(([ id, html ]) => `<p id="${id}">${html}</p>`).join(""));
const authors = Array.from(document.querySelectorAll('[id^="orphan-author-"]'));
const clickCounts = new Map(authors.map((el) => [ el, 0 ]));
for (const author of authors)
{
author.addEventListener("click", () => clickCounts.set(author, clickCounts.get(author) + 1));
}
const jz = createJuzhen({ jiya: false, jinze: false, longWord: false });
jz.render(document.body);
for (const [ id, , normalized, carrierText, insideOrphan ] of samples)
{
const paragraph = document.getElementById(id);
const gap = paragraph.querySelector("jz-hws");
const orphan = paragraph.querySelector("jz-orphan");
assert.ok(gap, `${id} gap survives orphan`);
assert.ok(orphan, `${id} orphan is active`);
assert.equal(gap.textContent, carrierText, `${id} carrier text`);
assert.equal(orphan.contains(gap), insideOrphan, `${id} carrier placement`);
if (insideOrphan)
{
assert.equal(gap.getAttribute("data-jz-kind"), "wrap", `${id} uses atomic wrapper`);
}
assert.equal(paragraph.textContent, setupDom(`<p>${normalized}</p>`).document.body.textContent, id);
assertNoUnpairedSurrogates(paragraph.textContent);
}
for (const author of authors)
{
assert.equal(document.getElementById(author.id), author, `${author.id} identity`);
assert.equal(document.querySelectorAll(`[id="${author.id}"]`).length, 1, `${author.id} unique`);
author.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clickCounts.get(author), 1, `${author.id} listener`);
}
jz.render(document.body);
for (const [ id ] of samples)
{
assert.equal(countTag(document.getElementById(id), "jz-hws"), 1, `${id} repeated render`);
}
jz.revert(document.body);
for (const [ id, , normalized ] of samples)
{
const paragraph = document.getElementById(id);
assert.equal(paragraph.innerHTML, normalized, id);
assertNoUnpairedSurrogates(paragraph.textContent);
}
for (const author of authors)
{
assert.equal(document.getElementById(author.id), author, `${author.id} revert identity`);
author.dispatchEvent(new document.defaultView.Event("click"));
assert.equal(clickCounts.get(author), 2, `${author.id} revert listener`);
}
});
test("IVS spacing: pill neighbors use complete sequences and UTF-16 offsets", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(
`<p id="left">${ivs} <code>x</code></p>`
+ `<p id="right"><code>x</code> ${ivs}</p>`,
);
const left = document.getElementById("left");
const right = document.getElementById("right");
const jz = createJuzhen(TEXT_ONLY);
jz.render(document.body);
assert.equal(left.textContent, ivs + "x", "author space before pill is removed without splitting IVS");
assert.equal(right.textContent, "x" + ivs, "author space after pill is removed without splitting IVS");
assert.equal(left.querySelector("jz-hws").textContent, ivs);
assert.equal(countTag(left, "jz-hws"), 1);
assert.equal(countTag(right, "jz-hws"), 1);
assertNoUnpairedSurrogates(document.body.textContent);
jz.revert(document.body);
assert.equal(left.textContent, ivs + "x");
assert.equal(right.textContent, "x" + ivs);
assertNoUnpairedSurrogates(document.body.textContent);
});
test("IVS jinze: opening and closing punctuation bind one complete variation sequence", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(
`<p id="open" lang="zh-Hant">「${ivs}A</p>`
+ `<p id="close" lang="zh-Hant">A${ivs}」</p>`,
);
const open = document.getElementById("open");
const close = document.getElementById("close");
const originalOpen = open.innerHTML;
const originalClose = close.innerHTML;
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
jz.render(document.body);
assert.equal(open.querySelector("jz-jinze").textContent, "「" + ivs);
assert.equal(close.querySelector("jz-jinze").textContent, ivs + "」");
assert.equal(open.textContent, "「" + ivs + "A");
assert.equal(close.textContent, "A" + ivs + "」");
assertNoUnpairedSurrogates(document.body.textContent);
jz.revert(document.body);
assert.equal(open.innerHTML, originalOpen);
assert.equal(close.innerHTML, originalClose);
});
test("IVS splitoff: transparent inline splits after the full variation sequence", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(`<p lang="zh-Hant">「<strong>${ivs}A</strong></p>`);
const paragraph = document.querySelector("p");
const originalHtml = paragraph.innerHTML;
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
jz.render(paragraph);
assert.equal(paragraph.querySelector("jz-jinze").textContent, "「" + ivs);
const splitoff = paragraph.querySelector('strong[data-jz-kind="splitoff"]');
assert.ok(splitoff);
assert.equal(splitoff.textContent, "A");
assert.equal(paragraph.textContent, "「" + ivs + "A");
assertNoUnpairedSurrogates(paragraph.textContent);
jz.revert(paragraph);
assert.equal(paragraph.innerHTML, originalHtml);
});
test("IVS orphan: base counts once, selector counts zero, and split keeps the sequence", () =>
{
const ivs = "\u{20000}\u{E0100}";
const { document } = setupDom(`<p id="enough">A${ivs}甲</p><p id="short">A${ivs}</p>`);
const enough = document.getElementById("enough");
const short = document.getElementById("short");
const enoughHtml = enough.innerHTML;
const shortHtml = short.innerHTML;
const jz = createJuzhen({ ...TEXT_ONLY, orphan: { chars: 2 }, spacing: false });
jz.render(document.body);
assert.equal(enough.querySelector("jz-orphan").textContent, ivs + "甲");
assert.equal(short.querySelector("jz-orphan"), null, "one base plus VS is only one substantive CJK");
assertNoUnpairedSurrogates(document.body.textContent);
jz.revert(document.body);
assert.equal(enough.innerHTML, enoughHtml);
assert.equal(short.innerHTML, shortHtml);
});
test("variation selectors: punctuation suffix is atomic and selectors without a base are not CJK", () =>
{
const punctuation = setupDom('<p lang="zh-Hant">,\uFE00A</p>');
const jz = createJuzhen({ spacing: false, jinze: false, longWord: false, orphan: false });
jz.render(punctuation.document.body);
const char = punctuation.document.querySelector("jz-char.bd-pause");
assert.ok(char);
assert.equal(char.textContent, ",\uFE00");
assert.equal(countTag(punctuation.document.body, "jz-char"), 1);
jz.revert(punctuation.document.body);
assert.equal(punctuation.document.body.textContent, ",\uFE00A");
const standalone = setupDom('<p id="bmp">\uFE00A</p><p id="supp">\u{E0100}A</p>');
createJuzhen(TEXT_ONLY).render(standalone.document.body);
assert.equal(countTag(standalone.document.getElementById("bmp"), "jz-hws"), 0);
assert.equal(countTag(standalone.document.getElementById("supp"), "jz-hws"), 0);
});
test("charClass append remains additive for BMP and astral variation sequences", () =>
{
const base = setupDom('<p id="bmp">〇A</p><p id="astral">😀\u{E0100}A</p>');
createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "〇😀" } }).render(base.document.body);
assert.equal(countTag(base.document.getElementById("bmp"), "jz-hws"), 1);
assert.equal(countTag(base.document.getElementById("astral"), "jz-hws"), 1);
const punctuation = setupDom('<p lang="zh-Hant">A😀B</p>');
const jz = createJuzhen({
spacing: false,
jinze: false,
longWord: false,
orphan: false,
charClass: { biaodian: { pause: "😀" } },
});
jz.render(punctuation.document.body);
const char = punctuation.document.querySelector("jz-char.bd-pause");
assert.ok(char, "astral custom punctuation is charified once");
assert.equal(char.textContent, "😀");
assert.equal(countTag(punctuation.document.body, "jz-char"), 1);
assertNoUnpairedSurrogates(punctuation.document.body.textContent);
});
+732
View File
@@ -0,0 +1,732 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createJuzhen } from "../dist/juzhen.mjs";
import { setupDom } from "./helpers.mjs";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
function installCountingMutationObserver(
maxCallbacks = Number.POSITIVE_INFINITY,
{ deferDelivery = false, wrapRecords = (records) => records } = {},
)
{
const NativeMutationObserver = globalThis.MutationObserver;
const instances = [];
class CountingMutationObserver
{
constructor(callback)
{
this.callback = callback;
this.observeCount = 0;
this.disconnectCount = 0;
this.callbackCount = 0;
this.pendingRecords = [];
this.suppressed = false;
this.inner = new NativeMutationObserver((records) =>
{
if (deferDelivery) { this.pendingRecords.push(...records); }
else { this.deliver(records); }
});
instances.push(this);
}
deliver(records)
{
this.callbackCount += 1;
if (this.callbackCount > maxCallbacks)
{
this.suppressed = true;
this.inner.disconnect();
return;
}
this.callback(wrapRecords(records), this);
}
observe(target, options)
{
this.observeCount += 1;
if (!this.suppressed) { this.inner.observe(target, options); }
}
disconnect()
{
this.disconnectCount += 1;
this.inner.disconnect();
}
takeRecords()
{
return this.inner.takeRecords();
}
}
globalThis.MutationObserver = CountingMutationObserver;
return {
instances,
restore()
{
globalThis.MutationObserver = NativeMutationObserver;
},
};
}
function installExpectationShiftProbe()
{
const nativeShift = Array.prototype.shift;
let calls = 0;
let work = 0;
Array.prototype.shift = function ()
{
const head = this[0];
if (head && typeof head === "object" && "target" in head
&& "oldValue" in head && "newValue" in head)
{
calls += 1;
work += this.length;
}
return nativeShift.call(this);
};
return {
result: () => ({ calls, work }),
restore()
{
Array.prototype.shift = nativeShift;
},
};
}
function installExpectationSpliceProbe()
{
const nativeSplice = Array.prototype.splice;
let calls = 0;
let removed = 0;
Array.prototype.splice = function (...args)
{
const [ start, deleteCount ] = args;
const head = this[0];
if (start === 0 && head && typeof head === "object" && "target" in head
&& "oldValue" in head && "newValue" in head)
{
calls += 1;
removed += deleteCount;
}
return nativeSplice.apply(this, args);
};
return {
result: () => ({ calls, removed }),
restore()
{
Array.prototype.splice = nativeSplice;
},
};
}
function installLayoutHarness(document, withFonts = false)
{
const NativeResizeObserver = globalThis.ResizeObserver;
const originalGetClientRects = window.Element.prototype.getClientRects;
const instances = [];
let rectsEnabled = true;
class CapturingResizeObserver
{
constructor(callback)
{
this.callback = callback;
this.target = null;
this.disconnected = false;
instances.push(this);
}
observe(target)
{
this.target = target;
}
disconnect()
{
this.disconnected = true;
}
fire()
{
this.callback([ { target: this.target } ], this);
}
}
globalThis.ResizeObserver = CapturingResizeObserver;
window.Element.prototype.getClientRects = function ()
{
if (!rectsEnabled) { return []; }
return [ {
bottom: 16, height: 16, left: 0, right: 16,
top: 0, width: 16, x: 0, y: 0,
} ];
};
if (withFonts)
{
Object.defineProperty(document, "fonts", {
configurable: true,
value: { ready: Promise.resolve() },
});
}
return {
instances,
setRectsEnabled(value)
{
rectsEnabled = value;
},
restore()
{
window.Element.prototype.getClientRects = originalGetClientRects;
if (NativeResizeObserver === undefined) { delete globalThis.ResizeObserver; }
else { globalThis.ResizeObserver = NativeResizeObserver; }
},
};
}
test("observer recovery: synchronous render failure restores later observation", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">原文,內容。</p></div>');
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
const expected = new Error("isSkipped render failure");
let throwNext = false;
const jz = createJuzhen({
isSkipped()
{
if (!throwNext) { return false; }
throwNext = false;
throw expected;
},
});
jz.render(root);
jz.observe(root);
jz.invalidate(root);
throwNext = true;
assert.throws(() => jz.render(root), (error) => error === expected);
paragraph.textContent = "錯誤後,仍可繼續觀察。";
await tick();
await tick();
jz.disconnect();
assert.ok(paragraph.querySelector("jz-char"), "之後的 DOM 變化仍自動處理");
assert.equal(paragraph.textContent, "錯誤後,仍可繼續觀察。");
});
test("observer recovery: mutation callback failure restores a later mutation", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">原文,內容。</p></div>');
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
const expected = new Error("isSkipped mutation failure");
const reported = [];
let throwNext = false;
const onError = (event) =>
{
reported.push(event.error);
event.preventDefault();
};
window.addEventListener("error", onError);
const jz = createJuzhen({
isSkipped()
{
if (!throwNext) { return false; }
throwNext = false;
throw expected;
},
});
try
{
jz.render(root);
jz.observe(root);
jz.invalidate(root);
throwNext = true;
paragraph.textContent = "第一次,觸發錯誤。";
await tick();
assert.ok(reported.includes(expected), "MutationObserver 回調錯誤仍照常對外呈現");
paragraph.textContent = "第二次,恢復處理。";
await tick();
await tick();
assert.ok(paragraph.querySelector("jz-char"), "後續變化仍被觀察");
assert.equal(paragraph.textContent, "第二次,恢復處理。");
}
finally
{
jz.disconnect();
window.removeEventListener("error", onError);
}
});
test("observer depth: nested render does not resume or observe partial outer changes", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">嵌套,渲染。</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
let reenter = false;
let jz;
try
{
jz = createJuzhen({
isSkipped()
{
if (reenter)
{
reenter = false;
jz.render(paragraph);
}
return false;
},
});
jz.render(root);
jz.observe(root);
assert.equal(counting.instances.length, 1);
jz.invalidate(root);
reenter = true;
jz.rerender(root);
const observer = counting.instances[0];
assert.equal(observer.disconnectCount, 1, "只有最外層 0→1 暫停會 disconnect");
assert.equal(observer.observeCount, 2, "只有最外層 1→0 恢復會 observe");
await tick();
await tick();
assert.equal(observer.callbackCount, 0, "嵌套處理不會觀察半成品或自觸發");
assert.ok(paragraph.querySelector("jz-char"));
}
finally
{
if (jz) { jz.disconnect(); }
counting.restore();
}
});
test("observer depth: observe replacement inside a paused render is deferred coherently", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">重建,觀察。</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
let replaceObserver = false;
let observeCallsDuringPause = -1;
let jz;
try
{
jz = createJuzhen({
isSkipped()
{
if (replaceObserver)
{
replaceObserver = false;
jz.disconnect();
jz.observe(root);
observeCallsDuringPause = counting.instances.reduce(
(sum, observer) => sum + observer.observeCount, 0,
);
}
return false;
},
});
jz.render(root);
jz.observe(root);
jz.invalidate(root);
replaceObserver = true;
jz.rerender(root);
assert.equal(counting.instances.length, 2, "嵌套 observe 建立新觀察者");
assert.equal(observeCallsDuringPause, 1, "暫停期內新觀察者尚未 observe");
assert.equal(
counting.instances.reduce((sum, observer) => sum + observer.observeCount, 0),
2,
"最外層結束後新觀察者恰恢復一次",
);
paragraph.textContent = "重建後,仍能處理。";
await tick();
await tick();
assert.ok(paragraph.querySelector("jz-char"));
assert.equal(counting.instances[1].callbackCount, 1, "新觀察者接手後續變化");
}
finally
{
if (jz) { jz.disconnect(); }
counting.restore();
}
});
test("observer provenance: asynchronous internal layout classes do not self-trigger", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">(版面回調</p></div>');
const counting = installCountingMutationObserver(8);
const layout = installLayoutHarness(document, true);
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
let jz;
try
{
jz = createJuzhen({ spacing: false, orphan: false });
jz.render(root);
jz.observe(root);
await tick();
await tick();
const observer = counting.instances[0];
assert.equal(layout.instances.length, 1, "未因內部 class 記錄重建 layout observer");
assert.equal(observer.callbackCount, 1, "同批 remove/add provenance 被精確消費");
assert.equal(observer.suppressed, false, "防掛上限未被觸發");
assert.ok(paragraph.querySelector("jz-char.jz-half-le"), "異步行端結果保持");
}
finally
{
if (jz) { jz.disconnect(); }
layout.restore();
counting.restore();
}
});
test("observer provenance: author whitelist attributes still trigger rerender", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">作者,屬性。</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const paragraph = root.querySelector("p");
const jz = createJuzhen();
try
{
jz.render(root);
jz.observe(root);
paragraph.classList.add("consumer-class");
await tick();
assert.equal(counting.instances[0].callbackCount, 1, "作者 class 觸發 observer");
assert.ok(paragraph.querySelector("jz-char"));
paragraph.setAttribute("data-jz-skip", "");
await tick();
assert.equal(counting.instances[0].callbackCount, 2, "作者非 class 白名單屬性亦觸發");
assert.equal(paragraph.querySelectorAll("[data-jz]").length, 0, "skip 重分類生效");
}
finally
{
jz.disconnect();
counting.restore();
}
});
test("observer provenance: external writes on owned products are not consumed", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hans">前文<strong>粗体文字</strong>,后续</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
const atom = root.querySelector("jz-char");
atom.classList.add("consumer-class");
await tick();
assert.equal(counting.instances[0].callbackCount, 1, "owned 元素外部 class 觸發");
assert.equal(atom.isConnected, false, "外部 class 變更進入 rerender");
const nextAtom = root.querySelector("jz-char");
nextAtom.setAttribute("lang", "en");
await tick();
assert.equal(counting.instances[0].callbackCount, 2, "owned 元素外部 lang 觸發");
assert.equal(nextAtom.isConnected, false);
const splitoff = root.querySelector('[data-jz-kind="splitoff"]');
splitoff.classList.add("consumer-splitoff");
await tick();
assert.equal(counting.instances[0].callbackCount, 3, "owned splitoff 外部 class 觸發");
assert.equal(splitoff.isConnected, false);
}
finally
{
jz.disconnect();
counting.restore();
}
});
test("observer provenance: mixed internal and external class records stay distinguishable", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">(混合記錄</p></div>');
const counting = installCountingMutationObserver();
const layout = installLayoutHarness(document);
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
const atom = root.querySelector("jz-char");
atom.classList.add("consumer-before-internal");
layout.instances[0].fire();
await tick();
assert.equal(counting.instances[0].callbackCount, 1, "同批記錄只派發一次");
assert.equal(atom.isConnected, false, "外部 mismatch 後之內部 transition 仍精確消費");
}
finally
{
jz.disconnect();
layout.restore();
counting.restore();
}
});
test(
"observer provenance: threshold compaction preserves a 1024-record tail across callbacks",
{ timeout: 20000 },
async () =>
{
const size = 2048;
const html = Array.from({ length: size }, (_, index) => `<p>(${index}</p>`).join("");
const { document } = setupDom(`<div id="root" lang="zh-Hant">${html}</div>`);
const counting = installCountingMutationObserver(4, { deferDelivery: true });
const layout = installLayoutHarness(document);
const spliceProbe = installExpectationSpliceProbe();
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
layout.setRectsEnabled(false);
layout.instances[0].fire();
await tick();
const observer = counting.instances[0];
const records = observer.pendingRecords.splice(0);
assert.equal(records.length, size, "每個 target 各排入一條 internal remove");
observer.deliver(records.slice(0, 1024));
assert.deepEqual(spliceProbe.result(), { calls: 1, removed: 1024 });
assert.equal(layout.instances.length, 1, "首批觸發 threshold compaction 而不 rerender");
observer.deliver(records.slice(1024));
assert.equal(observer.callbackCount, 2, "第二批由壓縮後 deque 繼續消費");
assert.equal(layout.instances.length, 1, "尾批全消費後 head/reset 正確");
assert.equal(root.querySelectorAll("jz-char.jz-half-le").length, 0);
}
finally
{
jz.disconnect();
spliceProbe.restore();
layout.restore();
counting.restore();
}
},
);
test(
"observer provenance: callback failure compacts the consumed prefix and preserves its tail",
{ timeout: 15000 },
async () =>
{
const size = 1025;
const html = Array.from({ length: size }, (_, index) => `<p>(${index}</p>`).join("");
const { document } = setupDom(`<div id="root" lang="zh-Hant">${html}</div>`);
const counting = installCountingMutationObserver(4, { deferDelivery: true });
const layout = installLayoutHarness(document);
const spliceProbe = installExpectationSpliceProbe();
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
const expected = new Error("mid-callback classification failure");
const throwingRecord = {
type: "characterData",
get target()
{
throw expected;
},
};
try
{
jz.render(root);
jz.observe(root);
layout.setRectsEnabled(false);
layout.instances[0].fire();
await tick();
const observer = counting.instances[0];
const records = observer.pendingRecords.splice(0);
assert.equal(records.length, size);
assert.throws(
() => observer.deliver([ ...records.slice(0, 1024), throwingRecord ]),
(error) => error === expected,
);
assert.deepEqual(
spliceProbe.result(),
{ calls: 1, removed: 1024 },
"拋錯 callback 之 finally 立即壓縮已消費前綴",
);
assert.equal(layout.instances.length, 1, "finally 壓縮已消費前綴而不 rerender");
observer.deliver([ records[1024] ]);
assert.equal(layout.instances.length, 1, "下一 callback 精確消費保留之 tail");
const atom = root.querySelector("jz-char");
atom.classList.add("consumer-after-callback-error");
await tick();
assert.equal(observer.pendingRecords.length, 1, "外部 class 排入獨立 record");
observer.deliver(observer.pendingRecords.splice(0));
assert.equal(observer.callbackCount, 3, "錯誤、tail、外部 record 各交付一次");
assert.equal(atom.isConnected, false, "外部 record 未被殘留 expectation 誤吞");
}
finally
{
jz.disconnect();
spliceProbe.restore();
layout.restore();
counting.restore();
}
},
);
test("observer provenance: foreign-instance product changes still trigger", async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">外來,產物。</p></div>');
const foreign = createJuzhen();
const root = document.getElementById("root");
foreign.render(root);
const atom = root.querySelector("jz-char");
const counting = installCountingMutationObserver();
const local = createJuzhen();
try
{
local.observe(root);
atom.classList.add("foreign-change");
await tick();
assert.equal(counting.instances[0].callbackCount, 1, "外來實例無本地 provenance");
}
finally
{
local.disconnect();
counting.restore();
}
});
for (const mutationKind of [ "childList", "characterData" ])
{
test(`observer provenance: owned ${mutationKind} changes still trigger`, async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">內容,變更。</p></div>');
const counting = installCountingMutationObserver();
const root = document.getElementById("root");
const jz = createJuzhen();
try
{
jz.render(root);
jz.observe(root);
const atom = root.querySelector("jz-char");
if (mutationKind === "childList") { atom.append("外"); }
else
{
const text = document.createTreeWalker(atom, NodeFilter.SHOW_TEXT).nextNode();
text.data += "外";
}
await tick();
assert.equal(counting.instances[0].callbackCount, 1);
assert.equal(atom.isConnected, false, `${mutationKind} 進入 rerender`);
}
finally
{
jz.disconnect();
counting.restore();
}
});
}
for (const lifecycle of [ "pause", "replace", "disconnect" ])
{
test(`observer provenance: ${lifecycle} clears stale expectations`, async () =>
{
const { document } = setupDom('<div id="root"><p lang="zh-Hant">(生命週期</p></div>');
const counting = installCountingMutationObserver();
const layout = installLayoutHarness(document);
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
layout.instances[0].fire();
if (lifecycle === "pause") { jz.rerender(root); }
else if (lifecycle === "replace") { jz.observe(root); }
else { jz.disconnect(); jz.observe(root); }
const activeObserver = counting.instances[counting.instances.length - 1];
root.querySelector("jz-char").classList.add("consumer-after-lifecycle");
await tick();
assert.equal(activeObserver.callbackCount, 1, "外部 class 未被陳舊 provenance 吞掉");
}
finally
{
jz.disconnect();
layout.restore();
counting.restore();
}
});
}
test("observer provenance: transition inference and queue consumption scale linearly", async (context) =>
{
const reports = [];
for (const size of [ 500, 1000, 2000 ])
{
const html = Array.from({ length: size }, (_, index) => `<p>(${index}</p>`).join("");
const { document } = setupDom(`<div id="root" lang="zh-Hant">${html}</div>`);
let recordAccesses = 0;
let recordCount = 0;
const counting = installCountingMutationObserver(Number.POSITIVE_INFINITY, {
wrapRecords(records)
{
recordCount += records.length;
return records.map((record) => new Proxy({}, {
get(_target, property)
{
recordAccesses += 1;
return record[property];
},
}));
},
});
const layout = installLayoutHarness(document);
const shiftProbe = installExpectationShiftProbe();
const root = document.getElementById("root");
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
try
{
jz.render(root);
jz.observe(root);
assert.equal(root.querySelectorAll("jz-char.jz-half-le").length, size);
layout.instances[0].fire();
await tick();
const shift = shiftProbe.result();
assert.equal(recordCount, size * 2, "每 target 恰有 remove/add 兩條 transition");
const report = { size, recordCount, recordAccesses, queueShiftWork: shift.work };
context.diagnostic("linear probe " + JSON.stringify(report));
assert.ok(
recordAccesses <= recordCount * 12 && shift.calls === 0 && shift.work === 0,
"transition 推斷與 queue 消費保持線性:" + JSON.stringify(report),
);
assert.equal(layout.instances.length, 1, "規模回放沒有自觸發 rerender");
reports.push(report);
}
finally
{
jz.disconnect();
shiftProbe.restore();
layout.restore();
counting.restore();
}
}
context.diagnostic("linear probes complete " + JSON.stringify(reports));
});
+273
View File
@@ -0,0 +1,273 @@
// createJuzhen 實例所有權:revert 只得復原本實例產物。
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
test("所有權:作者 data-jz 結構不可作為 revert 憑證", () =>
{
const html = '<section data-jz="spacing"><jz-hws data-jz="spacing" '
+ 'data-jz-kind="wrap">author</jz-hws></section>';
const { document } = setupDom(html);
const before = document.body.innerHTML;
createJuzhen().revert(document.body);
assert.equal(document.body.innerHTML, before);
});
test("所有權:實例 B 不得還原實例 A 之結構或 root 屬性", () =>
{
const { document } = setupDom('<p lang="zh-Hans">用Neovim寫,很好。</p>');
const before = document.body.innerHTML;
const a = createJuzhen({ jiya: { halfWidth: "margin" }, justifyAtoms: false });
const b = createJuzhen();
a.render(document.body);
const rendered = document.body.innerHTML;
assert.equal(document.body.getAttribute("data-jz-halfwidth"), "margin");
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
b.revert(document.body);
assert.equal(document.body.innerHTML, rendered);
assert.equal(document.body.getAttribute("data-jz-halfwidth"), "margin");
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
a.revert(document.body);
assert.equal(document.body.innerHTML, before);
});
test("所有權:兩實例同值屬性之早到者先 revert 不移除後到者聲明", () =>
{
const { document } = setupDom('<p lang="zh-Hans">純文字</p>');
const opts = {
spacing: false,
jiya: { halfWidth: "margin" },
jinze: false,
orphan: false,
longWord: false,
justifyAtoms: false,
};
const a = createJuzhen(opts);
const b = createJuzhen(opts);
a.render(document.body);
b.render(document.body);
a.revert(document.body);
assert.equal(document.body.getAttribute("data-jz-halfwidth"), "margin");
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
assert.equal(document.querySelector("p").getAttribute("data-jz-block-level"), "paragraph");
b.revert(document.body);
assert.equal(document.body.hasAttribute("data-jz-halfwidth"), false);
assert.equal(document.body.hasAttribute("data-jz-atoms"), false);
assert.equal(document.querySelector("p").hasAttribute("data-jz-block-level"), false);
});
test("所有權:屬性寫者亂序 revert 還原最近活躍值或原值", () =>
{
const options = {
spacing: false, jiya: false, jinze: false, orphan: false, longWord: false,
};
const first = setupDom("<p>純文字</p>");
const a = createJuzhen(options);
const b = createJuzhen({ ...options, level: { text: "p" } });
a.render(first.document.body);
b.render(first.document.body);
a.revert(first.document.body);
assert.equal(first.document.querySelector("p").getAttribute("data-jz-block-level"), "text");
b.revert(first.document.body);
assert.equal(first.document.querySelector("p").hasAttribute("data-jz-block-level"), false);
const second = setupDom("<p>純文字</p>");
const c = createJuzhen(options);
const d = createJuzhen({ ...options, level: { text: "p" } });
c.render(second.document.body);
d.render(second.document.body);
d.revert(second.document.body);
assert.equal(second.document.querySelector("p").getAttribute("data-jz-block-level"), "paragraph");
c.revert(second.document.body);
assert.equal(second.document.querySelector("p").hasAttribute("data-jz-block-level"), false);
});
test("所有權:外部改寫屬性後 revert fail closed,新寫者可以當前值重建基線", () =>
{
const options = {
spacing: false, jiya: false, jinze: false, orphan: false, longWord: false,
justifyAtoms: false,
};
const { document } = setupDom("<p>純文字</p>");
const a = createJuzhen(options);
a.render(document.body);
document.body.setAttribute("data-jz-atoms", "external");
document.querySelector("p").removeAttribute("data-jz-block-level");
const b = createJuzhen(options);
b.render(document.body);
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
assert.equal(document.querySelector("p").getAttribute("data-jz-block-level"), "paragraph");
a.revert(document.body);
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline");
assert.equal(document.querySelector("p").getAttribute("data-jz-block-level"), "paragraph");
b.revert(document.body);
assert.equal(document.body.getAttribute("data-jz-atoms"), "external");
assert.equal(document.querySelector("p").hasAttribute("data-jz-block-level"), false);
});
test("所有權:子根 render 後對祖先 revert 完整清理", () =>
{
const { document } = setupDom('<div><p lang="zh-Hans">用Neovim寫,很好。</p></div>');
const before = document.body.innerHTML;
const p = document.querySelector("p");
const jz = createJuzhen({ jiya: { halfWidth: "margin" }, justifyAtoms: false });
jz.render(p);
assert.equal(p.getAttribute("data-jz-halfwidth"), "margin");
assert.ok(p.querySelector("[data-jz]"));
jz.revert(document.body);
assert.equal(document.body.innerHTML, before);
});
test("所有權:本實例完整還原 wrapper 與 splitoff", () =>
{
const html = '<p lang="zh-Hans">前文<strong>粗体文字</strong>,后续</p>';
const { document } = setupDom(html);
const before = document.body.innerHTML;
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
jz.render(document.body);
assert.ok(document.querySelector('[data-jz-kind="wrap"]'));
assert.ok(document.querySelector('[data-jz-kind="splitoff"]'));
jz.revert(document.body);
assert.equal(document.body.innerHTML, before);
});
test("所有權:已有實例重複 render 仍冪等", () =>
{
const { document } = setupDom('<p lang="zh-Hans">用Neovim寫,很好。</p>');
const jz = createJuzhen();
jz.render(document.body);
const once = document.body.innerHTML;
jz.render(document.body);
assert.equal(document.body.innerHTML, once);
});
test("所有權:已還原 wrapper 之舊憑證即時撤銷", () =>
{
const { document } = setupDom("<p>中A文</p>");
const jz = createJuzhen({ jiya: false, longWord: false, jinze: false, orphan: false });
jz.render(document.body);
const gap = document.querySelector("jz-hws");
assert.ok(gap);
jz.revert(document.body);
document.querySelector("p").appendChild(gap);
const stale = gap.outerHTML;
jz.revert(document.body);
assert.ok(document.body.contains(gap));
assert.equal(gap.outerHTML, stale);
});
test("所有權:已知 tag 改成無生產者之 marker kind 亦 fail closed", () =>
{
const { document } = setupDom("<p>中A文</p>");
const jz = createJuzhen({ jiya: false, longWord: false, jinze: false, orphan: false });
jz.render(document.body);
const gap = document.querySelector("jz-hws");
assert.ok(gap);
gap.setAttribute("data-jz-kind", "marker");
const malformed = gap.outerHTML;
jz.revert(document.body);
assert.ok(document.body.contains(gap));
assert.equal(gap.outerHTML, malformed);
});
test("所有權:observe 移除獨立 render 根時清理脫離子樹與佈局觀察者", async () =>
{
const previousResizeObserver = globalThis.ResizeObserver;
const observers = [];
class MockResizeObserver
{
constructor()
{
this.targets = [];
this.disconnectCount = 0;
observers.push(this);
}
observe(target)
{
this.targets.push(target);
}
disconnect()
{
this.disconnectCount += 1;
}
}
globalThis.ResizeObserver = MockResizeObserver;
try
{
const { document } = setupDom('<div id="parent"></div>');
const parent = document.getElementById("parent");
const jz = createJuzhen({ jiya: { halfWidth: "margin" }, justifyAtoms: false });
jz.observe(parent);
const block = document.createElement("p");
block.lang = "zh-Hans";
block.innerHTML = "用Neovim寫,很好。";
const original = block.innerHTML;
parent.appendChild(block);
await tick();
const blockObservers = observers.filter((ro) => ro.targets.includes(block));
assert.equal(blockObservers.length, 2, "lineEdge 與 gapTrim 各持一觀察者");
assert.ok(block.querySelector("[data-jz]"));
assert.equal(block.getAttribute("data-jz-halfwidth"), "margin");
assert.equal(block.getAttribute("data-jz-atoms"), "inline");
block.setAttribute("data-jz-atoms", "external");
block.remove();
await tick();
assert.ok(blockObservers.every((ro) => ro.disconnectCount === 1));
assert.equal(block.querySelector("[data-jz]"), null);
assert.equal(block.innerHTML, original);
assert.equal(block.hasAttribute("data-jz-halfwidth"), false);
assert.equal(block.getAttribute("data-jz-atoms"), "external", "外部改寫 fail closed");
parent.appendChild(block);
await tick();
assert.ok(block.querySelector("[data-jz]"), "脫離根清理後可再插入重新 render");
jz.revert(parent);
jz.disconnect();
assert.equal(block.getAttribute("data-jz-atoms"), "external");
}
finally
{
if (previousResizeObserver === undefined) { delete globalThis.ResizeObserver; }
else { globalThis.ResizeObserver = previousResizeObserver; }
}
});
test("所有權:observe 移除非 render 根之行內內容不過度清理", async () =>
{
const { document } = setupDom('<div id="parent"><p>前<span>中A文</span>後</p></div>');
const parent = document.getElementById("parent");
const jz = createJuzhen({ jiya: false, jinze: false, orphan: false, longWord: false });
jz.render(parent);
jz.observe(parent);
const span = document.querySelector("span");
const rendered = span.innerHTML;
assert.ok(span.querySelector("jz-hws"));
span.remove();
await tick();
assert.equal(span.innerHTML, rendered, "非獨立 render 根之脫離內容保持原渲染結構");
assert.ok(span.querySelector("jz-hws"));
document.querySelector("p").appendChild(span);
await tick();
jz.revert(parent);
jz.disconnect();
});
+52
View File
@@ -0,0 +1,52 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
if (process.platform !== "darwin")
{
console.error("test:wkwebview requires macOS, Xcode, and an active GUI session.");
process.exit(1);
}
const testDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(testDir, "..");
const temporaryRoot = mkdtempSync(join(tmpdir(), "cjk-autospace-wkwebview-"));
const moduleCache = join(temporaryRoot, "ModuleCache");
const binary = join(temporaryRoot, "wkwebview-e2e");
mkdirSync(moduleCache);
function run(command, args)
{
const result = spawnSync(command, args, {
cwd: projectRoot,
encoding: "utf8",
stdio: "inherit",
});
if (result.error) { throw result.error; }
if (result.status !== 0) { process.exitCode = result.status ?? 1; }
return result.status === 0;
}
try
{
const compiled = run("xcrun", [
"swiftc",
"-parse-as-library",
"-swift-version", "5",
"-module-cache-path", moduleCache,
"-framework", "AppKit",
"-framework", "WebKit",
join(testDir, "wkwebview-e2e.swift"),
"-o", binary,
]);
if (compiled)
{
run(binary, [ join(projectRoot, "demo/e2e.html"), projectRoot ]);
}
}
finally
{
rmSync(temporaryRoot, { recursive: true, force: true });
}
+498
View File
@@ -0,0 +1,498 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import Ajv2020 from "ajv/dist/2020.js";
import { build } from "esbuild";
import { JSDOM } from "jsdom";
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
const ROOT = dirname(TEST_DIR);
const RULES_DIR = join(ROOT, "rules");
async function readJson(path)
{
return JSON.parse(await readFile(path, "utf8"));
}
const [ rules, rulesSchema, vectorsDoc, vectorsSchema, packageJson, spacingCss ] = await Promise.all([
readJson(join(RULES_DIR, "juzhen-rules.json")),
readJson(join(RULES_DIR, "juzhen-rules.schema.json")),
readJson(join(RULES_DIR, "golden-vectors.json")),
readJson(join(RULES_DIR, "golden-vectors.schema.json")),
readJson(join(ROOT, "package.json")),
readFile(join(ROOT, "src/css/_spacing.css"), "utf8"),
]);
const ajv = new Ajv2020({ allErrors: true, strict: true });
const validateRules = ajv.compile(rulesSchema);
const validateVectors = ajv.compile(vectorsSchema);
async function bundleSourceAdapter()
{
const source = [
'export * from "./src/core/unicode.ts";',
'export * from "./src/core/locale.ts";',
'export { createJuzhen, normalizeOptions } from "./src/index.ts";',
'export { jiyaPass, jiyaAdjacencyPass } from "./src/typeset/jiya.ts";',
'export { longWordPass } from "./src/typeset/longword.ts";',
'export { levelMarkPass } from "./src/typeset/levelmark.ts";',
'export { jinzePass } from "./src/typeset/jinze.ts";',
'export { spacingPass } from "./src/typeset/spacing.ts";',
'export { orphanPass } from "./src/typeset/orphan.ts";',
'export { lineEdgePass, gapTrimPass } from "./src/typeset/lineedge.ts";',
].join("\n");
const result = await build({
stdin: {
contents: source,
loader: "ts",
resolveDir: ROOT,
sourcefile: "shared-rules-adapter.ts",
},
bundle: true,
format: "esm",
platform: "node",
target: "es2022",
write: false,
logLevel: "silent",
});
const bundled = result.outputFiles[0].text + "\n//# sourceURL=shared-rules-adapter.mjs\n";
const encoded = Buffer.from(bundled).toString("base64");
return import("data:text/javascript;base64," + encoded);
}
const adapter = await bundleSourceAdapter();
const vectors = vectorsDoc.vectors;
function vectorsOf(category)
{
return vectors.filter((vector) => vector.category === category);
}
function renderText(text, options = {})
{
const dom = new JSDOM("<!doctype html><body><p></p></body>");
const paragraph = dom.window.document.querySelector("p");
paragraph.textContent = text;
const instance = adapter.createJuzhen({
spacing: false,
jiya: false,
jinze: false,
orphan: false,
longWord: false,
...options,
});
const globals = {
document: globalThis.document,
NodeFilter: globalThis.NodeFilter,
};
globalThis.document = dom.window.document;
globalThis.NodeFilter = dom.window.NodeFilter;
try
{
instance.render(paragraph);
}
finally
{
if (globals.document === undefined) { delete globalThis.document; }
else { globalThis.document = globals.document; }
if (globals.NodeFilter === undefined) { delete globalThis.NodeFilter; }
else { globalThis.NodeFilter = globals.NodeFilter; }
}
return { dom, paragraph, instance };
}
function punctuationCharacter(punctuationClass)
{
return {
stop: "。",
pause: ",",
open: "「",
close: "」",
middle: "·",
liga: "…",
}[punctuationClass];
}
function entryBounds(entry)
{
const start = Number.parseInt(entry.codePoint || entry.start, 16);
const end = Number.parseInt(entry.codePoint || entry.end, 16);
return { start, end };
}
function assertOrderedCodePointSet(name, entries)
{
let previousEnd = -1;
for (const entry of entries)
{
const { start, end } = entryBounds(entry);
assert.ok(start >= 0 && end <= 0x10FFFF, name + " Unicode bounds");
assert.ok(start <= end, name + " closed range order");
assert.ok(start > previousEnd, name + " ranges must be sorted and non-overlapping");
previousEnd = end;
}
}
function webEscape(codePoint)
{
const hex = codePoint.toString(16).toUpperCase();
return codePoint <= 0xFFFF
? "\\u" + hex.padStart(4, "0")
: "\\u{" + hex + "}";
}
function rangesToWebFragment(entries)
{
return entries.map((entry) =>
{
const { start, end } = entryBounds(entry);
return start === end
? webEscape(start)
: webEscape(start) + "-" + webEscape(end);
}).join("");
}
function assertEquivalentMembership(name, entries, sourceFragment)
{
assertOrderedCodePointSet(name, entries);
const canonical = new RegExp("^[" + rangesToWebFragment(entries) + "]$", "u");
const source = new RegExp("^[" + sourceFragment + "]$", "u");
for (let codePoint = 0; codePoint <= 0x10FFFF; codePoint += 1)
{
const character = String.fromCodePoint(codePoint);
assert.equal(
canonical.test(character),
source.test(character),
name + " differs at U+" + codePoint.toString(16).toUpperCase(),
);
}
}
function canonicalCodePoints(entries)
{
const out = [];
for (const entry of entries)
{
const { start, end } = entryBounds(entry);
for (let codePoint = start; codePoint <= end; codePoint += 1) { out.push(codePoint); }
}
return out;
}
test("shared rules: Ajv 2020 strict validation, rejection cases, exports, and coverage", () =>
{
assert.equal(rulesSchema.$schema, "https://json-schema.org/draft/2020-12/schema");
assert.equal(vectorsSchema.$schema, "https://json-schema.org/draft/2020-12/schema");
assert.equal(rules.schemaVersion, 1);
assert.equal(rules.rulesetVersion, "1.0.0");
assert.equal(rules.unicodeProfile.version, "17.0.0");
assert.equal(vectorsDoc.schemaVersion, 1);
assert.equal(vectorsDoc.rulesetVersion, rules.rulesetVersion);
assert.equal(validateRules(rules), true, ajv.errorsText(validateRules.errors));
assert.equal(validateVectors(vectorsDoc), true, ajv.errorsText(validateVectors.errors));
const extraRules = structuredClone(rules);
extraRules.unexpected = true;
assert.equal(validateRules(extraRules), false, "rules additionalProperties must be rejected");
const bogusJinze = structuredClone(vectorsDoc);
const jinzeIndex = bogusJinze.vectors.findIndex((vector) => vector.category === "jinze");
assert.notEqual(jinzeIndex, -1, "valid fixture must contain a jinze vector");
bogusJinze.vectors[jinzeIndex] = {
id: "bogus-jinze",
category: "jinze",
input: {},
expected: { reason: "bogus" },
};
assert.equal(validateVectors(bogusJinze), false, "malformed jinze vector must be rejected");
assert.equal(
validateVectors.errors?.some((error) => error.keyword === "minItems"),
false,
"bogus jinze rejection must not rely on document-wide vector count",
);
const extraVectorField = structuredClone(vectorsDoc);
extraVectorField.vectors[0].input.unexpected = true;
assert.equal(
validateVectors(extraVectorField),
false,
"category input additionalProperties must be rejected",
);
assert.ok(vectors.length >= 20, `expected at least 20 vectors, got ${vectors.length}`);
const ids = vectors.map((vector) => vector.id);
assert.equal(new Set(ids).size, ids.length, "golden vector ids must be unique");
for (const category of [ "jinze", "orphan" ])
{
assert.ok(
vectors.some((vector) => vector.category === category && vector.expected.atoms),
`${category} must retain at least one cross-feature atom vector`,
);
}
const requiredCategories = [
"language-classification",
"punctuation-classification",
"punctuation-style",
"punctuation-adjacency",
"spacing",
"ivs-atom",
"jinze",
"orphan",
];
for (const category of requiredCategories)
{
assert.ok(vectorsOf(category).length > 0, `missing vector category ${category}`);
}
assert.deepEqual(packageJson.exports["./rules"], "./rules/juzhen-rules.json");
assert.deepEqual(packageJson.exports["./rules/schema"], "./rules/juzhen-rules.schema.json");
assert.deepEqual(packageJson.exports["./rules/vectors"], "./rules/golden-vectors.json");
assert.deepEqual(
packageJson.exports["./rules/vectors-schema"],
"./rules/golden-vectors.schema.json",
);
assert.ok(packageJson.files.includes("rules"));
});
test("shared rules: canonical character classes equal the Web source constants", () =>
{
const canonical = rules.characterClasses;
assert.equal(canonical.representation, "unicode-code-point-ranges");
assertEquivalentMembership("cjk", canonical.cjk, adapter.CJK);
assertEquivalentMembership("ansAfterCjk", canonical.ansAfterCjk, adapter.ANS_AFTER_CJK);
assertEquivalentMembership("ansBeforeCjk", canonical.ansBeforeCjk, adapter.ANS_BEFORE_CJK);
assertEquivalentMembership(
"variationSelectors",
canonical.variationSelectors,
adapter.VARIATION_SELECTORS,
);
assert.deepEqual(canonical.biaodian.classificationOrder, [
"stop", "pause", "open", "close", "middle", "liga",
]);
for (const punctuationClass of canonical.biaodian.classificationOrder)
{
const entries = canonical.biaodian.classes[punctuationClass];
assertOrderedCodePointSet("biaodian." + punctuationClass, entries);
assert.deepEqual(
canonicalCodePoints(entries),
Array.from(adapter.DEFAULT_RULESET.biaodian[punctuationClass], (character) =>
character.codePointAt(0)).sort((a, b) => a - b),
"biaodian." + punctuationClass,
);
}
const extended = adapter.resolveRuleset({
cjk: "〇",
western: "★",
biaodian: { stop: "‼" },
});
assert.equal(extended.cjk, adapter.CJK + "〇");
assert.equal(extended.ansAfter, adapter.ANS_AFTER_CJK + "★");
assert.equal(extended.ansBefore, adapter.ANS_BEFORE_CJK + "★");
assert.equal(extended.biaodian.stop, adapter.DEFAULT_RULESET.biaodian.stop + "‼");
assert.equal(canonical.overrideSemantics.mode, "append");
assert.equal(canonical.overrideSemantics.removalAllowed, false);
assert.equal(canonical.overrideSemantics.reclassificationAllowed, false);
});
test("shared rules: locale defaults and all language vectors match the Web adapter", () =>
{
const config = { default: "zh-Hant", policy: {} };
for (const [ langClass, expectedStyle ] of Object.entries(rules.locales.defaultPolicies))
{
assert.equal(adapter.styleFor(langClass, config), expectedStyle, langClass);
}
for (const vector of vectorsOf("language-classification"))
{
assert.equal(
adapter.classify(vector.input.lang),
vector.expected.langClass,
vector.id,
);
}
assert.equal(adapter.classify("zhx"), "other", "BCP-47 boundary must reject zhx");
});
test("shared rules: punctuation classification and style vectors match Web behavior", () =>
{
for (const vector of vectorsOf("punctuation-classification"))
{
const actual = adapter.classifyBiaodian(vector.input.character);
const expected = vector.expected.punctuationClass;
assert.equal(actual, expected === null ? null : "bd-" + expected, vector.id);
}
for (const vector of vectorsOf("punctuation-style"))
{
const halfClasses = rules.punctuationStyles.baselineHalfWidthClasses[vector.input.style];
assert.equal(
halfClasses.includes(vector.input.punctuationClass),
vector.expected.halfWidth,
vector.id + " canonical",
);
const character = punctuationCharacter(vector.input.punctuationClass);
const { paragraph } = renderText(character, {
jiya: true,
lang: { default: "zh-Hant", style: vector.input.style },
});
const char = paragraph.querySelector("jz-char");
assert.ok(char, vector.id + " must be classified by Web adapter");
assert.equal(char.classList.contains("jz-half"), vector.expected.halfWidth, vector.id);
}
});
test("shared rules: punctuation adjacency vectors match Web behavior", () =>
{
for (const vector of vectorsOf("punctuation-adjacency"))
{
const leftCharacter = punctuationCharacter(vector.input.left);
const rightCharacter = punctuationCharacter(vector.input.right);
const { paragraph } = renderText(leftCharacter + rightCharacter, {
jiya: true,
lang: { default: "zh-Hant", style: "quanjiao" },
});
const chars = Array.from(paragraph.querySelectorAll("jz-char"));
assert.equal(chars.length, 2, vector.id);
assert.equal(
chars[0].classList.contains("jz-half"),
vector.expected.squeezeLeft,
vector.id + " left",
);
assert.equal(
chars[1].classList.contains("jz-half"),
vector.expected.squeezeRight,
vector.id + " right",
);
}
});
test("shared rules: spacing vectors match Web decisions and author-space normalization", () =>
{
assert.equal(rules.spacing.gapEm, 0.25);
const cssGap = /jz-hws\s*\{[^}]*margin-right:\s*([0-9.]+)em\s*;/s.exec(spacingCss);
assert.ok(cssGap, "Web adapter spacing CSS must declare jz-hws margin-right in em");
assert.equal(Number.parseFloat(cssGap[1]), rules.spacing.gapEm, "Web CSS gap drift");
for (const vector of vectorsOf("spacing"))
{
const { paragraph } = renderText(vector.input.text, { spacing: true });
assert.equal(
paragraph.querySelectorAll("jz-hws").length,
vector.expected.managedGaps,
vector.id + " gaps",
);
assert.equal(paragraph.textContent, vector.expected.normalizedText, vector.id + " text");
if (Object.hasOwn(vector.expected, "gapEm"))
{
assert.equal(vector.expected.gapEm, rules.spacing.gapEm, vector.id + " gap width");
}
}
});
test("shared rules: IVS vectors enforce base plus at most one selector", () =>
{
assert.equal(rules.atoms.maxVariationSelectors, 1);
for (const vector of vectorsOf("ivs-atom"))
{
assert.deepEqual(
adapter.splitVariationSequences(vector.input.text),
vector.expected.atoms,
vector.id,
);
}
});
test("Web adapter only: pass groups, orders, levels, and compatibility no-ops do not drift", () =>
{
const actual = {
charify: [ adapter.jiyaPass, adapter.longWordPass ],
standalone: [
adapter.levelMarkPass,
adapter.jiyaAdjacencyPass,
adapter.jinzePass,
adapter.spacingPass,
adapter.orphanPass,
adapter.lineEdgePass,
adapter.gapTrimPass,
],
};
assert.deepEqual(
actual.charify.map(({ name, order, level }) => ({ name, order, level })),
[
{ name: "jiya", order: 20, level: "text" },
{ name: "longWord", order: 60, level: "paragraph" },
],
"Web charify group",
);
assert.deepEqual(
actual.standalone.map(({ name, order, level }) => ({ name, order, level })),
[
{ name: "levelMark", order: 10, level: "text" },
{ name: "jiyaAdjacency", order: 25, level: "text" },
{ name: "jinze", order: 40, level: "paragraph" },
{ name: "spacing", order: 50, level: "text" },
{ name: "orphan", order: 70, level: "paragraph" },
{ name: "jiyaLineEdge", order: 90, level: "paragraph" },
{ name: "spacingGapTrim", order: 92, level: "paragraph" },
],
"Web standalone group",
);
const passNames = new Set([ ...actual.charify, ...actual.standalone ].map((pass) => pass.name));
for (const option of [ "hanging", "biaodian", "emphasis", "ruby" ])
{
assert.equal(passNames.has(option), false, option + " must remain unregistered");
}
});
test("shared rules: jinze and orphan semantic vectors match Web behavior", () =>
{
for (const vector of vectorsOf("jinze"))
{
if (vector.expected.atoms)
{
assert.deepEqual(
adapter.splitVariationSequences(vector.input.left + vector.input.right),
vector.expected.atoms,
vector.id + " atoms",
);
}
const { paragraph } = renderText(vector.input.left + vector.input.right, {
jiya: true,
jinze: true,
lang: { default: "zh-Hant", style: "quanjiao" },
});
assert.equal(
paragraph.querySelector("jz-jinze") !== null,
vector.expected.prohibitedBreak,
vector.id,
);
}
const anyCjk = new RegExp(
"[" + rangesToWebFragment(rules.characterClasses.cjk) + "]",
"u",
);
const punctuation = new Set(
Object.values(rules.characterClasses.biaodian.classes)
.flatMap((entries) => canonicalCodePoints(entries)),
);
for (const vector of vectorsOf("orphan"))
{
const atoms = adapter.splitVariationSequences(vector.input.text);
if (vector.expected.atoms)
{
assert.deepEqual(atoms, vector.expected.atoms, vector.id + " atoms");
}
const meaningful = atoms.filter((atom) =>
anyCjk.test(atom) && !punctuation.has(atom.codePointAt(0)));
assert.equal(meaningful.length, vector.expected.meaningfulCjkCount, vector.id + " count");
const { paragraph } = renderText(vector.input.text, {
orphan: { chars: vector.input.minimum },
});
const orphan = paragraph.querySelector("jz-orphan");
assert.equal(orphan ? orphan.textContent : null, vector.expected.protectedSuffix, vector.id);
}
});
+40
View File
@@ -2,6 +2,7 @@
// 在新引擎落地(回應使用者提問)。
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { setupDom, countTag } from "./helpers.mjs";
@@ -78,3 +79,42 @@ test("間隔號:姓名 · 兩側不插 jz-hws(· 屬 CJK 標點,非西文
createJuzhen(SPACING_ONLY).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, "· 兩側皆不應有間隙");
});
// ----- ASCII 左括號(Safari 16 無 lookbehind 路徑)-----
test("左括號:普通英數字後補間隙,連續樣式不漏匹配", () =>
{
const { document } = setupDom("<p>A(b[c{中</p>");
createJuzhen(SPACING_ONLY).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 3);
});
test("左括號:dot-prefixed 英數字串段不補間隙", () =>
{
const { document } = setupDom("<p>.foo(中</p><p>x.bar[文</p><p>.baz{字</p>");
createJuzhen(SPACING_ONLY).render(document.body);
for (const p of document.querySelectorAll("p"))
{
assert.equal(countTag(p, "jz-hws"), 0);
}
});
test("左括號:點號不在當前英數字 suffix 前時仍補間隙", () =>
{
const { document } = setupDom("<p>.foo-bar(中</p>");
createJuzhen(SPACING_ONLY).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 1);
});
test("Safari 16 產物:spacing 源碼與發佈 JS 不得含 lookbehind 語法", () =>
{
const lookbehindSyntax = /\(\?<([=!])/;
for (const path of [
new URL("../src/typeset/spacing.ts", import.meta.url),
new URL("../dist/juzhen.mjs", import.meta.url),
new URL("../dist/juzhen.iife.js", import.meta.url),
])
{
assert.doesNotMatch(readFileSync(path, "utf8"), lookbehindSyntax, path.pathname);
}
});
+196
View File
@@ -0,0 +1,196 @@
import AppKit
import Darwin
import Foundation
import WebKit
@MainActor
final class WkWebViewHarness: NSObject, WKNavigationDelegate
{
private let app: NSApplication
private let window: NSWindow
private let webView: WKWebView
private var finished = false
init(app: NSApplication, pagePath: String, readRoot: String)
{
self.app = app
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
webView = WKWebView(
frame: NSRect(x: 0, y: 0, width: 1224, height: 768),
configuration: configuration
)
window = NSWindow(
contentRect: webView.frame,
styleMask: [ .titled, .closable, .resizable ],
backing: .buffered,
defer: false
)
super.init()
webView.navigationDelegate = self
window.title = "Juzhen WKWebView E2E"
window.contentView = webView
window.center()
window.makeKeyAndOrderFront(nil)
let pageUrl = URL(fileURLWithPath: pagePath)
let readUrl = URL(fileURLWithPath: readRoot, isDirectory: true)
webView.loadFileURL(pageUrl, allowingReadAccessTo: readUrl)
DispatchQueue.main.asyncAfter(deadline: .now() + 20)
{ [weak self] in
self?.finish(code: 2, reason: "timeout")
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!)
{
evaluate("navigator.userAgent")
{ value in
print("WKWEBVIEW_USER_AGENT=\(value)")
}
click(buttonId: "run-core")
pollTitle(expectedPrefix: "CORE PASS", timeoutAt: Date().addingTimeInterval(8))
{ [weak self] coreTitle in
guard let self else { return }
guard coreTitle.hasPrefix("CORE PASS") else
{
self.collectAndFinish(code: 3, reason: "core title: \(coreTitle)")
return
}
self.click(buttonId: "run-observer")
self.pollTitle(expectedPrefix: "PASS", timeoutAt: Date().addingTimeInterval(8))
{ [weak self] finalTitle in
guard let self else { return }
let success = finalTitle.hasPrefix("PASS")
self.collectAndFinish(
code: success ? 0 : 4,
reason: "final title: \(finalTitle)"
)
}
}
}
func webView(
_ webView: WKWebView,
didFail navigation: WKNavigation!,
withError error: Error
)
{
finish(code: 5, reason: "navigation failed: \(error)")
}
func webView(
_ webView: WKWebView,
didFailProvisionalNavigation navigation: WKNavigation!,
withError error: Error
)
{
finish(code: 6, reason: "provisional navigation failed: \(error)")
}
private func click(buttonId: String)
{
let script = "document.getElementById('\(buttonId)')?.click(); document.title;"
evaluate(script)
{ value in
print("WKWEBVIEW_CLICK_\(buttonId)=\(value)")
}
}
private func pollTitle(
expectedPrefix: String,
timeoutAt: Date,
completion: @escaping (String) -> Void
)
{
evaluate("document.title")
{ [weak self] value in
guard let self else { return }
if value.hasPrefix(expectedPrefix) || value.hasPrefix("FAIL") || Date() >= timeoutAt
{
completion(value)
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1)
{ [weak self] in
self?.pollTitle(
expectedPrefix: expectedPrefix,
timeoutAt: timeoutAt,
completion: completion
)
}
}
}
private func collectAndFinish(code: Int32, reason: String)
{
let script = """
JSON.stringify({
title: document.title,
summary: document.getElementById('summary-text')?.textContent || '',
failures: Array.from(document.querySelectorAll('#results li[data-state="fail"]'))
.map((item) => item.textContent),
results: Array.from(document.querySelectorAll('#results li'))
.map((item) => item.textContent)
})
"""
evaluate(script)
{ [weak self] value in
print("WKWEBVIEW_RESULT=\(value)")
self?.finish(code: code, reason: reason)
}
}
private func evaluate(_ script: String, completion: @escaping (String) -> Void)
{
webView.evaluateJavaScript(script)
{ value, error in
if let error
{
completion("EVALUATION_ERROR: \(error)")
return
}
completion(String(describing: value ?? "nil"))
}
}
private func finish(code: Int32, reason: String)
{
guard !finished else { return }
finished = true
print("WKWEBVIEW_EXIT_REASON=\(reason)")
fflush(stdout)
window.close()
app.stop(nil)
exit(code)
}
}
@main
struct Main
{
@MainActor
private static var harness: WkWebViewHarness?
@MainActor
static func main()
{
guard CommandLine.arguments.count == 3 else
{
fputs("usage: harness <e2e.html> <read-root>\n", stderr)
exit(64)
}
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
harness = WkWebViewHarness(
app: app,
pagePath: CommandLine.arguments[1],
readRoot: CommandLine.arguments[2]
)
app.run()
}
}