From f0fff7f726bd8c493ad2207802261682eb41e4ff Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Mon, 3 Aug 2026 10:23:22 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=89=A9=E5=B1=95DOCX=E5=85=A8?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E8=A7=86=E8=A7=89=E7=9F=A9=E9=98=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PROGRESS.md | 13 +- docs/V0.6.1_DESIGN.md | 6 + package.json | 5 +- scripts/docx-layout-visual-matrix-cases.mjs | 203 +++++++++++++++ .../docx-layout-visual-matrix-cases.test.mjs | 98 +++++++ scripts/verify-docx-layout-visual-matrix.mjs | 241 +++++++++--------- 6 files changed, 440 insertions(+), 126 deletions(-) create mode 100644 scripts/docx-layout-visual-matrix-cases.mjs create mode 100644 scripts/docx-layout-visual-matrix-cases.test.mjs diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d3e65eb..4e417b8 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1492,9 +1492,16 @@ ECharts 第二阶段浏览器与 PDF 验证结果: 全项目类型检查和生产构建通过;真实 GitHub 紧凑 Letter 纵向场景观测 22 个正文块、输出 102 张失败局部热力图并正确阻断。PDF.js 泛化字体名 不作为字体硬门禁,真实字体解析留在阶段 D; -- 阶段 C:将语义块门禁扩展为 14 套内置主题 × 纵横两个方向 × 主题默认、 - 标准、紧凑、宽松、非对称装订五组页边距,共 140 个排版场景;主题 - 默认链路即使与显式数值相同也不得去重; +- 阶段 C:已完成。矩阵生成器从 14 套内置主题清单生成纵横两个方向 × + 主题默认、标准、紧凑、宽松、非对称装订五组页边距,共 140 个唯一 + 排版场景;主题默认链路保持 `marginMode: theme`,即使与显式数值相同 + 也不去重。机器报告记录定义、执行完整度以及主题、方向、边距分层汇总, + HTML 报告固定显示 14 行 × 10 列并链接三份引擎详细报告。5 项矩阵结构 + 测试通过;真实 `red-briefing--portrait--theme-default` 验证解析为主题 + 默认 25mm 四边距,14 个正文块中 Word 有 13 个失败、字体部件仍为 0, + 总计 76 个阻断问题;`formal-feasibility--landscape--binding` 验证 A4 + 横向、非对称装订边距和独立封面整页 strict 门禁,封面因墨迹与边缘 + IoU 不达标被阻断;全仓库测试、类型检查、生产构建和差异检查通过; - 阶段 D:修复主题封面、块元素样式和字体解析、嵌入、回退; - 阶段 E:完成源码服务、Docker Web API、Desktop 安装版发布后复验; - 阶段 F:完成 `v0.6.1` 版本、发行说明、发布产物、发布提交和标签。 diff --git a/docs/V0.6.1_DESIGN.md b/docs/V0.6.1_DESIGN.md index b6d744e..fafd407 100644 --- a/docs/V0.6.1_DESIGN.md +++ b/docs/V0.6.1_DESIGN.md @@ -116,6 +116,12 @@ flowchart LR Word PDF 和 WPS PDF,并在报告中保留主题、方向、边距场景、导出链和 渲染引擎维度,不允许用汇总绿灯掩盖单个失败。 +场景 ID 固定为 `----`。总报告同时 +输出机器可读 JSON 和 14 行 × 10 列 HTML 矩阵;每个单元格必须显示通过、 +失败或未执行,并链接到 Chromium/Word、Chromium/WPS、Word/WPS 三份 +详细报告。完整门禁必须执行 140 个场景后才允许标记 `complete: true`; +单场景筛选只用于开发复现,不能冒充全矩阵结果。 + ## 4. 实施阶段 1. 阶段 A:修复 failure 聚合策略,补齐四套封面场景和封面正文隔离; diff --git a/package.json b/package.json index e4324c2..61c7743 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "desktop:make:with-font-pack": "npm run build:font-pack:windows && npm run build:font-pack:archive && node scripts/build-desktop-with-font-pack.mjs", "release:stage": "node scripts/stage-release.mjs", "test:release-stage": "node --test scripts/stage-release.test.mjs", + "test:docx-layout-visual-matrix-cases": "node --test scripts/docx-layout-visual-matrix-cases.test.mjs", "theme:import-typora": "node scripts/import-typora-theme.mjs", "verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine", "verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine", @@ -45,8 +46,8 @@ "verify:docx-r4-matrix": "npm run verify:docx-themes && npm run verify:font-pack && npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-r4-matrix -w @md-to-pdf/server", "verify:docx-page-decorations": "npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-page-decoration-visuals.mjs", "verify:docx-media-visuals": "npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-media-visuals.mjs", - "verify:docx-layout-visual-matrix": "npm run verify:docx-r4-matrix && npm run build -w @md-to-pdf/document-visual-diff && node scripts/verify-docx-layout-visual-matrix.mjs", - "test": "npm run test:release-stage && npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/document-visual-diff && npm run test -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/font-pack-registry && npm run test -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/font-pack-builder && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop", + "verify:docx-layout-visual-matrix": "npm run test:docx-layout-visual-matrix-cases && npm run verify:font-pack && npm run build:web-runtime && npm run build -w @md-to-pdf/document-visual-diff && npm run build -w @md-to-pdf/server && node scripts/verify-docx-layout-visual-matrix.mjs", + "test": "npm run test:release-stage && npm run test:docx-layout-visual-matrix-cases && npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/document-visual-diff && npm run test -w @md-to-pdf/font-pack-registry && npm run build -w @md-to-pdf/font-pack-registry && npm run test -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/font-pack-builder && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop", "typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run typecheck -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-theme-engine && npm run typecheck -w @md-to-pdf/document-visual-diff && npm run typecheck -w @md-to-pdf/font-pack-registry && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/font-pack-builder && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop" }, "engines": { diff --git a/scripts/docx-layout-visual-matrix-cases.mjs b/scripts/docx-layout-visual-matrix-cases.mjs new file mode 100644 index 0000000..56c79a8 --- /dev/null +++ b/scripts/docx-layout-visual-matrix-cases.mjs @@ -0,0 +1,203 @@ +import fs from "node:fs"; +import path from "node:path"; + +export const DOCX_LAYOUT_MATRIX_ORIENTATIONS = Object.freeze([ + "portrait", + "landscape" +]); + +export const DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS = Object.freeze([ + Object.freeze({ + id: "theme-default", + label: "主题默认边距", + marginMode: "theme" + }), + Object.freeze({ + id: "standard", + label: "标准边距", + marginMode: "custom", + margins: Object.freeze({ + top: "25.4mm", + right: "25.4mm", + bottom: "25.4mm", + left: "25.4mm" + }) + }), + Object.freeze({ + id: "compact", + label: "紧凑边距", + marginMode: "custom", + margins: Object.freeze({ + top: "12.7mm", + right: "12.7mm", + bottom: "12.7mm", + left: "12.7mm" + }) + }), + Object.freeze({ + id: "wide", + label: "宽松边距", + marginMode: "custom", + margins: Object.freeze({ + top: "31.8mm", + right: "31.8mm", + bottom: "31.8mm", + left: "31.8mm" + }) + }), + Object.freeze({ + id: "binding", + label: "非对称装订边距", + marginMode: "custom", + margins: Object.freeze({ + top: "25.4mm", + right: "19.1mm", + bottom: "25.4mm", + left: "31.8mm" + }) + }) +]); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function hasIndependentCover(manifest) { + const profiles = Array.isArray(manifest.compatibleProfiles) + ? manifest.compatibleProfiles + : []; + return profiles.includes("project-report") || profiles.includes("tender"); +} + +export function readBundledThemeMatrixDefinitions(themesDirectory) { + assert( + typeof themesDirectory === "string" && themesDirectory.length > 0, + "主题目录不能为空" + ); + return fs.readdirSync(themesDirectory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => { + const manifestPath = path.join(themesDirectory, entry.name, "theme.json"); + if (!fs.existsSync(manifestPath)) { + return []; + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (manifest.bundled !== true) { + return []; + } + assert( + typeof manifest.id === "string" && manifest.id.length > 0, + `内置主题清单缺少 id:${manifestPath}` + ); + return [{ + id: manifest.id, + name: + typeof manifest.name === "string" && manifest.name.length > 0 + ? manifest.name + : manifest.id, + coverPageCount: hasIndependentCover(manifest) ? 1 : 0, + compatibleProfiles: Array.isArray(manifest.compatibleProfiles) + ? [...manifest.compatibleProfiles] + : [], + themeDefaultMargins: manifest.pageDefaults?.margins + ? structuredClone(manifest.pageDefaults.margins) + : undefined + }]; + }) + .sort((left, right) => left.id.localeCompare(right.id, "en")); +} + +function createPaperConfig(orientation, marginScenario) { + return { + format: "A4", + orientation, + marginMode: marginScenario.marginMode, + ...(marginScenario.marginMode === "custom" + ? { margins: structuredClone(marginScenario.margins) } + : {}) + }; +} + +export function createDocxLayoutVisualMatrixCases(themeDefinitions) { + assert(Array.isArray(themeDefinitions), "主题定义必须是数组"); + const ids = new Set(); + const cases = []; + for (const theme of themeDefinitions) { + assert( + theme && typeof theme.id === "string" && theme.id.length > 0, + "矩阵主题必须包含非空 id" + ); + assert(!ids.has(theme.id), `矩阵主题 ID 重复:${theme.id}`); + ids.add(theme.id); + for (const orientation of DOCX_LAYOUT_MATRIX_ORIENTATIONS) { + for (const marginScenario of DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS) { + const paper = createPaperConfig(orientation, marginScenario); + cases.push({ + id: `${theme.id}--${orientation}--${marginScenario.id}`, + themeId: theme.id, + themeName: theme.name, + orientation, + marginScenarioId: marginScenario.id, + marginScenarioLabel: marginScenario.label, + coverPageCount: theme.coverPageCount, + themeDefaultMargins: theme.themeDefaultMargins + ? structuredClone(theme.themeDefaultMargins) + : undefined, + paper: { format: "A4", orientation }, + config: { paper } + }); + } + } + } + return cases; +} + +export function summarizeDocxLayoutVisualMatrix(themeDefinitions, cases) { + const themeIds = themeDefinitions.map((theme) => theme.id); + const caseIds = cases.map((entry) => entry.id); + const expectedCaseCount = + themeIds.length * + DOCX_LAYOUT_MATRIX_ORIENTATIONS.length * + DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.length; + assert( + new Set(caseIds).size === caseIds.length, + "DOCX 布局视觉矩阵存在重复场景 ID" + ); + assert( + cases.length === expectedCaseCount, + `DOCX 布局视觉矩阵场景数错误:${cases.length},期望 ${expectedCaseCount}` + ); + for (const themeId of themeIds) { + const themeCaseCount = cases.filter( + (entry) => entry.themeId === themeId + ).length; + assert( + themeCaseCount === + DOCX_LAYOUT_MATRIX_ORIENTATIONS.length * + DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.length, + `主题 ${themeId} 的矩阵场景数错误:${themeCaseCount}` + ); + } + return { + themeCount: themeIds.length, + themeIds, + orientationCount: DOCX_LAYOUT_MATRIX_ORIENTATIONS.length, + orientations: [...DOCX_LAYOUT_MATRIX_ORIENTATIONS], + marginScenarioCount: DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.length, + marginScenarios: DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.map((scenario) => ({ + id: scenario.id, + label: scenario.label, + marginMode: scenario.marginMode, + ...(scenario.margins + ? { margins: structuredClone(scenario.margins) } + : {}) + })), + expectedCaseCount, + coverThemeCount: themeDefinitions.filter( + (theme) => theme.coverPageCount === 1 + ).length, + coverCaseCount: cases.filter((entry) => entry.coverPageCount === 1).length + }; +} diff --git a/scripts/docx-layout-visual-matrix-cases.test.mjs b/scripts/docx-layout-visual-matrix-cases.test.mjs new file mode 100644 index 0000000..d61b192 --- /dev/null +++ b/scripts/docx-layout-visual-matrix-cases.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + createDocxLayoutVisualMatrixCases, + DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS, + DOCX_LAYOUT_MATRIX_ORIENTATIONS, + readBundledThemeMatrixDefinitions, + summarizeDocxLayoutVisualMatrix +} from "./docx-layout-visual-matrix-cases.mjs"; + +const repositoryDirectory = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + ".." +); +const themesDirectory = path.join(repositoryDirectory, "themes"); + +function matrix() { + const themes = readBundledThemeMatrixDefinitions(themesDirectory); + const cases = createDocxLayoutVisualMatrixCases(themes); + return { themes, cases, summary: summarizeDocxLayoutVisualMatrix(themes, cases) }; +} + +test("生成 14×2×5 的完整 DOCX 布局视觉矩阵", () => { + const { cases, summary } = matrix(); + assert.equal(summary.themeCount, 14); + assert.equal(summary.orientationCount, 2); + assert.equal(summary.marginScenarioCount, 5); + assert.equal(summary.expectedCaseCount, 140); + assert.equal(cases.length, 140); + assert.equal(new Set(cases.map((entry) => entry.id)).size, 140); + assert.deepEqual(summary.orientations, ["portrait", "landscape"]); +}); + +test("每套主题都覆盖两个方向和五组边距", () => { + const { themes, cases } = matrix(); + for (const theme of themes) { + const themeCases = cases.filter((entry) => entry.themeId === theme.id); + assert.equal(themeCases.length, 10, theme.id); + assert.deepEqual( + [...new Set(themeCases.map((entry) => entry.orientation))], + DOCX_LAYOUT_MATRIX_ORIENTATIONS + ); + assert.deepEqual( + [...new Set(themeCases.map((entry) => entry.marginScenarioId))], + DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.map((scenario) => scenario.id) + ); + } +}); + +test("主题默认边距始终保留 theme 模式且不写 custom 数值", () => { + const { themes, cases } = matrix(); + const themeDefaultCases = cases.filter( + (entry) => entry.marginScenarioId === "theme-default" + ); + assert.equal(themeDefaultCases.length, 28); + for (const entry of themeDefaultCases) { + assert.equal(entry.config.paper.marginMode, "theme"); + assert.equal("margins" in entry.config.paper, false); + const theme = themes.find((candidate) => candidate.id === entry.themeId); + assert.deepEqual(entry.themeDefaultMargins, theme?.themeDefaultMargins); + } +}); + +test("显式边距场景全部使用 custom 模式并保留稳定数值", () => { + const { cases } = matrix(); + for (const scenario of DOCX_LAYOUT_MATRIX_MARGIN_SCENARIOS.slice(1)) { + const scenarioCases = cases.filter( + (entry) => entry.marginScenarioId === scenario.id + ); + assert.equal(scenarioCases.length, 28, scenario.id); + for (const entry of scenarioCases) { + assert.equal(entry.config.paper.marginMode, "custom"); + assert.deepEqual(entry.config.paper.margins, scenario.margins); + } + } +}); + +test("四套独立封面主题扩展为 40 个严格封面场景", () => { + const { cases, summary } = matrix(); + assert.equal(summary.coverThemeCount, 4); + assert.equal(summary.coverCaseCount, 40); + assert.deepEqual( + [...new Set( + cases + .filter((entry) => entry.coverPageCount === 1) + .map((entry) => entry.themeId) + )], + [ + "formal-feasibility", + "tender-blind", + "tender-business-blue", + "tender-classic" + ] + ); +}); diff --git a/scripts/verify-docx-layout-visual-matrix.mjs b/scripts/verify-docx-layout-visual-matrix.mjs index 26d2984..f0de9ba 100644 --- a/scripts/verify-docx-layout-visual-matrix.mjs +++ b/scripts/verify-docx-layout-visual-matrix.mjs @@ -18,6 +18,11 @@ import { formatPageNumber, resolvePageNumberAlignment } from "../packages/preview-engine/dist/index.js"; +import { + createDocxLayoutVisualMatrixCases, + readBundledThemeMatrixDefinitions, + summarizeDocxLayoutVisualMatrix +} from "./docx-layout-visual-matrix-cases.mjs"; const execFileAsync = promisify(execFile); const WORD_NAMESPACE = @@ -39,127 +44,16 @@ const matrixScript = path.join( "verify-docx-r4-matrix.mjs" ); const pageSizes = { - A4: { portrait: [595.276, 841.89], landscape: [841.89, 595.276] }, - Letter: { portrait: [612, 792], landscape: [792, 612] } + A4: { portrait: [595.276, 841.89], landscape: [841.89, 595.276] } }; -const cases = [ - { - id: "official-theme-a4-portrait", - themeId: "gov-red-standard", - coverPageCount: 0, - paper: { format: "A4", orientation: "portrait" }, - config: {} - }, - { - id: "official-asymmetric-a4-portrait", - themeId: "gov-red-standard", - coverPageCount: 0, - paper: { format: "A4", orientation: "portrait" }, - config: { - paper: { - format: "A4", - orientation: "portrait", - marginMode: "custom", - margins: { - top: "18mm", - right: "14mm", - bottom: "30mm", - left: "32mm" - } - } - } - }, - { - id: "project-cover-custom-a4-portrait", - themeId: "formal-feasibility", - coverPageCount: 1, - paper: { format: "A4", orientation: "portrait" }, - config: { - paper: { - format: "A4", - orientation: "portrait", - marginMode: "custom", - margins: { - top: "20mm", - right: "18mm", - bottom: "25mm", - left: "30mm" - } - } - } - }, - { - id: "tender-cover-custom-letter-landscape", - themeId: "tender-business-blue", - coverPageCount: 1, - paper: { format: "Letter", orientation: "landscape" }, - config: { - paper: { - format: "Letter", - orientation: "landscape", - marginMode: "custom", - margins: { - top: "16mm", - right: "18mm", - bottom: "20mm", - left: "24mm" - } - } - } - }, - { - id: "tender-blind-cover-a4-portrait", - themeId: "tender-blind", - coverPageCount: 1, - paper: { format: "A4", orientation: "portrait" }, - config: {} - }, - { - id: "tender-classic-cover-a4-portrait", - themeId: "tender-classic", - coverPageCount: 1, - paper: { format: "A4", orientation: "portrait" }, - config: {} - }, - { - id: "formal-custom-a4-landscape", - themeId: "formal-regulation", - coverPageCount: 0, - paper: { format: "A4", orientation: "landscape" }, - config: { - paper: { - format: "A4", - orientation: "landscape", - marginMode: "custom", - margins: { - top: "15mm", - right: "22mm", - bottom: "18mm", - left: "25mm" - } - } - } - }, - { - id: "general-compact-letter-portrait", - themeId: "typora-github", - coverPageCount: 0, - paper: { format: "Letter", orientation: "portrait" }, - config: { - paper: { - format: "Letter", - orientation: "portrait", - marginMode: "custom", - margins: { - top: "12.7mm", - right: "12.7mm", - bottom: "12.7mm", - left: "12.7mm" - } - } - } - } -]; +const themeDefinitions = readBundledThemeMatrixDefinitions( + path.join(repositoryDirectory, "themes") +); +const cases = createDocxLayoutVisualMatrixCases(themeDefinitions); +const matrixDefinition = summarizeDocxLayoutVisualMatrix( + themeDefinitions, + cases +); const selectedCaseId = process.env.MD_TO_PDF_LAYOUT_VISUAL_CASE?.trim() || undefined; @@ -415,6 +309,83 @@ function reportSummary(report) { }; } +function summarizeDimension(entries, key) { + return Object.fromEntries( + [...new Set(entries.map((entry) => entry[key]))] + .sort((left, right) => left.localeCompare(right, "en")) + .map((value) => { + const dimensionCases = entries.filter((entry) => entry[key] === value); + const failedCases = dimensionCases.filter( + (entry) => entry.gateFailures.length > 0 + ); + return [value, { + caseCount: dimensionCases.length, + passedCaseCount: dimensionCases.length - failedCases.length, + failedCaseCount: failedCases.length, + gatePassed: failedCases.length === 0 + }]; + }) + ); +} + +function summarizeExecution(entries) { + return { + byTheme: summarizeDimension(entries, "themeId"), + byOrientation: summarizeDimension(entries, "orientation"), + byMarginScenario: summarizeDimension(entries, "marginScenarioId") + }; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function renderMatrixSummaryHtml(summary) { + const caseByDimension = new Map( + summary.cases.map((entry) => [ + `${entry.themeId}|${entry.orientation}|${entry.marginScenarioId}`, + entry + ]) + ); + const columns = summary.matrixDefinition.orientations.flatMap( + (orientation) => + summary.matrixDefinition.marginScenarios.map((marginScenario) => ({ + orientation, + marginScenario + })) + ); + const header = columns.map(({ orientation, marginScenario }) => + `${orientation === "portrait" ? "纵向" : "横向"}${escapeHtml(marginScenario.label)}` + ).join(""); + const rows = summary.matrixDefinition.themeIds.map((themeId) => { + const cells = columns.map(({ orientation, marginScenario }) => { + const entry = caseByDimension.get( + `${themeId}|${orientation}|${marginScenario.id}` + ); + if (!entry) { + return '未执行'; + } + const failed = entry.gateFailures.length > 0; + const base = `./${entry.id}/raster-pages`; + return `${failed ? `失败 ${entry.gateFailures.length}` : "通过"}`; + }).join(""); + const themeName = summary.cases.find( + (entry) => entry.themeId === themeId + )?.themeName; + return `${escapeHtml(themeId)}${escapeHtml(themeName ?? "")}${cells}`; + }).join(""); + return ` +DOCX 视觉门禁矩阵 +

