Files
cjk-autospace/test/juzhen.test.mjs
T
admin 2d42c7ecf2 feat: 聚珍切片 1 — 中西間隙/標點擠壓/禁則 + v1 相容層
從零重寫之第一垂直切片(依 ARCHITECTURE.md 契約),涵蓋並超越 v1 全部功能。

核心基座(src/core):unicode 字元表+標點子類+字形側位;locale 語言與
全角/開明政策(全域 lang.style +逐 lang policy);dom(jz-* 工廠+可還原
標記);finder 遍歷引擎(avoid/scope.include/邏輯 run/pill 鄰字/分塊閘);
pass 聚合管線。

功能 pass(src/typeset):
- spacing:中西間隙採 margin 模型——包裹邊界左側字、以 margin-right 留隙,
  不注入空白字元(textContent 完全不變、複製緊湊、justify 不暴增、不撐框、
  行首乾淨)。含跨樣式邊界、pill、原檔空格移除。
- gapTrim(lineedge):layout pass,行末去隙,修正 justify 右緣內縮。
- longword:長英文詞 <span lang> + hyphens。
- jiya:標點 charify + 全角/開明/半角(全域 + 逐 lang);半形以字型 OpenType
  halt 渲染(按字形正確定位、不重疊,居中字形 !? 與繁體 locl 句逗點號皆正確);
  行內連續擠壓。
- jinze:行首行末避頭尾(閉類/點號綁前字、開類綁後字,以詞元為邊界、長引文
  中段可斷);亦為 justify 綁定載體。
- lineedge:行端擠壓 layout pass(括號引號;ResizeObserver 重算,瀏覽器專用)。

§8 justify:jz-char inline-block 原子,句末標點單份間距。
v1 相容層(compat/v1):createCjkAutospace().apply 映射,IIFE 全域別名。
構建:esbuild → ESM/IIFE/CSS;tsc → d.ts。測試:39 個 jsdom 案例。
demo:簡繁並列+justify+強制繁體開明+標點寬度量測,載 Noto webfont 作參考
(擠壓依賴字型 halt)。間隔號 · 不觸發中西間隙;200% 等詞元不被禁則切斷。
2026-06-01 21:31:07 +08:00

319 lines
15 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 { 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:跨樣式行內邊界(<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"), "繁體內建全角");
});
// ----- 長英文詞(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 被黑名單停用");
});
// ----- 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 不退化:仍擠句內");
});