Files
cjk-autospace/test/juzhen.test.mjs
T
admin e3296104a6 refactor: 專案整理階段二——透明判定單一真相源+跨透明邊界避頭尾+avoid 排除歸一
承階段一之下游審計。核心貫穿問題:「透明穿越」判定實有兩套口徑散落各處,抵觸 finder
檔頭「所有遍歷共用同一判定」之聲明。用戶抉擇:**嚴格白名單**(未知行內標籤=邊界,
透明須顯式登記 styleInlines)。

透明判定單一真相源(2a):
・transparentInline 提升為公開 isTransparent,作邏輯文本流之單一透明判定。
・collectRuns 改由 isTransparent 判定——未在 styleInlines 之未知行內標籤(如 <button>/自訂
  元素)當隔離式邊界(斷 run、內部自成脈絡);isolate 與未知行內併為同一分支。
・jiyaAdjacency 同改用 finder.isTransparent;adjacentLogicalChar 沿用(僅隨改名)。
・分層澄清(承 #4 教訓,不強行一統):邏輯文本流統一為 isTransparent;另兩層各有正當之
  不同邊界、刻意不併入——結構切分安全(split canSplit:更窄白名單,<a>/ABBR 不可切)與
  物理版面(lineedge:getClientRects 物理相鄰,須跨 isolate 找真實鄰接)。檔頭改分層表述。

跨透明元素邊界避頭尾綁定(2b,修下游審計 #3):
・避頭類標點若落於透明元素邊緣(「你好<strong>。世界</strong>」之 。位於 strong 首位、與
  「好」不同 DOM parent),舊版 forbiddenBreak 僅認直接 jz-char、render 僅處理 jz-char 之直接
  父(strong),外層 p 之「好↔strong」綁定從不被評估 → 。漏綁、可誤置行首。
・偵測:edgeCharEl 經 finder.isTransparent 穿越透明元素定位邊緣 jz-char。
・父級處理:render 沿透明元素邊緣上攀納入外層 parent,父集深者先處理。綁定端沿用
  isolateBoundaryToken(可切則最小綁定;邊界字為已 charify 之 jz-char 無法越過時退化整綁,
  安全、與 <a> 同型)。

avoid 排除機制歸一(2c):
・isAvoided 改單一組合入口(自身即邊界 或 祖先鏈任一邊界),免除「avoidsSelf ‖ isAvoided」
  易漏一半之誤用(階段一 #2 即此類);avoidsSelf 保留供遍歷原語逐元素下探自判(免 O(n²))。
・processPills/orphan/spacing root 收斂為 isAvoided(x);doc 補齊七種 avoid 來源與優先序。

一致性小項(2e):scope.include 型別去多餘 `| null`;PASSES 陣列補註(執行序由 pass.order
分群排序決定);gapTrim 補 featureEnabledFor("spacing"),與 lineEdge relayout 逐節點閘對稱。

評估後不做(2d):五處幂等判斷(jiya 祖先/longword 父/spacing 父/orphan 子/jinze 父)檢查
不同位置關係、無單一 isJz 形狀可全覆蓋;真正缺陷(jinze 二次 render 巢狀)已於階段一以 isJz
修復,其餘各自正確,強行歸一徒增風險(承 #4 教訓:正當差異不硬併)。

新增契約/回歸測試(button 嚴格白名單、跨透明邊界避頭);121 測試全綠、構建乾淨。
2026-07-07 21:05:25 +08:00

1227 lines
66 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 } 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 句界:。「→ 句末點號保留右空白(。全形、「半形;全角/開明皆 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 還原");
});
// ----- 標點懸掛已移除(§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 導出已刪");
});