c5a503580c
承 Tier1(S2+S3,已於 main 168e9e7)。本批為效能第二層+第三層,全程獨立複驗(差分/往返/ 增量≡全量/進程隔離測速/真瀏覽器)+4 份真人撰寫真實文檔驗收,行為保真、提速真實。 掃描層(無狀態、保行為): - 中間發現+S4:insidePill/isAvoided 之「逐文本節點沿祖先鏈」以 ancestorOrSelfPill/…Avoided 迭代回填記憶化(每元素恰算一次、兄弟共用祖先鏈快取),O(文本×depth)→O(元素)。實測掃描層 el.matches() 累計約降五成;深嵌套 render 進程隔離墻鐘 pre-Tier1 707ms→254ms(2.8×)。 佈局層(僅瀏覽器、正確性敏感): - S1 gapTrim 對齊門:尾隨 margin 僅在右緣 flush 之對齊可見,唯 left/start(ltr) 之 ragged 右緣 可整批跳過,省其 O(gaps) 次同步重排。實測非兩端對齊內容 gapTrim 數千 ms→數十 ms;justify/ center/right/RTL 保留完整逐一定案、行為不變(真瀏覽器差分:justify trim 集指紋逐位相同)。 增量恢復(第三層,向下兼容 API): - C1 持久化 Finder:跨 render 復用元素鍵快取(category/level/feature/祖先游走)。正確性靠 失效:新增 finder.invalidate(el) 清整棵子樹快取(祖先屬性變則後代快取變脏),revert 自動失效 其子樹(安全預設)。 - C3 rerender(root)=revert+render;局部恢復(實測編輯 400 段中一段 766×、matches 1441×)。 - C2 observe/disconnect:MutationObserver 自動增量——內容/分類屬性變化重處理最近塊、新增之塊 直接 render;render/revert 期間暫停觀察,自身注入絕不自觸發。 - append 修復(IV-3 差分複驗發現、單測漏掉):向葉塊(帶 jz-orphan)追加子塊使其轉非葉,須撤 容器陳舊 orphan——追加塊時若塊級父有 jz-orphan 直接子則改重處理該父。 測試 130→140(+S2 非葉塊、C1/C3、C2 觀察/失效/append、不自觸發)。基準設施:bench-incremental (增量恢復)、bench-layout(版面層,headless Chrome)。dist 重建。
1525 lines
81 KiB
JavaScript
1525 lines
81 KiB
JavaScript
// 聚珍切片 1 jsdom 測試(架構 §12):驗證 DOM 變換正確性——包裝結構、
|
||
// bd-* class、邊界、冪等、revert 還原、copy-clean、lang 政策分支、作用域。
|
||
|
||
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import { fileURLToPath } from "node:url";
|
||
import { test } from "node:test";
|
||
|
||
import { setupDom, countTag } from "./helpers.mjs";
|
||
import { createJuzhen } from "../dist/juzhen.mjs";
|
||
|
||
// ----- 中西間隙(spacing)-----
|
||
|
||
test("spacing:CJK↔Latin 邊界以 jz-hws 包裹左側字(margin 留隙,不注入空白)", () =>
|
||
{
|
||
const { document } = setupDom("<p>我用Neovim寫程式</p>");
|
||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.equal(countTag(p, "jz-hws"), 2, "用|Neovim 與 Neovim|寫 兩處邊界,各包左側字");
|
||
assert.equal(p.textContent, "我用Neovim寫程式", "textContent 完全不變(無注入空白,純 copy-clean)");
|
||
assert.equal(p.querySelector("jz-hws").textContent.length, 1, "jz-hws 內為單一邊界字");
|
||
});
|
||
|
||
test("spacing:textContent 完全不變(margin 留隙,不注入也不刪語義字元)", () =>
|
||
{
|
||
const { document } = setupDom("<p>用Neovim,很爽。</p>");
|
||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
|
||
assert.equal(document.querySelector("p").textContent, "用Neovim,很爽。");
|
||
});
|
||
|
||
test("spacing:冪等——重跑不重複插入 jz-hws", () =>
|
||
{
|
||
const { document } = setupDom("<p>我用Neovim寫程式</p>");
|
||
const jz = createJuzhen({ jiya: false, longWord: false, jinze: false });
|
||
jz.render(document.body);
|
||
const first = countTag(document.body, "jz-hws");
|
||
jz.render(document.body);
|
||
assert.equal(countTag(document.body, "jz-hws"), first, "第二次 render 不應增加");
|
||
});
|
||
|
||
test("spacing:revert 還原至原文(無 jz-hws,textContent 回到原樣)", () =>
|
||
{
|
||
const { document } = setupDom("<p>我用Neovim寫程式</p>");
|
||
const jz = createJuzhen({ jiya: false, longWord: false, jinze: false });
|
||
jz.render(document.body);
|
||
jz.revert(document.body);
|
||
assert.equal(countTag(document.body, "jz-hws"), 0);
|
||
assert.equal(document.querySelector("p").textContent, "我用Neovim寫程式");
|
||
});
|
||
|
||
test("spacing:全角省略號 …… 後接 CJK 不插中西間隙(問題 B:原 DOTS_CJK `|…` 誤匹配)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">他竟然走了……留下一地</p>');
|
||
createJuzhen({ lang: { style: "quanjiao" } }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
// …… 屬 CJK 標點(已 charify 為 bd-liga),後接 CJK 不應有西文間隙。
|
||
assert.equal(countTag(p, "jz-hws"), 0, "…… 與後接 CJK 之間不應有 jz-hws");
|
||
});
|
||
|
||
test("spacing:貨幣符號 € 視為西文——與 CJK 邊界補隙、手工空格正規化(問題 D)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">售價 €1099 都行</p>');
|
||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.equal(/售價\s€/.test(p.textContent), false, "售價↔€ 之手工空格應正規化剝除");
|
||
assert.ok(countTag(p, "jz-hws") >= 2, "價↔€ 與 9↔都 兩處中西邊界各補 jz-hws");
|
||
});
|
||
|
||
test("spacing:擴充西文集——拼音聲調 ā/希臘 α/西里爾 Я 皆視為西文補隙(問題 D 完整化)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">中ā文α字Я尾</p>');
|
||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
// ā(U+0101 拉丁擴充)、α(U+03B1 希臘,驗證希臘集補全)、Я(U+042F 西里爾) 皆為西文。
|
||
assert.ok(countTag(p, "jz-hws") >= 5, "ā/α/Я 與相鄰 CJK 各成邊界、補 jz-hws");
|
||
});
|
||
|
||
test("spacing:CJK 標點介於 CJK 間一律不補中西隙(標點稽核:。!?「」()《》……—— 等)", () =>
|
||
{
|
||
const samples = [
|
||
"前。後", "前!後", "前?後", "前,後", "前、後", "前;後", "前:後",
|
||
"前「後", "前」後", "前(後", "前)後", "前《後", "前》後", "前【後", "前】後",
|
||
"前〔後", "前〕後", "前……後", "前——後", "前·後", "前~後", "前“後", "前”後",
|
||
];
|
||
for (const s of samples)
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">' + s + "</p>");
|
||
createJuzhen({ lang: { style: "quanjiao" } }).render(document.body);
|
||
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, s + " 不應有 jz-hws");
|
||
}
|
||
});
|
||
|
||
test("spacing:收尾彎引號 ” 後接拉丁/數字不補隙(標點稽核移除 QUOTE_AN ” 偏例,與 」)》 一致)", () =>
|
||
{
|
||
// 原 pangu QUOTE_AN 令 ”+字母數字補隙,使 ” 成唯一會補隙之收尾標點(其他收尾標點皆不)。
|
||
const { document } = setupDom('<p lang="zh-Hans">“中文”ABC 與 ”123</p>');
|
||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
// “”ABC、”123 中之彎引號鄰拉丁/數字皆不補隙;惟「與」兩側作者空格為 CJK↔Latin
|
||
// 邊界(與|ABC 之 ABC 結尾、123|與 之類)仍會處理,故只斷言不含「彎引號旁之隙」——
|
||
// 以 textContent 不變 + 彎引號未被 jz-hws 包裹驗證。
|
||
const hwsTexts = Array.from(p.querySelectorAll("jz-hws")).map((h) => h.textContent);
|
||
assert.equal(hwsTexts.includes("”"), false, "收尾彎引號 ” 不應被包入 jz-hws(不補隙)");
|
||
});
|
||
|
||
test("spacing:跨樣式行內邊界(<strong>是</strong>Neovim)亦識別(I7)", () =>
|
||
{
|
||
const { document } = setupDom("<p>這<strong>是</strong>Neovim</p>");
|
||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
|
||
// 是|Neovim 邊界(跨 </strong>)應插入 jz-hws。
|
||
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 1);
|
||
});
|
||
|
||
test("spacing:avoid 子樹(code/pre)不處理", () =>
|
||
{
|
||
const { document } = setupDom("<p>看<code>foo是bar</code>這</p>");
|
||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
|
||
// code 內 foo是bar 不應被 charify/spacing;但 pill 邊界(看|code、code|這)會加間隙。
|
||
assert.equal(document.querySelector("code").textContent, "foo是bar");
|
||
});
|
||
|
||
// ----- pill 邊界(spacing Pass B)-----
|
||
|
||
test("spacing:pill(code)兩側與 CJK 相鄰插入 jz-hws", () =>
|
||
{
|
||
const { document } = setupDom("<p>用<code>vim</code>編輯</p>");
|
||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
|
||
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 2);
|
||
});
|
||
|
||
// T6:processPills 須過 per-node 功能閘——data-juzhen-off="spacing" 區域內 pill 不補間隙。
|
||
test("spacing T6:data-juzhen-off=spacing 區域內 pill 邊界不補 jz-hws", () =>
|
||
{
|
||
const { document } = setupDom('<p data-juzhen-off="spacing">用<code>vim</code>編輯</p>');
|
||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
|
||
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, "spacing 停用區域內 pill 不補隙");
|
||
});
|
||
|
||
// T5:jiyaAdjacency 須把 pill 當相鄰邊界(斷相鄰、不穿越)——pill 兩側之標點不應被當
|
||
// 連續標點對而擠壓(pill 內容對排版不可見)。空 pill 才能凸顯此路徑(含文本之 pill 由
|
||
// 其內文本天然斷相鄰)。
|
||
test("jiya T5:pill 隔斷標點相鄰——,<code></code>「不互擠(pill 斷相鄰)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">甲,<code></code>「乙」</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false, lang: { style: "quanjiao" } })
|
||
.render(document.body);
|
||
const comma = document.querySelector("jz-char.bd-pause"); // ,
|
||
assert.ok(comma, ",已 charify");
|
||
assert.ok(!comma.classList.contains("jz-half"), ",與「之間隔 pill → 不視為相鄰、不擠壓");
|
||
});
|
||
|
||
// ----- 標點擠壓(jiya)-----
|
||
|
||
test("jiya:標點 charify 為 jz-char>jz-inner,標 bd-* class,textContent 不變(copy-clean)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">他說:「好,走。」</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.equal(p.textContent, "他說:「好,走。」", "標點被包裝但文字不變");
|
||
assert.ok(countTag(p, "jz-char") >= 5);
|
||
assert.ok(countTag(p, "jz-inner") >= 5);
|
||
const pause = p.querySelector("jz-char.bd-pause");
|
||
assert.ok(pause, ",/:應標 bd-pause");
|
||
const stop = p.querySelector("jz-char.bd-stop");
|
||
assert.ok(stop, "。應標 bd-stop");
|
||
});
|
||
|
||
test("jiya:開明式(zh-Hans)句內點號半形(jz-half),句末點號不收(機制 C)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">好,走。</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
const comma = p.querySelector("jz-char.bd-pause"); // ,
|
||
const period = p.querySelector("jz-char.bd-stop"); // 。
|
||
assert.ok(comma.classList.contains("jz-half"), ",開明式半形(halt)");
|
||
assert.ok(!period.classList.contains("jz-half"), "。句末保持全形");
|
||
});
|
||
|
||
test("jiya:開明式括號引號半形(開類/閉類皆 jz-half)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">說「好」嗎</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.ok(p.querySelector("jz-char.bd-open").classList.contains("jz-half"), "「半形");
|
||
assert.ok(p.querySelector("jz-char.bd-close").classList.contains("jz-half"), "」半形");
|
||
});
|
||
|
||
test("jiya:全角式(zh-Hant)baseline 一律不收(孤立標點無 jz-half)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">好,走。</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
assert.equal(countTag(document.querySelector("p"), "jz-char.jz-half"), 0, "全角式不擠(無相鄰標點)");
|
||
});
|
||
|
||
// ----- 逐元素標點樣式覆寫(data-jz-style 屬性 / lang.stylePolicy 選擇器)-----
|
||
|
||
test("樣式覆寫:data-jz-style=\"banjiao\" 令該子樹句末點號半形,p 仍走 lang 之全角", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<div lang="zh-Hant"><h1 data-jz-style="banjiao">甲。乙</h1><p>丙。丁</p></div>',
|
||
);
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
assert.ok(document.querySelector("h1 jz-char.bd-stop").classList.contains("jz-half"),
|
||
"h1(banjiao)句末點號半形");
|
||
assert.ok(!document.querySelector("p jz-char.bd-stop").classList.contains("jz-half"),
|
||
"p(zh-Hant 全角式)句末點號不收");
|
||
});
|
||
|
||
test("樣式覆寫:lang.stylePolicy 選擇器命中之元素用指定樣式(h2→banjiao)", () =>
|
||
{
|
||
const { document } = setupDom('<div lang="zh-Hans"><h2>甲。乙</h2><p>丙。丁</p></div>');
|
||
createJuzhen({
|
||
spacing: false, longWord: false, jinze: false,
|
||
lang: { default: "zh-Hant", stylePolicy: [ { selector: "h2", style: "banjiao" } ] },
|
||
}).render(document.body);
|
||
assert.ok(document.querySelector("h2 jz-char.bd-stop").classList.contains("jz-half"),
|
||
"h2(stylePolicy banjiao)句末點號半形");
|
||
assert.ok(!document.querySelector("p jz-char.bd-stop").classList.contains("jz-half"),
|
||
"p(zh-Hans 開明式)句末點號不收");
|
||
});
|
||
|
||
test("樣式覆寫:屬性優先於選擇器(data-jz-style=\"quanjiao\" 覆蓋 stylePolicy 之 banjiao)", () =>
|
||
{
|
||
const { document } = setupDom('<h1 lang="zh-Hant" data-jz-style="quanjiao">甲。乙</h1>');
|
||
createJuzhen({
|
||
spacing: false, longWord: false, jinze: false,
|
||
lang: { stylePolicy: [ { selector: "h1", style: "banjiao" } ] },
|
||
}).render(document.body);
|
||
assert.ok(!document.querySelector("jz-char.bd-stop").classList.contains("jz-half"),
|
||
"屬性 quanjiao 勝出、句末點號不收");
|
||
});
|
||
|
||
test("樣式覆寫:最近祖先勝(內層 data-jz-style 覆蓋外層)", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<div lang="zh-Hant" data-jz-style="banjiao"><span data-jz-style="quanjiao">甲。乙</span></div>',
|
||
);
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
assert.ok(!document.querySelector("jz-char.bd-stop").classList.contains("jz-half"),
|
||
"內層 span quanjiao 勝過外層 div banjiao、不收");
|
||
});
|
||
|
||
test("樣式覆寫:非法屬性值忽略、回退 lang", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans" data-jz-style="bogus">甲,乙</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
assert.ok(document.querySelector("jz-char.bd-pause").classList.contains("jz-half"),
|
||
"非法值忽略、回退 zh-Hans 開明式(句內點號 ,半形)");
|
||
});
|
||
|
||
test("樣式覆寫 revert:data-jz-style 為作者屬性,render→revert 文字與屬性還原", () =>
|
||
{
|
||
const { document } = setupDom('<h1 lang="zh-Hant" data-jz-style="banjiao">標題。內容</h1>');
|
||
const jz = createJuzhen({ spacing: false, longWord: false, jinze: false });
|
||
jz.render(document.body);
|
||
jz.revert(document.body);
|
||
const h1 = document.querySelector("h1");
|
||
assert.equal(h1.textContent, "標題。內容");
|
||
assert.equal(h1.getAttribute("data-jz-style"), "banjiao", "作者屬性保留");
|
||
assert.equal(countTag(document.body, "jz-char"), 0);
|
||
});
|
||
|
||
test("jiya 機制 A:句末標點後接閉引號,前者半形(。」→ 。jz-half,全角亦然)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">好。」嗎</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
const period = document.querySelector("jz-char.bd-stop"); // 。
|
||
assert.ok(period.classList.contains("jz-half"), "。鄰接閉引號 → 半形(行內連續擠壓)");
|
||
});
|
||
|
||
test("jiya 機制 A:?!→ 僅 ?半形,!保持全形 1em(修正居中字形單側收縮缺陷)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">怎么办?!真</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
const stops = document.querySelectorAll("jz-char.bd-stop"); // ? !
|
||
assert.equal(stops.length, 2);
|
||
assert.ok(stops[0].classList.contains("jz-half"), "?鄰接 ! → 半形");
|
||
assert.ok(!stops[1].classList.contains("jz-half"), "!保持全形(1em,不被單側收縮拖拽重疊)");
|
||
});
|
||
|
||
test("jiya 機制 A:連續開類,後者半形(「「→ 第二個 jz-half)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">「「好</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
const opens = document.querySelectorAll("jz-char.bd-open");
|
||
assert.equal(opens.length, 2);
|
||
assert.ok(!opens[0].classList.contains("jz-half"), "首個開類左側對漢字/行首,不由鄰接收");
|
||
assert.ok(opens[1].classList.contains("jz-half"), "第二個開類內側 → 半形");
|
||
});
|
||
|
||
test("jiya 機制 A:,「→ 兩側皆收半形(雙側獨立判斷,合占 1em)", () =>
|
||
{
|
||
// ,右側有空白且右鄰標點「→ 收,;「左側有空白且左鄰標點,→ 收「。兩者皆半 = 1em。
|
||
const { document } = setupDom('<p lang="zh-Hant">甲,「乙</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
const comma = document.querySelector("jz-char.bd-pause"); // ,
|
||
const open = document.querySelector("jz-char.bd-open"); // 「
|
||
assert.ok(comma.classList.contains("jz-half"), ",右空白鄰標點 → 半形");
|
||
assert.ok(open.classList.contains("jz-half"), "「左空白鄰標點 → 半形(雙側收,非二選一)");
|
||
});
|
||
|
||
test("jiya 機制 A:。」→ 僅 。半形(」左為墨、無空白可挤,全角式合占 1.5em)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">甲。」乙</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
const stop = document.querySelector("jz-char.bd-stop"); // 。
|
||
const close = document.querySelector("jz-char.bd-close"); // 」
|
||
assert.ok(stop.classList.contains("jz-half"), "。右空白鄰標點 → 半形");
|
||
assert.ok(!close.classList.contains("jz-half"), "」左為墨、無空白 → 不收(全角式 1.5em)");
|
||
});
|
||
|
||
test("jiya 機制 A 句界:。「→ 句末點號保留右空白(。全形、「半形;全角/開明皆 1.5em)", () =>
|
||
{
|
||
for (const lang of [ "zh-Hant", "zh-Hans" ]) // 全角式/開明式皆 1.5em
|
||
{
|
||
const { document } = setupDom(`<p lang="${lang}">甲。「乙</p>`);
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
const stop = document.querySelector("jz-char.bd-stop"); // 。
|
||
const open = document.querySelector("jz-char.bd-open"); // 「
|
||
assert.ok(!stop.classList.contains("jz-half"), `${lang}:。句末點號接開類為句界 → 保留右空白(全形)`);
|
||
assert.ok(open.classList.contains("jz-half"), `${lang}:「開類左空白仍收(半形)→ 合占 1.5em`);
|
||
}
|
||
});
|
||
|
||
test("jiya 機制 A 句界:例外僅限句末點號+開類——,「 與 」「 仍兩側皆收 1em", () =>
|
||
{
|
||
// 句內點號(pause)與閉類後接開類不屬句界,照舊兩側收。
|
||
const c = setupDom('<p lang="zh-Hant">甲,「乙</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(c.document.body);
|
||
assert.ok(c.document.querySelector("jz-char.bd-pause").classList.contains("jz-half"), ",仍收(句內點號非句界)");
|
||
assert.ok(c.document.querySelector("jz-char.bd-open").classList.contains("jz-half"), ",「之「仍收");
|
||
|
||
const b = setupDom('<p lang="zh-Hant">甲」「乙</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(b.document.body);
|
||
assert.ok(b.document.querySelector("jz-char.bd-close").classList.contains("jz-half"), "」仍收(雙括號非句界)");
|
||
assert.ok(b.document.querySelector("jz-char.bd-open").classList.contains("jz-half"), "」「之「仍收");
|
||
});
|
||
|
||
test("jiya 機制 A 句界:半角式不回歸——。「 句末點號仍半形(守衛僅跳過 adjacency 追加,baseline 已半)", () =>
|
||
{
|
||
// 半角式所有可壓縮標點皆半形(baseline 機制 C)。句界守衛只跳過 adjacency 之重複
|
||
// 追加,絕不移除 baseline 之 jz-half,故半角式句末點號不得退回全寬。
|
||
const { document } = setupDom("<p>甲。「乙</p>");
|
||
createJuzhen({
|
||
lang: { default: "zh-Hans", style: "banjiao" },
|
||
spacing: false, longWord: false, jinze: false,
|
||
}).render(document.body);
|
||
const stop = document.querySelector("jz-char.bd-stop"); // 。
|
||
const open = document.querySelector("jz-char.bd-open"); // 「
|
||
assert.ok(stop.classList.contains("jz-half"), "半角式 。仍半形(baseline 已收,守衛不回退)");
|
||
assert.ok(open.classList.contains("jz-half"), "半角式 「仍半形 → 合占 1em");
|
||
});
|
||
|
||
test("jiya 機制 A:跨透明 inline 邊界之連續標點亦正確擠(,「 分置 strong/em,雙側皆收)", () =>
|
||
{
|
||
// 下游回饋:原「同 parent 直接兄弟」掃描漏掉跨 strong/em 邊界之連續標點。
|
||
const { document } = setupDom('<p lang="zh-Hant">甲<strong>,</strong><em>「</em>乙</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
assert.ok(document.querySelector("jz-char.bd-pause").classList.contains("jz-half"), "跨邊界 ,半形");
|
||
assert.ok(document.querySelector("jz-char.bd-open").classList.contains("jz-half"), "跨邊界 「半形(透明穿越)");
|
||
});
|
||
|
||
test("jiya 機制 A:多標點鏈 ,」。逐對判斷(,半/」半/。全)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">甲,」。乙</p>');
|
||
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
const comma = p.querySelector("jz-char.bd-pause"); // ,右空白鄰」→ 半
|
||
const close = p.querySelector("jz-char.bd-close"); // 」右空白鄰。→ 半
|
||
const stop = p.querySelector("jz-char.bd-stop"); // 。左為墨、右鄰漢字 → 全
|
||
assert.ok(comma.classList.contains("jz-half"), ",→ 半");
|
||
assert.ok(close.classList.contains("jz-half"), "」右空白鄰標點。→ 半");
|
||
assert.ok(!stop.classList.contains("jz-half"), "。左為墨、右側無標點鄰 → 全");
|
||
});
|
||
|
||
test("jiya:revert 還原標點(無 jz-char,textContent 不變)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">好,走。</p>');
|
||
const jz = createJuzhen({ spacing: false, longWord: false, jinze: false });
|
||
jz.render(document.body);
|
||
jz.revert(document.body);
|
||
assert.equal(countTag(document.body, "jz-char"), 0);
|
||
assert.equal(countTag(document.body, "jz-inner"), 0);
|
||
assert.equal(document.querySelector("p").textContent, "好,走。");
|
||
});
|
||
|
||
// ----- 全域擠壓政策(lang.style)+逐 lang 覆寫(lang.policy)-----
|
||
|
||
const JIYA_ONLY = { spacing: false, longWord: false, jinze: false };
|
||
|
||
test("政策:全域 style=banjiao → 各 lang 句末點號亦半形(。得 jz-half)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">好。</p>');
|
||
createJuzhen({ ...JIYA_ONLY, lang: { style: "banjiao" } }).render(document.body);
|
||
const period = document.querySelector("jz-char.bd-stop");
|
||
assert.ok(period.classList.contains("jz-half"), "半角式:句末亦半形");
|
||
});
|
||
|
||
test("政策:全域 style=kaiming 套用於繁體(覆寫內建全角預設,句內 ,半形)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">好,走</p>');
|
||
createJuzhen({ ...JIYA_ONLY, lang: { style: "kaiming" } }).render(document.body);
|
||
assert.ok(document.querySelector("jz-char.bd-pause").classList.contains("jz-half"), "全域開明 → 繁體句內亦擠");
|
||
});
|
||
|
||
test("政策:全域 style=quanjiao,但 policy 逐 lang 覆寫 zh-Hans→kaiming(最高優先)", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<p lang="zh-Hans">简,</p><p lang="zh-Hant">繁,</p>',
|
||
);
|
||
createJuzhen({
|
||
...JIYA_ONLY,
|
||
lang: { style: "quanjiao", policy: { "zh-Hans": "kaiming" } },
|
||
}).render(document.body);
|
||
const ps = document.querySelectorAll("p");
|
||
assert.ok(ps[0].querySelector("jz-char.bd-pause").classList.contains("jz-half"), "簡體 policy 開明 → 擠");
|
||
assert.ok(!ps[1].querySelector("jz-char.bd-pause").classList.contains("jz-half"), "繁體用全域全角 → 不擠");
|
||
});
|
||
|
||
test("政策:未設 style/policy 時回退內建預設(繁全角不擠、簡開明擠)", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<p lang="zh-Hans">简,</p><p lang="zh-Hant">繁,</p>',
|
||
);
|
||
createJuzhen(JIYA_ONLY).render(document.body);
|
||
const ps = document.querySelectorAll("p");
|
||
assert.ok(ps[0].querySelector("jz-char.bd-pause").classList.contains("jz-half"), "簡體內建開明");
|
||
assert.ok(!ps[1].querySelector("jz-char.bd-pause").classList.contains("jz-half"), "繁體內建全角");
|
||
});
|
||
|
||
// ----- 字形偏側(jz-ink-*)+ margin 後備模式(.feedback)-----
|
||
|
||
test("偏側:jz-ink-* 恆於 charify 標記(halt 預設模式亦然,root 不設 margin 屬性)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">好,「走」。</p>');
|
||
createJuzhen(JIYA_ONLY).render(document.body);
|
||
// 簡體點號偏左;角括號「」墨色強偏一側 → 開 right、閉 left(圓方括號才 center)。
|
||
assert.ok(document.querySelector("jz-char.bd-pause").classList.contains("jz-ink-left"), "簡體 ,偏左");
|
||
assert.ok(document.querySelector("jz-char.bd-open").classList.contains("jz-ink-right"), "角括號「墨偏右 → right");
|
||
assert.ok(document.querySelector("jz-char.bd-close").classList.contains("jz-ink-left"), "角括號」墨偏左 → left");
|
||
assert.equal(document.body.getAttribute("data-jz-halfwidth"), null, "halt 預設模式不設 root 屬性");
|
||
});
|
||
|
||
test("偏側:繁體點號居中(jz-ink-center),!?恆居中", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">走,真?</p>');
|
||
createJuzhen(JIYA_ONLY).render(document.body);
|
||
assert.ok(document.querySelector("jz-char.bd-pause").classList.contains("jz-ink-center"), "繁體 ,居中");
|
||
assert.ok(document.querySelector("jz-char.bd-stop").classList.contains("jz-ink-center"), "?居中");
|
||
});
|
||
|
||
test("margin 模式:render 於 root 設 data-jz-halfwidth=margin,revert 清除", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">好,走。</p>');
|
||
const jz = createJuzhen({ ...JIYA_ONLY, jiya: { halfWidth: "margin" } });
|
||
jz.render(document.body);
|
||
assert.equal(document.body.getAttribute("data-jz-halfwidth"), "margin", "margin 模式設 root 屬性");
|
||
// 偏側 class 與 jz-half 並存,CSS 始能定向收縮。
|
||
const comma = document.querySelector("jz-char.bd-pause");
|
||
assert.ok(comma.classList.contains("jz-half") && comma.classList.contains("jz-ink-left"), ",半形+偏左");
|
||
jz.revert(document.body);
|
||
assert.equal(document.body.getAttribute("data-jz-halfwidth"), null, "revert 清除 root 屬性");
|
||
});
|
||
|
||
test("margin 模式:消費端 bias 覆寫參考表(如把簡體 ,改判居中)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">好,走</p>');
|
||
createJuzhen({
|
||
...JIYA_ONLY,
|
||
jiya: { halfWidth: "margin", bias: { "zh-Hans": { ",": "center" } } },
|
||
}).render(document.body);
|
||
const comma = document.querySelector("jz-char.bd-pause");
|
||
assert.ok(comma.classList.contains("jz-ink-center"), "覆寫後 ,判居中");
|
||
assert.ok(!comma.classList.contains("jz-ink-left"), "不再用參考預設之偏左");
|
||
});
|
||
|
||
// ----- 長英文詞(longWord)-----
|
||
|
||
test("longWord:[A-Za-z]{6,} 包進 span[lang],textContent 不變", () =>
|
||
{
|
||
const { document } = setupDom("<p>用 TypeScript 寫</p>");
|
||
createJuzhen({ spacing: false, jiya: false, jinze: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
const span = p.querySelector('span[lang="en"]');
|
||
assert.ok(span, "TypeScript 應被包裝");
|
||
assert.equal(span.textContent, "TypeScript");
|
||
assert.equal(p.textContent, "用 TypeScript 寫");
|
||
});
|
||
|
||
test("longWord:短詞(<6)不包裝", () =>
|
||
{
|
||
const { document } = setupDom("<p>vim 好</p>");
|
||
createJuzhen({ spacing: false, jiya: false, jinze: false }).render(document.body);
|
||
assert.equal(countTag(document.body, 'span[lang="en"]'), 0);
|
||
});
|
||
|
||
// ----- 禁則(jinze):行首行末避頭尾 -----
|
||
|
||
test("jinze 避頭:閉類/點號綁到前一字(。」不置行首),中段文字仍可斷", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">他說好。」嗎</p>');
|
||
createJuzhen({ spacing: false, longWord: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
const group = p.querySelector("jz-jinze");
|
||
assert.ok(group, "應有綁定群組");
|
||
assert.equal(group.textContent, "好。」", "前一字 好 + 。」 綁為 nowrap 單元");
|
||
assert.equal(countTag(group, "jz-char"), 2, "群組含 。與 」");
|
||
assert.equal(p.textContent, "他說好。」嗎", "文字不變(他說 在外、嗎 在外)");
|
||
});
|
||
|
||
test("jinze 避尾:開類綁到後一字(「不置行末),長引文中段可斷", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">說「好」嗎</p>');
|
||
createJuzhen({ spacing: false, longWord: false }).render(document.body);
|
||
const group = document.querySelector("jz-jinze");
|
||
assert.ok(group, "應有綁定群組");
|
||
assert.equal(group.textContent, "「好」", "「綁後字、」綁前字 → 「好」一體");
|
||
});
|
||
|
||
test("jinze 避頭:孤立逗號亦綁前字(逗號不可置行首)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">中文,好</p>');
|
||
createJuzhen({ spacing: false, longWord: false }).render(document.body);
|
||
const group = document.querySelector("jz-jinze");
|
||
assert.equal(group.textContent, "文,", "前字 文 + , 綁定(中 在外可斷)");
|
||
});
|
||
|
||
// 行內樣式元素最小綁定(下游 Bug:整個 <strong> 入 nowrap inline-block 致行末整體掉行)。
|
||
// 邊界詞元經 core/split 克隆切分,只綁末/首詞元,餘下留原位(splitoff 克隆)。
|
||
test("jinze 元素最小綁定:<strong>長串</strong>,只綁末字+逗號(餘下留原位)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">前文<strong>粗体文字</strong>,后续</p>');
|
||
createJuzhen({ spacing: false, longWord: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
const group = p.querySelector("jz-jinze");
|
||
assert.ok(group, "應有綁定群組");
|
||
assert.equal(group.textContent, "字,", "只綁 strong 末字「字」+逗號(非整個 strong)");
|
||
const splitoff = p.querySelector('strong[data-jz-kind="splitoff"]');
|
||
assert.ok(splitoff && splitoff.textContent === "字", "切出之克隆持末字「字」");
|
||
const orig = Array.from(p.querySelectorAll("strong"))
|
||
.find((s) => !s.hasAttribute("data-jz-kind"));
|
||
assert.ok(orig && orig.textContent === "粗体文", "原 strong 餘「粗体文」留原位");
|
||
assert.equal(p.textContent, "前文粗体文字,后续", "textContent 不變");
|
||
});
|
||
|
||
test("jinze 元素最小綁定:「<strong>引文</strong> 只綁開引號+首字", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">他說「<strong>引文內容</strong>結束</p>');
|
||
createJuzhen({ spacing: false, longWord: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
const group = p.querySelector("jz-jinze");
|
||
assert.ok(group, "應有綁定群組");
|
||
assert.equal(group.textContent, "「引", "開引號「綁 strong 首字「引」(非整個 strong)");
|
||
assert.equal(p.textContent, "他說「引文內容結束", "textContent 不變");
|
||
});
|
||
|
||
test("jinze 元素最小綁定 revert:還原為單一 <strong>,文字回原樣", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">前文<strong>粗体文字</strong>,后续</p>');
|
||
const jz = createJuzhen({ spacing: false, longWord: false });
|
||
jz.render(document.body);
|
||
jz.revert(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.equal(countTag(p, "jz-jinze"), 0, "無殘留 jz-jinze");
|
||
assert.equal(p.querySelectorAll("strong").length, 1, "兩個 strong 併回單一");
|
||
assert.equal(p.querySelector("strong").textContent, "粗体文字", "strong 內容還原");
|
||
assert.equal(p.textContent, "前文粗体文字,后续");
|
||
});
|
||
|
||
test("jinze 不可切元素退化整綁:<a> 連結整體綁定(不克隆出兩個錨點)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">见<a href="/x">链接文字</a>,详情</p>');
|
||
createJuzhen({ spacing: false, longWord: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.equal(p.querySelectorAll("a").length, 1, "連結不被切分(仍單一 <a>)");
|
||
const group = p.querySelector("jz-jinze");
|
||
assert.ok(group && group.querySelector("a"), "整個 <a> 入綁(退化但安全)");
|
||
});
|
||
|
||
test("jinze 跨透明邊界避頭:<strong> 首位句末點號綁至前字(你好<strong>。世界</strong>)", () =>
|
||
{
|
||
// 下游審計 #3:。位於 <strong> 首位、與「好」不同 DOM parent,舊版僅處理 jz-char 之
|
||
// 直接父(strong),外層 p 之「好↔strong」綁定不被評估致 。可誤置行首。修後於外層
|
||
// 偵測(edgeCharEl 穿越透明元素)並綁定。
|
||
const { document } = setupDom('<p lang="zh-Hant">你好<strong>。世界</strong>結束</p>');
|
||
const jz = createJuzhen({ spacing: false, longWord: false });
|
||
jz.render(document.body);
|
||
const p = document.querySelector("p");
|
||
const group = p.querySelector("jz-jinze");
|
||
assert.ok(group, "產生跨邊界 jz-jinze 綁定組");
|
||
assert.ok(group.textContent.includes("好") && group.textContent.includes("。"),
|
||
"。(strong 首位、避頭)與前字「好」綁入同一 nowrap 群組、不置行首");
|
||
jz.revert(document.body);
|
||
assert.equal(p.textContent, "你好。世界結束", "revert 還原");
|
||
assert.equal(countTag(p, "jz-jinze"), 0);
|
||
});
|
||
|
||
test("jinze:revert 還原(無 jz-jinze/jz-char,文字回原樣)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">他說好。」嗎</p>');
|
||
const jz = createJuzhen({ spacing: false, longWord: false });
|
||
jz.render(document.body);
|
||
jz.revert(document.body);
|
||
assert.equal(countTag(document.body, "jz-jinze"), 0);
|
||
assert.equal(countTag(document.body, "jz-char"), 0);
|
||
assert.equal(document.querySelector("p").textContent, "他說好。」嗎");
|
||
});
|
||
|
||
test("jinze 冪等:二次 render(未 revert)不巢狀增殖 jz-jinze(守衛 parent 自身即產物)", () =>
|
||
{
|
||
// 缺守衛時,二次 render 之 jz-char 其父正是上輪之 jz-jinze,會被再包一層(3→6 巢狀)。
|
||
const { document } = setupDom('<p lang="zh-Hant">漢字。「引文」結束</p>');
|
||
const jz = createJuzhen({ spacing: false, longWord: false });
|
||
jz.render(document.body);
|
||
const after1 = countTag(document.body, "jz-jinze");
|
||
jz.render(document.body); // 二次 render,未 revert
|
||
assert.equal(countTag(document.body, "jz-jinze"), after1, "jz-jinze 數不增(不重複包裹)");
|
||
assert.equal(countTag(document.body, "jz-jinze jz-jinze"), 0, "無巢狀 jz-jinze");
|
||
jz.revert(document.body);
|
||
assert.equal(countTag(document.body, "jz-jinze"), 0, "revert 完全還原");
|
||
assert.equal(document.querySelector("p").textContent, "漢字。「引文」結束");
|
||
});
|
||
|
||
// ----- 作用域與分塊(§3.6)-----
|
||
|
||
test("scope.include:未命中子樹不處理", () =>
|
||
{
|
||
const { document } = setupDom('<p>我用Vim</p><div class="jz"><p>他用Emacs寫</p></div>');
|
||
createJuzhen({
|
||
jiya: false, jinze: false,
|
||
scope: { include: ".jz" },
|
||
}).render(document.body);
|
||
const ps = document.querySelectorAll("p");
|
||
assert.equal(countTag(ps[0], "jz-hws"), 0, "include 外不處理");
|
||
assert.ok(countTag(ps[1], "jz-hws") >= 1, "include 內處理");
|
||
});
|
||
|
||
test("分塊:data-juzhen 白名單只啟用列出功能", () =>
|
||
{
|
||
const { document } = setupDom('<p data-juzhen="spacing" lang="zh-Hans">用Vim,好</p>');
|
||
createJuzhen({}).render(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.ok(countTag(p, "jz-hws") >= 1, "spacing 啟用");
|
||
assert.equal(countTag(p, "jz-char"), 0, "jiya 未在白名單,停用");
|
||
});
|
||
|
||
test("分塊:data-juzhen-off 黑名單停用列出功能,其餘繼承", () =>
|
||
{
|
||
const { document } = setupDom('<p data-juzhen-off="jiya" lang="zh-Hans">用Vim,好</p>');
|
||
createJuzhen({}).render(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.ok(countTag(p, "jz-hws") >= 1, "spacing 繼承啟用");
|
||
assert.equal(countTag(p, "jz-char"), 0, "jiya 被黑名單停用");
|
||
});
|
||
|
||
// ----- 下游回饋修復(問題一 1a/1b、問題二)-----
|
||
|
||
test("1a:pill 後接 CJK 標點時,pill 左緣仍補 jz-hws(穿越 jz-jinze)", () =>
|
||
{
|
||
const { document } = setupDom("<p>代码<code>build</code>,后</p>");
|
||
createJuzhen({ pillSelector: "code, kbd", lang: { default: "zh-Hans", style: "quanjiao" } })
|
||
.render(document.body);
|
||
// jinze 已把 code+,納入 jz-jinze;spacing 仍須能找到 code 左鄰字「码」並補間隙。
|
||
const hws = document.querySelector("jz-hws");
|
||
assert.ok(hws && hws.textContent === "码", "pill 左側「码」包入 jz-hws(不因右側標點/jz-jinze 而遺失)");
|
||
});
|
||
|
||
test("1a:pill 後接 CJK 標點且有作者空格 → 空格被剝除、左緣補間隙", () =>
|
||
{
|
||
const { document } = setupDom("<p>代码 <code>build</code>,后</p>");
|
||
createJuzhen({ pillSelector: "code, kbd", lang: { default: "zh-Hans", style: "quanjiao" } })
|
||
.render(document.body);
|
||
assert.ok(document.querySelector("jz-hws"), "補了 jz-hws");
|
||
assert.equal(document.querySelector("p").textContent, "代码build,后", "作者空格被剝除(與無空格輸入一致)");
|
||
});
|
||
|
||
test("1b:CJK↔CJK 贅餘作者空格被剝除(含跨樣式行內標籤)", () =>
|
||
{
|
||
const { document } = setupDom('<p><span class="b">事件</span> 元件</p>');
|
||
createJuzhen({ lang: { default: "zh-Hans" } }).render(document.body);
|
||
assert.equal(document.querySelector("p").textContent, "事件元件", "跨 <span> 之 CJK↔CJK 空格剝除");
|
||
});
|
||
|
||
test("1b:CJK↔CJK 不插 jz-hws(僅剝空格,無留隙)", () =>
|
||
{
|
||
const { document } = setupDom("<p>事件 元件</p>");
|
||
createJuzhen({ spacing: true, jiya: false, jinze: false, longWord: false }).render(document.body);
|
||
assert.equal(countTag(document.body, "jz-hws"), 0, "CJK↔CJK 不需間隙,僅刪空格");
|
||
assert.equal(document.querySelector("p").textContent, "事件元件");
|
||
});
|
||
|
||
// pill 左鄰為標點(經 jinze 包入 jz-char/jz-jinze):不應於標點前補間隙。
|
||
// 根因:adjacentLogicalChar 之 descend 舊版跳過不透明之 jz-char、越過標點取到更前之
|
||
// 漢字,誤包 jz-hws;修復=jz-char/jz-inner 透明,descend 回傳標點(非 pillNeighbor)。
|
||
test("pill 左鄰標點:汉字。<code> 不於「字」「。」間補 jz-hws(jz-char 透明)", () =>
|
||
{
|
||
const { document } = setupDom("<p>汉字。<code>x</code></p>");
|
||
createJuzhen({ lang: { default: "zh-Hans", style: "quanjiao" } }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.equal(countTag(p, "jz-hws"), 0, "pill 左鄰為句號(非 pillNeighbor),不補間隙");
|
||
});
|
||
|
||
test("pill 左鄰漢字(無標點):汉字<code> 仍於「字」補 jz-hws", () =>
|
||
{
|
||
const { document } = setupDom("<p>汉字<code>x</code></p>");
|
||
createJuzhen({ lang: { default: "zh-Hans" } }).render(document.body);
|
||
const hws = document.querySelector("jz-hws");
|
||
assert.ok(hws && hws.textContent === "字", "pill 左鄰為漢字,正常補間隙(修復無回歸)");
|
||
});
|
||
|
||
test("pill 右鄰標點:<code>x</code>。汉 不於 pill 與句號間補 jz-hws", () =>
|
||
{
|
||
const { document } = setupDom("<p><code>x</code>。汉字</p>");
|
||
createJuzhen({ lang: { default: "zh-Hans", style: "quanjiao" } }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.equal(countTag(p, "jz-hws"), 0, "pill 右鄰為句號(非 pillNeighbor),不補間隙");
|
||
});
|
||
|
||
test("pill 左鄰句內點號:事项:<code> 不補 jz-hws(bd-pause 同理)", () =>
|
||
{
|
||
const { document } = setupDom("<p>事项:<code>x</code></p>");
|
||
createJuzhen({ lang: { default: "zh-Hans", style: "quanjiao" } }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.equal(countTag(p, "jz-hws"), 0, "pill 左鄰為冒號(非 pillNeighbor),不補間隙");
|
||
});
|
||
|
||
test("問題二:justifyAtoms:false → root 設 data-jz-atoms=inline,revert 清除", () =>
|
||
{
|
||
const { document } = setupDom("<p lang='zh-Hans'>测试,(甲)。</p>");
|
||
const jz = createJuzhen({ lang: { default: "zh-Hans" }, justifyAtoms: false });
|
||
jz.render(document.body);
|
||
assert.equal(document.body.getAttribute("data-jz-atoms"), "inline", "設 inline 原子屬性");
|
||
jz.revert(document.body);
|
||
assert.equal(document.body.getAttribute("data-jz-atoms"), null, "revert 清除");
|
||
});
|
||
|
||
test("問題二:justifyAtoms 預設 true → 不設屬性(維持 inline-block 原子)", () =>
|
||
{
|
||
const { document } = setupDom("<p lang='zh-Hans'>测试,</p>");
|
||
createJuzhen({ lang: { default: "zh-Hans" } }).render(document.body);
|
||
assert.equal(document.body.getAttribute("data-jz-atoms"), null, "預設不設屬性");
|
||
});
|
||
|
||
// ----- text-indent 漏溢防護(下游回報)-----
|
||
|
||
test("CSS:jz-* 原子歸零「區塊容器首/末行級」繼承屬性,免 inline-block 重套(透明原則)", () =>
|
||
{
|
||
// jsdom 無版面,無法量寬;改驗構建 CSS 確含原子歸零規則。
|
||
// 機制見 src/css/_normalize.css:此類繼承屬性會被 inline-block 原子(jz-char/
|
||
// jz-jinze)重新套用於其內部首/末行,故須於原子上歸零(段落本身不受影響)。
|
||
// 整類僅兩項(headless Chrome 逐屬性實測:letter-spacing/word-spacing/text-align
|
||
// 系皆不洩漏)。
|
||
const cssPath = fileURLToPath(new URL("../dist/juzhen.css", import.meta.url));
|
||
const css = readFileSync(cssPath, "utf8");
|
||
assert.match(css, /text-indent\s*:\s*0/, "構建 CSS 應含 text-indent:0 重設");
|
||
assert.match(css, /hanging-punctuation\s*:\s*none/, "構建 CSS 應含 hanging-punctuation:none 重設");
|
||
});
|
||
|
||
test("CSS:jz-* 原子 text-decoration:inherit,補裝飾盒跳過原子致下劃線斷缺(透明原則·欠套用面)", () =>
|
||
{
|
||
// 另一機制:text-decoration 非繼承、由段落裝飾盒繪製並跳過原子級 inline-block,
|
||
// 致下劃線於每個標點原子下斷缺。令原子 inherit 自繪、接續(headless Chrome 像素掃描
|
||
// 實測 halt/margin coverage 皆回 1.0)。
|
||
const cssPath = fileURLToPath(new URL("../dist/juzhen.css", import.meta.url));
|
||
const css = readFileSync(cssPath, "utf8");
|
||
assert.match(css, /text-decoration\s*:\s*inherit/, "構建 CSS 應含 text-decoration:inherit");
|
||
});
|
||
|
||
test("CSS:聚珍不內建 font-family(字型交消費端),半形顯式保留 locl(下游回饋)", () =>
|
||
{
|
||
// 字型屬消費端領域,聚珍不內建任何字型堆疊(移除 _lang.css);僅以 _normalize 之
|
||
// `font: inherit` 使原子繼承消費端字型、不強加,並於半形 feature 顯式併入 locl,
|
||
// 確保泛 CJK 字型之地域標點字形不因設 halt 而失。body locl 由消費端字型+lang 決定。
|
||
const cssPath = fileURLToPath(new URL("../dist/juzhen.css", import.meta.url));
|
||
const css = readFileSync(cssPath, "utf8");
|
||
assert.equal(/font-family/.test(css), false, "聚珍 CSS 不應內建任何 font-family");
|
||
assert.match(css, /font-feature-settings:\s*"locl"\s+1,\s*"halt"\s+1/, "半形 feature 應顯式保留 locl");
|
||
});
|
||
|
||
// ----- charClass:自定義字符分類規則集(向下兼容+擴充)-----
|
||
|
||
test("jiya:英文全角句號 . 已內建為句末標點 bd-stop(本次納入;Noto CJK 有 halt)", () =>
|
||
{
|
||
// . U+FF0E(異於表意句號 。U+3002)已納入內置 BD_STOP,預設即 charify 為 bd-stop。
|
||
const { document } = setupDom('<p lang="zh-Hans">中.文。</p>');
|
||
createJuzhen({ lang: { style: "kaiming" } }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
const dot = Array.from(p.querySelectorAll("jz-char")).find((c) => c.textContent === ".");
|
||
assert.ok(dot && dot.classList.contains("bd-stop"), ". 預設 charify 為 bd-stop");
|
||
// 開明式句末不挤(與 。一致);banjiao 才挤——此處僅確認分類與政策一致。
|
||
assert.equal(dot.classList.contains("jz-half"), false, "開明式句末標點不挤半(保持全角,同 。)");
|
||
});
|
||
|
||
test("charClass.biaodian.stop:把仍未分類之 ‥ 歸為全寬句末標點 → charify 為 bd-stop", () =>
|
||
{
|
||
// ‥ U+2025 預設未分類;以 charClass 歸句末標點以示「追加」機制。
|
||
const base = setupDom('<p lang="zh-Hans">中‥文</p>');
|
||
createJuzhen({ lang: { style: "kaiming" } }).render(base.document.body);
|
||
assert.equal(countTag(base.document.querySelector("p"), "jz-char"), 0, "‥ 預設未分類、不 charify");
|
||
const { document } = setupDom('<p lang="zh-Hans">中‥文</p>');
|
||
createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { stop: "‥" } } }).render(document.body);
|
||
assert.ok(countTag(document.querySelector("p"), "jz-char.bd-stop") >= 1, "‥ 經 charClass 歸 bd-stop");
|
||
});
|
||
|
||
test("charClass.western:把未分類符號 ★ 歸為西文 → 與 CJK 邊界補 jz-hws", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">中★文</p>');
|
||
// 先確認預設不補隙
|
||
createJuzhen({}).render(document.body);
|
||
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, "預設 ★ 非西文、不補隙");
|
||
const dom2 = setupDom('<p lang="zh-Hans">中★文</p>');
|
||
createJuzhen({ charClass: { western: "★" } }).render(dom2.document.body);
|
||
assert.ok(countTag(dom2.document.querySelector("p"), "jz-hws") >= 1, "★ 歸西文後,中★/★文 邊界補隙");
|
||
});
|
||
|
||
test("charClass.cjk:把符號歸為中文 → 與西文邊界補 jz-hws", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">〇A</p>');
|
||
createJuzhen({ charClass: { cjk: "〇" } }).render(document.body);
|
||
assert.ok(countTag(document.querySelector("p"), "jz-hws") >= 1, "〇 歸中文後,〇↔A 為中西邊界、補隙");
|
||
});
|
||
|
||
test("charClass:實例隔離——覆寫不污染內置默認(resolveRuleset 不 mutate DEFAULT_RULESET)", () =>
|
||
{
|
||
// 先建帶覆寫之實例(★ 視為西文)。
|
||
const a = setupDom('<p lang="zh-Hans">中★文</p>');
|
||
createJuzhen({ charClass: { western: "★" } }).render(a.document.body);
|
||
assert.ok(countTag(a.document.querySelector("p"), "jz-hws") >= 1, "覆寫實例:★ 補隙");
|
||
// 再建無覆寫之實例:★ 應仍未分類、不補隙(前實例之覆寫未洩漏至默認)。
|
||
const b = setupDom('<p lang="zh-Hans">中★文</p>');
|
||
createJuzhen({}).render(b.document.body);
|
||
assert.equal(countTag(b.document.querySelector("p"), "jz-hws"), 0,
|
||
"無覆寫實例:★ 仍未分類、不補隙(默認規則集未被前實例污染)");
|
||
});
|
||
|
||
test("charClass.biaodian.pause:未分類符號歸句內點號 → 開明式挤半形(jz-half)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">中•文</p>');
|
||
createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { pause: "•" } } }).render(document.body);
|
||
const c = document.querySelector("jz-char.bd-pause");
|
||
assert.ok(c, "• charify 為 bd-pause");
|
||
assert.ok(c.classList.contains("jz-half"), "開明式句內點號挤半形(jz-half)");
|
||
});
|
||
|
||
test("charClass.biaodian.open:歸開括號 → 禁則避尾,與後字綁為 jz-jinze", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">甲•乙</p>');
|
||
createJuzhen({ charClass: { biaodian: { open: "•" } } }).render(document.body);
|
||
assert.ok(countTag(document.querySelector("p"), "jz-jinze") >= 1, "開類 • 與後字綁定(避尾)為 jz-jinze");
|
||
});
|
||
|
||
test("charClass.western:追加不破壞內置——€(內置)與 ★(追加)皆補隙", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">價€元★好</p>');
|
||
createJuzhen({ jiya: false, longWord: false, jinze: false, charClass: { western: "★" } }).render(document.body);
|
||
const n = countTag(document.querySelector("p"), "jz-hws");
|
||
// 價↔€、€↔元、元↔★、★↔好 四處中西邊界皆補隙(內置 € 不因追加而失)。
|
||
assert.ok(n >= 4, "內置 € 與追加 ★ 之四邊界皆補隙,實得 " + n);
|
||
});
|
||
|
||
test("charClass.biaodian:多子類同傳,各自生效(stop/pause)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">甲‥乙•丙</p>');
|
||
createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { stop: "‥", pause: "•" } } }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.ok(countTag(p, "jz-char.bd-stop") >= 1, "‥ → bd-stop");
|
||
const pause = p.querySelector("jz-char.bd-pause");
|
||
assert.ok(pause && pause.classList.contains("jz-half"), "• → bd-pause 且開明式挤半形");
|
||
});
|
||
|
||
// ----- 功能分級(§6.5.0a,text-level vs paragraph-level)-----
|
||
|
||
test("分級:data-jz-level=text 之子樹只跑文本級——jiya/spacing 生效、jinze 跳過", () =>
|
||
{
|
||
const { document } = setupDom('<p data-jz-level="text" lang="zh-Hant">說「你好」用Neovim寫</p>');
|
||
createJuzhen().render(document.body);
|
||
const p = document.querySelector("p");
|
||
// 文本級:標點 charify(jz-char)、中西間隙(jz-hws)照常。
|
||
assert.ok(countTag(p, "jz-char") >= 1, "text-level 仍跑 jiya(jz-char)");
|
||
assert.ok(countTag(p, "jz-hws") >= 1, "text-level 仍跑 spacing(jz-hws)");
|
||
// 段落級:禁則跳過——無 jz-jinze(「」本會綁鄰字)。
|
||
assert.equal(countTag(p, "jz-jinze"), 0, "text-level 段落級禁則跳過(無 jz-jinze)");
|
||
});
|
||
|
||
test("分級:data-jz-level=text 跳過段落級 longWord(charify 亦受閘)", () =>
|
||
{
|
||
const { document } = setupDom('<p data-jz-level="text">用 configuration 設定</p>');
|
||
createJuzhen().render(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.equal(p.querySelectorAll('[data-jz="longWord"]').length, 0, "text-level 不跑 longWord(無 span[data-jz=longWord])");
|
||
});
|
||
|
||
test("分級:選項 level.text 選擇器標記(h1 → text-level,禁則跳過)", () =>
|
||
{
|
||
const { document } = setupDom('<h1 lang="zh-Hant">說「你好」結束</h1><p lang="zh-Hant">說「你好」結束</p>');
|
||
createJuzhen({ level: { text: "h1" } }).render(document.body);
|
||
const h1 = document.querySelector("h1");
|
||
const p = document.querySelector("p");
|
||
assert.equal(countTag(h1, "jz-jinze"), 0, "h1 命中 level.text → 段落級禁則跳過");
|
||
assert.ok(countTag(h1, "jz-char") >= 1, "h1 文本級 jiya 仍生效");
|
||
assert.ok(countTag(p, "jz-jinze") >= 1, "未命中之 p 仍跑禁則");
|
||
});
|
||
|
||
test("分級:data-jz-level=paragraph 巢狀於 text 子樹內,最近祖先勝(恢復全功能)", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<div data-jz-level="text" lang="zh-Hant">'
|
||
+ '<p data-jz-level="paragraph">說「你好」結束</p></div>',
|
||
);
|
||
createJuzhen().render(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.ok(countTag(p, "jz-jinze") >= 1, "最近 data-jz-level=paragraph 勝 → 禁則恢復");
|
||
});
|
||
|
||
test("分級:spacing 文本級支持單元素 root(render 於 <span>)", () =>
|
||
{
|
||
const { document } = setupDom("<span>我用Neovim寫程式</span>");
|
||
const span = document.querySelector("span");
|
||
createJuzhen({ jiya: false, jinze: false, longWord: false }).render(span);
|
||
assert.ok(countTag(span, "jz-hws") >= 1, "行內單元素 root 亦補中西間隙");
|
||
assert.equal(span.textContent, "我用Neovim寫程式", "textContent 不變");
|
||
});
|
||
|
||
test("分級:預設(無標記)塊級元素為段落級——禁則照常", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">說「你好」結束</p>');
|
||
createJuzhen().render(document.body);
|
||
assert.ok(countTag(document.querySelector("p"), "jz-jinze") >= 1, "預設段落級,禁則綁定");
|
||
});
|
||
|
||
// ----- 垂懸字避免(§6.5.1,方案 C)-----
|
||
|
||
test("垂懸字:末 N 實義字+尾隨標點包入 jz-orphan(預設 chars=2)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">甲乙,丙丁。</p>');
|
||
createJuzhen().render(document.body);
|
||
const p = document.querySelector("p");
|
||
const orphan = p.querySelector("jz-orphan");
|
||
assert.ok(orphan, "應產生 jz-orphan");
|
||
assert.equal(orphan.textContent, "丙丁。", "綁定末 2 實義字(丙丁)+尾隨句號");
|
||
assert.equal(p.textContent, "甲乙,丙丁。", "textContent 不變");
|
||
});
|
||
|
||
test("垂懸字:chars 選項可調(chars=3 綁末 3 實義字)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">甲乙,丙丁。</p>');
|
||
createJuzhen({ orphan: { chars: 3 } }).render(document.body);
|
||
const orphan = document.querySelector("jz-orphan");
|
||
assert.equal(orphan.textContent, "乙,丙丁。", "綁定末 3 實義字(乙丙丁)+其間/尾標點");
|
||
});
|
||
|
||
test("垂懸字:標點/符號不計實義字數(逗號不計)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">讀書,思考,寫作。</p>');
|
||
createJuzhen().render(document.body);
|
||
const orphan = document.querySelector("jz-orphan");
|
||
// 實義字 讀書思考寫作(6),末 2=寫作;逗號不計入。
|
||
assert.equal(orphan.textContent, "寫作。", "末 2 實義字=寫作,標點不計");
|
||
});
|
||
|
||
test("垂懸字:段落實義字 < N 不綁(單字段)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">嗯。</p>');
|
||
createJuzhen().render(document.body);
|
||
assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "實義字 1 < 2,不綁");
|
||
});
|
||
|
||
test("垂懸字:邊界字落於 jz-jinze 內 → 綁定整個群組(跨 jinze 攀爬、不切割)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">他說「好」</p>');
|
||
createJuzhen({ orphan: { chars: 1 } }).render(document.body);
|
||
const orphan = document.querySelector("jz-orphan");
|
||
assert.equal(orphan.textContent, "「好」", "末 1 實義字 好 在 jz-jinze 內 → 綁整個 「好」");
|
||
assert.equal(orphan.querySelectorAll("jz-jinze").length, 1, "jz-jinze 整體納入、未被切割");
|
||
});
|
||
|
||
test("垂懸字:orphan:false 關閉(無 jz-orphan)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">甲乙,丙丁。</p>');
|
||
createJuzhen({ orphan: false }).render(document.body);
|
||
assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "關閉時不產生 jz-orphan");
|
||
});
|
||
|
||
test("垂懸字:冪等——重跑不重複包裹", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">甲乙,丙丁。</p>');
|
||
const jz = createJuzhen();
|
||
jz.render(document.body);
|
||
const first = countTag(document.body, "jz-orphan");
|
||
jz.render(document.body);
|
||
assert.equal(countTag(document.body, "jz-orphan"), first, "第二次 render 不增加 jz-orphan");
|
||
});
|
||
|
||
test("垂懸字:revert 還原(無 jz-orphan,textContent 回原樣)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">甲乙,丙丁。</p>');
|
||
const jz = createJuzhen();
|
||
jz.render(document.body);
|
||
jz.revert(document.body);
|
||
assert.equal(countTag(document.body, "jz-orphan"), 0, "revert 清除 jz-orphan");
|
||
assert.equal(document.querySelector("p").textContent, "甲乙,丙丁。", "文字回原樣");
|
||
});
|
||
|
||
test("垂懸字:段落級——data-jz-level=text 子樹跳過", () =>
|
||
{
|
||
const { document } = setupDom('<p data-jz-level="text" lang="zh-Hant">甲乙,丙丁。</p>');
|
||
createJuzhen().render(document.body);
|
||
assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "text-level 不跑垂懸字");
|
||
});
|
||
|
||
// 邊界實義字落於可切行內元素內 → 克隆切分出 [邊界字..元素末],避免整個長元素入
|
||
// nowrap 之 jz-orphan(可溢出容器)。
|
||
test("垂懸字 元素最小綁定:段末長 <strong> 只綁末 N 實義字(非整個元素)", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<p lang="zh-Hant">這是比較長的開頭文字而結尾為<strong>一段很長的加粗文字</strong></p>',
|
||
);
|
||
createJuzhen().render(document.body);
|
||
const p = document.querySelector("p");
|
||
const orphan = p.querySelector("jz-orphan");
|
||
assert.ok(orphan, "應產生 jz-orphan");
|
||
assert.equal(orphan.textContent, "文字", "只綁 strong 末 2 實義字「文字」");
|
||
const splitoff = p.querySelector('strong[data-jz-kind="splitoff"]');
|
||
assert.ok(splitoff && splitoff.textContent === "文字", "切出克隆持末 2 字");
|
||
const orig = Array.from(p.querySelectorAll("strong"))
|
||
.find((s) => !s.hasAttribute("data-jz-kind"));
|
||
assert.ok(orig && orig.textContent === "一段很長的加粗", "原 strong 餘部留原位(未入 orphan)");
|
||
assert.equal(p.textContent, "這是比較長的開頭文字而結尾為一段很長的加粗文字", "textContent 不變");
|
||
});
|
||
|
||
test("垂懸字 元素最小綁定 revert:兩個 <strong> 併回單一、文字還原", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<p lang="zh-Hant">這是比較長的開頭文字而結尾為<strong>一段很長的加粗文字</strong></p>',
|
||
);
|
||
const jz = createJuzhen();
|
||
jz.render(document.body);
|
||
jz.revert(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.equal(countTag(p, "jz-orphan"), 0, "無殘留 jz-orphan");
|
||
assert.equal(p.querySelectorAll("strong").length, 1, "克隆併回單一 strong");
|
||
assert.equal(p.querySelector("strong").textContent, "一段很長的加粗文字", "strong 還原");
|
||
});
|
||
|
||
// 攀升對稱守衛(下游 .selection-bar-message 病例):段末實義字落於 opaque <button>,
|
||
// 而 button 巢於可切 <span>(塊級直接子)內。splitElementAt 僅驗頂層 span 可切,若無攀升
|
||
// 守衛,splitTreeAfter 逐層克隆 textNode→span 時會把中間 opaque button 一併克隆肢解
|
||
// (複製出 splitoff button)。守衛須沿整條祖先鏈驗可切,遇 button 即放棄、退化為綁整 span。
|
||
test("垂懸字 攀升守衛:末實義字在 opaque <button>(巢於可切 span)→ 綁整 span、絕不肢解 button", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<p lang="zh-Hant">開頭較長的一段文字內容之後<span>接續<button>結尾文字</button></span></p>',
|
||
);
|
||
createJuzhen().render(document.body);
|
||
const p = document.querySelector("p");
|
||
const orphan = p.querySelector("jz-orphan");
|
||
assert.ok(orphan, "應產生 jz-orphan");
|
||
assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0, "無克隆切分殼層(button 未被肢解)");
|
||
assert.equal(p.querySelectorAll("button").length, 1, "button 未被複製(仍單一)");
|
||
assert.equal(p.querySelector("button").textContent, "結尾文字", "button 內容完整無缺");
|
||
assert.equal(orphan.textContent, "接續結尾文字", "退化綁定整個 span(含 button)");
|
||
assert.equal(p.textContent, "開頭較長的一段文字內容之後接續結尾文字", "textContent 不變");
|
||
});
|
||
|
||
test("垂懸字 攀升守衛 revert:綁整 span 之退化路徑 render→revert 還原一致", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<p lang="zh-Hant">開頭較長的一段文字內容之後<span>接續<button>結尾文字</button></span></p>',
|
||
);
|
||
const jz = createJuzhen();
|
||
jz.render(document.body);
|
||
jz.revert(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.equal(countTag(p, "jz-orphan"), 0, "無殘留 jz-orphan");
|
||
assert.equal(p.querySelectorAll("span").length, 1, "span 單一");
|
||
assert.equal(p.querySelectorAll("button").length, 1, "button 單一");
|
||
assert.equal(p.textContent, "開頭較長的一段文字內容之後接續結尾文字", "textContent 還原");
|
||
});
|
||
|
||
// S2 非葉塊預計算(取代逐塊 querySelector)等價性:含巢狀塊之容器(div>p)——容器為非葉、
|
||
// 直文字不綁(實義字屬內層段落),僅內層葉塊 <p> 綁定。鎖定「非葉塊跳過」路徑之行為不變。
|
||
test("垂懸字 S2 非葉塊:含巢狀塊之容器跳過、僅內層葉塊綁定", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<div lang="zh-Hant">容器直接文字結尾甲乙<p>內層段落的實義字丙丁。</p></div>',
|
||
);
|
||
createJuzhen().render(document.body);
|
||
const div = document.querySelector("div");
|
||
const p = document.querySelector("p");
|
||
const pOrphan = p.querySelector("jz-orphan");
|
||
assert.ok(pOrphan, "內層葉塊 <p> 應產生 jz-orphan");
|
||
assert.equal(pOrphan.textContent, "丙丁。", "綁定 <p> 末 2 實義字+句號");
|
||
const directOrphan = Array.from(div.children).some((c) => c.nodeName === "JZ-ORPHAN");
|
||
assert.equal(directOrphan, false, "非葉容器 <div> 之直文字不應被綁入 jz-orphan");
|
||
});
|
||
|
||
// ----- 標點懸掛已移除(§6.5.6):hanging 選項保留為 no-op,永不產生 jz-hang-* -----
|
||
// 純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器);原生
|
||
// hanging-punctuation 為唯一正解但僅 Safari 支援、且與 inline-block 原子互斥。
|
||
// 下列測試確保 API 相容(hanging:true/data-juzhen/blocks 任一要求皆被接受但無效果)。
|
||
|
||
test("懸掛已移除:hanging 選項被接受但無任何效果(API 相容,render 不拋)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">春。夏。秋。冬。</p>');
|
||
const jz = createJuzhen({ hanging: true });
|
||
jz.render(document.body); // 不應拋錯;hanging 為 no-op
|
||
assert.equal(countTag(document.body, "jz-char.jz-hang-r"), 0, "無 jz-hang-r");
|
||
assert.equal(countTag(document.body, "jz-char.jz-hang-l"), 0, "無 jz-hang-l");
|
||
});
|
||
|
||
test("懸掛已移除:hanging:true 之輸出與不傳 hanging 完全一致(無行為差異)", () =>
|
||
{
|
||
const html = '<p lang="zh-Hans">春。夏。秋。冬。</p>';
|
||
const a = setupDom(html);
|
||
createJuzhen({ justifyAtoms: false }).render(a.document.body);
|
||
const withoutHang = a.document.body.innerHTML;
|
||
const b = setupDom(html);
|
||
createJuzhen({ hanging: true, justifyAtoms: false }).render(b.document.body);
|
||
assert.equal(b.document.body.innerHTML, withoutHang, "hanging:true 不改變任何輸出");
|
||
});
|
||
|
||
test("懸掛已移除:data-juzhen=\"hanging\" 與 blocks features:[hanging] 皆無效果", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<p lang="zh-Hans" data-juzhen="spacing jiya jinze hanging">春。夏。秋。</p>'
|
||
+ '<p class="q" lang="zh-Hans">春。夏。秋。</p>',
|
||
);
|
||
createJuzhen({
|
||
justifyAtoms: false,
|
||
blocks: [ { selector: ".q", features: [ "spacing", "jiya", "hanging" ] } ],
|
||
}).render(document.body);
|
||
assert.equal(countTag(document.body, "jz-char.jz-hang-r"), 0, "無 jz-hang-r");
|
||
assert.equal(countTag(document.body, "jz-char.jz-hang-l"), 0, "無 jz-hang-l");
|
||
});
|
||
|
||
test("行端半形:lineedge 仍正常擠壓(squeeze 保留)——jsdom 無版面故僅驗 pass 不拋", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">「測試」。</p>');
|
||
const jz = createJuzhen();
|
||
jz.render(document.body); // lineedge 於 jsdom(無 ResizeObserver)跳過,不應拋
|
||
jz.revert(document.body);
|
||
assert.equal(countTag(document.body, "jz-char"), 0, "revert 清除");
|
||
});
|
||
|
||
// ----- 行內元素三分類(§4.1):透明/隔離(isolate)/整體(pill)(下游問題六)-----
|
||
|
||
// KaTeX 式巢狀 span 結構(全樹皆 SPAN——僅憑標籤名判透明會整棵下潛肢解)。
|
||
const KATEX = '<span class="katex"><span class="katex-html">'
|
||
+ '<span class="base"><span class="mord">4.4</span><span class="mbin">x</span>'
|
||
+ '<span class="mord">10</span><span class="msupsub"><span class="vlist">'
|
||
+ '<span class="mord">-17</span></span></span></span></span></span>';
|
||
const KATEX_OPTS = { pillSelector: "code, kbd, .katex", lang: { default: "zh-Hans", style: "kaiming" } };
|
||
|
||
test("問題六:pill(.katex)後接句內點號——整體綁入 jz-jinze,絕不肢解(無 splitoff)", () =>
|
||
{
|
||
const { document } = setupDom("<p>結果為 " + KATEX + ",在浮點中</p>");
|
||
createJuzhen(KATEX_OPTS).render(document.body);
|
||
assert.equal(document.querySelectorAll(".katex").length, 1, "公式仍為單一元素(未裂)");
|
||
assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0, "無克隆切分殼層");
|
||
const jinze = document.querySelector("jz-jinze");
|
||
assert.ok(jinze && jinze.querySelector(".katex"), "整個 .katex 作為原子鄰字單位入 jz-jinze");
|
||
assert.ok(jinze.querySelector("jz-char.bd-pause"), "逗號與公式同組(避頭綁定)");
|
||
});
|
||
|
||
test("問題六:開類+pill+閉類(雙側禁斷)——公式仍完整、不裂為多塊", () =>
|
||
{
|
||
const { document } = setupDom("<p>(" + KATEX + ")在浮點中</p>");
|
||
createJuzhen(KATEX_OPTS).render(document.body);
|
||
assert.equal(document.querySelectorAll(".katex").length, 1, "雙側綁定亦不肢解");
|
||
assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0);
|
||
});
|
||
|
||
test("問題六:scope.avoid 自身命中(.katex)——avoid 邊界以自身判定,不被透明穿越", () =>
|
||
{
|
||
const { document } = setupDom("<p>結果為 " + KATEX + ",在浮點中</p>");
|
||
createJuzhen({ scope: { avoid: ".katex" }, lang: { default: "zh-Hans", style: "kaiming" } })
|
||
.render(document.body);
|
||
assert.equal(document.querySelectorAll(".katex").length, 1, "avoid 元素未被切分");
|
||
assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0);
|
||
assert.equal(countTag(document.body, ".katex jz-char"), 0, "avoid 內部不 charify");
|
||
});
|
||
|
||
test("問題六:skipAttribute 自身命中——同 avoid,不切分不下潛", () =>
|
||
{
|
||
const html = KATEX.replace('class="katex"', 'class="katex" data-md-key="x"');
|
||
const { document } = setupDom("<p>結果為 " + html + ",在浮點中</p>");
|
||
createJuzhen({ skipAttribute: "data-md-key", lang: { default: "zh-Hans", style: "kaiming" } })
|
||
.render(document.body);
|
||
assert.equal(document.querySelectorAll(".katex").length, 1);
|
||
assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0);
|
||
});
|
||
|
||
test("三分類互斥:預設 code 為 pill(非 skipTags)——兩端補間隙、內部不可見", () =>
|
||
{
|
||
// code/kbd 由預設 pillSelector 涵蓋、**不再列入 skipTags**(避免同元素既 pill 又
|
||
// avoid、pill 兩端間隙被 avoid 抹掉之互斥違反)。故預設下 code 為純 pill。
|
||
const { document } = setupDom("<p>看<code>x</code>這</p>");
|
||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
assert.equal(countTag(p, "jz-hws"), 2, "code(pill)兩端補間隙(看|code、code|這)");
|
||
assert.equal(countTag(p, "code jz-hws"), 0, "code 內部無間隙(pill 內部不可見)");
|
||
});
|
||
|
||
test("三分類互斥:pill 自身 avoid(data-jz-skip)→ 兩端不補間隙(processPills 查 avoidsSelf)", () =>
|
||
{
|
||
// isAvoided 只查祖先鏈、不含自身;自身命中 avoid 之 pill 須另判 avoidsSelf,否則
|
||
// avoid 對自身形同虛設(下游問題六同型)。
|
||
const { document } = setupDom("<p>看<code data-jz-skip>x</code>這</p>");
|
||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
|
||
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0,
|
||
"自身 avoid 之 pill 兩端不補間隙");
|
||
});
|
||
|
||
test("嚴格白名單:未知行內標籤(<button>)為邊界,登記 styleInlines 後轉透明(單一真相源)", () =>
|
||
{
|
||
// 邏輯文本流之三原語(collectRuns/adjacentLogicalChar/jiyaAdjacency)共用 isTransparent:
|
||
// 未在 styleInlines 之行內標籤一律當隔離式邊界(斷 run/相鄰、內部自成脈絡)。
|
||
const a = setupDom("<p>你好<button>Neo</button>寫字</p>");
|
||
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(a.document.body);
|
||
assert.equal(countTag(a.document.querySelector("p"), "jz-hws"), 0,
|
||
"預設 button 為邊界:內外不相鄰、無跨界中西隙");
|
||
|
||
// 顯式登記 styleInlines → 透明穿越:好↔Neo、Neo↔寫 兩處中西邊界補隙。
|
||
const b = setupDom("<p>你好<button>Neo</button>寫字</p>");
|
||
createJuzhen({ jiya: false, longWord: false, jinze: false, styleInlines: [ "BUTTON" ] })
|
||
.render(b.document.body);
|
||
assert.equal(countTag(b.document.querySelector("p"), "jz-hws"), 2,
|
||
"登記後透明穿越,跨界補中西隙");
|
||
});
|
||
|
||
test("問題六:相鄰兩 pill——descend 不刺穿,jz-hws 絕不插入公式內部", () =>
|
||
{
|
||
const { document } = setupDom("<p>公式" + KATEX + KATEX + "文字</p>");
|
||
createJuzhen(KATEX_OPTS).render(document.body);
|
||
assert.equal(countTag(document.body, ".katex jz-hws"), 0, "公式內部無間隙標記");
|
||
assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0);
|
||
});
|
||
|
||
test("pill 內部對一切 pass 不可見:內部標點不 charify、內部不插間隙", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<p>公式<span class="katex"><span class="mord">內文,標點Word混排</span></span>文字</p>',
|
||
);
|
||
createJuzhen(KATEX_OPTS).render(document.body);
|
||
assert.equal(countTag(document.body, ".katex jz-char"), 0, "pill 內部標點不 charify");
|
||
assert.equal(countTag(document.body, ".katex jz-hws"), 0, "pill 內部不插中西間隙");
|
||
assert.equal(countTag(document.body, ".katex span[lang]"), 0, "pill 內部不做長詞包裝");
|
||
});
|
||
|
||
test("問題六 revert:pill 整綁後還原——DOM 結構回原樣(公式逐字節一致)", () =>
|
||
{
|
||
// 無作者空格之夾具:spacing 對 pill 左緣作者空格之吸收(正規化為受管間隙)
|
||
// 屬既有設計、revert 不復活,故 I6 斷言聚焦結構還原。
|
||
const { document } = setupDom("<p>結果為" + KATEX + ",在浮點中</p>");
|
||
const before = document.querySelector("p").innerHTML;
|
||
const jz = createJuzhen(KATEX_OPTS);
|
||
jz.render(document.body);
|
||
jz.revert(document.body);
|
||
assert.equal(document.querySelector("p").innerHTML, before, "render→revert 還原一致(I6)");
|
||
});
|
||
|
||
// ----- 隔離(isolate,預設 <mark>):邊界如 pill、內部照常 -----
|
||
|
||
test("isolate:mark 兩端與 CJK 相鄰補間隙(兩端需要空白)", () =>
|
||
{
|
||
const { document } = setupDom("<p>高亮<mark>重點</mark>文字</p>");
|
||
createJuzhen({ jiya: false, jinze: false, longWord: false }).render(document.body);
|
||
const p = document.querySelector("p");
|
||
// 左緣:包「亮」之 jz-hws;右緣:包整個 mark 之 jz-hws。
|
||
assert.equal(countTag(p, "jz-hws"), 2, "mark 兩端各一處間隙");
|
||
assert.ok(p.querySelector("jz-hws > mark"), "右緣間隙以包裹 mark 自身承載");
|
||
});
|
||
|
||
test("isolate:mark 內部照常處理(內部標點 charify、內部中西補隙)", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<p lang="zh-Hans"><mark>內文,混排Word之後</mark></p>',
|
||
);
|
||
createJuzhen({ jinze: false, longWord: false }).render(document.body);
|
||
assert.ok(document.querySelector("mark jz-char.bd-pause"), "內部標點照常 charify");
|
||
assert.ok(document.querySelector("mark jz-hws"), "內部中西邊界照常補隙");
|
||
});
|
||
|
||
test("isolate:mark 不被克隆切分——mark+逗號整綁入 jz-jinze(背景切分有可見接縫)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">前文<mark>高亮一段話</mark>,後文</p>');
|
||
createJuzhen().render(document.body);
|
||
assert.equal(document.querySelectorAll("mark").length, 1, "mark 未被切分");
|
||
assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0);
|
||
const jinze = document.querySelector("jz-jinze");
|
||
assert.ok(jinze && jinze.querySelector("mark"), "整個 mark 入 jz-jinze(退化整綁)");
|
||
});
|
||
|
||
test("isolate:跨 mark 邊界之相鄰標點不互擠(斷相鄰,內部自成脈絡)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">說:<mark>「重點」</mark>之外。</p>');
|
||
createJuzhen({ jinze: false }).render(document.body);
|
||
// :與「之間隔 mark 邊界——不得配對互擠(:右空白不因「而收)。
|
||
const colon = document.querySelector("jz-char.bd-pause");
|
||
assert.ok(colon, ":已 charify");
|
||
// 開明式 baseline 本就半形;驗證點在「不因鄰接而額外互動」——此處以
|
||
// 全角式重跑驗證邊界斷相鄰。
|
||
const dom2 = setupDom('<p lang="zh-Hant">說:<mark>「重點」</mark>之外。</p>');
|
||
createJuzhen({ jinze: false, lang: { style: "quanjiao" } }).render(dom2.document.body);
|
||
dom2.document.querySelectorAll("jz-char").forEach((c) =>
|
||
{
|
||
assert.ok(!c.classList.contains("jz-half"), "全角式+邊界斷相鄰:無任何互擠半形");
|
||
});
|
||
});
|
||
|
||
test("isolate:isolateSelector 傳 \"\" 停用——mark 回歸透明(無邊界間隙、可切分)", () =>
|
||
{
|
||
const { document } = setupDom("<p>高亮<mark>重點</mark>文字</p>");
|
||
createJuzhen({ isolateSelector: "", jiya: false, jinze: false, longWord: false })
|
||
.render(document.body);
|
||
assert.equal(countTag(document.body, "jz-hws"), 0, "純 CJK 透明穿越,無間隙");
|
||
});
|
||
|
||
test("isolate revert:mark 整綁+邊界間隙還原——DOM 回原樣", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hans">前文<mark>高亮</mark>,混排Word後</p>');
|
||
const before = document.querySelector("p").innerHTML;
|
||
const jz = createJuzhen();
|
||
jz.render(document.body);
|
||
jz.revert(document.body);
|
||
assert.equal(document.querySelector("p").innerHTML, before, "render→revert 還原一致(I6)");
|
||
});
|
||
|
||
test("三分類優先序:pill 行為覆蓋 isolate——兩端補隙同口徑,雙選擇器同命中時 pill 勝出", () =>
|
||
{
|
||
// pill 之邊界口徑=isolate(兩端補隙),差異僅在內部;同一元素同時命中
|
||
// pillSelector 與 isolateSelector 時,pill 語義(內部不可見)勝出。
|
||
const { document } = setupDom('<p lang="zh-Hans">前文<mark class="x">內,文</mark>後文</p>');
|
||
createJuzhen({ pillSelector: ".x", isolateSelector: "mark", jinze: false })
|
||
.render(document.body);
|
||
assert.equal(countTag(document.body, "jz-hws"), 2, "兩端各一間隙(同 isolate 口徑)");
|
||
assert.equal(countTag(document.body, "mark jz-char"), 0, "內部不可見(pill 勝出,標點不 charify)");
|
||
});
|
||
|
||
// ----- v1 相容層移除 -----
|
||
|
||
test("v1 相容層已移除:createCjkAutospace 不再導出(消費端已全數遷移 v2 API)", async () =>
|
||
{
|
||
const mod = await import("../dist/juzhen.mjs");
|
||
assert.equal(mod.createCjkAutospace, undefined, "compat 導出已刪");
|
||
});
|
||
|
||
// ----- 第三層 增量恢復(C1 持久化 Finder + 失效/C3 rerender)-----
|
||
|
||
test("C3 rerender:局部子樹 revert+render,內容變化被重新處理", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<p lang="zh-Hant">春天來了,很暖和。</p><p lang="zh-Hant">夏天到了,很热。</p>',
|
||
);
|
||
const jz = createJuzhen();
|
||
jz.render(document.body);
|
||
const p1 = document.querySelectorAll("p")[0];
|
||
// 改 p1 內容 → rerender(p1) 只重處理 p1。
|
||
jz.revert(p1);
|
||
p1.textContent = "秋天涼了,落葉紛紛。";
|
||
jz.render(p1);
|
||
assert.equal(p1.textContent, "秋天涼了,落葉紛紛。", "p1 內容更新");
|
||
assert.ok(p1.querySelector("jz-char"), "p1 重新 charify(標點原子化)");
|
||
// rerender 便利方法亦可用(等價 revert+render)。
|
||
jz.rerender(p1);
|
||
assert.ok(p1.querySelector("jz-char"), "rerender 後仍正確處理");
|
||
});
|
||
|
||
test("C1 失效正確性:既有元素分類屬性變化(transparent→pill)經 revert+render 正確重分類", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">前文<span>甲,乙</span>後文標點。</p>');
|
||
const jz = createJuzhen({ pillSelector: ".pill" });
|
||
jz.render(document.body);
|
||
const span = document.querySelector("span");
|
||
// 初始 span 透明 → 其內標點 ,被 charify。
|
||
assert.ok(span.querySelector("jz-char"), "透明 span 內標點被 charify");
|
||
// revert(自動失效子樹快取),令 span 成 pill,再 render。
|
||
jz.revert(document.body);
|
||
span.classList.add("pill");
|
||
jz.render(document.body);
|
||
// pill 內部不可見 → 不 charify。若持久化快取未失效、沿用「透明」則會誤 charify——本測驗證
|
||
// revert 之自動失效確實生效(元素分類屬性變化被正確反映)。
|
||
assert.equal(
|
||
document.querySelector("span").querySelectorAll("jz-char").length, 0,
|
||
"成為 pill 後其內不再 charify(快取已失效、重新分類為 pill)",
|
||
);
|
||
});
|
||
|
||
test("C1 invalidate:顯式失效後元素分類屬性變化亦被反映(不經 revert 之失效入口)", () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">前文<span>丙,丁</span>尾。</p>');
|
||
const jz = createJuzhen({ pillSelector: ".pill" });
|
||
jz.render(document.body);
|
||
// 手動移除產物(模擬消費端自管 DOM),令 span 成 pill,顯式 invalidate 後 render。
|
||
jz.revert(document.body);
|
||
// 先 render 一次填充快取(span 為透明),再顯式 invalidate 驗證其清快取之效。
|
||
jz.render(document.body);
|
||
jz.revert(document.body);
|
||
const span = document.querySelector("span");
|
||
span.classList.add("pill");
|
||
jz.invalidate(document.body); // 顯式失效
|
||
jz.render(document.body);
|
||
assert.equal(
|
||
document.querySelector("span").querySelectorAll("jz-char").length, 0,
|
||
"invalidate 後成為 pill 之 span 內不再 charify",
|
||
);
|
||
});
|
||
|
||
test("C1 持久化快取不串擾:同實例多次 render 不同子樹,結果各自正確", () =>
|
||
{
|
||
const { document } = setupDom(
|
||
'<div id="a" lang="zh-Hant">甲文字,甲標點。</div><div id="b" lang="zh-Hant">乙文字、乙標點。</div>',
|
||
);
|
||
const jz = createJuzhen();
|
||
const a = document.getElementById("a");
|
||
const b = document.getElementById("b");
|
||
jz.render(a);
|
||
jz.render(b);
|
||
assert.ok(a.querySelector("jz-char") && b.querySelector("jz-char"), "兩子樹各自 charify");
|
||
assert.equal(a.textContent, "甲文字,甲標點。", "a 內容不變");
|
||
assert.equal(b.textContent, "乙文字、乙標點。", "b 內容不變");
|
||
jz.revert(a);
|
||
assert.equal(countTag(a, "jz-char"), 0, "revert a 清 a 產物");
|
||
assert.ok(b.querySelector("jz-char"), "b 不受 revert a 影響");
|
||
});
|
||
|
||
// ----- 第三層 C2 MutationObserver 增量模式(jsdom 支持 MutationObserver)-----
|
||
|
||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||
|
||
test("C2 觀察:內容編輯自動重處理最近塊", async () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">原本的文字,內容。</p>');
|
||
const jz = createJuzhen();
|
||
jz.render(document.body);
|
||
jz.observe(document.body);
|
||
const p = document.querySelector("p");
|
||
p.textContent = "改後的文字、新標點。";
|
||
await tick();
|
||
jz.disconnect();
|
||
assert.equal(p.textContent, "改後的文字、新標點。", "內容為新值");
|
||
assert.ok(p.querySelector("jz-char"), "編輯後自動重 charify(觀察生效)");
|
||
});
|
||
|
||
test("C2 觀察:追加新塊自動 render(不重掃既有容器)", async () =>
|
||
{
|
||
const { document } = setupDom('<div id="c" lang="zh-Hant"><p>既有段落文字。</p></div>');
|
||
const c = document.getElementById("c");
|
||
const jz = createJuzhen();
|
||
jz.render(c);
|
||
jz.observe(c);
|
||
const np = document.createElement("p");
|
||
np.textContent = "追加的新段落,實義字甲乙丙丁。";
|
||
c.appendChild(np);
|
||
await tick();
|
||
jz.disconnect();
|
||
assert.ok(np.querySelector("jz-char"), "新塊自動 charify");
|
||
assert.ok(np.querySelector("jz-orphan"), "新塊亦跑段落級 pass(垂懸字)");
|
||
});
|
||
|
||
test("C2 觀察:分類屬性變化(transparent→pill)自動重分類(快取失效聯動)", async () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">前文<span>甲,乙</span>後標點。</p>');
|
||
const jz = createJuzhen({ pillSelector: ".pill" });
|
||
jz.render(document.body);
|
||
jz.observe(document.body);
|
||
assert.ok(document.querySelector("span").querySelector("jz-char"), "初始透明 span 內標點被 charify");
|
||
document.querySelector("span").classList.add("pill");
|
||
await tick();
|
||
jz.disconnect();
|
||
assert.equal(
|
||
document.querySelector("span").querySelectorAll("jz-char").length, 0,
|
||
"成 pill 後自動重處理、其內不再 charify(觀察→revert 失效→重分類)",
|
||
);
|
||
});
|
||
|
||
test("C2 觀察:不自觸發(juzhen 自身注入不致無限迴圈;一次編輯僅結算一次)", async () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">測試文字,標點。</p>');
|
||
const jz = createJuzhen();
|
||
jz.render(document.body);
|
||
jz.observe(document.body);
|
||
const p = document.querySelector("p");
|
||
p.textContent = "新文字、標點。";
|
||
await tick();
|
||
await tick(); // 若自觸發、迴圈未止,多等一輪應顯露(產物重複增生)
|
||
jz.disconnect();
|
||
// 冪等且穩定:末尾標點恰一個 jz-char(無因迴圈重複增生)。
|
||
assert.ok(p.querySelector("jz-char"), "已處理");
|
||
assert.equal(countTag(p, "jz-orphan") <= 1, true, "無迴圈致 jz-orphan 增生");
|
||
});
|
||
|
||
test("C2 disconnect:停止觀察後編輯不再自動處理", async () =>
|
||
{
|
||
const { document } = setupDom('<p lang="zh-Hant">初始文字,標點。</p>');
|
||
const jz = createJuzhen();
|
||
jz.render(document.body);
|
||
jz.observe(document.body);
|
||
jz.disconnect();
|
||
const p = document.querySelector("p");
|
||
p.textContent = "斷開後編輯標點。";
|
||
await tick();
|
||
assert.equal(countTag(p, "jz-char"), 0, "disconnect 後不自動處理(純文本、無 jz-char)");
|
||
});
|
||
|
||
test("C2 觀察:向葉塊追加子塊 → 撤除容器陳舊 jz-orphan(IV-3 差分複驗所發現之修復)", async () =>
|
||
{
|
||
const { document } = setupDom('<div id="c" lang="zh-Hant">容器直接文字結尾實義字甲乙丙丁。</div>');
|
||
const c = document.getElementById("c");
|
||
const jz = createJuzhen();
|
||
jz.render(c);
|
||
jz.observe(c);
|
||
assert.ok(c.querySelector("jz-orphan"), "初始:葉塊 div 有 jz-orphan");
|
||
const np = document.createElement("p");
|
||
np.textContent = "追加段落實義字戊己庚辛。";
|
||
c.appendChild(np);
|
||
await tick();
|
||
jz.disconnect();
|
||
// div 轉非葉 → 其**直接**內容之 jz-orphan 須撤除(僅內層新 p 之 jz-orphan 保留)。
|
||
// 舊實作只 render 新塊、殘留容器陳舊 orphan → 與全樹重渲染分歧(IV-3 發現)。
|
||
const directOrphan = Array.from(c.children).some((x) => x.nodeName === "JZ-ORPHAN");
|
||
assert.equal(directOrphan, false, "容器轉非葉後,其直接 jz-orphan 已撤除");
|
||
assert.ok(np.querySelector("jz-orphan"), "新塊 p 自身之 jz-orphan 正常");
|
||
});
|