甲
import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; import Ajv2020 from "ajv/dist/2020.js"; import { build } from "esbuild"; import { JSDOM } from "jsdom"; const TEST_DIR = dirname(fileURLToPath(import.meta.url)); const ROOT = dirname(TEST_DIR); const RULES_DIR = join(ROOT, "rules"); async function readJson(path) { return JSON.parse(await readFile(path, "utf8")); } const [ rules, rulesSchema, vectorsDoc, vectorsSchema, packageJson, spacingCss ] = await Promise.all([ readJson(join(RULES_DIR, "juzhen-rules.json")), readJson(join(RULES_DIR, "juzhen-rules.schema.json")), readJson(join(RULES_DIR, "golden-vectors.json")), readJson(join(RULES_DIR, "golden-vectors.schema.json")), readJson(join(ROOT, "package.json")), readFile(join(ROOT, "src/css/_spacing.css"), "utf8"), ]); const ajv = new Ajv2020({ allErrors: true, strict: true }); const validateRules = ajv.compile(rulesSchema); const validateVectors = ajv.compile(vectorsSchema); async function bundleSourceAdapter() { const source = [ 'export * from "./src/core/unicode.ts";', 'export * from "./src/core/locale.ts";', 'export { createJuzhen, normalizeOptions } from "./src/index.ts";', 'export { jiyaPass, jiyaAdjacencyPass } from "./src/typeset/jiya.ts";', 'export { longWordPass } from "./src/typeset/longword.ts";', 'export { levelMarkPass } from "./src/typeset/levelmark.ts";', 'export { jinzePass } from "./src/typeset/jinze.ts";', 'export { spacingPass } from "./src/typeset/spacing.ts";', 'export { orphanPass } from "./src/typeset/orphan.ts";', 'export { lineEdgePass, gapTrimPass } from "./src/typeset/lineedge.ts";', ].join("\n"); const result = await build({ stdin: { contents: source, loader: "ts", resolveDir: ROOT, sourcefile: "shared-rules-adapter.ts", }, bundle: true, format: "esm", platform: "node", target: "es2022", write: false, logLevel: "silent", }); const bundled = result.outputFiles[0].text + "\n//# sourceURL=shared-rules-adapter.mjs\n"; const encoded = Buffer.from(bundled).toString("base64"); return import("data:text/javascript;base64," + encoded); } const adapter = await bundleSourceAdapter(); const vectors = vectorsDoc.vectors; function vectorsOf(category) { return vectors.filter((vector) => vector.category === category); } function renderText(text, options = {}) { const dom = new JSDOM("
"); const paragraph = dom.window.document.querySelector("p"); paragraph.textContent = text; const instance = adapter.createJuzhen({ spacing: false, jiya: false, jinze: false, orphan: false, longWord: false, ...options, }); const globals = { document: globalThis.document, NodeFilter: globalThis.NodeFilter, }; globalThis.document = dom.window.document; globalThis.NodeFilter = dom.window.NodeFilter; try { instance.render(paragraph); } finally { if (globals.document === undefined) { delete globalThis.document; } else { globalThis.document = globals.document; } if (globals.NodeFilter === undefined) { delete globalThis.NodeFilter; } else { globalThis.NodeFilter = globals.NodeFilter; } } return { dom, paragraph, instance }; } function punctuationCharacter(punctuationClass) { return { stop: "。", pause: ",", open: "「", close: "」", middle: "·", liga: "…", }[punctuationClass]; } function entryBounds(entry) { const start = Number.parseInt(entry.codePoint || entry.start, 16); const end = Number.parseInt(entry.codePoint || entry.end, 16); return { start, end }; } function assertOrderedCodePointSet(name, entries) { let previousEnd = -1; for (const entry of entries) { const { start, end } = entryBounds(entry); assert.ok(start >= 0 && end <= 0x10FFFF, name + " Unicode bounds"); assert.ok(start <= end, name + " closed range order"); assert.ok(start > previousEnd, name + " ranges must be sorted and non-overlapping"); previousEnd = end; } } function webEscape(codePoint) { const hex = codePoint.toString(16).toUpperCase(); return codePoint <= 0xFFFF ? "\\u" + hex.padStart(4, "0") : "\\u{" + hex + "}"; } function rangesToWebFragment(entries) { return entries.map((entry) => { const { start, end } = entryBounds(entry); return start === end ? webEscape(start) : webEscape(start) + "-" + webEscape(end); }).join(""); } function assertEquivalentMembership(name, entries, sourceFragment) { assertOrderedCodePointSet(name, entries); const canonical = new RegExp("^[" + rangesToWebFragment(entries) + "]$", "u"); const source = new RegExp("^[" + sourceFragment + "]$", "u"); for (let codePoint = 0; codePoint <= 0x10FFFF; codePoint += 1) { const character = String.fromCodePoint(codePoint); assert.equal( canonical.test(character), source.test(character), name + " differs at U+" + codePoint.toString(16).toUpperCase(), ); } } function canonicalCodePoints(entries) { const out = []; for (const entry of entries) { const { start, end } = entryBounds(entry); for (let codePoint = start; codePoint <= end; codePoint += 1) { out.push(codePoint); } } return out; } test("shared rules: Ajv 2020 strict validation, rejection cases, exports, and coverage", () => { assert.equal(rulesSchema.$schema, "https://json-schema.org/draft/2020-12/schema"); assert.equal(vectorsSchema.$schema, "https://json-schema.org/draft/2020-12/schema"); assert.equal(rules.schemaVersion, 1); assert.equal(rules.rulesetVersion, "1.0.1"); assert.equal(rules.unicodeProfile.version, "17.0.0"); assert.equal(vectorsDoc.schemaVersion, 1); assert.equal(vectorsDoc.rulesetVersion, rules.rulesetVersion); assert.equal(validateRules(rules), true, ajv.errorsText(validateRules.errors)); assert.equal(validateVectors(vectorsDoc), true, ajv.errorsText(validateVectors.errors)); const extraRules = structuredClone(rules); extraRules.unexpected = true; assert.equal(validateRules(extraRules), false, "rules additionalProperties must be rejected"); const bogusJinze = structuredClone(vectorsDoc); const jinzeIndex = bogusJinze.vectors.findIndex((vector) => vector.category === "jinze"); assert.notEqual(jinzeIndex, -1, "valid fixture must contain a jinze vector"); bogusJinze.vectors[jinzeIndex] = { id: "bogus-jinze", category: "jinze", input: {}, expected: { reason: "bogus" }, }; assert.equal(validateVectors(bogusJinze), false, "malformed jinze vector must be rejected"); assert.equal( validateVectors.errors?.some((error) => error.keyword === "minItems"), false, "bogus jinze rejection must not rely on document-wide vector count", ); const extraVectorField = structuredClone(vectorsDoc); extraVectorField.vectors[0].input.unexpected = true; assert.equal( validateVectors(extraVectorField), false, "category input additionalProperties must be rejected", ); assert.ok(vectors.length >= 20, `expected at least 20 vectors, got ${vectors.length}`); const ids = vectors.map((vector) => vector.id); assert.equal(new Set(ids).size, ids.length, "golden vector ids must be unique"); for (const category of [ "jinze", "orphan" ]) { assert.ok( vectors.some((vector) => vector.category === category && vector.expected.atoms), `${category} must retain at least one cross-feature atom vector`, ); } const requiredCategories = [ "language-classification", "punctuation-classification", "punctuation-style", "punctuation-adjacency", "spacing", "ivs-atom", "jinze", "orphan", ]; for (const category of requiredCategories) { assert.ok(vectorsOf(category).length > 0, `missing vector category ${category}`); } assert.deepEqual(packageJson.exports["./rules"], "./rules/juzhen-rules.json"); assert.deepEqual(packageJson.exports["./rules/schema"], "./rules/juzhen-rules.schema.json"); assert.deepEqual(packageJson.exports["./rules/vectors"], "./rules/golden-vectors.json"); assert.deepEqual( packageJson.exports["./rules/vectors-schema"], "./rules/golden-vectors.schema.json", ); assert.ok(packageJson.files.includes("rules")); }); test("shared rules: canonical character classes equal the Web source constants", () => { const canonical = rules.characterClasses; assert.equal(canonical.representation, "unicode-code-point-ranges"); assertEquivalentMembership("cjk", canonical.cjk, adapter.CJK); assertEquivalentMembership("ansAfterCjk", canonical.ansAfterCjk, adapter.ANS_AFTER_CJK); assertEquivalentMembership("ansBeforeCjk", canonical.ansBeforeCjk, adapter.ANS_BEFORE_CJK); assertEquivalentMembership( "variationSelectors", canonical.variationSelectors, adapter.VARIATION_SELECTORS, ); assert.deepEqual(canonical.biaodian.classificationOrder, [ "stop", "pause", "open", "close", "middle", "liga", ]); for (const punctuationClass of canonical.biaodian.classificationOrder) { const entries = canonical.biaodian.classes[punctuationClass]; assertOrderedCodePointSet("biaodian." + punctuationClass, entries); assert.deepEqual( canonicalCodePoints(entries), Array.from(adapter.DEFAULT_RULESET.biaodian[punctuationClass], (character) => character.codePointAt(0)).sort((a, b) => a - b), "biaodian." + punctuationClass, ); } const extended = adapter.resolveRuleset({ cjk: "〇", western: "★", biaodian: { stop: "‼" }, }); assert.equal(extended.cjk, adapter.CJK + "〇"); assert.equal(extended.ansAfter, adapter.ANS_AFTER_CJK + "★"); assert.equal(extended.ansBefore, adapter.ANS_BEFORE_CJK + "★"); assert.equal(extended.biaodian.stop, adapter.DEFAULT_RULESET.biaodian.stop + "‼"); assert.equal(canonical.overrideSemantics.mode, "append"); assert.equal(canonical.overrideSemantics.removalAllowed, false); assert.equal(canonical.overrideSemantics.reclassificationAllowed, false); }); test("shared rules: explicit script identifiers match whole subtags", () => { for (const [ lang, expected ] of [ [ "zh-CN-x-shant", "zh-Hans" ], [ "zh-TW-x-hansford", "zh-Hant" ], [ "ZH-CN-X-EHANT", "zh-Hans" ], [ "zh-Hant-CN", "zh-Hant" ], [ "zh-Hans-TW", "zh-Hans" ], ]) { assert.equal(adapter.classify(lang), expected, lang); } }); test("Web locale: own lang and explicit unknown stop inheritance", () => { const dom = new JSDOM('甲