Files
admin c5a503580c perf+feat: 掃描/佈局提速(中間/S4+S1)+增量恢復(Tier3 C1/C3/C2)
承 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 重建。
2026-07-12 02:20:20 +08:00

1525 lines
81 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 聚珍切片 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("spacingCJK↔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("spacingtextContent 完全不變(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("spacingrevert 還原至原文(無 jz-hwstextContent 回到原樣)", () =>
{
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("spacingCJK 標點介於 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("spacingavoid 子樹(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("spacingpillcode)兩側與 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);
});
// T6processPills 須過 per-node 功能閘——data-juzhen-off="spacing" 區域內 pill 不補間隙。
test("spacing T6data-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 不補隙");
});
// T5jiyaAdjacency 須把 pill 當相鄰邊界(斷相鄰、不穿越)——pill 兩側之標點不應被當
// 連續標點對而擠壓(pill 內容對排版不可見)。空 pill 才能凸顯此路徑(含文本之 pill 由
// 其內文本天然斷相鄰)。
test("jiya T5pill 隔斷標點相鄰——,<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-* classtextContent 不變(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-Hantbaseline 一律不收(孤立標點無 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"),
"h1banjiao)句末點號半形");
assert.ok(!document.querySelector("p jz-char.bd-stop").classList.contains("jz-half"),
"pzh-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"),
"h2stylePolicy banjiao)句末點號半形");
assert.ok(!document.querySelector("p jz-char.bd-stop").classList.contains("jz-half"),
"pzh-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("樣式覆寫 revertdata-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("jiyarevert 還原標點(無 jz-chartextContent 不變)", () =>
{
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=marginrevert 清除", () =>
{
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("jinzerevert 還原(無 jz-jinzejz-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("1apill 後接 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-jinzespacing 仍須能找到 code 左鄰字「码」並補間隙。
const hws = document.querySelector("jz-hws");
assert.ok(hws && hws.textContent === "码", "pill 左側「码」包入 jz-hws(不因右側標點/jz-jinze 而遺失)");
});
test("1apill 後接 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("1bCJK↔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-charjz-jinze):不應於標點前補間隙。
// 根因:adjacentLogicalChar 之 descend 舊版跳過不透明之 jz-char、越過標點取到更前之
// 漢字,誤包 jz-hws;修復=jz-charjz-inner 透明,descend 回傳標點(非 pillNeighbor)。
test("pill 左鄰標點:汉字。<code> 不於「字」「。」間補 jz-hwsjz-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-hwsbd-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=inlinerevert 清除", () =>
{
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-spacingword-spacingtext-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("CSSjz-* 原子 text-decoration:inherit,補裝飾盒跳過原子致下劃線斷缺(透明原則·欠套用面)", () =>
{
// 另一機制:text-decoration 非繼承、由段落裝飾盒繪製並跳過原子級 inline-block
// 致下劃線於每個標點原子下斷缺。令原子 inherit 自繪、接續(headless Chrome 像素掃描
// 實測 haltmargin 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:多子類同傳,各自生效(stoppause", () =>
{
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.0atext-level vs paragraph-level-----
test("分級:data-jz-level=text 之子樹只跑文本級——jiyaspacing 生效、jinze 跳過", () =>
{
const { document } = setupDom('<p data-jz-level="text" lang="zh-Hant">說「你好」用Neovim寫</p>');
createJuzhen().render(document.body);
const p = document.querySelector("p");
// 文本級:標點 charifyjz-char)、中西間隙(jz-hws)照常。
assert.ok(countTag(p, "jz-char") >= 1, "text-level 仍跑 jiyajz-char");
assert.ok(countTag(p, "jz-hws") >= 1, "text-level 仍跑 spacingjz-hws");
// 段落級:禁則跳過——無 jz-jinze(「」本會綁鄰字)。
assert.equal(countTag(p, "jz-jinze"), 0, "text-level 段落級禁則跳過(無 jz-jinze");
});
test("分級:data-jz-level=text 跳過段落級 longWordcharify 亦受閘)", () =>
{
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 文本級支持單元素 rootrender 於 <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-orphantextContent 回原樣)", () =>
{
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:truedata-juzhenblocks 任一要求皆被接受但無效果)。
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)——兩端補間隙、內部不可見", () =>
{
// codekbd 由預設 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, "codepill)兩端補間隙(看|code、code|這)");
assert.equal(countTag(p, "code jz-hws"), 0, "code 內部無間隙(pill 內部不可見)");
});
test("三分類互斥:pill 自身 avoiddata-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 後轉透明(單一真相源)", () =>
{
// 邏輯文本流之三原語(collectRunsadjacentLogicalCharjiyaAdjacency)共用 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("問題六 revertpill 整綁後還原——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("isolatemark 兩端與 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("isolatemark 內部照常處理(內部標點 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("isolatemark 不被克隆切分——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("isolateisolateSelector 傳 \"\" 停用——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 revertmark 整綁+邊界間隙還原——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 正常");
});