Files
cjk-autospace/test/juzhen.test.mjs
T
admin 2890549f44 fix: 行內排版透明性——pill 前標點誤隙+行內元素整體掉行+審計(Bug1/Bug2/T4-T6)
統一「行內元素對排版透明」原則在各遍歷原語之口徑,修復兩個下游 Bug 及三項同根隱患。

Bug1(pill 前標點誤加間隙):
  isTransparentInline 補 JZ-CHAR/JZ-INNER。`漢字。<code>` 經禁則綁定後,
  adjacentLogicalChar 之 descend 遇不透明 jz-char 既不進入亦不終止、而跳過它越過
  標點取到「字」誤補 jz-hws;透明後回傳標點「。」(非 pill 鄰字)→ 不補隙,與
  collectRuns 既有透明遞迴對齊。

Bug2(行內樣式元素整體掉行):
  新增 core/split.ts 之 isolateBoundaryToken/splitElementAt——沿祖先鏈逐層把邊界
  詞元剝入淺克隆(保留標籤/class/style/lang、去 id;標 data-jz-kind="splitoff"),
  jinze/orphan 改最小綁定:`<strong>長串</strong>,` 只綁末字+逗號(非整串入 nowrap
  inline-block 致行末整體掉行);段末長 strong 不再整綁溢出容器。revertPass 增延後
  階段把 splitoff 併回原元素(先解包 wrap、後併 splitoff),render→revert 還原一致。
  純樣式標籤白名單可切,a/abbr/ruby/帶 id 退化整綁。

審計(同根隱患):
  T4 lineedge edgeRect 攀爬集改由 options.finder.styleInlines 派生(棄硬編碼子集,
     免遺漏 ins/del/sub 等致行端誤判)。
  T5 jiyaAdjacency 遇 pill(pillMatches)斷相鄰、不穿越(內容對排版不可見)。
  T6 spacing processPills 補 per-node 功能閘(data-juzhen-off=spacing 區域內不補隙)。

文檔:README 切片表(spacing/jinze/orphan 三行)+ARCHITECTURE §4.1 透明性原則、
  §4.4 splitoff 還原、§6.4 行內元素最小綁定、§5 jz-orphan 詞彙。

測試 90→102(Bug1×4、Bug2×6、T5/T6×2)。headless Chrome 實證 Bug2:段末 strong
最小切分、orphan 寬 60px 不溢出版心。
2026-06-11 16:06:17 +08:00

971 lines
51 KiB
JavaScript
Raw 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, createCjkAutospace } 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, "全角式不擠(無相鄰標點)");
});
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:跨透明 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("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, "他說好。」嗎");
});
// ----- 作用域與分塊(§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, "預設不設屬性");
});
// ----- v1 相容層(§3.5-----
test("compatcreateCjkAutospace().apply() 可跑,產生中西間隙(margin 模型,copy-clean", () =>
{
const { document } = setupDom("<p>我用Neovim</p>");
createCjkAutospace().apply(document.body);
const p = document.querySelector("p");
assert.ok(p.querySelector("jz-hws"), "間隙仍產生(jz-hws 包左側字 + margin");
assert.equal(p.textContent, "我用Neovim", "textContent 不變(無注入空白)");
});
test("compatpunctuationSqueeze:false 關閉 jiya", () =>
{
const { document } = setupDom('<p lang="zh-Hant">好,走</p>');
createCjkAutospace({ punctuationSqueeze: false }).apply(document.body);
assert.equal(countTag(document.body, "jz-char"), 0);
});
test("compat:預設強制 kaiming,句內點號半形(jz-half)(不因預設 zh-Hant 而退化)", () =>
{
const { document } = setupDom('<p lang="zh-Hant">好,走</p>');
createCjkAutospace().apply(document.body);
const comma = document.querySelector("jz-char.bd-pause");
assert.ok(comma && comma.classList.contains("jz-half"), "compat 不退化:仍擠句內");
});
// ----- 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 還原");
});
// ----- 標點懸掛(§6.5.2,行端模式接線;版面凸出量於 headless Chrome 驗證)-----
test("懸掛:已全面停用——hanging 預設關,行端模式 squeeze", () =>
{
const jz = createJuzhen();
assert.equal(jz.options.lineEnd, "squeeze", "行端模式恒 squeeze");
});
test("懸掛:防逃逸——即使 hanging:true,行端模式仍鎖死 squeeze(不啟用 hang", () =>
{
const jz = createJuzhen({ hanging: true });
// option 仍被接受(API 不破),但 lineEnd 鎖死 squeeze——hang 不可能激活(§6.5.6 停用)。
assert.equal(jz.options.lineEnd, "squeeze", "hanging:true 亦不轉 hang(無逃逸)");
});
test("懸掛:防逃逸——hanging:true render 後不產生任何 jz-hang-* 標記", () =>
{
const { document } = setupDom('<p lang="zh-Hans">春。夏。秋。冬。</p>');
createJuzhen({ hanging: true, justifyAtoms: false }).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("懸掛:防逃逸——下游 data-juzhen=\"hanging\" 亦不啟用(lineEnd 不受 per-node 影響)", () =>
{
const { document } = setupDom('<p lang="zh-Hans" data-juzhen="spacing jiya jinze hanging">春。夏。秋。冬。</p>');
const jz = createJuzhen({ hanging: true, justifyAtoms: false });
jz.render(document.body);
assert.equal(jz.options.lineEnd, "squeeze", "lineEnd 恒 squeeze");
assert.equal(countTag(document.body, "jz-char.jz-hang-r"), 0, "data-juzhen 要求亦無 jz-hang-r");
assert.equal(countTag(document.body, "jz-char.jz-hang-l"), 0, "data-juzhen 要求亦無 jz-hang-l");
});
test("懸掛:防逃逸——blocks features:[\"hanging\"] 亦不啟用", () =>
{
const { document } = setupDom('<p class="q" lang="zh-Hans">春。夏。秋。冬。</p>');
createJuzhen({
hanging: true, justifyAtoms: false,
blocks: [ { selector: ".q", features: [ "spacing", "jiya", "hanging" ] } ],
}).render(document.body);
assert.equal(countTag(document.body, "jz-char.jz-hang-r"), 0, "blocks 要求亦無 jz-hang-r");
assert.equal(countTag(document.body, "jz-char.jz-hang-l"), 0, "blocks 要求亦無 jz-hang-l");
});