feat(rules): 建立共享排版契约并收束 WebKit 与 Unicode 基线
This commit is contained in:
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
const html = cases.map((text, index) =>
|
||||
{
|
||||
const bmp = text.split(cjk).join(shadow);
|
||||
return `<p id="a-${index}">${escapeHtml(text)}</p>`
|
||||
+ `<p id="b-${index}">${escapeHtml(bmp)}</p>`;
|
||||
}).join("");
|
||||
const { document } = setupDom(html);
|
||||
createJuzhen(TEXT_ONLY).render(document.body);
|
||||
|
||||
const boundaries = (paragraph) => Array.from(paragraph.querySelectorAll("jz-hws")).map((gap) =>
|
||||
{
|
||||
const range = document.createRange();
|
||||
range.setStart(paragraph, 0);
|
||||
range.setEndAfter(gap);
|
||||
let atoms = 0;
|
||||
for (const ch of range.toString())
|
||||
{
|
||||
const cp = ch.codePointAt(0);
|
||||
if (!((cp >= 0xFE00 && cp <= 0xFE0F) || (cp >= 0xE0100 && cp <= 0xE01EF)))
|
||||
{
|
||||
atoms += 1;
|
||||
}
|
||||
}
|
||||
return atoms;
|
||||
});
|
||||
|
||||
for (let index = 0; index < cases.length; index += 1)
|
||||
{
|
||||
const astral = document.getElementById(`a-${index}`);
|
||||
const bmp = document.getElementById(`b-${index}`);
|
||||
assert.deepEqual(boundaries(astral), boundaries(bmp), `gap actions ${index}`);
|
||||
assert.equal(
|
||||
astral.textContent.split(cjk).join(shadow),
|
||||
bmp.textContent,
|
||||
`space deletion and atom order ${index}`,
|
||||
);
|
||||
assertNoUnpairedSurrogates(astral.textContent);
|
||||
}
|
||||
});
|
||||
|
||||
test("IVS spacing: both directions and transparent inline boundaries keep base plus VS atomic", () =>
|
||||
{
|
||||
const ivs = "\u{20000}\u{E0100}";
|
||||
const bmpVs = "中\uFE00";
|
||||
const { document } = setupDom(
|
||||
`<p id="ivs-left">${ivs}A</p><p id="ivs-right">A${ivs}</p>`
|
||||
+ `<p id="bmp-left">${bmpVs}A</p><p id="bmp-right">A${bmpVs}</p>`
|
||||
+ `<p id="inline-left"><strong>${ivs}</strong>A</p>`
|
||||
+ `<p id="inline-right">A<strong>${ivs}</strong></p>`,
|
||||
);
|
||||
const ids = [ "ivs-left", "ivs-right", "bmp-left", "bmp-right", "inline-left", "inline-right" ];
|
||||
const originals = new Map(ids.map((id) => [ id, document.getElementById(id).innerHTML ]));
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
|
||||
jz.render(document.body);
|
||||
for (const id of ids) { assert.equal(countTag(document.getElementById(id), "jz-hws"), 1, id); }
|
||||
assert.equal(document.querySelector("#ivs-left jz-hws").textContent, ivs);
|
||||
assert.equal(document.querySelector("#bmp-left jz-hws").textContent, bmpVs);
|
||||
assert.equal(document.querySelector("#inline-left jz-hws").textContent, ivs);
|
||||
assert.equal(document.getElementById("ivs-left").textContent, ivs + "A");
|
||||
assert.equal(document.getElementById("ivs-right").textContent, "A" + ivs);
|
||||
assertNoUnpairedSurrogates(document.body.textContent);
|
||||
|
||||
jz.revert(document.body);
|
||||
for (const id of ids) { assert.equal(document.getElementById(id).innerHTML, originals.get(id), id); }
|
||||
assertNoUnpairedSurrogates(document.body.textContent);
|
||||
});
|
||||
|
||||
test("IVS spacing: base and selector across transparent nodes share one DOM span", () =>
|
||||
{
|
||||
const samples = [
|
||||
[
|
||||
"bmp-left",
|
||||
'<strong id="author-bmp-left">中</strong>\uFE00A',
|
||||
'<strong id="author-bmp-left">中</strong>\uFE00A',
|
||||
],
|
||||
[
|
||||
"bmp-left-space",
|
||||
'<strong id="author-bmp-left-space">中</strong>\uFE00 A',
|
||||
'<strong id="author-bmp-left-space">中</strong>\uFE00A',
|
||||
],
|
||||
[
|
||||
"bmp-left-partial-start",
|
||||
'<strong id="author-bmp-partial-start">甲中</strong>\uFE00 A',
|
||||
'<strong id="author-bmp-partial-start">甲中</strong>\uFE00A',
|
||||
],
|
||||
[
|
||||
"bmp-left-partial-end",
|
||||
'<strong id="author-bmp-partial-end-base">中</strong>'
|
||||
+ '<em id="author-bmp-partial-end-vs">\uFE00x</em>',
|
||||
'<strong id="author-bmp-partial-end-base">中</strong>'
|
||||
+ '<em id="author-bmp-partial-end-vs">\uFE00x</em>',
|
||||
],
|
||||
[
|
||||
"bmp-right",
|
||||
'A<strong id="author-bmp-right">中</strong>\uFE00',
|
||||
'A<strong id="author-bmp-right">中</strong>\uFE00',
|
||||
],
|
||||
[
|
||||
"bmp-right-space",
|
||||
'A <strong id="author-bmp-right-space">中</strong>\uFE00',
|
||||
'A<strong id="author-bmp-right-space">中</strong>\uFE00',
|
||||
],
|
||||
[
|
||||
"ivs-left",
|
||||
'<strong id="author-ivs-left">\u{20000}</strong>\u{E0100}A',
|
||||
'<strong id="author-ivs-left">\u{20000}</strong>\u{E0100}A',
|
||||
],
|
||||
[
|
||||
"ivs-left-space",
|
||||
'<strong id="author-ivs-left-space">\u{20000}</strong>\u{E0100} A',
|
||||
'<strong id="author-ivs-left-space">\u{20000}</strong>\u{E0100}A',
|
||||
],
|
||||
[
|
||||
"ivs-right",
|
||||
'A<strong id="author-ivs-right">\u{20000}</strong>\u{E0100}',
|
||||
'A<strong id="author-ivs-right">\u{20000}</strong>\u{E0100}',
|
||||
],
|
||||
[
|
||||
"ivs-right-space",
|
||||
'A <strong id="author-ivs-right-space">\u{20000}</strong>\u{E0100}',
|
||||
'A<strong id="author-ivs-right-space">\u{20000}</strong>\u{E0100}',
|
||||
],
|
||||
];
|
||||
const { document } = setupDom(samples.map(([ id, html ]) => `<p id="${id}">${html}</p>`).join(""));
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
const authors = Array.from(document.querySelectorAll('[id^="author-"]'));
|
||||
const clickCounts = new Map(authors.map((el) => [ el, 0 ]));
|
||||
for (const author of authors)
|
||||
{
|
||||
author.addEventListener("click", () => clickCounts.set(author, clickCounts.get(author) + 1));
|
||||
}
|
||||
|
||||
const assertAuthorState = (expectedClicks) =>
|
||||
{
|
||||
for (const author of authors)
|
||||
{
|
||||
assert.equal(document.getElementById(author.id), author, `${author.id} identity`);
|
||||
assert.equal(document.querySelectorAll(`[id="${author.id}"]`).length, 1, `${author.id} unique`);
|
||||
author.dispatchEvent(new document.defaultView.Event("click"));
|
||||
assert.equal(clickCounts.get(author), expectedClicks, `${author.id} listener`);
|
||||
}
|
||||
};
|
||||
|
||||
jz.render(document.body);
|
||||
for (const [ id, , normalized ] of samples)
|
||||
{
|
||||
const paragraph = document.getElementById(id);
|
||||
assert.equal(countTag(paragraph, "jz-hws"), 1, id);
|
||||
assert.equal(paragraph.textContent, paragraph.textContent.replace(" ", ""), id);
|
||||
assertNoUnpairedSurrogates(paragraph.textContent);
|
||||
const gap = paragraph.querySelector("jz-hws");
|
||||
if (id.includes("left"))
|
||||
{
|
||||
const sequence = id.startsWith("bmp") ? "中\uFE00" : "\u{20000}\u{E0100}";
|
||||
assert.equal(gap.textContent, sequence, `${id} wraps the complete sequence`);
|
||||
assert.equal(gap.getAttribute("data-jz-kind"), "wrap", `${id} wrapper kind`);
|
||||
assert.equal(
|
||||
Array.from(gap.childNodes).filter((node) => node.nodeType === 3).length,
|
||||
2,
|
||||
`${id} wrapper retains both Text parts`,
|
||||
);
|
||||
assert.equal(gap.getAttribute("data-jz-cross-node-atom"), "", `${id} cross-node flag`);
|
||||
}
|
||||
else { assert.equal(gap.textContent, "A", `${id} Latin carrier remains unchanged`); }
|
||||
assert.equal(paragraph.textContent, setupDom(`<p>${normalized}</p>`).document.body.textContent, id);
|
||||
}
|
||||
assertAuthorState(1);
|
||||
|
||||
jz.render(document.body);
|
||||
for (const [ id ] of samples)
|
||||
{
|
||||
assert.equal(countTag(document.getElementById(id), "jz-hws"), 1, `${id} repeated render`);
|
||||
}
|
||||
assertAuthorState(2);
|
||||
|
||||
jz.revert(document.body);
|
||||
for (const [ id, , normalized ] of samples)
|
||||
{
|
||||
const paragraph = document.getElementById(id);
|
||||
assert.equal(paragraph.innerHTML, normalized, id);
|
||||
assertNoUnpairedSurrogates(paragraph.textContent);
|
||||
}
|
||||
assertAuthorState(3);
|
||||
});
|
||||
|
||||
test("moved IVS ownership: repeat and peer instances never claim an existing gap", async () =>
|
||||
{
|
||||
const countAnchors = (root) =>
|
||||
{
|
||||
const walker = root.ownerDocument.createTreeWalker(
|
||||
root,
|
||||
root.ownerDocument.defaultView.NodeFilter.SHOW_COMMENT,
|
||||
);
|
||||
let count = 0;
|
||||
while (walker.nextNode())
|
||||
{
|
||||
if (walker.currentNode.data.startsWith("jz-spacing-")) { count += 1; }
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const { document } = setupDom(
|
||||
'<p><strong id="repeat-author">\u{20000}</strong><em>\u{E0100}A</em></p>',
|
||||
);
|
||||
const paragraph = document.querySelector("p");
|
||||
const author = document.getElementById("repeat-author");
|
||||
const originalHtml = paragraph.innerHTML;
|
||||
let clicks = 0;
|
||||
author.addEventListener("click", () => { clicks += 1; });
|
||||
const first = createJuzhen(TEXT_ONLY);
|
||||
first.render(paragraph);
|
||||
const gap = paragraph.querySelector("jz-hws");
|
||||
assert.equal(countAnchors(paragraph), 2, "one original-side and one gap-side anchor");
|
||||
|
||||
const mutations = [];
|
||||
const observer = new document.defaultView.MutationObserver((records) => mutations.push(...records));
|
||||
observer.observe(paragraph, { childList: true, subtree: true, attributes: true });
|
||||
first.render(paragraph);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
observer.disconnect();
|
||||
assert.equal(paragraph.querySelector("jz-hws"), gap, "repeat preserves exact gap object");
|
||||
assert.equal(countAnchors(paragraph), 2, "repeat creates no anchor");
|
||||
const gapMutations = mutations.filter((record) => record.target === gap || gap.contains(record.target)
|
||||
|| Array.from(record.addedNodes).some(
|
||||
(node) => node.nodeType === 8 && node.data.startsWith("jz-spacing-"),
|
||||
));
|
||||
assert.equal(gapMutations.length, 0, "repeat does not mutate the gap or add anchors");
|
||||
|
||||
const second = createJuzhen(TEXT_ONLY);
|
||||
second.render(paragraph);
|
||||
second.revert(paragraph);
|
||||
assert.equal(paragraph.querySelector("jz-hws"), gap, "peer render/revert leaves first gap intact");
|
||||
assert.equal(countAnchors(paragraph), 2, "peer registers no moved part");
|
||||
first.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, originalHtml);
|
||||
assert.equal(document.getElementById("repeat-author"), author);
|
||||
author.dispatchEvent(new document.defaultView.Event("click"));
|
||||
assert.equal(clicks, 1);
|
||||
|
||||
const reverseDom = setupDom(
|
||||
'<p><strong id="reverse-author">\u{20000}</strong><em>\u{E0100}A</em></p>',
|
||||
);
|
||||
const reverse = reverseDom.document.querySelector("p");
|
||||
const reverseHtml = reverse.innerHTML;
|
||||
const a = createJuzhen(TEXT_ONLY);
|
||||
const b = createJuzhen(TEXT_ONLY);
|
||||
b.render(reverse);
|
||||
a.render(reverse);
|
||||
a.revert(reverse);
|
||||
assert.equal(countTag(reverse, "jz-hws"), 1, "reverse order preserves first owner");
|
||||
b.revert(reverse);
|
||||
assert.equal(reverse.innerHTML, reverseHtml);
|
||||
|
||||
for (const attributes of [ "", ' data-jz="spacing" data-jz-kind="wrap"' ])
|
||||
{
|
||||
const foreignDom = setupDom(
|
||||
`<p><jz-hws id="foreign"${attributes}>`
|
||||
+ '<strong>\u{20000}</strong>\u{E0100}</jz-hws>A</p>',
|
||||
);
|
||||
const foreignParagraph = foreignDom.document.querySelector("p");
|
||||
const foreignHtml = foreignParagraph.innerHTML;
|
||||
createJuzhen(TEXT_ONLY).render(foreignParagraph);
|
||||
assert.equal(foreignParagraph.innerHTML, foreignHtml, "foreign/forged gap is a strict no-op");
|
||||
assert.equal(countAnchors(foreignParagraph), 0, "foreign/forged gap is never registered");
|
||||
assert.equal(
|
||||
foreignParagraph.querySelector("jz-hws").hasAttribute("data-jz-cross-node-atom"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("moved IVS ownership: replaced selector parents discard stale text and rebuild", async () =>
|
||||
{
|
||||
const ivs = "\u{E0100}";
|
||||
const methods = [ "textContent", "replaceChildren", "innerHTML" ];
|
||||
for (const method of methods)
|
||||
{
|
||||
const { document } = setupDom(
|
||||
'<p><strong id="replace-base">\u{20000}</strong>'
|
||||
+ '<em id="replace-selector">\u{E0100}A</em></p>',
|
||||
);
|
||||
const paragraph = document.querySelector("p");
|
||||
const base = document.getElementById("replace-base");
|
||||
const selectorParent = document.getElementById("replace-selector");
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
jz.render(document.body);
|
||||
const oldSelector = Array.from(paragraph.querySelector("jz-hws").childNodes)
|
||||
.find((node) => node.nodeType === 3 && node.data === ivs);
|
||||
assert.ok(oldSelector, `${method} captures moved selector`);
|
||||
|
||||
const replacement = ivs + method[0].toUpperCase();
|
||||
if (method !== "innerHTML") { jz.observe(document.body); }
|
||||
if (method === "textContent") { selectorParent.textContent = replacement; }
|
||||
else if (method === "replaceChildren")
|
||||
{
|
||||
selectorParent.replaceChildren(document.createTextNode(replacement));
|
||||
}
|
||||
else
|
||||
{
|
||||
selectorParent.innerHTML = replacement;
|
||||
jz.rerender(document.body);
|
||||
}
|
||||
if (method !== "innerHTML")
|
||||
{
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
jz.disconnect();
|
||||
}
|
||||
|
||||
assert.equal(document.getElementById("replace-base"), base, `${method} base identity`);
|
||||
assert.equal(
|
||||
document.getElementById("replace-selector"),
|
||||
selectorParent,
|
||||
`${method} selector-parent identity`,
|
||||
);
|
||||
assert.equal(paragraph.textContent, "\u{20000}" + replacement, `${method} exact author text`);
|
||||
assert.equal(paragraph.textContent.split(ivs).length - 1, 1, `${method} has one selector`);
|
||||
assert.equal(oldSelector.isConnected, false, `${method} stale selector is discarded`);
|
||||
assert.equal(countTag(paragraph, "jz-hws"), 1, `${method} rebuilt gap`);
|
||||
assert.equal(paragraph.querySelector("jz-hws").textContent, "\u{20000}" + ivs);
|
||||
assertNoUnpairedSurrogates(paragraph.textContent);
|
||||
|
||||
jz.revert(document.body);
|
||||
assert.equal(
|
||||
paragraph.innerHTML,
|
||||
'<strong id="replace-base">\u{20000}</strong>'
|
||||
+ `<em id="replace-selector">${replacement}</em>`,
|
||||
`${method} revert preserves only replacement`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("moved IVS ownership: empty selector parents never resurrect stale text", async () =>
|
||||
{
|
||||
for (const method of [ "textContent", "replaceChildren", "innerHTML" ])
|
||||
{
|
||||
const { document } = setupDom(
|
||||
'<p><strong id="empty-base">\u{20000}</strong>'
|
||||
+ '<em id="empty-selector">\u{E0100}</em>A</p>',
|
||||
);
|
||||
const paragraph = document.querySelector("p");
|
||||
const base = document.getElementById("empty-base");
|
||||
const selectorParent = document.getElementById("empty-selector");
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
jz.render(document.body);
|
||||
const oldSelector = Array.from(paragraph.querySelector("jz-hws").childNodes)
|
||||
.find((node) => node.nodeType === 3 && node.data === "\u{E0100}");
|
||||
assert.ok(oldSelector, `${method} captures moved selector`);
|
||||
|
||||
if (method !== "innerHTML") { jz.observe(document.body); }
|
||||
if (method === "textContent") { selectorParent.textContent = ""; }
|
||||
else if (method === "replaceChildren") { selectorParent.replaceChildren(); }
|
||||
else
|
||||
{
|
||||
selectorParent.innerHTML = "";
|
||||
jz.rerender(document.body);
|
||||
}
|
||||
if (method !== "innerHTML")
|
||||
{
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
jz.disconnect();
|
||||
}
|
||||
|
||||
assert.equal(paragraph.textContent, "\u{20000}A", `${method} preserves author deletion`);
|
||||
assert.equal(paragraph.textContent.includes("\u{E0100}"), false, `${method} has zero selectors`);
|
||||
assert.equal(oldSelector.isConnected, false, `${method} stale selector stays discarded`);
|
||||
assert.equal(document.getElementById("empty-base"), base, `${method} base identity`);
|
||||
assert.equal(document.getElementById("empty-selector"), selectorParent, `${method} parent identity`);
|
||||
assert.equal(selectorParent.childNodes.length, 0, `${method} original parent remains empty`);
|
||||
const gap = paragraph.querySelector("jz-hws");
|
||||
assert.ok(gap, `${method} rebuilds ordinary base-to-Latin gap`);
|
||||
assert.equal(gap.textContent, "\u{20000}", `${method} ordinary gap wraps only base`);
|
||||
assert.equal(
|
||||
gap.hasAttribute("data-jz-cross-node-atom"),
|
||||
false,
|
||||
`${method} ordinary gap is not cross-node`,
|
||||
);
|
||||
assertNoUnpairedSurrogates(paragraph.textContent);
|
||||
|
||||
jz.revert(document.body);
|
||||
assert.equal(
|
||||
paragraph.innerHTML,
|
||||
'<strong id="empty-base">\u{20000}</strong><em id="empty-selector"></em>A',
|
||||
`${method} revert keeps selector deletion`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("moved IVS ownership: externally moved selector-only anchor restores to original parent", () =>
|
||||
{
|
||||
const original = '<strong id="anchor-base">\u{20000}</strong>'
|
||||
+ '<em id="anchor-selector">\u{E0100}</em>A';
|
||||
for (const side of [ "common", "base", "original-parent", "external-anchor" ])
|
||||
{
|
||||
const { document } = setupDom(`<div id="anchor-outside"></div><p>${original}</p>`);
|
||||
const paragraph = document.querySelector("p");
|
||||
const base = document.getElementById("anchor-base");
|
||||
const selectorParent = document.getElementById("anchor-selector");
|
||||
const outside = document.getElementById("anchor-outside");
|
||||
let clicks = 0;
|
||||
selectorParent.addEventListener("click", () => { clicks += 1; });
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
jz.render(document.body);
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
paragraph,
|
||||
document.defaultView.NodeFilter.SHOW_COMMENT,
|
||||
);
|
||||
let anchor = null;
|
||||
while (walker.nextNode())
|
||||
{
|
||||
if (walker.currentNode.data === "jz-spacing-anchor")
|
||||
{
|
||||
anchor = walker.currentNode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(anchor, `${side} original-side anchor`);
|
||||
assert.equal(selectorParent.childNodes.length, 1, `${side} selector parent initially has anchor`);
|
||||
outside.appendChild(anchor);
|
||||
assert.equal(selectorParent.childNodes.length, 0, `${side} original parent is now empty`);
|
||||
|
||||
const root = side === "common" ? paragraph
|
||||
: side === "base" ? base
|
||||
: side === "original-parent" ? selectorParent
|
||||
: outside;
|
||||
jz.revert(root);
|
||||
|
||||
assert.equal(paragraph.innerHTML, original, `${side} exact author DOM`);
|
||||
assert.equal(paragraph.querySelector("#anchor-base"), base, `${side} base identity`);
|
||||
assert.equal(paragraph.querySelector("#anchor-selector"), selectorParent, `${side} parent identity`);
|
||||
assert.equal(selectorParent.textContent, "\u{E0100}", `${side} selector preserved`);
|
||||
assert.equal(countTag(paragraph, "jz-hws"), 0, `${side} linked gap removed`);
|
||||
const outsideComments = Array.from(outside.childNodes)
|
||||
.filter((node) => node.nodeType === 8 && node.data.startsWith("jz-spacing-"));
|
||||
assert.equal(outsideComments.length, 0, `${side} external parent has no internal comment`);
|
||||
selectorParent.dispatchEvent(new document.defaultView.Event("click"));
|
||||
assert.equal(clicks, 1, `${side} listener`);
|
||||
assertNoUnpairedSurrogates(paragraph.textContent);
|
||||
}
|
||||
});
|
||||
|
||||
test("moved IVS ownership: anchor and text-move throws roll back exactly", () =>
|
||||
{
|
||||
const fixture = '<p><strong id="throw-author">\u{20000}</strong><em>\u{E0100}A</em></p>';
|
||||
const modes = [ "original-anchor", "gap-anchor", "selector" ];
|
||||
for (const mode of modes)
|
||||
{
|
||||
for (const throwAfterNative of [ false, true ])
|
||||
{
|
||||
const { document } = setupDom(fixture);
|
||||
const paragraph = document.querySelector("p");
|
||||
const author = document.getElementById("throw-author");
|
||||
const nodePrototype = document.defaultView.Node.prototype;
|
||||
const insertBefore = nodePrototype.insertBefore;
|
||||
const appendChild = nodePrototype.appendChild;
|
||||
const error = new Error(`${mode}-${throwAfterNative ? "after" : "before"}`);
|
||||
let thrown = false;
|
||||
|
||||
const shouldThrow = (node) => mode === "original-anchor"
|
||||
? node.nodeType === 8 && node.data === "jz-spacing-anchor"
|
||||
: mode === "gap-anchor"
|
||||
? node.nodeType === 8 && node.data === "jz-spacing-gap-anchor"
|
||||
: node.nodeType === 3 && node.data === "\u{E0100}";
|
||||
nodePrototype.insertBefore = function (node, reference)
|
||||
{
|
||||
if (!thrown && shouldThrow(node) && mode === "original-anchor")
|
||||
{
|
||||
thrown = true;
|
||||
if (!throwAfterNative) { throw error; }
|
||||
const result = insertBefore.call(this, node, reference);
|
||||
throw error;
|
||||
return result;
|
||||
}
|
||||
return insertBefore.call(this, node, reference);
|
||||
};
|
||||
nodePrototype.appendChild = function (node)
|
||||
{
|
||||
if (!thrown && shouldThrow(node) && mode !== "original-anchor")
|
||||
{
|
||||
thrown = true;
|
||||
if (!throwAfterNative) { throw error; }
|
||||
const result = appendChild.call(this, node);
|
||||
throw error;
|
||||
return result;
|
||||
}
|
||||
return appendChild.call(this, node);
|
||||
};
|
||||
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
try { assert.throws(() => jz.render(paragraph), (got) => got === error); }
|
||||
finally
|
||||
{
|
||||
nodePrototype.insertBefore = insertBefore;
|
||||
nodePrototype.appendChild = appendChild;
|
||||
}
|
||||
assert.equal(thrown, true, `${mode} ${throwAfterNative} exercised`);
|
||||
assert.equal(paragraph.innerHTML, fixture.slice(3, -4), `${mode} exact rollback`);
|
||||
assert.equal(countTag(paragraph, "jz-hws"), 0);
|
||||
assert.equal(
|
||||
Array.from(paragraph.childNodes).filter((node) => node.nodeType === 8).length,
|
||||
0,
|
||||
`${mode} leaves no direct anchor`,
|
||||
);
|
||||
assert.equal(document.getElementById("throw-author"), author);
|
||||
|
||||
jz.render(paragraph);
|
||||
assert.equal(countTag(paragraph, "jz-hws"), 1, `${mode} retry`);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, fixture.slice(3, -4), `${mode} retry/revert`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("moved IVS ownership: partial roots and detached links restore as one unit", () =>
|
||||
{
|
||||
const original = '<strong id="partial-base">\u{20000}</strong>'
|
||||
+ '<em id="partial-selector">\u{E0100}A</em>';
|
||||
const cases = [ "common", "anchor-side", "base-side", "detached-gap", "moved-anchor", "detached" ];
|
||||
for (const kind of cases)
|
||||
{
|
||||
const { document } = setupDom(
|
||||
`<div id="outside"></div><div id="partial-container"><p>${original}</p></div>`,
|
||||
);
|
||||
const paragraph = document.querySelector("p");
|
||||
const container = document.getElementById("partial-container");
|
||||
const base = document.getElementById("partial-base");
|
||||
const selectorParent = document.getElementById("partial-selector");
|
||||
const outside = document.getElementById("outside");
|
||||
let clicks = 0;
|
||||
base.addEventListener("click", () => { clicks += 1; });
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
jz.render(kind === "detached" ? paragraph : document.body);
|
||||
const gap = paragraph.querySelector("jz-hws");
|
||||
const anchorWalker = document.createTreeWalker(
|
||||
paragraph,
|
||||
document.defaultView.NodeFilter.SHOW_COMMENT,
|
||||
);
|
||||
let anchor = null;
|
||||
while (anchorWalker.nextNode())
|
||||
{
|
||||
if (anchorWalker.currentNode.data === "jz-spacing-anchor")
|
||||
{
|
||||
anchor = anchorWalker.currentNode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(anchor, `${kind} anchor exists`);
|
||||
|
||||
let revertRoot = paragraph;
|
||||
if (kind === "anchor-side") { revertRoot = selectorParent; }
|
||||
else if (kind === "base-side") { revertRoot = base; }
|
||||
else if (kind === "detached-gap")
|
||||
{
|
||||
gap.parentNode.removeChild(gap);
|
||||
revertRoot = selectorParent;
|
||||
}
|
||||
else if (kind === "moved-anchor")
|
||||
{
|
||||
outside.appendChild(anchor);
|
||||
revertRoot = base;
|
||||
}
|
||||
else if (kind === "detached")
|
||||
{
|
||||
paragraph.parentNode.removeChild(paragraph);
|
||||
revertRoot = container;
|
||||
}
|
||||
|
||||
jz.revert(revertRoot);
|
||||
assert.equal(paragraph.innerHTML, original, `${kind} restores exact author DOM`);
|
||||
assert.equal(paragraph.querySelector("#partial-base"), base, `${kind} base identity`);
|
||||
assert.equal(paragraph.querySelector("#partial-selector"), selectorParent, `${kind} parent identity`);
|
||||
assert.equal(selectorParent.textContent, "\u{E0100}A", `${kind} selector original parent`);
|
||||
assert.equal(countTag(paragraph, "jz-hws"), 0, `${kind} removes linked gap`);
|
||||
assert.equal(
|
||||
Array.from(outside.childNodes).filter((node) => node.nodeType === 8).length,
|
||||
0,
|
||||
`${kind} never follows anchor to arbitrary parent`,
|
||||
);
|
||||
base.dispatchEvent(new document.defaultView.Event("click"));
|
||||
assert.equal(clicks, 1, `${kind} listener`);
|
||||
assertNoUnpairedSurrogates(paragraph.textContent);
|
||||
|
||||
if (kind === "detached")
|
||||
{
|
||||
container.appendChild(paragraph);
|
||||
jz.render(paragraph);
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, original, "detached record was cleaned before reuse");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("IVS spacing survives orphan reparenting without replacing author elements", () =>
|
||||
{
|
||||
const samples = [
|
||||
[
|
||||
"ivs-forward-space",
|
||||
'<strong id="orphan-author-ivs-forward">甲\u{20000}</strong>\u{E0100} A',
|
||||
'<strong id="orphan-author-ivs-forward">甲\u{20000}</strong>\u{E0100}A',
|
||||
"\u{20000}\u{E0100}",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"bmp-forward",
|
||||
'<strong id="orphan-author-bmp-forward">甲中</strong>\uFE00A',
|
||||
'<strong id="orphan-author-bmp-forward">甲中</strong>\uFE00A',
|
||||
"中\uFE00",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"bmp-forward-nested-carrier",
|
||||
'<strong id="orphan-author-bmp-nested-base">甲中</strong>'
|
||||
+ '<em id="orphan-author-bmp-nested-vs">\uFE00 A</em>',
|
||||
'<strong id="orphan-author-bmp-nested-base">甲中</strong>'
|
||||
+ '<em id="orphan-author-bmp-nested-vs">\uFE00A</em>',
|
||||
"中\uFE00",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"ivs-reverse-space",
|
||||
'A <strong id="orphan-author-ivs-reverse">\u{20000}</strong>\u{E0100}乙',
|
||||
'A<strong id="orphan-author-ivs-reverse">\u{20000}</strong>\u{E0100}乙',
|
||||
"A",
|
||||
false,
|
||||
],
|
||||
[
|
||||
"bmp-reverse",
|
||||
'A<strong id="orphan-author-bmp-reverse">中</strong>\uFE00乙',
|
||||
'A<strong id="orphan-author-bmp-reverse">中</strong>\uFE00乙',
|
||||
"A",
|
||||
false,
|
||||
],
|
||||
];
|
||||
const { document } = setupDom(samples.map(([ id, html ]) => `<p id="${id}">${html}</p>`).join(""));
|
||||
const authors = Array.from(document.querySelectorAll('[id^="orphan-author-"]'));
|
||||
const clickCounts = new Map(authors.map((el) => [ el, 0 ]));
|
||||
for (const author of authors)
|
||||
{
|
||||
author.addEventListener("click", () => clickCounts.set(author, clickCounts.get(author) + 1));
|
||||
}
|
||||
|
||||
const jz = createJuzhen({ jiya: false, jinze: false, longWord: false });
|
||||
jz.render(document.body);
|
||||
|
||||
for (const [ id, , normalized, carrierText, insideOrphan ] of samples)
|
||||
{
|
||||
const paragraph = document.getElementById(id);
|
||||
const gap = paragraph.querySelector("jz-hws");
|
||||
const orphan = paragraph.querySelector("jz-orphan");
|
||||
assert.ok(gap, `${id} gap survives orphan`);
|
||||
assert.ok(orphan, `${id} orphan is active`);
|
||||
assert.equal(gap.textContent, carrierText, `${id} carrier text`);
|
||||
assert.equal(orphan.contains(gap), insideOrphan, `${id} carrier placement`);
|
||||
if (insideOrphan)
|
||||
{
|
||||
assert.equal(gap.getAttribute("data-jz-kind"), "wrap", `${id} uses atomic wrapper`);
|
||||
}
|
||||
assert.equal(paragraph.textContent, setupDom(`<p>${normalized}</p>`).document.body.textContent, id);
|
||||
assertNoUnpairedSurrogates(paragraph.textContent);
|
||||
}
|
||||
for (const author of authors)
|
||||
{
|
||||
assert.equal(document.getElementById(author.id), author, `${author.id} identity`);
|
||||
assert.equal(document.querySelectorAll(`[id="${author.id}"]`).length, 1, `${author.id} unique`);
|
||||
author.dispatchEvent(new document.defaultView.Event("click"));
|
||||
assert.equal(clickCounts.get(author), 1, `${author.id} listener`);
|
||||
}
|
||||
|
||||
jz.render(document.body);
|
||||
for (const [ id ] of samples)
|
||||
{
|
||||
assert.equal(countTag(document.getElementById(id), "jz-hws"), 1, `${id} repeated render`);
|
||||
}
|
||||
|
||||
jz.revert(document.body);
|
||||
for (const [ id, , normalized ] of samples)
|
||||
{
|
||||
const paragraph = document.getElementById(id);
|
||||
assert.equal(paragraph.innerHTML, normalized, id);
|
||||
assertNoUnpairedSurrogates(paragraph.textContent);
|
||||
}
|
||||
for (const author of authors)
|
||||
{
|
||||
assert.equal(document.getElementById(author.id), author, `${author.id} revert identity`);
|
||||
author.dispatchEvent(new document.defaultView.Event("click"));
|
||||
assert.equal(clickCounts.get(author), 2, `${author.id} revert listener`);
|
||||
}
|
||||
});
|
||||
|
||||
test("IVS spacing: pill neighbors use complete sequences and UTF-16 offsets", () =>
|
||||
{
|
||||
const ivs = "\u{20000}\u{E0100}";
|
||||
const { document } = setupDom(
|
||||
`<p id="left">${ivs} <code>x</code></p>`
|
||||
+ `<p id="right"><code>x</code> ${ivs}</p>`,
|
||||
);
|
||||
const left = document.getElementById("left");
|
||||
const right = document.getElementById("right");
|
||||
const jz = createJuzhen(TEXT_ONLY);
|
||||
|
||||
jz.render(document.body);
|
||||
assert.equal(left.textContent, ivs + "x", "author space before pill is removed without splitting IVS");
|
||||
assert.equal(right.textContent, "x" + ivs, "author space after pill is removed without splitting IVS");
|
||||
assert.equal(left.querySelector("jz-hws").textContent, ivs);
|
||||
assert.equal(countTag(left, "jz-hws"), 1);
|
||||
assert.equal(countTag(right, "jz-hws"), 1);
|
||||
assertNoUnpairedSurrogates(document.body.textContent);
|
||||
|
||||
jz.revert(document.body);
|
||||
assert.equal(left.textContent, ivs + "x");
|
||||
assert.equal(right.textContent, "x" + ivs);
|
||||
assertNoUnpairedSurrogates(document.body.textContent);
|
||||
});
|
||||
|
||||
test("IVS jinze: opening and closing punctuation bind one complete variation sequence", () =>
|
||||
{
|
||||
const ivs = "\u{20000}\u{E0100}";
|
||||
const { document } = setupDom(
|
||||
`<p id="open" lang="zh-Hant">「${ivs}A</p>`
|
||||
+ `<p id="close" lang="zh-Hant">A${ivs}」</p>`,
|
||||
);
|
||||
const open = document.getElementById("open");
|
||||
const close = document.getElementById("close");
|
||||
const originalOpen = open.innerHTML;
|
||||
const originalClose = close.innerHTML;
|
||||
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
|
||||
|
||||
jz.render(document.body);
|
||||
assert.equal(open.querySelector("jz-jinze").textContent, "「" + ivs);
|
||||
assert.equal(close.querySelector("jz-jinze").textContent, ivs + "」");
|
||||
assert.equal(open.textContent, "「" + ivs + "A");
|
||||
assert.equal(close.textContent, "A" + ivs + "」");
|
||||
assertNoUnpairedSurrogates(document.body.textContent);
|
||||
|
||||
jz.revert(document.body);
|
||||
assert.equal(open.innerHTML, originalOpen);
|
||||
assert.equal(close.innerHTML, originalClose);
|
||||
});
|
||||
|
||||
test("IVS splitoff: transparent inline splits after the full variation sequence", () =>
|
||||
{
|
||||
const ivs = "\u{20000}\u{E0100}";
|
||||
const { document } = setupDom(`<p lang="zh-Hant">「<strong>${ivs}A</strong></p>`);
|
||||
const paragraph = document.querySelector("p");
|
||||
const originalHtml = paragraph.innerHTML;
|
||||
const jz = createJuzhen({ spacing: false, longWord: false, orphan: false });
|
||||
|
||||
jz.render(paragraph);
|
||||
assert.equal(paragraph.querySelector("jz-jinze").textContent, "「" + ivs);
|
||||
const splitoff = paragraph.querySelector('strong[data-jz-kind="splitoff"]');
|
||||
assert.ok(splitoff);
|
||||
assert.equal(splitoff.textContent, "A");
|
||||
assert.equal(paragraph.textContent, "「" + ivs + "A");
|
||||
assertNoUnpairedSurrogates(paragraph.textContent);
|
||||
|
||||
jz.revert(paragraph);
|
||||
assert.equal(paragraph.innerHTML, originalHtml);
|
||||
});
|
||||
|
||||
test("IVS orphan: base counts once, selector counts zero, and split keeps the sequence", () =>
|
||||
{
|
||||
const ivs = "\u{20000}\u{E0100}";
|
||||
const { document } = setupDom(`<p id="enough">A${ivs}甲</p><p id="short">A${ivs}</p>`);
|
||||
const enough = document.getElementById("enough");
|
||||
const short = document.getElementById("short");
|
||||
const enoughHtml = enough.innerHTML;
|
||||
const shortHtml = short.innerHTML;
|
||||
const jz = createJuzhen({ ...TEXT_ONLY, orphan: { chars: 2 }, spacing: false });
|
||||
|
||||
jz.render(document.body);
|
||||
assert.equal(enough.querySelector("jz-orphan").textContent, ivs + "甲");
|
||||
assert.equal(short.querySelector("jz-orphan"), null, "one base plus VS is only one substantive CJK");
|
||||
assertNoUnpairedSurrogates(document.body.textContent);
|
||||
|
||||
jz.revert(document.body);
|
||||
assert.equal(enough.innerHTML, enoughHtml);
|
||||
assert.equal(short.innerHTML, shortHtml);
|
||||
});
|
||||
|
||||
test("variation selectors: punctuation suffix is atomic and selectors without a base are not CJK", () =>
|
||||
{
|
||||
const punctuation = setupDom('<p lang="zh-Hant">,\uFE00A</p>');
|
||||
const jz = createJuzhen({ spacing: false, jinze: false, longWord: false, orphan: false });
|
||||
jz.render(punctuation.document.body);
|
||||
const char = punctuation.document.querySelector("jz-char.bd-pause");
|
||||
assert.ok(char);
|
||||
assert.equal(char.textContent, ",\uFE00");
|
||||
assert.equal(countTag(punctuation.document.body, "jz-char"), 1);
|
||||
jz.revert(punctuation.document.body);
|
||||
assert.equal(punctuation.document.body.textContent, ",\uFE00A");
|
||||
|
||||
const standalone = setupDom('<p id="bmp">\uFE00A</p><p id="supp">\u{E0100}A</p>');
|
||||
createJuzhen(TEXT_ONLY).render(standalone.document.body);
|
||||
assert.equal(countTag(standalone.document.getElementById("bmp"), "jz-hws"), 0);
|
||||
assert.equal(countTag(standalone.document.getElementById("supp"), "jz-hws"), 0);
|
||||
});
|
||||
|
||||
test("charClass append remains additive for BMP and astral variation sequences", () =>
|
||||
{
|
||||
const base = setupDom('<p id="bmp">〇A</p><p id="astral">😀\u{E0100}A</p>');
|
||||
createJuzhen({ ...TEXT_ONLY, charClass: { cjk: "〇😀" } }).render(base.document.body);
|
||||
assert.equal(countTag(base.document.getElementById("bmp"), "jz-hws"), 1);
|
||||
assert.equal(countTag(base.document.getElementById("astral"), "jz-hws"), 1);
|
||||
|
||||
const punctuation = setupDom('<p lang="zh-Hant">A😀B</p>');
|
||||
const jz = createJuzhen({
|
||||
spacing: false,
|
||||
jinze: false,
|
||||
longWord: false,
|
||||
orphan: false,
|
||||
charClass: { biaodian: { pause: "😀" } },
|
||||
});
|
||||
jz.render(punctuation.document.body);
|
||||
const char = punctuation.document.querySelector("jz-char.bd-pause");
|
||||
assert.ok(char, "astral custom punctuation is charified once");
|
||||
assert.equal(char.textContent, "😀");
|
||||
assert.equal(countTag(punctuation.document.body, "jz-char"), 1);
|
||||
assertNoUnpairedSurrogates(punctuation.document.body.textContent);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user