// 聚珍切片 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("spacing:CJK↔Latin 邊界以 jz-hws 包裹左側字(margin 留隙,不注入空白)", () => { const { document } = setupDom("
我用Neovim寫程式
"); createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body); const p = document.querySelector("p"); assert.equal(countTag(p, "jz-hws"), 2, "用|Neovim 與 Neovim|寫 兩處邊界,各包左側字"); assert.equal(p.textContent, "我用Neovim寫程式", "textContent 完全不變(無注入空白,純 copy-clean)"); assert.equal(p.querySelector("jz-hws").textContent.length, 1, "jz-hws 內為單一邊界字"); }); test("spacing:textContent 完全不變(margin 留隙,不注入也不刪語義字元)", () => { const { document } = setupDom("用Neovim,很爽。
"); createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body); assert.equal(document.querySelector("p").textContent, "用Neovim,很爽。"); }); test("spacing:冪等——重跑不重複插入 jz-hws", () => { const { document } = setupDom("我用Neovim寫程式
"); const jz = createJuzhen({ jiya: false, longWord: false, jinze: false }); jz.render(document.body); const first = countTag(document.body, "jz-hws"); jz.render(document.body); assert.equal(countTag(document.body, "jz-hws"), first, "第二次 render 不應增加"); }); test("spacing:revert 還原至原文(無 jz-hws,textContent 回到原樣)", () => { const { document } = setupDom("我用Neovim寫程式
"); 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('他竟然走了……留下一地
'); 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('售價 €1099 都行
'); 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('中ā文α字Я尾
'); createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body); const p = document.querySelector("p"); // ā(U+0101 拉丁擴充)、α(U+03B1 希臘,驗證希臘集補全)、Я(U+042F 西里爾) 皆為西文。 assert.ok(countTag(p, "jz-hws") >= 5, "ā/α/Я 與相鄰 CJK 各成邊界、補 jz-hws"); }); test("spacing:CJK 標點介於 CJK 間一律不補中西隙(標點稽核:。!?「」()《》……—— 等)", () => { const samples = [ "前。後", "前!後", "前?後", "前,後", "前、後", "前;後", "前:後", "前「後", "前」後", "前(後", "前)後", "前《後", "前》後", "前【後", "前】後", "前〔後", "前〕後", "前……後", "前——後", "前·後", "前~後", "前“後", "前”後", ]; for (const s of samples) { const { document } = setupDom('' + s + "
"); 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('“中文”ABC 與 ”123
'); 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:跨樣式行內邊界(是Neovim)亦識別(I7)", () => { const { document } = setupDom("這是Neovim
"); createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body); // 是|Neovim 邊界(跨 )應插入 jz-hws。 assert.equal(countTag(document.querySelector("p"), "jz-hws"), 1); }); test("spacing:avoid 子樹(code/pre)不處理", () => { const { document } = setupDom("看foo是bar這
用vim編輯
用vim編輯
「不互擠(pill 斷相鄰)", () =>
{
const { document } = setupDom('甲,「乙」
他說:「好,走。」
'); 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('好,走。
'); 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('說「好」嗎
'); createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body); const p = document.querySelector("p"); assert.ok(p.querySelector("jz-char.bd-open").classList.contains("jz-half"), "「半形"); assert.ok(p.querySelector("jz-char.bd-close").classList.contains("jz-half"), "」半形"); }); test("jiya:全角式(zh-Hant)baseline 一律不收(孤立標點無 jz-half)", () => { const { document } = setupDom('好,走。
'); 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('好。」嗎
'); 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('怎么办?!真
'); 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('「「好
'); 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('甲,「乙
'); 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('甲。」乙
'); 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('甲,「乙
'); 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('甲,」。乙
'); createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body); const p = document.querySelector("p"); const comma = p.querySelector("jz-char.bd-pause"); // ,右空白鄰」→ 半 const close = p.querySelector("jz-char.bd-close"); // 」右空白鄰。→ 半 const stop = p.querySelector("jz-char.bd-stop"); // 。左為墨、右鄰漢字 → 全 assert.ok(comma.classList.contains("jz-half"), ",→ 半"); assert.ok(close.classList.contains("jz-half"), "」右空白鄰標點。→ 半"); assert.ok(!stop.classList.contains("jz-half"), "。左為墨、右側無標點鄰 → 全"); }); test("jiya:revert 還原標點(無 jz-char,textContent 不變)", () => { const { document } = setupDom('好,走。
'); 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('好。
'); 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('好,走
'); 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( '简,
繁,
', ); 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( '简,
繁,
', ); 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('好,「走」。
'); 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('走,真?
'); createJuzhen(JIYA_ONLY).render(document.body); assert.ok(document.querySelector("jz-char.bd-pause").classList.contains("jz-ink-center"), "繁體 ,居中"); assert.ok(document.querySelector("jz-char.bd-stop").classList.contains("jz-ink-center"), "?居中"); }); test("margin 模式:render 於 root 設 data-jz-halfwidth=margin,revert 清除", () => { const { document } = setupDom('好,走。
'); 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('好,走
'); 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("用 TypeScript 寫
"); 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("vim 好
"); 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('他說好。」嗎
'); 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('說「好」嗎
'); 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('中文,好
'); createJuzhen({ spacing: false, longWord: false }).render(document.body); const group = document.querySelector("jz-jinze"); assert.equal(group.textContent, "文,", "前字 文 + , 綁定(中 在外可斷)"); }); // 行內樣式元素最小綁定(下游 Bug:整個 入 nowrap inline-block 致行末整體掉行)。 // 邊界詞元經 core/split 克隆切分,只綁末/首詞元,餘下留原位(splitoff 克隆)。 test("jinze 元素最小綁定:長串,只綁末字+逗號(餘下留原位)", () => { const { document } = setupDom('前文粗体文字,后续
'); 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 元素最小綁定:「引文 只綁開引號+首字", () => { const { document } = setupDom('他說「引文內容結束
'); 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:還原為單一 ,文字回原樣", () => { const { document } = setupDom('前文粗体文字,后续
'); 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 不可切元素退化整綁: 連結整體綁定(不克隆出兩個錨點)", () => { const { document } = setupDom('见链接文字,详情
'); createJuzhen({ spacing: false, longWord: false }).render(document.body); const p = document.querySelector("p"); assert.equal(p.querySelectorAll("a").length, 1, "連結不被切分(仍單一 )"); const group = p.querySelector("jz-jinze"); assert.ok(group && group.querySelector("a"), "整個 入綁(退化但安全)"); }); test("jinze:revert 還原(無 jz-jinze/jz-char,文字回原樣)", () => { const { document } = setupDom('他說好。」嗎
'); 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('我用Vim
他用Emacs寫
用Vim,好
'); 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('用Vim,好
'); createJuzhen({}).render(document.body); const p = document.querySelector("p"); assert.ok(countTag(p, "jz-hws") >= 1, "spacing 繼承啟用"); assert.equal(countTag(p, "jz-char"), 0, "jiya 被黑名單停用"); }); // ----- 下游回饋修復(問題一 1a/1b、問題二)----- test("1a:pill 後接 CJK 標點時,pill 左緣仍補 jz-hws(穿越 jz-jinze)", () => { const { document } = setupDom("代码build,后
代码 build,后
事件 元件
'); createJuzhen({ lang: { default: "zh-Hans" } }).render(document.body); assert.equal(document.querySelector("p").textContent, "事件元件", "跨 之 CJK↔CJK 空格剝除"); }); test("1b:CJK↔CJK 不插 jz-hws(僅剝空格,無留隙)", () => { const { document } = setupDom("事件 元件
"); createJuzhen({ spacing: true, jiya: false, jinze: false, longWord: false }).render(document.body); assert.equal(countTag(document.body, "jz-hws"), 0, "CJK↔CJK 不需間隙,僅刪空格"); assert.equal(document.querySelector("p").textContent, "事件元件"); }); // pill 左鄰為標點(經 jinze 包入 jz-char/jz-jinze):不應於標點前補間隙。 // 根因:adjacentLogicalChar 之 descend 舊版跳過不透明之 jz-char、越過標點取到更前之 // 漢字,誤包 jz-hws;修復=jz-char/jz-inner 透明,descend 回傳標點(非 pillNeighbor)。 test("pill 左鄰標點:汉字。 不於「字」「。」間補 jz-hws(jz-char 透明)", () =>
{
const { document } = setupDom("汉字。x
");
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 左鄰漢字(無標點):汉字 仍於「字」補 jz-hws", () =>
{
const { document } = setupDom("汉字x
");
createJuzhen({ lang: { default: "zh-Hans" } }).render(document.body);
const hws = document.querySelector("jz-hws");
assert.ok(hws && hws.textContent === "字", "pill 左鄰為漢字,正常補間隙(修復無回歸)");
});
test("pill 右鄰標點:x。汉 不於 pill 與句號間補 jz-hws", () =>
{
const { document } = setupDom("x。汉字
");
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 左鄰句內點號:事项: 不補 jz-hws(bd-pause 同理)", () =>
{
const { document } = setupDom("事项:x
");
createJuzhen({ lang: { default: "zh-Hans", style: "quanjiao" } }).render(document.body);
const p = document.querySelector("p");
assert.equal(countTag(p, "jz-hws"), 0, "pill 左鄰為冒號(非 pillNeighbor),不補間隙");
});
test("問題二:justifyAtoms:false → root 設 data-jz-atoms=inline,revert 清除", () =>
{
const { document } = setupDom("测试,(甲)。
");
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("测试,
");
createJuzhen({ lang: { default: "zh-Hans" } }).render(document.body);
assert.equal(document.body.getAttribute("data-jz-atoms"), null, "預設不設屬性");
});
// ----- v1 相容層(§3.5)-----
test("compat:createCjkAutospace().apply() 可跑,產生中西間隙(margin 模型,copy-clean)", () =>
{
const { document } = setupDom("我用Neovim
");
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("compat:punctuationSqueeze:false 關閉 jiya", () =>
{
const { document } = setupDom('好,走
');
createCjkAutospace({ punctuationSqueeze: false }).apply(document.body);
assert.equal(countTag(document.body, "jz-char"), 0);
});
test("compat:預設強制 kaiming,句內點號半形(jz-half)(不因預設 zh-Hant 而退化)", () =>
{
const { document } = setupDom('好,走
');
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-spacing/word-spacing/text-align
// 系皆不洩漏)。
const cssPath = fileURLToPath(new URL("../dist/juzhen.css", import.meta.url));
const css = readFileSync(cssPath, "utf8");
assert.match(css, /text-indent\s*:\s*0/, "構建 CSS 應含 text-indent:0 重設");
assert.match(css, /hanging-punctuation\s*:\s*none/, "構建 CSS 應含 hanging-punctuation:none 重設");
});
test("CSS:jz-* 原子 text-decoration:inherit,補裝飾盒跳過原子致下劃線斷缺(透明原則·欠套用面)", () =>
{
// 另一機制:text-decoration 非繼承、由段落裝飾盒繪製並跳過原子級 inline-block,
// 致下劃線於每個標點原子下斷缺。令原子 inherit 自繪、接續(headless Chrome 像素掃描
// 實測 halt/margin coverage 皆回 1.0)。
const cssPath = fileURLToPath(new URL("../dist/juzhen.css", import.meta.url));
const css = readFileSync(cssPath, "utf8");
assert.match(css, /text-decoration\s*:\s*inherit/, "構建 CSS 應含 text-decoration:inherit");
});
test("CSS:聚珍不內建 font-family(字型交消費端),半形顯式保留 locl(下游回饋)", () =>
{
// 字型屬消費端領域,聚珍不內建任何字型堆疊(移除 _lang.css);僅以 _normalize 之
// `font: inherit` 使原子繼承消費端字型、不強加,並於半形 feature 顯式併入 locl,
// 確保泛 CJK 字型之地域標點字形不因設 halt 而失。body locl 由消費端字型+lang 決定。
const cssPath = fileURLToPath(new URL("../dist/juzhen.css", import.meta.url));
const css = readFileSync(cssPath, "utf8");
assert.equal(/font-family/.test(css), false, "聚珍 CSS 不應內建任何 font-family");
assert.match(css, /font-feature-settings:\s*"locl"\s+1,\s*"halt"\s+1/, "半形 feature 應顯式保留 locl");
});
// ----- charClass:自定義字符分類規則集(向下兼容+擴充)-----
test("jiya:英文全角句號 . 已內建為句末標點 bd-stop(本次納入;Noto CJK 有 halt)", () =>
{
// . U+FF0E(異於表意句號 。U+3002)已納入內置 BD_STOP,預設即 charify 為 bd-stop。
const { document } = setupDom('中.文。
');
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('中‥文
');
createJuzhen({ lang: { style: "kaiming" } }).render(base.document.body);
assert.equal(countTag(base.document.querySelector("p"), "jz-char"), 0, "‥ 預設未分類、不 charify");
const { document } = setupDom('中‥文
');
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('中★文
');
// 先確認預設不補隙
createJuzhen({}).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, "預設 ★ 非西文、不補隙");
const dom2 = setupDom('中★文
');
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('〇A
');
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('中★文
');
createJuzhen({ charClass: { western: "★" } }).render(a.document.body);
assert.ok(countTag(a.document.querySelector("p"), "jz-hws") >= 1, "覆寫實例:★ 補隙");
// 再建無覆寫之實例:★ 應仍未分類、不補隙(前實例之覆寫未洩漏至默認)。
const b = setupDom('中★文
');
createJuzhen({}).render(b.document.body);
assert.equal(countTag(b.document.querySelector("p"), "jz-hws"), 0,
"無覆寫實例:★ 仍未分類、不補隙(默認規則集未被前實例污染)");
});
test("charClass.biaodian.pause:未分類符號歸句內點號 → 開明式挤半形(jz-half)", () =>
{
const { document } = setupDom('中•文
');
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('甲•乙
');
createJuzhen({ charClass: { biaodian: { open: "•" } } }).render(document.body);
assert.ok(countTag(document.querySelector("p"), "jz-jinze") >= 1, "開類 • 與後字綁定(避尾)為 jz-jinze");
});
test("charClass.western:追加不破壞內置——€(內置)與 ★(追加)皆補隙", () =>
{
const { document } = setupDom('價€元★好
');
createJuzhen({ jiya: false, longWord: false, jinze: false, charClass: { western: "★" } }).render(document.body);
const n = countTag(document.querySelector("p"), "jz-hws");
// 價↔€、€↔元、元↔★、★↔好 四處中西邊界皆補隙(內置 € 不因追加而失)。
assert.ok(n >= 4, "內置 € 與追加 ★ 之四邊界皆補隙,實得 " + n);
});
test("charClass.biaodian:多子類同傳,各自生效(stop/pause)", () =>
{
const { document } = setupDom('甲‥乙•丙
');
createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { stop: "‥", pause: "•" } } }).render(document.body);
const p = document.querySelector("p");
assert.ok(countTag(p, "jz-char.bd-stop") >= 1, "‥ → bd-stop");
const pause = p.querySelector("jz-char.bd-pause");
assert.ok(pause && pause.classList.contains("jz-half"), "• → bd-pause 且開明式挤半形");
});
// ----- 功能分級(§6.5.0a,text-level vs paragraph-level)-----
test("分級:data-jz-level=text 之子樹只跑文本級——jiya/spacing 生效、jinze 跳過", () =>
{
const { document } = setupDom('說「你好」用Neovim寫
');
createJuzhen().render(document.body);
const p = document.querySelector("p");
// 文本級:標點 charify(jz-char)、中西間隙(jz-hws)照常。
assert.ok(countTag(p, "jz-char") >= 1, "text-level 仍跑 jiya(jz-char)");
assert.ok(countTag(p, "jz-hws") >= 1, "text-level 仍跑 spacing(jz-hws)");
// 段落級:禁則跳過——無 jz-jinze(「」本會綁鄰字)。
assert.equal(countTag(p, "jz-jinze"), 0, "text-level 段落級禁則跳過(無 jz-jinze)");
});
test("分級:data-jz-level=text 跳過段落級 longWord(charify 亦受閘)", () =>
{
const { document } = setupDom('用 configuration 設定
');
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('說「你好」結束
說「你好」結束
');
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(
''
+ '說「你好」結束
',
);
createJuzhen().render(document.body);
const p = document.querySelector("p");
assert.ok(countTag(p, "jz-jinze") >= 1, "最近 data-jz-level=paragraph 勝 → 禁則恢復");
});
test("分級:spacing 文本級支持單元素 root(render 於 )", () =>
{
const { document } = setupDom("我用Neovim寫程式");
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('說「你好」結束
');
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('甲乙,丙丁。
');
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('甲乙,丙丁。
');
createJuzhen({ orphan: { chars: 3 } }).render(document.body);
const orphan = document.querySelector("jz-orphan");
assert.equal(orphan.textContent, "乙,丙丁。", "綁定末 3 實義字(乙丙丁)+其間/尾標點");
});
test("垂懸字:標點/符號不計實義字數(逗號不計)", () =>
{
const { document } = setupDom('讀書,思考,寫作。
');
createJuzhen().render(document.body);
const orphan = document.querySelector("jz-orphan");
// 實義字 讀書思考寫作(6),末 2=寫作;逗號不計入。
assert.equal(orphan.textContent, "寫作。", "末 2 實義字=寫作,標點不計");
});
test("垂懸字:段落實義字 < N 不綁(單字段)", () =>
{
const { document } = setupDom('嗯。
');
createJuzhen().render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "實義字 1 < 2,不綁");
});
test("垂懸字:邊界字落於 jz-jinze 內 → 綁定整個群組(跨 jinze 攀爬、不切割)", () =>
{
const { document } = setupDom('他說「好」
');
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('甲乙,丙丁。
');
createJuzhen({ orphan: false }).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "關閉時不產生 jz-orphan");
});
test("垂懸字:冪等——重跑不重複包裹", () =>
{
const { document } = setupDom('甲乙,丙丁。
');
const jz = createJuzhen();
jz.render(document.body);
const first = countTag(document.body, "jz-orphan");
jz.render(document.body);
assert.equal(countTag(document.body, "jz-orphan"), first, "第二次 render 不增加 jz-orphan");
});
test("垂懸字:revert 還原(無 jz-orphan,textContent 回原樣)", () =>
{
const { document } = setupDom('甲乙,丙丁。
');
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('甲乙,丙丁。
');
createJuzhen().render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "text-level 不跑垂懸字");
});
// 邊界實義字落於可切行內元素內 → 克隆切分出 [邊界字..元素末],避免整個長元素入
// nowrap 之 jz-orphan(可溢出容器)。
test("垂懸字 元素最小綁定:段末長 只綁末 N 實義字(非整個元素)", () =>
{
const { document } = setupDom(
'這是比較長的開頭文字而結尾為一段很長的加粗文字
',
);
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:兩個 併回單一、文字還原", () =>
{
const { document } = setupDom(
'這是比較長的開頭文字而結尾為一段很長的加粗文字
',
);
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('春。夏。秋。冬。
');
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('春。夏。秋。冬。
');
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('春。夏。秋。冬。
');
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");
});