DOCX 视觉门禁矩阵

定义:${summary.matrixDefinition.themeCount} 套主题 × ${summary.matrixDefinition.orientationCount} 个方向 × ${summary.matrixDefinition.marginScenarioCount} 组页边距 = ${summary.matrixDefinition.expectedCaseCount} 个场景;本次执行 ${summary.execution.executedCaseCount} 个,整体 ${summary.gatePassed ? "通过" : "失败"}。

${header}${rows}
主题
`; +} + function writeSnapshotPages(directory, prefix, snapshot) { for (const page of snapshot.pages) { if (page.raster) { @@ -479,6 +450,13 @@ function writeReportArtifacts(directory, prefix, report) { } fs.mkdirSync(outputDirectory, { recursive: true }); +assert( + matrixDefinition.themeCount === 14 && + matrixDefinition.orientationCount === 2 && + matrixDefinition.marginScenarioCount === 5 && + matrixDefinition.expectedCaseCount === 140, + `布局视觉矩阵定义必须为 14×2×5=140,实际为 ${JSON.stringify(matrixDefinition)}` +); assert( selectedCases.length > 0, `视觉矩阵用例不存在:${selectedCaseId}` @@ -604,6 +582,11 @@ for (const caseDefinition of selectedCases) { summaries.push({ id: caseDefinition.id, themeId: caseDefinition.themeId, + themeName: caseDefinition.themeName, + orientation: caseDefinition.orientation, + marginScenarioId: caseDefinition.marginScenarioId, + marginScenarioLabel: caseDefinition.marginScenarioLabel, + themeDefaultMargins: caseDefinition.themeDefaultMargins, paper: caseDefinition.paper, exportConfig: result.exportConfig, coverPageCount: caseDefinition.coverPageCount, @@ -632,7 +615,15 @@ for (const caseDefinition of selectedCases) { const summary = { schemaVersion: 1, generatedAt: new Date().toISOString(), + matrixDefinition, selectedCaseId, + execution: { + mode: selectedCaseId ? "single-case" : "full-matrix", + executedCaseCount: summaries.length, + expectedFullMatrixCaseCount: matrixDefinition.expectedCaseCount, + complete: !selectedCaseId && summaries.length === matrixDefinition.expectedCaseCount + }, + executionSummary: summarizeExecution(summaries), word: wordCapability, wps: wpsCapability, caseCount: summaries.length, @@ -640,8 +631,16 @@ const summary = { gateFailures, cases: summaries }; -const summaryPath = path.join(outputDirectory, "summary.json"); +const summaryPath = path.join( + outputDirectory, + selectedCaseId ? `summary-${selectedCaseId}.json` : "summary.json" +); +const matrixHtmlPath = path.join( + outputDirectory, + selectedCaseId ? `matrix-${selectedCaseId}.html` : "matrix.html" +); fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8"); +fs.writeFileSync(matrixHtmlPath, renderMatrixSummaryHtml(summary), "utf8"); process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); assert( gateFailures.length === 0,