Files
cjk-autospace/test/juzhen.test.mjs
T
admin b821cd1134 feat: 字符分類規則集抽象(charClass,可配置;向下兼容)
把散落於各 pass 之硬編碼字符分類(中文 CJK/西文 ANS/全寬標點 BD_* 子類)統一抽象為
規則集 Ruleset:內置默認即現狀值,消費端經 options.charClass 擴充,以覆寫 spacing/
jiya/jinze 對特定字符之分類判定。

動機:英文全角句號 .(U+FF0E,異於中文句號 。U+3002)實證為**完全未分類**——不被
charify、不挤壓、與 CJK 間不補隙。與其每次硬編碼補字符(如先前 € 之西文集),不如給
消費端可配置之分類鉤子。

API(追加語義):
  charClass?: {
    cjk?: string;        // 追加為中文
    western?: string;    // 追加為西文(半寬標點亦歸此)
    biaodian?: { stop?, pause?, open?, close?, middle?, liga? };  // 追加為全寬標點,按子類
  }

實作:
- core/unicode.ts:新增 Ruleset/CharClassOverride/BdSubclass 型別、DEFAULT_RULESET、
  resolveRuleset(追加合併);classifyBiaodian(ch, biaodian) 改吃規則集(預設參數向下兼容)。
- types.ts:JuzhenOptions.charClass;ResolvedOptions.ruleset;index 再匯出公開型別。
- index.ts:resolveRuleset(opts.charClass) → ResolvedOptions.ruleset。
- typeset/spacing.ts:所有 CJK/ANS 依賴正則移入 makeRules(ruleset),per-instance 編譯
  (非每 block);ALNUM/ALPHA/運算符等結構性 ASCII 仍為固定常數。
- typeset/jiya.ts:classifyBiaodian(ch, ruleset.biaodian)。
- typeset/jinze.ts:詞元邊界之 anyCjk 由 ruleset.cjk 建。

向下兼容:不傳 charClass 則 ruleset === DEFAULT_RULESET、行為與重構前完全一致。
v1 為追加語義,不支援重分類/移除內置字符(範圍式集合難挖洞、需求罕見,列後續)。

驗證:實 Chromium end-to-end——charClass.biaodian.stop="." → . charify 為 bd-stop
(开明式句末維持全角,比照 。);western="★"/cjk="〇" → 與對側正確補/不補中西間隙。
測試 66 通過(含 9 條 charClass 新測——默認未分類/各子類 charify/實例隔離不可變
(resolveRuleset 不污染 DEFAULT)/追加不破壞內置/多子類組合;57 既有不變)。

- README「自定義字符分類」節+ARCHITECTURE §4.2 規則集抽象。dist 重建。
2026-06-04 16:22:14 +08:00

596 lines
31 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);
});
// ----- 標點擠壓(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("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, "文,", "前字 文 + , 綁定(中 在外可斷)");
});
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, "事件元件");
});
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("charClass:預設不傳時,英文全角句號 . 未分類(不 charify、不挤、不補隙)", () =>
{
const { document } = setupDom('<p lang="zh-Hans">中.文</p>');
createJuzhen({ lang: { style: "kaiming" } }).render(document.body);
const p = document.querySelector("p");
assert.equal(countTag(p, "jz-char"), 0, " 預設未分類為標點,不 charify");
assert.equal(countTag(p, "jz-hws"), 0, ". 預設非西文,無中西間隙");
});
test("charClass.biaodian.stop:把 歸為全寬句末標點 → 被 jiya charify 為 bd-stop", () =>
{
const { document } = setupDom('<p lang="zh-Hans">中.文.</p>');
createJuzhen({ lang: { style: "kaiming" }, charClass: { biaodian: { stop: "" } } }).render(document.body);
const p = document.querySelector("p");
assert.ok(countTag(p, "jz-char.bd-stop") >= 1, " 應 charify 為 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 且開明式挤半形");
});