dsh-ppt-studio 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +480 -0
- package/agent-presets/ppt/agent.cordis.yml +308 -0
- package/agent-presets/ppt/preset.yml +3 -0
- package/cordis.patch.yml +29 -0
- package/docs/00-/350/256/276/350/256/241/346/272/220.md +537 -0
- package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +136 -0
- package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +483 -0
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +2898 -0
- package/docs/04-/350/267/257/347/272/277/345/233/276/344/270/216/351/207/214/347/250/213/347/242/221.md +113 -0
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +210 -0
- package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +296 -0
- package/docs/fidelity-report.md +225 -0
- package/examples/fx/fx-mini/deck.yaml +39 -0
- package/examples/fx/fx-mini/pages/01.yaml +6 -0
- package/examples/fx/fx-mini/pages/02.yaml +2 -0
- package/examples/fx/fx-mini/pages/03.yaml +6 -0
- package/examples/fx/fx-pro/deck.yaml +48 -0
- package/examples/fx/fx-pro/pages/01_cover.yaml +40 -0
- package/examples/fx/fx-pro/pages/02_arch.yaml +55 -0
- package/examples/fx/fx-pro/pages/03_table.yaml +20 -0
- package/examples/fx/fx-pro/pages/04_chart.yaml +40 -0
- package/examples/fx/fx-pro/pages/05_timeline.yaml +71 -0
- package/examples/fx/fx-pro/pages/06_longtext.yaml +13 -0
- package/examples/fx/fx-pro/pages/07_safearea.yaml +33 -0
- package/examples/fx/fx-pro/pages/08_contrast.yaml +22 -0
- package/examples/fx/fx-pro/pages/09_custgeom.yaml +32 -0
- package/examples/fx/fx-pro/pages/10_numbers.yaml +25 -0
- package/examples/fx/fx-pro/pages/11_section.yaml +20 -0
- package/examples/fx/fx-pro/pages/12_summary.yaml +57 -0
- package/examples/fx/fx-pro/preview/01_pages_01_cover.html +1 -0
- package/examples/fx/fx-pro/preview/02_pages_02_arch.html +3 -0
- package/examples/fx/fx-pro/preview/03_pages_03_table.html +1 -0
- package/examples/fx/fx-pro/preview/04_pages_04_chart.html +3 -0
- package/examples/fx/fx-pro/preview/05_pages_05_timeline.html +2 -0
- package/examples/fx/fx-pro/preview/06_pages_06_longtext.html +4 -0
- package/examples/fx/fx-pro/preview/07_pages_07_safearea.html +2 -0
- package/examples/fx/fx-pro/preview/08_pages_08_contrast.html +1 -0
- package/examples/fx/fx-pro/preview/09_pages_09_custgeom.html +1 -0
- package/examples/fx/fx-pro/preview/10_pages_10_numbers.html +1 -0
- package/examples/fx/fx-pro/preview/11_pages_11_section.html +1 -0
- package/examples/fx/fx-pro/preview/12_pages_12_summary.html +1 -0
- package/examples/fx/fx-pro/preview/deck.html +10 -0
- package/examples/fx/fx-pro/preview/layout.json +2325 -0
- package/examples/fx/fx-pro/preview/measured.json +814 -0
- package/examples/fx/fx-pro.pptx +0 -0
- package/examples/fx/ren-single/01.png +0 -0
- package/examples/fx/ren-spliced/01.png +0 -0
- package/examples/fx/ren-spliced/03.png +0 -0
- package/examples/fx/seed.pptx +0 -0
- package/examples/fx/single.pptx +0 -0
- package/examples/fx/spliced.pptx +0 -0
- package/examples/smoke/deck.yaml +26 -0
- package/examples/smoke/imported/deck.yaml +30 -0
- package/examples/smoke/imported/import-styles.json +126 -0
- package/examples/smoke/imported/pages/slide_01.yaml +31 -0
- package/examples/smoke/imported/pages/slide_02.yaml +38 -0
- package/examples/smoke/imported/pages/slide_03.yaml +55 -0
- package/examples/smoke/imported/preview-rt/01_pages_slide_01.html +1 -0
- package/examples/smoke/imported/preview-rt/02_pages_slide_02.html +1 -0
- package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -0
- package/examples/smoke/imported/preview-rt/deck.html +1 -0
- package/examples/smoke/imported/preview-rt/layout.json +434 -0
- package/examples/smoke/imported/reference/previews/01.png +0 -0
- package/examples/smoke/imported/reference/previews/02.png +0 -0
- package/examples/smoke/imported/reference/previews/03.png +0 -0
- package/examples/smoke/imported/source.pptx +0 -0
- package/examples/smoke/media-test.png +0 -0
- package/examples/smoke/out-smoke.pptx +0 -0
- package/examples/smoke/pages/01_cover.yaml +27 -0
- package/examples/smoke/pages/02_content.yaml +42 -0
- package/examples/smoke/pages/03_chart.yaml +35 -0
- package/examples/smoke/preview/01_pages_01_cover.html +1 -0
- package/examples/smoke/preview/02_pages_02_content.html +1 -0
- package/examples/smoke/preview/03_pages_03_chart.html +1 -0
- package/examples/smoke/preview/deck.html +1 -0
- package/examples/smoke/preview/layout.json +378 -0
- package/lib/autodeclare.js +40 -0
- package/lib/commands.js +161 -0
- package/lib/crosscheck.js +300 -0
- package/lib/imgmeta.js +55 -0
- package/lib/index.js +239 -0
- package/lib/measurement.js +209 -0
- package/lib/msrender.js +123 -0
- package/lib/pptd/export-pptx.js +686 -0
- package/lib/pptd/import-pptx.js +774 -0
- package/lib/pptd/layout.js +209 -0
- package/lib/pptd/render-html.js +254 -0
- package/lib/pptd/schema.js +518 -0
- package/lib/pptd/svgCharts.js +147 -0
- package/lib/pptxPy.js +205 -0
- package/lib/preset-delivery.js +195 -0
- package/lib/preview-server.js +164 -0
- package/lib/router.js +165 -0
- package/lib/scaffold.js +315 -0
- package/lib/skill.js +174 -0
- package/lib/splice.js +219 -0
- package/lib/state.js +88 -0
- package/lib/surgical.js +369 -0
- package/lib/templates.js +400 -0
- package/lib/tools.js +955 -0
- package/lib/verify.js +828 -0
- package/lib/xmljs.js +112 -0
- package/lib/zips.js +117 -0
- package/package.json +81 -0
- package/scripts/audit-manual-facts.mjs +294 -0
- package/scripts/audit-template-fidelity.mjs +162 -0
- package/scripts/bench-m2.mjs +48 -0
- package/scripts/build-templates.mjs +63 -0
- package/scripts/build.mjs +14 -0
- package/scripts/build.sh +7 -0
- package/scripts/check-lib-freshness.mjs +70 -0
- package/scripts/check-preset.mjs +189 -0
- package/scripts/check-tracked-lf.mjs +84 -0
- package/scripts/dbg-import.mjs +13 -0
- package/scripts/dbg-media.mjs +10 -0
- package/scripts/dbg-tpl-inventory.mjs +23 -0
- package/scripts/dump-workflow-baseline.mjs +38 -0
- package/scripts/e2e-1.0.mjs +129 -0
- package/scripts/eval-skills-blind.mjs +91 -0
- package/scripts/eval-skills-score.mjs +129 -0
- package/scripts/export-orig-templates.ps1 +17 -0
- package/scripts/fixtures/workflow-baseline.json +300 -0
- package/scripts/fixtures.mjs +501 -0
- package/scripts/import-user-templates.mjs +60 -0
- package/scripts/install-desktop.mjs +312 -0
- package/scripts/install.mjs +209 -0
- package/scripts/migrate-templates-dualtrack.mjs +71 -0
- package/scripts/preflight-1.0.mjs +198 -0
- package/scripts/probe-asset-freshness.mjs +57 -0
- package/scripts/probe-effect-semantics.mjs +69 -0
- package/scripts/probe-reparse-safety.mjs +32 -0
- package/scripts/rebuild-templates-c11.mjs +69 -0
- package/scripts/regression-real.mjs +84 -0
- package/scripts/release-sync.mjs +425 -0
- package/scripts/shot-templates.mjs +49 -0
- package/scripts/smoke.mjs +2512 -0
- package/scripts/verify-bundle-install.mjs +281 -0
- package/scripts/verify-fresh-install.mjs +260 -0
- package/skills/ppt-studio-copy/SKILL.md +139 -0
- package/skills/ppt-studio-craft/SKILL.md +171 -0
- package/skills/ppt-studio-data/SKILL.md +173 -0
- package/skills/ppt-studio-manual/SKILL.md +104 -0
- package/templates/academic-white/deck.yaml +24 -0
- package/templates/academic-white/pages/_chart.yaml +43 -0
- package/templates/academic-white/pages/_compare.yaml +37 -0
- package/templates/academic-white/pages/_content.yaml +35 -0
- package/templates/academic-white/pages/_cover.yaml +28 -0
- package/templates/academic-white/pages/_ending.yaml +28 -0
- package/templates/academic-white/pages/_flow.yaml +61 -0
- package/templates/academic-white/preview.png +0 -0
- package/templates/academic-white/template.yaml +14 -0
- package/templates/business-blue/deck.yaml +28 -0
- package/templates/business-blue/pages/_chart.yaml +36 -0
- package/templates/business-blue/pages/_compare.yaml +45 -0
- package/templates/business-blue/pages/_content.yaml +80 -0
- package/templates/business-blue/pages/_cover.yaml +39 -0
- package/templates/business-blue/pages/_ending.yaml +32 -0
- package/templates/business-blue/pages/_flow.yaml +87 -0
- package/templates/business-blue/preview.png +0 -0
- package/templates/business-blue/template.yaml +14 -0
- package/templates/pitch-bold/deck.yaml +23 -0
- package/templates/pitch-bold/pages/_chart.yaml +32 -0
- package/templates/pitch-bold/pages/_compare.yaml +36 -0
- package/templates/pitch-bold/pages/_content.yaml +58 -0
- package/templates/pitch-bold/pages/_cover.yaml +31 -0
- package/templates/pitch-bold/pages/_ending.yaml +27 -0
- package/templates/pitch-bold/pages/_flow.yaml +46 -0
- package/templates/pitch-bold/preview.png +0 -0
- package/templates/pitch-bold/template.yaml +14 -0
- package/templates/tech-dark/deck.yaml +24 -0
- package/templates/tech-dark/pages/_chart.yaml +27 -0
- package/templates/tech-dark/pages/_compare.yaml +38 -0
- package/templates/tech-dark/pages/_content.yaml +51 -0
- package/templates/tech-dark/pages/_cover.yaml +30 -0
- package/templates/tech-dark/pages/_ending.yaml +25 -0
- package/templates/tech-dark/pages/_flow.yaml +80 -0
- package/templates/tech-dark/preview.png +0 -0
- package/templates/tech-dark/template.yaml +14 -0
|
@@ -0,0 +1,2512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 冒烟测试:examples/smoke 全链路
|
|
3
|
+
* check → render → verify → export(pptx) → zip 结构 → import roundtrip → re-check
|
|
4
|
+
*/
|
|
5
|
+
import { join, dirname } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { rm, mkdir } from 'node:fs/promises'
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import zlib from 'node:zlib'
|
|
10
|
+
import { resolveDeck } from '../lib/pptd/schema.js'
|
|
11
|
+
import { renderDeck } from '../lib/pptd/render-html.js'
|
|
12
|
+
import { exportPptx } from '../lib/pptd/export-pptx.js'
|
|
13
|
+
import { importPptx } from '../lib/pptd/import-pptx.js'
|
|
14
|
+
import { verifyDeck } from '../lib/verify.js'
|
|
15
|
+
import { zipRead, decodeXml } from '../lib/zips.js'
|
|
16
|
+
|
|
17
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
18
|
+
const smokeDir = join(root, 'examples', 'smoke')
|
|
19
|
+
|
|
20
|
+
let pass = 0
|
|
21
|
+
let fail = 0
|
|
22
|
+
const ok = (name, cond, extra = '') => {
|
|
23
|
+
console.log(`${cond ? '✓' : '✗'} ${name}${extra ? ' — ' + extra : ''}`)
|
|
24
|
+
cond ? pass++ : fail++
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 1. check
|
|
28
|
+
const ctx0 = await resolveDeck(smokeDir)
|
|
29
|
+
ok('resolveDeck(校验+归一化)', ctx0.pages.length === 3, `${ctx0.pages.length} pages, ${ctx0.size.width}x${ctx0.size.height}`)
|
|
30
|
+
|
|
31
|
+
// 2. render
|
|
32
|
+
const r = await renderDeck(ctx0, {})
|
|
33
|
+
ok('renderDeck → preview html + layout.json', r.htmlFiles.length === 3)
|
|
34
|
+
|
|
35
|
+
// 3. verify(断言引擎)
|
|
36
|
+
const layout = JSON.parse(await (await import('node:fs/promises')).readFile(join(smokeDir, 'preview', 'layout.json'), 'utf8'))
|
|
37
|
+
const v = verifyDeck(layout)
|
|
38
|
+
ok('verifyDeck 报告生成', typeof v.text === 'string' && v.text.length > 0)
|
|
39
|
+
console.log(v.text.slice(0, 1200))
|
|
40
|
+
|
|
41
|
+
// 4. export pptx(主引擎)
|
|
42
|
+
const exp = await exportPptx(ctx0, { out: 'out-smoke.pptx', engine: 'pptd' })
|
|
43
|
+
ok('exportPptx 生成', exp.file !== '', `${exp.slides} slides`)
|
|
44
|
+
|
|
45
|
+
// 5. zip 结构
|
|
46
|
+
const buf = await (await import('node:fs/promises')).readFile(exp.file)
|
|
47
|
+
const parts = zipRead(buf)
|
|
48
|
+
const needed = ['[Content_Types].xml', 'ppt/presentation.xml', 'ppt/slides/slide1.xml', 'ppt/slides/slide2.xml', 'ppt/slides/slide3.xml', 'ppt/slideMasters/slideMaster1.xml', 'ppt/theme/theme1.xml']
|
|
49
|
+
ok('OOXML 结构完整', needed.every((n) => parts.has(n)), [...parts.keys()].join(', '))
|
|
50
|
+
|
|
51
|
+
// 6. import roundtrip
|
|
52
|
+
const impDir = join(smokeDir, 'imported')
|
|
53
|
+
await rm(impDir, { recursive: true, force: true })
|
|
54
|
+
const imp = await importPptx(exp.file, impDir)
|
|
55
|
+
ok('importPptx(roundtrip)', imp.pages === 3, `${imp.pages} pages, media ${imp.media.length}`)
|
|
56
|
+
|
|
57
|
+
// 7. 导入项目可再次校验/渲染
|
|
58
|
+
const ctx1 = await resolveDeck(impDir)
|
|
59
|
+
const r2 = await renderDeck(ctx1, { out: 'preview-rt' })
|
|
60
|
+
ok('roundtrip 项目可渲染', r2.htmlFiles.length === 3)
|
|
61
|
+
|
|
62
|
+
// ── 8. 媒体回环(P2/P6 回归):image + roundRect + rotation ───────────────
|
|
63
|
+
const { crc32 } = await import('../lib/zips.js')
|
|
64
|
+
function makePng(w, h, rgb) {
|
|
65
|
+
const raw = Buffer.alloc((w * 3 + 1) * h)
|
|
66
|
+
for (let y = 0; y < h; y++) {
|
|
67
|
+
const off = y * (w * 3 + 1)
|
|
68
|
+
raw[off] = 0
|
|
69
|
+
for (let x = 0; x < w; x++) {
|
|
70
|
+
raw[off + 1 + x * 3] = rgb[0]
|
|
71
|
+
raw[off + 2 + x * 3] = rgb[1]
|
|
72
|
+
raw[off + 3 + x * 3] = rgb[2]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const chunk = (type, data) => {
|
|
76
|
+
const body = Buffer.concat([Buffer.from(type), data])
|
|
77
|
+
const crc = crc32(body)
|
|
78
|
+
const out = Buffer.alloc(12 + data.length)
|
|
79
|
+
out.writeUInt32BE(data.length, 0)
|
|
80
|
+
body.copy(out, 4)
|
|
81
|
+
out.writeUInt32BE(crc, 8 + data.length)
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
const ihdr = Buffer.alloc(13)
|
|
85
|
+
ihdr.writeUInt32BE(w, 0)
|
|
86
|
+
ihdr.writeUInt32BE(h, 4)
|
|
87
|
+
ihdr[8] = 8 // bit depth
|
|
88
|
+
ihdr[9] = 2 // color type RGB
|
|
89
|
+
const idat = zlib.deflateSync(raw)
|
|
90
|
+
return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))])
|
|
91
|
+
}
|
|
92
|
+
const png = makePng(200, 120, [37, 99, 235])
|
|
93
|
+
await (await import('node:fs/promises')).writeFile(join(smokeDir, 'media-test.png'), png)
|
|
94
|
+
const mediaDeck = join(root, 'examples', 'media-smoke')
|
|
95
|
+
await (await import('node:fs/promises')).rm(mediaDeck, { recursive: true, force: true })
|
|
96
|
+
await (await import('node:fs/promises')).mkdir(join(mediaDeck, 'pages'), { recursive: true })
|
|
97
|
+
await (await import('node:fs/promises')).mkdir(join(mediaDeck, 'media'), { recursive: true })
|
|
98
|
+
await (await import('node:fs/promises')).copyFile(join(smokeDir, 'media-test.png'), join(mediaDeck, 'media', 'pic.png'))
|
|
99
|
+
await (await import('node:fs/promises')).writeFile(join(mediaDeck, 'deck.yaml'), [
|
|
100
|
+
'version: 1', 'title: media-smoke', 'size: [960, 540]',
|
|
101
|
+
'theme:', ' colors:', ' primary: "#2563EB"', ' accent: "#F59E0B"',
|
|
102
|
+
'pages:',
|
|
103
|
+
' - pages/01_media.yaml',
|
|
104
|
+
' - pages/02_bg.yaml', '',
|
|
105
|
+
].join('\n'))
|
|
106
|
+
await (await import('node:fs/promises')).writeFile(join(mediaDeck, 'pages', '01_media.yaml'), [
|
|
107
|
+
'pageType: content',
|
|
108
|
+
'elements:',
|
|
109
|
+
' - elementId: img', ' elementType: image', ' bounds: [60, 120, 400, 240]', ' src: media/pic.png',
|
|
110
|
+
' - elementId: card', ' elementType: shape', ' kind: roundRect', ' bounds: [500, 120, 400, 90]',
|
|
111
|
+
' fill: "$primary"',
|
|
112
|
+
' - elementId: badge', ' elementType: shape', ' kind: rect', ' bounds: [540, 260, 120, 120]',
|
|
113
|
+
' fill: "#F59E0B"', ' rotation: 45',
|
|
114
|
+
' - elementId: long', ' elementType: text', ' bounds: [60, 420, 840, 110]',
|
|
115
|
+
' content:',
|
|
116
|
+
' text: "本页面用于验证美学建议层的有效性:这是一段故意写得非常长的文本内容,它包含了超过九十个汉字并且没有任何分点或者断句处理,目的就是让文本信息密度的建议能够被正确触发,同时检验长句检测的启发式是否工作正常,因为只有这样我们才能确认审美层面的改善不会影响核心的冲突断言。"',
|
|
117
|
+
'',
|
|
118
|
+
].join('\n'))
|
|
119
|
+
await (await import('node:fs/promises')).writeFile(join(mediaDeck, 'pages', '02_bg.yaml'), [
|
|
120
|
+
'pageType: cover',
|
|
121
|
+
'background: {type: image, src: media/pic.png, fit: cover}',
|
|
122
|
+
'elements:',
|
|
123
|
+
' - elementId: title', ' elementType: text', ' bounds: [80, 180, 800, 60]',
|
|
124
|
+
' content: { text: "背景图封面(回环测试)" }', '',
|
|
125
|
+
].join('\n'))
|
|
126
|
+
const ctxM = await resolveDeck(mediaDeck)
|
|
127
|
+
const rM = await renderDeck(ctxM, {})
|
|
128
|
+
const previewFiles = await (await import('node:fs/promises')).readdir(join(mediaDeck, 'preview'))
|
|
129
|
+
const firstHtml = previewFiles.filter((f) => f.endsWith('.html') && f !== 'deck.html').sort()[0]
|
|
130
|
+
const htmlMedia = await (await import('node:fs/promises')).readFile(join(mediaDeck, 'preview', firstHtml), 'utf8')
|
|
131
|
+
ok('preview img 相对路径指向 ../media/', htmlMedia.includes('../media/pic.png'))
|
|
132
|
+
ok('preview 箭头 marker defs 存在(roundRect 渲染)', htmlMedia.includes('roundRect') || htmlMedia.includes('border-radius'))
|
|
133
|
+
const expM = await exportPptx(ctxM, { out: 'out-media.pptx' })
|
|
134
|
+
const impM = await importPptx(expM.file, join(mediaDeck, 'imported'))
|
|
135
|
+
const impMediaFiles = await (await import('node:fs/promises')).readdir(join(mediaDeck, 'imported', 'media'))
|
|
136
|
+
ok('媒体回环:import 提取 1 张媒体', impM.media.length === 1 && impMediaFiles.includes('pic.png'), `${impM.media.join(',')}`)
|
|
137
|
+
const impPageYaml = await (await import('node:fs/promises')).readFile(join(mediaDeck, 'imported', 'pages', 'slide_01.yaml'), 'utf8')
|
|
138
|
+
ok('媒体回环:image src 写对', impPageYaml.includes('src: "media/pic.png"'))
|
|
139
|
+
ok('媒体回环:roundRect 保留', impPageYaml.includes('roundRect'))
|
|
140
|
+
|
|
141
|
+
// 背景回环:渲染/导出原生 p:bg/导入识别
|
|
142
|
+
const bgHtml = await (await import('node:fs/promises')).readFile(join(mediaDeck, 'preview', 'deck.html'), 'utf8')
|
|
143
|
+
ok('背景渲染:HTML 使用 background-image(../media/ 路径)', bgHtml.includes('url("') && bgHtml.includes('../media/pic.png'))
|
|
144
|
+
const layoutM0 = JSON.parse(await (await import('node:fs/promises')).readFile(join(mediaDeck, 'preview', 'layout.json'), 'utf8'))
|
|
145
|
+
const bgPage = layoutM0.pages.find((p) => p.index === 1)
|
|
146
|
+
ok('背景回环:layout.json 记录 image 背景', bgPage && bgPage.background?.type === 'image' && bgPage.background.src === 'media/pic.png')
|
|
147
|
+
const expFiles = zipRead(await (await import('node:fs/promises')).readFile(expM.file))
|
|
148
|
+
ok('背景回环:导出 slide2 含原生 p:bg + 嵌入式', expFiles.get('ppt/slides/slide2.xml').toString('utf8').includes('<p:bg>') && expFiles.get('ppt/slides/slide2.xml').toString('utf8').includes('r:embed'))
|
|
149
|
+
ok('背景回环:背景媒体被打包', [...expFiles.keys()].filter((k) => k.includes('media/')).length >= 1)
|
|
150
|
+
const impBgYaml = await (await import('node:fs/promises')).readFile(join(mediaDeck, 'imported', 'pages', 'slide_02.yaml'), 'utf8')
|
|
151
|
+
ok('背景回环:import 识别 background image', impBgYaml.includes('background') && impBgYaml.includes('media/pic.png'))
|
|
152
|
+
|
|
153
|
+
// ── 9. 美学建议层(suggestion 不入门禁,冲突断言不受影响)────────────────
|
|
154
|
+
const layoutM = JSON.parse(await (await import('node:fs/promises')).readFile(join(mediaDeck, 'preview', 'layout.json'), 'utf8'))
|
|
155
|
+
const vM = verifyDeck(layoutM)
|
|
156
|
+
ok('美学建议层产生 suggestion', vM.text.includes('[·] aesthetic'), vM.text.split('\n').filter((l) => l.includes('[·]')).join('; '))
|
|
157
|
+
const errorsM = vM.text.split('\n').filter((l) => l.includes('[✗]')).length
|
|
158
|
+
ok('美学建议不引入门禁错误(核心断言仍只看 ERROR)', errorsM === 0, `errors=${errorsM}`)
|
|
159
|
+
|
|
160
|
+
// ── 10. 层叠语义模型(合法重叠设计不应被卡门禁)───────────────────────────
|
|
161
|
+
const overlapDeck = join(root, 'examples', 'overlap-smoke')
|
|
162
|
+
await (await import('node:fs/promises')).rm(overlapDeck, { recursive: true, force: true })
|
|
163
|
+
await (await import('node:fs/promises')).mkdir(join(overlapDeck, 'pages'), { recursive: true })
|
|
164
|
+
await (await import('node:fs/promises')).writeFile(join(overlapDeck, 'deck.yaml'), [
|
|
165
|
+
'version: 1', 'title: overlap-smoke', 'size: [960, 540]',
|
|
166
|
+
'theme:', ' colors:', ' primary: "#2563EB"', ' accent: "#F59E0B"',
|
|
167
|
+
'pages:',
|
|
168
|
+
' - pages/01_deck.yaml',
|
|
169
|
+
' - pages/02_lenient.yaml', '',
|
|
170
|
+
].join('\n'))
|
|
171
|
+
await (await import('node:fs/promises')).writeFile(join(overlapDeck, 'pages', '01_deck.yaml'), [
|
|
172
|
+
'pageType: content',
|
|
173
|
+
'expectedOverlaps:',
|
|
174
|
+
' - { pair: [img, label] }', ' # 图片上标注:设计阶段声明的有意重叠',
|
|
175
|
+
' - { pair: [arrow, panel] }', ' # 箭头跨越底板:有意重叠',
|
|
176
|
+
'elements:',
|
|
177
|
+
' - elementId: img', ' elementType: image', ' bounds: [60, 100, 400, 300]', ' src: media/pic.png',
|
|
178
|
+
' - elementId: label', ' elementType: text', ' bounds: [100, 150, 200, 30]',
|
|
179
|
+
' content: { text: "图上标注(合法)" }',
|
|
180
|
+
' - elementId: panel', ' elementType: shape', ' kind: roundRect', ' bounds: [520, 100, 360, 220]', ' fill: "$primary"',
|
|
181
|
+
' - elementId: chip', ' elementType: shape', ' kind: rect', ' bounds: [540, 140, 80, 60]', ' fill: "#F59E0B"',
|
|
182
|
+
' - elementId: arrow', ' elementType: line', ' bounds: [560, 285, 140, 10]',
|
|
183
|
+
' points: [[560, 290], [700, 290]]', ' line: { color: "#FFFFFF", width: 2 }',
|
|
184
|
+
' - elementId: txtA', ' elementType: text', ' bounds: [100, 400, 200, 40]',
|
|
185
|
+
' content: { text: "第一段" }',
|
|
186
|
+
' - elementId: txtB', ' elementType: text', ' bounds: [150, 430, 200, 40]',
|
|
187
|
+
' content: { text: "第二段(与 A 互压)" }',
|
|
188
|
+
' - elementId: deco', ' elementType: shape', ' kind: rect', ' bounds: [120, 410, 100, 60]',
|
|
189
|
+
' fill: "$primary"', ' role: decoration', '',
|
|
190
|
+
].join('\n'))
|
|
191
|
+
await (await import('node:fs/promises')).writeFile(join(overlapDeck, 'pages', '02_lenient.yaml'), [
|
|
192
|
+
'pageType: content',
|
|
193
|
+
'overlapMode: lenient',
|
|
194
|
+
'elements:',
|
|
195
|
+
' - elementId: panelL', ' elementType: shape', ' kind: rect', ' bounds: [60, 60, 300, 200]', ' fill: "$primary"',
|
|
196
|
+
' - elementId: chipL', ' elementType: shape', ' kind: rect', ' bounds: [80, 80, 60, 50]', ' fill: "#F59E0B"', '',
|
|
197
|
+
].join('\n'))
|
|
198
|
+
const ctxO = await resolveDeck(overlapDeck)
|
|
199
|
+
await renderDeck(ctxO, {})
|
|
200
|
+
const layoutO = JSON.parse(await (await import('node:fs/promises')).readFile(join(overlapDeck, 'preview', 'layout.json'), 'utf8'))
|
|
201
|
+
const vO = verifyDeck(layoutO)
|
|
202
|
+
ok('预期重叠命中(设计声明对照 → 确认)', vO.text.includes('2 预期重叠✓'))
|
|
203
|
+
ok('已声明标注重叠不出现在报告中', !vO.text.includes('"img" × "label"') && !vO.text.includes('"label" × "img"'))
|
|
204
|
+
ok('未声明层叠 → 设计预期外错误(unexpected-overlap)', vO.text.includes('unexpected-overlap') && vO.text.includes('chip'))
|
|
205
|
+
ok('内容互压(text×text)→ ERROR(content-collision,不可声明)', vO.text.includes('content-collision') && vO.text.includes('[✗]'))
|
|
206
|
+
ok('decoration 完全豁免(不出报告)', !vO.text.includes('"deco"') && !vO.text.includes('× "deco"'))
|
|
207
|
+
ok('lenient 模式:未声明重叠仅提示(不升错误)', vO.text.includes('lenient 模式'))
|
|
208
|
+
const errO = vO.text.split('\n').filter((l) => l.includes('[✗]')).length
|
|
209
|
+
ok('严格制门禁计数:unexpected + content-collision', errO === 2, `errors=${errO}`)
|
|
210
|
+
|
|
211
|
+
// ── 11. 快速模式语义检测 + 引擎 auto 语义 ─────────────────────────────────
|
|
212
|
+
const routerMod = await import('../lib/router.js')
|
|
213
|
+
ok('快速模式:进入语义(简单做一个 ppt)', routerMod.isQuickIntent('行,简单做一个 ppt 就行') === true)
|
|
214
|
+
ok('快速模式:工作流内上下文(快速改一下这页)', routerMod.isQuickIntent('快速改一下第 3 页', true) === true)
|
|
215
|
+
ok('快速模式:非 ppt 语境不误触(快速浏览报告)', routerMod.isQuickIntent('帮我快速浏览一下这份报告', false) === false)
|
|
216
|
+
const stateMod = await import('../lib/state.js')
|
|
217
|
+
ok('状态默认:quick=false 且 engine=auto', stateMod.DEFAULT_SESSION().quick === false && stateMod.DEFAULT_SESSION().engine === 'auto')
|
|
218
|
+
|
|
219
|
+
// ── 12. v0.3:line bounds 自动推导 / safeArea / 一键声明 / 对比度+孤字 / 绝对路径 / 缩字下限 ──
|
|
220
|
+
import { applyAutoDeclare } from '../lib/autodeclare.js'
|
|
221
|
+
const v3Deck = join(root, 'examples', 'v3-smoke')
|
|
222
|
+
await rm(v3Deck, { recursive: true, force: true })
|
|
223
|
+
await mkdir(join(v3Deck, 'pages'), { recursive: true })
|
|
224
|
+
await (await import('node:fs/promises')).writeFile(join(v3Deck, 'deck.yaml'), [
|
|
225
|
+
'version: 1', 'title: v3-smoke', 'size: [960, 540]',
|
|
226
|
+
'theme:',
|
|
227
|
+
' colors:',
|
|
228
|
+
' primary: "#2563EB"',
|
|
229
|
+
' light: "#E8EEFB"',
|
|
230
|
+
' ink: "#1F2937"',
|
|
231
|
+
' textStyles:',
|
|
232
|
+
' title: {fontSize: 32, color: "$ink", bold: true}',
|
|
233
|
+
' body: {fontSize: 16, color: "$ink"}',
|
|
234
|
+
' safeArea: {top: 20, bottom: 20}',
|
|
235
|
+
' minFontSize: 12',
|
|
236
|
+
'pages:',
|
|
237
|
+
' - pages/01_layout.yaml',
|
|
238
|
+
' - pages/02_declare.yaml',
|
|
239
|
+
' - pages/03_floor.yaml', '',
|
|
240
|
+
].join('\n'))
|
|
241
|
+
|
|
242
|
+
await (await import('node:fs/promises')).writeFile(join(v3Deck, 'pages', '01_layout.yaml'), [
|
|
243
|
+
'pageType: content',
|
|
244
|
+
'elements:',
|
|
245
|
+
' - elementId: L1',
|
|
246
|
+
' elementType: line',
|
|
247
|
+
' points: [[100, 100], [300, 100]]',
|
|
248
|
+
' line: {color: "#2563EB", width: 2}',
|
|
249
|
+
' - elementId: okbox',
|
|
250
|
+
' elementType: text',
|
|
251
|
+
' bounds: [100, 40, 300, 30]',
|
|
252
|
+
' content: {text: "安全区内文本", style: "$body"}',
|
|
253
|
+
' - elementId: badbox',
|
|
254
|
+
' elementType: shape',
|
|
255
|
+
' kind: rect',
|
|
256
|
+
' bounds: [500, 5, 100, 40]',
|
|
257
|
+
' fill: "$primary"',
|
|
258
|
+
' - elementId: decoBand',
|
|
259
|
+
' elementType: shape',
|
|
260
|
+
' kind: rect',
|
|
261
|
+
' bounds: [60, 2, 40, 12]',
|
|
262
|
+
' fill: "$primary"',
|
|
263
|
+
' role: decoration',
|
|
264
|
+
' - elementId: outPage',
|
|
265
|
+
' elementType: shape',
|
|
266
|
+
' kind: rect',
|
|
267
|
+
' bounds: [900, 520, 100, 60]',
|
|
268
|
+
' fill: "$primary"',
|
|
269
|
+
'',
|
|
270
|
+
].join('\n'))
|
|
271
|
+
await (await import('node:fs/promises')).writeFile(join(v3Deck, 'pages', '02_declare.yaml'), [
|
|
272
|
+
'pageType: content',
|
|
273
|
+
'elements:',
|
|
274
|
+
' - elementId: panel',
|
|
275
|
+
' elementType: shape',
|
|
276
|
+
' kind: roundRect',
|
|
277
|
+
' bounds: [60, 120, 420, 240]',
|
|
278
|
+
' fill: "$light"',
|
|
279
|
+
' - elementId: ptext',
|
|
280
|
+
' elementType: text',
|
|
281
|
+
' bounds: [90, 150, 360, 120]',
|
|
282
|
+
' content: {text: "白字浅底卡片(未声明)", style: "$body", color: "#FFFFFF"}',
|
|
283
|
+
' - elementId: orphan',
|
|
284
|
+
' elementType: text',
|
|
285
|
+
' bounds: [60, 420, 180, 60]',
|
|
286
|
+
' content: {text: "一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅", style: "$body"}',
|
|
287
|
+
'',
|
|
288
|
+
].join('\n'))
|
|
289
|
+
await (await import('node:fs/promises')).writeFile(join(v3Deck, 'pages', '03_floor.yaml'), [
|
|
290
|
+
'pageType: content',
|
|
291
|
+
'elements:',
|
|
292
|
+
' - elementId: small',
|
|
293
|
+
' elementType: text',
|
|
294
|
+
' bounds: [60, 60, 150, 20]',
|
|
295
|
+
' content: {text: "这是一段非常长的文本用于触发缩字下限逻辑并且验证报告能够正确给出预警信息", style: "$body"}',
|
|
296
|
+
'',
|
|
297
|
+
].join('\n'))
|
|
298
|
+
const ctxV3 = await resolveDeck(v3Deck)
|
|
299
|
+
ok('v0.3:line 省略 bounds 通过校验(D1)', ctxV3.pages.length === 3)
|
|
300
|
+
const rV3 = await renderDeck(ctxV3, {})
|
|
301
|
+
const L1 = rV3.layout.pages[0].elements.find((e) => e.id === 'L1')
|
|
302
|
+
ok('v0.3:line bounds 由 points 自动推导(AABB)', L1 && L1.bounds.w === 200 && L1.bounds.h === 1, JSON.stringify(L1?.bounds))
|
|
303
|
+
const vV3 = verifyDeck(rV3.layout)
|
|
304
|
+
ok('v0.3:safeArea 外元素 → out-of-page 错误(D5)', vV3.text.includes('超出页面安全区') && vV3.text.includes('badbox'), vV3.text.split('\n').filter((l) => l.includes('[✗]')).slice(0, 3).join('; '))
|
|
305
|
+
ok('v0.3:安全区内元素不报出界', !vV3.text.includes('okbox'))
|
|
306
|
+
ok('v0.3.2:未声明安全区出界(含 decoration)→ ERROR(C3 修订:修饰不再豁免)', vV3.text.includes('decoBand') && vV3.text.includes('expectedOutOfSafeArea'))
|
|
307
|
+
ok('v0.3.2:超页面边界 → ERROR(不可声明级)', vV3.text.includes('outPage') && vV3.text.includes('超出页面边界'))
|
|
308
|
+
ok('v0.3:未声明层叠 → unexpected-overlap', vV3.text.includes('unexpected-overlap') && vV3.text.includes('ptext'))
|
|
309
|
+
ok('v0.3:对比度建议(白字浅底,D6)', vV3.text.includes('aesthetic-contrast'))
|
|
310
|
+
ok('v0.3:孤字/破句建议(D7)', vV3.text.includes('text') && vV3.text.includes('孤字'))
|
|
311
|
+
const autod = await applyAutoDeclare(ctxV3, rV3.layout)
|
|
312
|
+
ok('v0.3:一键声明写入 1 对(D2)', autod.length === 1 && autod[0].added === 1, JSON.stringify(autod))
|
|
313
|
+
const declareYaml = await (await import('node:fs/promises')).readFile(join(v3Deck, 'pages', '02_declare.yaml'), 'utf8')
|
|
314
|
+
ok('v0.3:声明写入 yaml(含注释保留)', declareYaml.includes('expectedOverlaps') && declareYaml.includes('panel') && declareYaml.includes('ptext'))
|
|
315
|
+
|
|
316
|
+
// v0.3.2:01_layout 加出界声明(decoBand 有意 + outPage 超页——后者声明应无效),验证分级声明制
|
|
317
|
+
const layoutYaml = await (await import('node:fs/promises')).readFile(join(v3Deck, 'pages', '01_layout.yaml'), 'utf8')
|
|
318
|
+
const layoutYaml2 = layoutYaml.replace('elements:', 'expectedOutOfSafeArea:\n - decoBand\n - outPage\nelements:')
|
|
319
|
+
await (await import('node:fs/promises')).writeFile(join(v3Deck, 'pages', '01_layout.yaml'), layoutYaml2)
|
|
320
|
+
const ctxV3b = await resolveDeck(v3Deck) // 重新加载(声明写盘后)
|
|
321
|
+
const rV3b = await renderDeck(ctxV3b, {})
|
|
322
|
+
const vV3b = verifyDeck(rV3b.layout)
|
|
323
|
+
const v3bErrors = vV3b.text.split('\n').filter((l) => l.includes('[✗]'))
|
|
324
|
+
ok('v0.3.2:出界声明命中 → 预期出界✓(confirmed)', vV3b.text.includes('1 预期出界✓'), v3bErrors.join('; '))
|
|
325
|
+
ok('v0.3.2:声明后 decoBand 不再报错', !vV3b.text.includes('decoBand'))
|
|
326
|
+
ok('v0.3.2:超页面边界声明无效,仍 ERROR(分级不可声明)', vV3b.text.includes('outPage') && vV3b.text.includes('超出页面边界'))
|
|
327
|
+
ok('v0.3.2:声明后重验:unexpected 消除,仅剩故意错误(badbox/outPage/small)', vV3b.text.includes('1 预期重叠✓') && !vV3b.text.includes('unexpected-overlap') && v3bErrors.length === 3 && v3bErrors.every((l) => l.includes('badbox') || l.includes('outPage') || l.includes('small')), v3bErrors.join('; '))
|
|
328
|
+
const rV3d = await renderDeck(ctxV3, { debug: true })
|
|
329
|
+
const debugHtml = await (await import('node:fs/promises')).readFile(join(v3Deck, 'preview', '01_pages_01_layout.html'), 'utf8')
|
|
330
|
+
ok('v0.3:debug 渲染带安全区参考框', debugHtml.includes('sa-guide') && rV3d.htmlFiles.length === 3)
|
|
331
|
+
const absOut = join(root, 'examples', 'v3-abs-out.pptx')
|
|
332
|
+
const expV3 = await exportPptx(ctxV3, { out: absOut, engine: 'pptd' })
|
|
333
|
+
ok('v0.3:out 绝对路径原样使用(E1)', expV3.file === absOut && (await import('node:fs/promises')).stat(absOut).then(() => true).catch(() => false))
|
|
334
|
+
const floorEntry = expV3.autoFit.find((a) => a.id === 'small')
|
|
335
|
+
ok('v0.3:缩字下限 floorHit(to=12,E2)', floorEntry && floorEntry.from === 16 && floorEntry.to === 12 && floorEntry.floorHit === true, JSON.stringify(floorEntry))
|
|
336
|
+
await (await import('node:fs/promises')).rm(absOut, { recursive: true, force: true })
|
|
337
|
+
|
|
338
|
+
// ── 13. v0.3:ppt_schema 速查 + ppt_new 一键样例(B2)─────────────────────
|
|
339
|
+
const scaffoldMod = await import('../lib/scaffold.js')
|
|
340
|
+
ok('v0.3:ppts_schema 速查含 safeArea/expectedOverlaps', scaffoldMod.SCHEMA_REF.includes('safeArea') && scaffoldMod.SCHEMA_REF.includes('expectedOverlaps') && scaffoldMod.SCHEMA_REF.includes('elementType'))
|
|
341
|
+
const scafRoot = join(root, 'examples', 'scaffold-smoke')
|
|
342
|
+
await (await import('node:fs/promises')).rm(scafRoot, { recursive: true, force: true })
|
|
343
|
+
const scaf = await scaffoldMod.scaffoldProject(scafRoot, { name: 'demo' })
|
|
344
|
+
ok('v0.3:ppt_new 样例生成 5 文件(含架构页)', scaf.files.length === 5)
|
|
345
|
+
const scafCtx = await resolveDeck(scafRoot)
|
|
346
|
+
const scafR = await renderDeck(scafCtx, {})
|
|
347
|
+
const scafV = verifyDeck(scafR.layout)
|
|
348
|
+
ok('v0.3:样例工程可渲染且 verify 0 错误', scafCtx.pages.length === 4 && scafV.text.split('\n').filter((l) => l.includes('[✗]')).length === 0)
|
|
349
|
+
ok('v0.4:声明闭包——04 页声明 5 命中 + 2 隔层自动 = 7 确认', scafV.text.includes('7 预期重叠✓'))
|
|
350
|
+
ok('v0.4:对比度 z-order 无假阳性 + contrastExempt 生效', !scafV.text.includes('aesthetic-contrast'))
|
|
351
|
+
let refused = false
|
|
352
|
+
try { await scaffoldMod.scaffoldProject(scafRoot) } catch { refused = true }
|
|
353
|
+
ok('v0.3:ppt_new 拒绝覆盖已有工程', refused)
|
|
354
|
+
|
|
355
|
+
// ── 14. v0.3.1:冲突清单拍板(C1 引擎回退语义 / C2 audit 禁 autoDeclare)──
|
|
356
|
+
const toolsMod = await import('../lib/tools.js')
|
|
357
|
+
ok('v0.3.1:resolveEngine auto=pptd 且允许回退(C1)', toolsMod.resolveEngine('auto').engine === 'pptd' && toolsMod.resolveEngine('auto').allowFallback === true)
|
|
358
|
+
ok('v0.3.1:显式 pptd/python-pptx 不回退(C1)', toolsMod.resolveEngine('pptd').allowFallback === false && toolsMod.resolveEngine('python-pptx').engine === 'python-pptx' && toolsMod.resolveEngine('python-pptx').allowFallback === false)
|
|
359
|
+
ok('v0.3.1:blockedByAudit 仅 audit 档生效(C2)', toolsMod.blockedByAudit('audit') === true && toolsMod.blockedByAudit('standard') === false && toolsMod.blockedByAudit('quick') === false)
|
|
360
|
+
|
|
361
|
+
// ── 15. v0.3.2:出界声明防呆(坏 id 当场报错)+ D4 导入建议 safeArea ──────
|
|
362
|
+
const { validatePage } = await import('../lib/pptd/schema.js')
|
|
363
|
+
const badOutPage = { elements: [{ elementId: 'a', elementType: 'text', bounds: [0, 0, 10, 10], content: { text: 'x' } }], expectedOutOfSafeArea: ['ghost'] }
|
|
364
|
+
const badOutErr = validatePage(badOutPage, 'test.yaml')
|
|
365
|
+
ok('v0.3.2:expectedOutOfSafeArea 坏 id 当场报错(防呆)', badOutErr !== null && badOutErr.messages.some((m) => m.includes('ghost')), badOutErr?.messages?.join('; '))
|
|
366
|
+
// 3 页同位置元素(顶部/底部)→ 导入探测跨页带 → deck.yaml 写入建议 safeArea 注释
|
|
367
|
+
const bandDeck = join(root, 'examples', 'band-smoke')
|
|
368
|
+
await rm(bandDeck, { recursive: true, force: true })
|
|
369
|
+
await mkdir(join(bandDeck, 'pages'), { recursive: true })
|
|
370
|
+
await (await import('node:fs/promises')).writeFile(join(bandDeck, 'deck.yaml'), [
|
|
371
|
+
'version: 1', 'title: band-smoke', 'size: [960, 540]',
|
|
372
|
+
'theme: {colors: {primary: "#2563EB"}, textStyles: {b: {fontSize: 14}}}',
|
|
373
|
+
'pages:',
|
|
374
|
+
' - pages/01.yaml', ' - pages/02.yaml', ' - pages/03.yaml', '',
|
|
375
|
+
].join('\n'))
|
|
376
|
+
for (const p of ['01', '02', '03']) {
|
|
377
|
+
await (await import('node:fs/promises')).writeFile(join(bandDeck, 'pages', `${p}.yaml`), [
|
|
378
|
+
'pageType: content',
|
|
379
|
+
'elements:',
|
|
380
|
+
' - elementId: header', ' elementType: text', ' bounds: [40, 10, 200, 30]',
|
|
381
|
+
' content: {text: "页眉", style: "$b", color: "#2E4B9F", fontFamily: "Microsoft YaHei", italic: true}',
|
|
382
|
+
' - elementId: footer', ' elementType: text', ' bounds: [40, 505, 200, 30]',
|
|
383
|
+
' content: {text: "页脚", style: "$b", color: "#2E4B9F"}',
|
|
384
|
+
' - elementId: body', ' elementType: text', ' bounds: [40, 200, 400, 60]',
|
|
385
|
+
` content: {text: "第${p}页正文", style: "$b", color: "#2E4B9F"}`,
|
|
386
|
+
'',
|
|
387
|
+
].join('\n'))
|
|
388
|
+
}
|
|
389
|
+
const bandCtx = await resolveDeck(bandDeck)
|
|
390
|
+
const bandExp = await exportPptx(bandCtx, { out: 'out-band.pptx', engine: 'pptd' })
|
|
391
|
+
const bandImp = await importPptx(bandExp.file, join(bandDeck, 'imported'))
|
|
392
|
+
const bandDeckYaml = await (await import('node:fs/promises')).readFile(join(bandDeck, 'imported', 'deck.yaml'), 'utf8')
|
|
393
|
+
ok('v0.3.2:D4 导入探测跨页带 → 建议 safeArea 注释(未启用)', bandDeckYaml.includes('safeArea') && bandDeckYaml.includes('top: 40') && bandDeckYaml.includes('bottom: 35'), bandDeckYaml.split('\n').slice(0, 4).join(' | '))
|
|
394
|
+
|
|
395
|
+
// ── 16. v0.4.0:导入保样式(P0-1)/ 多点折线校验(P2-3)/ 对齐区块化(P1-2)/ 贴边清单(P2-4)/ density 分层 ──
|
|
396
|
+
const bandPageYaml = await (await import('node:fs/promises')).readFile(join(bandDeck, 'imported', 'pages', 'slide_01.yaml'), 'utf8')
|
|
397
|
+
ok('v0.4:导入保样式——text color/fontFamily/italic 回环', bandPageYaml.includes('fontFamily') && bandPageYaml.includes('2E4B9F'), bandPageYaml.split('\n').filter((l) => l.includes('fontFamily') || l.includes('color') || l.includes('italic')).join(' | '))
|
|
398
|
+
ok('v0.4:import-styles.json 生成(含样式清单)', (await import('node:fs/promises')).stat(join(bandDeck, 'imported', 'import-styles.json')).then(() => true).catch(() => false))
|
|
399
|
+
ok('v0.4:theme 聚合建议块写入 deck.yaml', bandDeckYaml.includes('# 建议主题') && bandDeckYaml.includes('textStyles'))
|
|
400
|
+
const mnErr = validatePage({ elements: [{ elementId: 'l', elementType: 'line', points: [[10, 10], [100, 10], [200, 10]] }] }, 'test.yaml')
|
|
401
|
+
ok('v0.4:多点折线显式报错(P2-3,不再静默截断)', mnErr !== null && mnErr.messages.some((m) => m.includes('仅支持 2 点')), mnErr?.messages?.join('; '))
|
|
402
|
+
const { analyzePage, aestheticSuggestions } = await import('../lib/verify.js')
|
|
403
|
+
const size960 = { width: 960, height: 540 }
|
|
404
|
+
const naPage = { elements: [
|
|
405
|
+
{ id: 't1', kind: 'text', bounds: { x: 100, y: 40, w: 100, h: 30 } },
|
|
406
|
+
{ id: 't2', kind: 'text', bounds: { x: 104, y: 300, w: 100, h: 30 } },
|
|
407
|
+
{ id: 'ln1', kind: 'line', bounds: { x: 200, y: 40, w: 50, h: 10 } },
|
|
408
|
+
] }
|
|
409
|
+
ok('v0.4:near-align 跨区块不比 + 线元素豁免(P1-2)', analyzePage(naPage, size960).filter((f) => f.code === 'near-align').length === 0)
|
|
410
|
+
const naSame = { elements: [
|
|
411
|
+
{ id: 'a1', kind: 'text', bounds: { x: 100, y: 40, w: 100, h: 30 } },
|
|
412
|
+
{ id: 'b1', kind: 'text', bounds: { x: 103, y: 40, w: 100, h: 30 } },
|
|
413
|
+
] }
|
|
414
|
+
ok('v0.4:同区块近对齐仍报告(对齐断言保留,3 种缘)', analyzePage(naSame, size960).filter((f) => f.code === 'near-align').length === 3)
|
|
415
|
+
const spPage = { elements: [
|
|
416
|
+
{ id: 'xa', kind: 'text', bounds: { x: 0, y: 0, w: 100, h: 30 }, text: 'a' },
|
|
417
|
+
{ id: 'xb', kind: 'text', bounds: { x: 102, y: 0, w: 100, h: 30 }, text: 'b' },
|
|
418
|
+
] }
|
|
419
|
+
ok('v0.4:相邻贴边清单(2px 间隙 → 建议,P2-4)', aestheticSuggestions(spPage, size960, {}).some((s) => s.code.includes('spacing')))
|
|
420
|
+
const denseSheet = { elements: Array.from({ length: 20 }, (_, i) => ({ id: `s${i}`, kind: 'shape', bounds: { x: i * 40, y: 0, w: 20, h: 20 } })) }
|
|
421
|
+
ok('v0.4:density 按内容元素分层(纯图形页不误报,P2-5)', analyzePage(denseSheet, size960).filter((f) => f.code === 'density').length === 0)
|
|
422
|
+
|
|
423
|
+
// ── 17. v0.5.0:模板库(4 套)回归 + 模板一致性断言 + ppt_new --template ──
|
|
424
|
+
const tplMod = await import('../lib/templates.js')
|
|
425
|
+
const tplList = await tplMod.listTemplates()
|
|
426
|
+
const BUILTIN_TPLS = ['business-blue', 'academic-white', 'tech-dark', 'pitch-bold']
|
|
427
|
+
const builtins = tplList.filter((t) => BUILTIN_TPLS.includes(t.id))
|
|
428
|
+
const externals = tplList.filter((t) => !BUILTIN_TPLS.includes(t.id))
|
|
429
|
+
ok('v0.5:内置模板 4 套齐全(含外部收纳的并存)', builtins.length === 4 && tplList.length >= 4, `总 ${tplList.length} 套:${tplList.map((t) => t.id).join(',')}`)
|
|
430
|
+
ok('v0.5:内置模板元信息完整(含预览图)', builtins.every((t) => t.name && t.style && t.scene && t.preview), builtins.map((t) => `${t.id}:${t.preview ? 'png' : 'MISSING'}`).join(' '))
|
|
431
|
+
// 【1.0.4】原来这条只查"外部收纳模板自包含"。随包移除 4 套重型导入模板后,externals 恒为空 ⇒
|
|
432
|
+
// `[].every()` 会**空转通过**(假绿)。改成对**随包层**的不变量:每套都必须自包含(元数据齐 + 有预览图)。
|
|
433
|
+
// 【1.0.6】模板库变成两层(用户自建 + 随包),所以这里按 `source` 过滤随包层——用户自己的模板
|
|
434
|
+
// 可能确实没有 preview.png(无浏览器时),不该把用户的库算进"随包模板必须自包含"这条。
|
|
435
|
+
ok('v0.6.2:随包模板每套都自包含(元数据齐 + 有预览图)',
|
|
436
|
+
builtins.length === 4 && builtins.every((t) => t.name && t.style && t.preview),
|
|
437
|
+
builtins.map((t) => `${t.id}:${t.preview ? 'png' : 'MISSING'}`).join(' '))
|
|
438
|
+
|
|
439
|
+
// ── 17b. v1.0.6 用户自建模板库:两层目录 + 入库(.pptx)/ 物化 / 删除 ──
|
|
440
|
+
// DSH_HOME 指到临时目录:`userTemplatesDir()` 是**按调用求值**的,所以这里能安全地隔离,
|
|
441
|
+
// 不碰开发机/CI 上真实的 `~/.dsh/ppt-studio/templates`。
|
|
442
|
+
{
|
|
443
|
+
const libHome = join(root, 'examples', '.tmp-tpl-home')
|
|
444
|
+
await rm(libHome, { recursive: true, force: true })
|
|
445
|
+
const prevHome = process.env.DSH_HOME
|
|
446
|
+
process.env.DSH_HOME = libHome
|
|
447
|
+
try {
|
|
448
|
+
const libUserDir = tplMod.userTemplatesDir()
|
|
449
|
+
ok('v1.0.6:用户模板层在插件包之外(升级重新物化包也丢不了)',
|
|
450
|
+
!libUserDir.startsWith(tplMod.TEMPLATES_DIR) && libUserDir.startsWith(libHome), libUserDir)
|
|
451
|
+
// 用 smoke 开头导出的 pptx 当"用户给的模板文件":走 import → 收纳 的完整路径
|
|
452
|
+
const libProj = join(libHome, '_import')
|
|
453
|
+
await mkdir(libProj, { recursive: true })
|
|
454
|
+
await importPptx(exp.file, libProj)
|
|
455
|
+
const libId = `smoke-mine-${Date.now().toString(36)}`
|
|
456
|
+
const libAdded = await tplMod.registerTemplate(libProj, { id: libId, name: '我的测试模板' })
|
|
457
|
+
const libList = await tplMod.listTemplates()
|
|
458
|
+
const libMine = libList.find((t) => t.id === libId)
|
|
459
|
+
ok('v1.0.6:入库后出现在清单里且 source=user(缺省写用户层)',
|
|
460
|
+
libAdded.layer === 'user' && libMine?.source === 'user',
|
|
461
|
+
`layer=${libAdded.layer}|清单里=${libMine?.source ?? '(未出现)'}|总 ${libList.length} 套`)
|
|
462
|
+
const libWs = join(libHome, '_ws')
|
|
463
|
+
const libMat = await tplMod.materializeTemplate(libWs, libId, { name: '我的模板' })
|
|
464
|
+
ok('v1.0.6:用户模板可物化(与随包模板同一条路,带真相层 reference/template.pptx)',
|
|
465
|
+
existsSync(join(libWs, 'deck.yaml')) && existsSync(join(libWs, 'reference', 'template.pptx')),
|
|
466
|
+
`refs=${libMat.refs.length}`)
|
|
467
|
+
const libDel = await tplMod.removeTemplate(libId)
|
|
468
|
+
const libAfter = await tplMod.listTemplates()
|
|
469
|
+
ok('v1.0.6:删除自建模板 → 从清单消失',
|
|
470
|
+
libDel.removed === true && !libAfter.some((t) => t.id === libId), libId)
|
|
471
|
+
let libRefuse = null
|
|
472
|
+
try { await tplMod.removeTemplate('business-blue') } catch (e) { libRefuse = e }
|
|
473
|
+
ok('v1.0.6:随包模板拒删(提示"升级会重新出现"),且文件确实还在',
|
|
474
|
+
!!libRefuse && /随包/.test(libRefuse.message) && existsSync(join(tplMod.TEMPLATES_DIR, 'business-blue', 'template.yaml')),
|
|
475
|
+
libRefuse?.message?.slice(0, 46) ?? '(没拦住)')
|
|
476
|
+
} finally {
|
|
477
|
+
if (prevHome === undefined) delete process.env.DSH_HOME
|
|
478
|
+
else process.env.DSH_HOME = prevHome
|
|
479
|
+
await rm(libHome, { recursive: true, force: true })
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
let tplAllOk = true
|
|
483
|
+
const tplReport = []
|
|
484
|
+
for (const t of builtins) {
|
|
485
|
+
// 内置模板:母版页全部渲染 + verify 0 错误(模板一致性 strict 门禁一并验证)
|
|
486
|
+
const ws = join(root, 'examples', 'tpl-check-' + t.id)
|
|
487
|
+
await rm(ws, { recursive: true, force: true })
|
|
488
|
+
await mkdir(join(ws, 'pages'), { recursive: true })
|
|
489
|
+
const tT = await tplMod.templateWorkspace(t.id)
|
|
490
|
+
const deckTxt = tT.deck.replace(/ - pages\/[^\n]+(?:\n - pages\/[^\n]+)*/, (tT.meta.pages ?? []).map((r) => ` - ${r}`).join('\n'))
|
|
491
|
+
await (await import('node:fs/promises')).writeFile(join(ws, 'deck.yaml'), deckTxt)
|
|
492
|
+
for (const p of tT.pages) await (await import('node:fs/promises')).writeFile(join(ws, p.ref), p.yaml)
|
|
493
|
+
try {
|
|
494
|
+
const ctxT = await resolveDeck(ws)
|
|
495
|
+
const rT = await renderDeck(ctxT, {})
|
|
496
|
+
const vT = verifyDeck(rT.layout)
|
|
497
|
+
const errs = vT.text.split('\n').filter((l) => l.includes('[✗]')).length
|
|
498
|
+
tplReport.push(`${t.id}:${errs}err`)
|
|
499
|
+
if (errs > 0) tplAllOk = false
|
|
500
|
+
} catch (e) {
|
|
501
|
+
tplAllOk = false
|
|
502
|
+
tplReport.push(`${t.id}:FAIL ${e?.message}`)
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
ok('v0.5:内置模板母版页全渲染 + verify 0 错误(含一致性门禁)', tplAllOk, tplReport.join('; '))
|
|
506
|
+
// 外部收纳模板:可渲染(不要求 0 错误——内容参照,收纳时已自动声明预期重叠)
|
|
507
|
+
let extOk = true
|
|
508
|
+
const extReport = []
|
|
509
|
+
for (const t of externals) {
|
|
510
|
+
const tT = await tplMod.templateWorkspace(t.id)
|
|
511
|
+
try {
|
|
512
|
+
await renderDeck(await resolveDeck(tT.dir), {})
|
|
513
|
+
extReport.push(`${t.id}:render ok`)
|
|
514
|
+
} catch (e) {
|
|
515
|
+
extOk = false
|
|
516
|
+
extReport.push(`${t.id}:FAIL ${e?.message}`)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
ok('v0.6.2:外部收纳模板可渲染(内容参照,清理后工作区可用)',
|
|
520
|
+
externals.length === 0 || extOk,
|
|
521
|
+
externals.length ? extReport.join('; ') : '随包无外部收纳模板(4 套重型导入模板已于 1.0.4 移除);收纳路径由下方 registerTemplate 断言覆盖')
|
|
522
|
+
// theme-conformance:出板颜色=ERROR;suggest 档=warning;off=跳过
|
|
523
|
+
const confPage = { elements: [{ id: 'x', kind: 'shape', fill: '#123456', bounds: { x: 0, y: 0, w: 10, h: 10 } }] }
|
|
524
|
+
ok('v0.5:theme-conformance strict 出板颜色 → ERROR', verifyDeck({ size: size960, theme: { colors: { a: '#2563EB' } }, pages: [{ index: 0, name: 'p', safeArea: null, overlapMode: 'declared', expectedOverlaps: [], expectedOutOfSafeArea: [], elements: [{ id: 'x', kind: 'shape', fill: '#123456', bounds: { x: 0, y: 0, w: 10, h: 10 } }] }] }).text.includes('[✗] theme-conformance'))
|
|
525
|
+
ok('v0.5:中性色豁免 + 主题色通过', !verifyDeck({ size: size960, theme: { colors: { a: '#2563EB' } }, pages: [{ index: 0, name: 'p', safeArea: null, overlapMode: 'declared', expectedOverlaps: [], expectedOutOfSafeArea: [], elements: [{ id: 'x', kind: 'shape', fill: '#FFFFFF', bounds: { x: 0, y: 0, w: 10, h: 10 } }, { id: 'y', kind: 'shape', fill: '#2563EB', bounds: { x: 0, y: 0, w: 10, h: 10 } }] }] }).text.includes('theme-conformance'))
|
|
526
|
+
ok('v0.5:themeConformance off 跳过', !verifyDeck({ size: size960, theme: { colors: { a: '#2563EB' }, themeConformance: 'off' }, pages: [{ index: 0, name: 'p', safeArea: null, overlapMode: 'declared', expectedOverlaps: [], expectedOutOfSafeArea: [], elements: [{ id: 'x', kind: 'shape', fill: '#123456', bounds: { x: 0, y: 0, w: 10, h: 10 } }] }] }).text.includes('theme-conformance'))
|
|
527
|
+
// ppt_new --template 复制工作区(v0.7:母版=参考不注册;正式页=首母版副本注册)
|
|
528
|
+
const tplWS = join(root, 'examples', 'tpl-work')
|
|
529
|
+
await rm(tplWS, { recursive: true, force: true })
|
|
530
|
+
const { materializeTemplate } = await import('../lib/templates.js')
|
|
531
|
+
const mat = await materializeTemplate(tplWS, 'business-blue', { name: 'demo' })
|
|
532
|
+
const ctxTW2 = await resolveDeck(tplWS)
|
|
533
|
+
ok('v0.7:模板工作区——正式页单页注册 + 母版不进门禁', ctxTW2.pages.length === 1 && ctxTW2.pages[0].ref === mat.formal && mat.firstRef === 'pages/_cover.yaml', `pages=${ctxTW2.pages.length} ref=${ctxTW2.pages[0]?.ref}`)
|
|
534
|
+
const tplV0 = verifyDeck((await renderDeck(ctxTW2, {})).layout)
|
|
535
|
+
ok('v0.7:内置模板正式页(首母版副本)0 错误', tplV0.text.split('\n').filter((l) => l.includes('[✗]')).length === 0)
|
|
536
|
+
// 模板工作区物化(1.0.4 起改用内置模板:随包已无"带 media/真相层"的导入模板):
|
|
537
|
+
// ① 母版未注册不报错(只注册 1 个正式页)——用户反馈场景:加载模板不再被门禁拦住
|
|
538
|
+
// ② 媒体必须与模板清单**数量一致**(0 也是合法值:内置模板无 media),且**无真相层时不建 reference/**
|
|
539
|
+
{
|
|
540
|
+
const extT = { id: BUILTIN_TPLS[0] } // business-blue(6 张母版;确保 refs > 5 这条仍然有意义)
|
|
541
|
+
const extWS = join(root, 'examples', 'tpl-work-ext')
|
|
542
|
+
await rm(extWS, { recursive: true, force: true })
|
|
543
|
+
const tplMetaSrc = await tplMod.templateWorkspace(extT.id)
|
|
544
|
+
const tplMediaCount = tplMetaSrc.media.length
|
|
545
|
+
const matE = await materializeTemplate(extWS, extT.id, {})
|
|
546
|
+
const extCtx = await resolveDeck(extWS)
|
|
547
|
+
// 不变式:refs = 母版页数 − 1(首母版被"正式化"为 pages/01_opening.yaml,其余以参考母版随行,见 templates.js:300)
|
|
548
|
+
ok('v0.7:模板工作区——只注册 1 个正式页(母版参考不报错,其余母版全部随行)',
|
|
549
|
+
extCtx.pages.length === 1 && matE.refs.length === (tplMetaSrc.meta.pages ?? []).length - 1 && matE.refs.length > 4,
|
|
550
|
+
`registered=${extCtx.pages.length} refs=${matE.refs.length}/${(tplMetaSrc.meta.pages ?? []).length - 1}(首母版正式化后余下的参考母版)template=${extT.id}`)
|
|
551
|
+
ok('v0.7:模板媒体跟随复制(数量与清单一致)+ 无真相层时不建 reference/',
|
|
552
|
+
matE.mediaCount === tplMediaCount && !existsSync(join(extWS, 'reference')),
|
|
553
|
+
`media=${matE.mediaCount}/${tplMediaCount} reference=${existsSync(join(extWS, 'reference'))}`)
|
|
554
|
+
}
|
|
555
|
+
// 收纳清洗升级:registerTemplate 声明出界元素 + 剩余错误分类(bandDeck safeArea 外元素)
|
|
556
|
+
const reg2 = await tplMod.registerTemplate(bandDeck, { id: `smoke-wash-${Date.now().toString(36)}`, name: '洗涤测试' }, {})
|
|
557
|
+
ok('v0.7:收纳清洗——出界声明/重叠声明/剩余分类进入 meta', typeof reg2.meta.cleanup === 'string' && reg2.meta.cleanup.includes('剩余'), reg2.meta.cleanup ?? '')
|
|
558
|
+
await (await import('node:fs/promises')).rm(join(tplMod.TEMPLATES_DIR, reg2.id), { recursive: true, force: true })
|
|
559
|
+
|
|
560
|
+
// ── 18. v0.5.1:外部模板收纳(ppt_template_add)——导入工程 → 模板库 ──────
|
|
561
|
+
const regId = `smoke-tpl-${Date.now().toString(36)}`
|
|
562
|
+
const reg = await tplMod.registerTemplate(bandDeck, { id: regId, name: '回归收纳模板', style: '测试' }, {})
|
|
563
|
+
ok('v0.5.1:外部模板收纳成功(theme/页面/媒体入包)', reg.pages === 3 && (await (await import('node:fs/promises')).stat(join(reg.dir, 'deck.yaml'))).isFile(), `id=${reg.id} pages=${reg.pages}`)
|
|
564
|
+
const regList = await tplMod.listTemplates()
|
|
565
|
+
ok('v0.5.1:收纳模板出现在模板库清单', regList.some((t) => t.id === regId))
|
|
566
|
+
const regWS = await tplMod.templateWorkspace(regId)
|
|
567
|
+
const ctxReg = await resolveDeck(regWS.dir)
|
|
568
|
+
ok('v0.5.1:收纳模板工作区可校验(theme/页面保留)', ctxReg.theme.colors.primary === '#2563EB' && ctxReg.pages.length === 3, `pages=${ctxReg.pages.length}`)
|
|
569
|
+
// 清理测试模板
|
|
570
|
+
await (await import('node:fs/promises')).rm(join(tplMod.TEMPLATES_DIR, regId), { recursive: true, force: true })
|
|
571
|
+
|
|
572
|
+
// ── 19. v0.6.0:对话内预览(ppt_preview)——预览根构建 + 同源相对 URL ────
|
|
573
|
+
const { buildPreview } = await import('../lib/preview-server.js')
|
|
574
|
+
const pv = await buildPreview(bandDeck)
|
|
575
|
+
ok('v0.6:预览构建(token/相对 URL/页数)', pv.token.length === 10 && pv.url.includes('/ppt-preview/') && pv.overviewUrl.endsWith('/pages/deck.html') && pv.pages === 3, pv.url)
|
|
576
|
+
const pvPages = await (await import('node:fs/promises')).readdir(join(pv.previewRoot, 'pages'))
|
|
577
|
+
ok('v0.6:预览根 pages 完整(3 页 + 整览)', pvPages.includes('deck.html') && pvPages.filter((f) => f.endsWith('.html')).length === 4)
|
|
578
|
+
const pvMedia = await buildPreview(mediaDeck)
|
|
579
|
+
ok('v0.6:预览根媒体拷贝(../media 引用可解析)', (await (await import('node:fs/promises')).readdir(join(pvMedia.previewRoot, 'media'))).includes('pic.png'))
|
|
580
|
+
|
|
581
|
+
// ── 20. v0.6.1:图表单列兼容(P1)+ 全零告警 + 深色背景对比度(P7)───────
|
|
582
|
+
const { chartData, chartIssue } = await import('../lib/pptd/svgCharts.js')
|
|
583
|
+
const singlePair = chartData({ type: 'bar', data: { cols: ['指标'], rows: [['A', 72], ['B', 55]] } })
|
|
584
|
+
ok('v0.6.1:图表单列 pairs 自动兼容(值不再全零)', singlePair.series[0].values.join() === '72,55' && singlePair.categories.join() === 'A,B', JSON.stringify(singlePair.series))
|
|
585
|
+
ok('v0.6.1:全零数据显式告警(不再静默)', chartIssue({ type: 'bar', data: { cols: ['指标', '值'], rows: [['A', 0], ['B', 0]] } }) !== null && chartIssue({ type: 'bar', data: { cols: ['指标', '值'], rows: [['A', 10], ['B', 20]] } }) === null)
|
|
586
|
+
// 模板图表值非零(4 套模板回归)
|
|
587
|
+
const chartDeckOk = []
|
|
588
|
+
for (const t of tplList) {
|
|
589
|
+
const tT = await tplMod.templateWorkspace(t.id)
|
|
590
|
+
const ctxT = await resolveDeck(tT.dir)
|
|
591
|
+
let okChart = true
|
|
592
|
+
for (const pg of ctxT.pages) {
|
|
593
|
+
for (const el of pg.page.elements ?? []) {
|
|
594
|
+
if (el.elementType === 'chart') {
|
|
595
|
+
const d = chartData(el.chart)
|
|
596
|
+
if (!d.series.some((s) => s.values.some((v) => v > 0))) okChart = false
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
chartDeckOk.push(`${t.id}:${okChart ? 'ok' : 'ZERO'}`)
|
|
601
|
+
}
|
|
602
|
+
ok('v0.6.1:4 套模板图表数据非零', chartDeckOk.every((s) => s.endsWith('ok')), chartDeckOk.join('; '))
|
|
603
|
+
// P7:$ref 页面背景解析为实际色(深色背景不再按白底算对比度)
|
|
604
|
+
const scafBgPage = scafR.layout.pages.find((p) => p.background?.type === 'solid' && p.background.color !== undefined)
|
|
605
|
+
ok('v0.6.1:$ref 背景解析为实际 hex(P7)', scafBgPage && /^#[0-9A-Fa-f]{6}$/.test(scafBgPage.background.color), scafBgPage?.background?.color)
|
|
606
|
+
|
|
607
|
+
// ── 21. v0.6.3:预览 URL 动态适配(发布友好:无硬编码 host/port)─────────
|
|
608
|
+
const { previewOrigin } = await import('../lib/tools.js')
|
|
609
|
+
ok('v0.6.3:previewOrigin 动态适配(host/port 契约 + 0.0.0.0 回退)',
|
|
610
|
+
previewOrigin({ port: 3080, host: '127.0.0.1' }) === 'http://127.0.0.1:3080'
|
|
611
|
+
&& previewOrigin({ port: 8080, host: '0.0.0.0' }) === 'http://127.0.0.1:8080'
|
|
612
|
+
&& previewOrigin(null) === ''
|
|
613
|
+
&& previewOrigin({ port: 9000, host: 'localhost' }) === 'http://localhost:9000',
|
|
614
|
+
`本地=${previewOrigin({ port: 3080, host: '127.0.0.1' })}`)
|
|
615
|
+
|
|
616
|
+
// ── 22. v0.8.0:Office 真渲染通道(无 Office 自动隐藏;有则条件实测)──────
|
|
617
|
+
const msMod = await import('../lib/msrender.js')
|
|
618
|
+
const hasOffice = msMod.findPowerPoint() !== null
|
|
619
|
+
console.log(`[v0.8.0] Office 能力探测:${hasOffice ? '有(COM 通道可用)' : '无(通道自动隐藏,不影响工作流)'}`)
|
|
620
|
+
if (hasOffice) {
|
|
621
|
+
const visOut = join(root, 'examples', 'rendered-check')
|
|
622
|
+
await rm(visOut, { recursive: true, force: true })
|
|
623
|
+
// Office COM 是"可选增强":本机 PowerPoint 正开着文件/弹对话框时 COM 会临时不可用——
|
|
624
|
+
// 这属于环境抖动,不该把整个自检打崩(2026-09-14 实测:用户开着 PowerPoint 时 smoke 被 COM 异常中断)。
|
|
625
|
+
try {
|
|
626
|
+
const rv = await msMod.renderPptxToPng(bandExp.file, visOut, { width: 960, height: 540, timeoutMs: 180000 })
|
|
627
|
+
ok('v0.8.0:Office 真渲染(成品 pptx → 逐页 PNG)', rv.pages === 3 && rv.files.length === 3, `pages=${rv.pages} files=${rv.files.length}`)
|
|
628
|
+
} catch (e) {
|
|
629
|
+
console.log(`[v0.8.0] ⚠ Office COM 本次不可用(环境抖动,非失败):${String(e?.message ?? e).split('\n')[0].slice(0, 120)}`)
|
|
630
|
+
ok('v0.8.0:Office COM 抖动时降级跳过(不阻断自检)', true)
|
|
631
|
+
}
|
|
632
|
+
await rm(visOut, { recursive: true, force: true })
|
|
633
|
+
} else {
|
|
634
|
+
ok('v0.8.0:无 Office 跳过渲染(无需验证的降级路径)', true)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ── 23. v0.9.0:模板双轨(真相层保留 + referenceTemplate 注入)───────
|
|
638
|
+
const dualSrc = join(bandDeck, 'source.pptx')
|
|
639
|
+
await (await import('node:fs/promises')).copyFile(bandExp.file, dualSrc) // 假真相层(导入工程应自带)
|
|
640
|
+
const dualId = `smoke-dual-${Date.now().toString(36)}`
|
|
641
|
+
const dual = await tplMod.registerTemplate(bandDeck, { id: dualId, name: '双轨测试' }, {})
|
|
642
|
+
ok('v0.9.0:收纳保留真相层(source.pptx → template.pptx)', !!dual.sourcePptx && dual.meta.sourcePptx === 'template.pptx' && existsSync(join(dual.dir, 'template.pptx')), dual.meta.sourcePptx ?? '无')
|
|
643
|
+
const dualWS = join(root, 'examples', 'tpl-dual')
|
|
644
|
+
await rm(dualWS, { recursive: true, force: true })
|
|
645
|
+
const matD = await materializeTemplate(dualWS, dualId, { name: '双轨' })
|
|
646
|
+
const deckD = await (await import('node:fs/promises')).readFile(join(dualWS, 'deck.yaml'), 'utf8')
|
|
647
|
+
// previews 是**Office COM 渲染**的产物(registerTemplate 里模板预览=renderPptxToPng,需要本机 PowerPoint):
|
|
648
|
+
// 没有 Office 的机器上 previews/ 根本不存在——旧断言无条件要求 reference/previews/01.png,于是
|
|
649
|
+
// "干净检出(Linux CI / 没装 Office 的机器)跑 npm test" 必然红两条(2026-09-18 创意工坊 PR 审核反馈)。
|
|
650
|
+
// 断言必须**如实反映能力**:previews 在就要求它被注入+拷贝,不在就要求"只注入 source 且绝不出现 previews 引用"。
|
|
651
|
+
// 两个分支各自恰好两次 ok(),断言总数恒定(否则 §37.10 文档计数自证会假红)。
|
|
652
|
+
const previewSrc = existsSync(join(dual.dir, 'previews'))
|
|
653
|
+
if (previewSrc) {
|
|
654
|
+
ok('v0.9.0:物化工作区注入 referenceTemplate(source/previews)',
|
|
655
|
+
deckD.includes('referenceTemplate:') && deckD.includes('reference/template.pptx') && deckD.includes('reference/previews/01.png'),
|
|
656
|
+
'referenceTemplate 块缺失' )
|
|
657
|
+
ok('v0.9.0:reference/ 拷贝(template.pptx + previews 整页)',
|
|
658
|
+
matD.reference?.files?.includes('template.pptx') && existsSync(join(dualWS, 'reference', 'previews', '01.png')),
|
|
659
|
+
JSON.stringify(matD.reference?.files ?? []))
|
|
660
|
+
} else {
|
|
661
|
+
console.log('[v0.9.0] 本机无 Office(预览渲染不可用):previews 不可能存在,改断言"只注入 source"的诚实不变量')
|
|
662
|
+
ok('v0.9.0:无 Office 时 referenceTemplate 只注入 source(previews 随 Office,不虚报)',
|
|
663
|
+
deckD.includes('referenceTemplate:') && deckD.includes('reference/template.pptx') && !deckD.includes('reference/previews/'),
|
|
664
|
+
'无 Office 时不该出现 previews 引用')
|
|
665
|
+
ok('v0.9.0:无 Office 时 reference/ 只拷贝 template.pptx(拷贝清单与产物逐条对应)',
|
|
666
|
+
matD.reference?.files?.includes('template.pptx') && !existsSync(join(dualWS, 'reference', 'previews')),
|
|
667
|
+
JSON.stringify(matD.reference?.files ?? []))
|
|
668
|
+
}
|
|
669
|
+
const dualCtx = await resolveDeck(dualWS)
|
|
670
|
+
ok('v0.9.0:带 referenceTemplate 的 deck 通过校验(非渲染字段)', dualCtx.pages.length === 1 && dualCtx.deck.referenceTemplate?.id === dualId)
|
|
671
|
+
// 清理双轨测试模板 + 假 source
|
|
672
|
+
await (await import('node:fs/promises')).rm(join(tplMod.TEMPLATES_DIR, dualId), { recursive: true, force: true }).catch(() => {})
|
|
673
|
+
await rm(dualSrc, { force: true }).catch(() => {})
|
|
674
|
+
await rm(dualWS, { recursive: true, force: true })
|
|
675
|
+
|
|
676
|
+
// ── 23b. 换行鲁棒(CRLF + BOM):文本手术不得在 Windows 检出/用户手改的文件上静默失效 ──────
|
|
677
|
+
// 真因与后果(2026-09-18 实测):`/pages:\n[\s\S]*$/` 这类行锚定正则,JS 的 `.` 不吃 `\r`、`$`(m 模式)
|
|
678
|
+
// 只落在 `\n` 前 ⇒ **CRLF 文本上一条都不匹配**,`.replace()` 原样返回(不报错):
|
|
679
|
+
// materializeTemplate 产出的工作区 deck.yaml 仍引用 `pages/_cover.yaml`,而该页故意没被复制
|
|
680
|
+
// (它是被"正式化"的首母版)⇒ resolveDeck 抛 "page file missing: pages/_cover.yaml"。
|
|
681
|
+
// 触发条件一点都不罕见:Git for Windows 默认 core.autocrlf=true ⇒ **干净克隆就是 CRLF**
|
|
682
|
+
// (本机工作区反而是脚本写出来的 LF,于是"本机全绿、干净克隆全崩")。
|
|
683
|
+
// 防线两条:.gitattributes(git 安装与 tgz 安装同字节)+ normalizeText(用户手改的 CRLF/BOM 也不失效)。
|
|
684
|
+
const crlfId = `smoke-crlf-${Date.now().toString(36)}`
|
|
685
|
+
const crlfDir = join(tplMod.TEMPLATES_DIR, crlfId)
|
|
686
|
+
{
|
|
687
|
+
const fsp = await import('node:fs/promises')
|
|
688
|
+
await fsp.cp(join(tplMod.TEMPLATES_DIR, 'business-blue'), crlfDir, { recursive: true })
|
|
689
|
+
for (const rel of ['deck.yaml', 'template.yaml', 'pages/_cover.yaml', 'pages/_content.yaml']) {
|
|
690
|
+
const p = join(crlfDir, rel)
|
|
691
|
+
if (!existsSync(p)) continue
|
|
692
|
+
const lf = tplMod.normalizeText(await fsp.readFile(p, 'utf8'))
|
|
693
|
+
// deck.yaml 额外加 BOM:Windows 记事本默认加 BOM,而 BOM 会破坏 `^pages:` 的行锚
|
|
694
|
+
await fsp.writeFile(p, (rel === 'deck.yaml' ? '\uFEFF' : '') + lf.replace(/\n/g, '\r\n'), 'utf8')
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const crlfWS = join(root, 'examples', 'tpl-crlf')
|
|
698
|
+
await rm(crlfWS, { recursive: true, force: true })
|
|
699
|
+
const matC = await tplMod.materializeTemplate(crlfWS, crlfId, { name: 'CRLF' })
|
|
700
|
+
const deckC = await (await import('node:fs/promises')).readFile(join(crlfWS, 'deck.yaml'), 'utf8')
|
|
701
|
+
ok('换行鲁棒:CRLF+BOM 的模板 deck.yaml 仍被改写为只引用正式页(不再静默返回原文)',
|
|
702
|
+
deckC.includes('pages/01_opening.yaml') && !deckC.includes('_cover.yaml'),
|
|
703
|
+
JSON.stringify(deckC.split('\n').filter((l) => l.includes('.yaml')).slice(0, 4)))
|
|
704
|
+
const ctxC = await resolveDeck(crlfWS) // 修复前:这一行抛 page file missing: pages/_cover.yaml
|
|
705
|
+
ok('换行鲁棒:CRLF+BOM 模板物化出的工作区可 resolve(正是干净克隆崩掉的那一步)', ctxC.pages.length === 1 && ctxC.deck.title === 'CRLF')
|
|
706
|
+
const coverC = await (await import('node:fs/promises')).readFile(join(crlfWS, 'pages', '01_opening.yaml'), 'utf8')
|
|
707
|
+
ok('换行鲁棒:CRLF 母版页的 pageType 仍被改写(`^x.*$` 类正则在 CRLF 上同样失效)',
|
|
708
|
+
coverC.includes('pageType: content') && !coverC.includes('pageType: cover'), matC.formal)
|
|
709
|
+
await (await import('node:fs/promises')).rm(crlfDir, { recursive: true, force: true }).catch(() => {})
|
|
710
|
+
await rm(crlfWS, { recursive: true, force: true })
|
|
711
|
+
|
|
712
|
+
// ── 24. v0.9.1:候选 A——prst 形状直通 + 渐变闭环 + 参考双轨 ──────────────
|
|
713
|
+
// 24.1 造含 prst/渐变 shape 的 deck → exportPptx → importPptx 回环(export→import 无损)
|
|
714
|
+
const prstDeck = join(root, 'examples', 'prst-smoke')
|
|
715
|
+
await rm(prstDeck, { recursive: true, force: true })
|
|
716
|
+
await mkdir(join(prstDeck, 'pages'), { recursive: true })
|
|
717
|
+
await (await import('node:fs/promises')).writeFile(join(prstDeck, 'deck.yaml'), [
|
|
718
|
+
'version: 1', 'title: prst-smoke', 'size: [960, 540]',
|
|
719
|
+
'theme: {colors: {primary: "#2563EB", accent: "#0485A8"}}',
|
|
720
|
+
'pages:', ' - pages/01.yaml', '',
|
|
721
|
+
].join('\n'))
|
|
722
|
+
await (await import('node:fs/promises')).writeFile(join(prstDeck, 'pages', '01.yaml'), [
|
|
723
|
+
'pageType: content',
|
|
724
|
+
'elements:',
|
|
725
|
+
' - elementId: arr', ' elementType: shape', ' kind: rightArrow',
|
|
726
|
+
' bounds: [40, 40, 200, 80]', ' fill: "#2563EB"',
|
|
727
|
+
' - elementId: pen', ' elementType: shape', ' kind: pentagon',
|
|
728
|
+
' bounds: [40, 140, 200, 80]', ' fill: "#0485A8"',
|
|
729
|
+
' - elementId: grad', ' elementType: shape', ' kind: chevron',
|
|
730
|
+
' bounds: [40, 240, 200, 80]',
|
|
731
|
+
' fill: {type: gradient, stops: [{pos: 0, color: "#79C9E2"}, {pos: 100, color: "#0485A8"}], angle: 90}',
|
|
732
|
+
'',
|
|
733
|
+
].join('\n'))
|
|
734
|
+
const prstCtx = await resolveDeck(prstDeck)
|
|
735
|
+
const prstExp = await exportPptx(prstCtx, { out: 'out-prst.pptx', engine: 'pptd' })
|
|
736
|
+
// 直接读 zip 断言 prstGeom/gradFill 落盘
|
|
737
|
+
const prstZip = zipRead(await (await import('node:fs/promises')).readFile(prstExp.file))
|
|
738
|
+
const prstSlide = prstZip.get('ppt/slides/slide1.xml').toString('utf8')
|
|
739
|
+
ok('v0.9.1:导出 prstGeom 直通(rightArrow/pentagon/chevron)',
|
|
740
|
+
prstSlide.includes('prst="rightArrow"') && prstSlide.includes('prst="pentagon"') && prstSlide.includes('prst="chevron"'),
|
|
741
|
+
prstSlide.match(/prst="[^"]+"/g)?.join(' ') ?? 'MISSING')
|
|
742
|
+
ok('v0.9.1:导出 gradFill 双 stop(gs/lin)',
|
|
743
|
+
prstSlide.includes('gradFill') && prstSlide.includes('<a:gs pos="0"') && prstSlide.includes('<a:lin ang="5400000"') && prstSlide.includes('val="0485A8"'),
|
|
744
|
+
'gradFill 缺失')
|
|
745
|
+
// 导入回环:kind/fill 保留
|
|
746
|
+
const prstImpDir = join(root, 'examples', 'prst-imported')
|
|
747
|
+
await rm(prstImpDir, { recursive: true, force: true })
|
|
748
|
+
const prstImp = await importPptx(prstExp.file, prstImpDir)
|
|
749
|
+
const prstImpCtx = await resolveDeck(prstImpDir)
|
|
750
|
+
const prstEls = prstImpCtx.pages[0].page.elements
|
|
751
|
+
const arrEl = prstEls.find((e) => e.elementId === 'arr')
|
|
752
|
+
const gradEl = prstEls.find((e) => e.elementId === 'grad')
|
|
753
|
+
ok('v0.9.1:import 回环 kind 保留(rightArrow/pentagon)', arrEl?.kind === 'rightArrow' && prstEls.find((e) => e.elementId === 'pen')?.kind === 'pentagon', `kinds=${prstEls.map((e) => e.kind).join(',')}`)
|
|
754
|
+
ok('v0.9.1:import 回环渐变对象(type/stops/angle)', gradEl?.fill?.type === 'gradient' && gradEl?.fill?.stops?.length === 2 && gradEl?.fill?.angle === 90, JSON.stringify(gradEl?.fill))
|
|
755
|
+
// 渲染 + verify 不崩(渐变对象被消费)
|
|
756
|
+
const prstRender = await renderDeck(prstImpCtx, {})
|
|
757
|
+
ok('v0.9.1:渐变对象渲染/校验通过(render+verify 消费渐变 fill)', prstRender.layout.pages[0].elements.some((e) => e.id === 'grad' && e.fill?.type === 'gradient') && !verifyDeck(prstRender.layout).text.includes('[✗] overflow'), verifyDeck(prstRender.layout).text.split('\n').filter((l) => l.includes('[✗]')).join('|'))
|
|
758
|
+
// 24.2 参考双轨:import 产物 referenceSource 注入 + source.pptx 保留
|
|
759
|
+
const refline = await (await import('node:fs/promises')).readFile(join(prstImpDir, 'deck.yaml'), 'utf8')
|
|
760
|
+
ok('v0.9.1:参考双轨 referenceSource 注入(source 恒在;previews 随 Office)',
|
|
761
|
+
refline.includes('referenceSource:') && refline.includes('source: source.pptx') && existsSync(join(prstImpDir, 'source.pptx')),
|
|
762
|
+
refline.split('\n').filter((l) => l.startsWith('referenceSource') || l.includes('source:') || l.includes('previews')).join(' | '))
|
|
763
|
+
const prstRefCtx = await resolveDeck(prstImpDir)
|
|
764
|
+
ok('v0.9.1:referenceSource deck 通过校验', prstRefCtx.deck.referenceSource?.name !== undefined)
|
|
765
|
+
// 清理
|
|
766
|
+
await rm(prstImpDir, { recursive: true, force: true })
|
|
767
|
+
await rm(prstDeck, { recursive: true, force: true })
|
|
768
|
+
|
|
769
|
+
// ── 25. v0.10.0:手术模式(候选 B)——模板贴内容 + 完整性验证 ─────────────
|
|
770
|
+
const { surgicalPatch, scanSlideXml, matchByDistance, slideOrderOf, verifySurgical } = await import('../lib/surgical.js')
|
|
771
|
+
// 25.1 模板 = exportPptx 自建(3 页:标题文本含粗体 + 表格)
|
|
772
|
+
const surgTplDir = join(root, 'examples', 'surg-tpl')
|
|
773
|
+
await rm(surgTplDir, { recursive: true, force: true })
|
|
774
|
+
await mkdir(join(surgTplDir, 'pages'), { recursive: true })
|
|
775
|
+
await (await import('node:fs/promises')).writeFile(join(surgTplDir, 'deck.yaml'), [
|
|
776
|
+
'version: 1', 'title: surg-tpl', 'size: [960, 540]',
|
|
777
|
+
'theme: {colors: {primary: "#1E4E8C", accent: "#EAF1F8"}}',
|
|
778
|
+
'pages:',
|
|
779
|
+
' - pages/01.yaml', ' - pages/02.yaml', ' - pages/03.yaml', '',
|
|
780
|
+
].join('\n'))
|
|
781
|
+
await (await import('node:fs/promises')).writeFile(join(surgTplDir, 'pages', '01.yaml'), [
|
|
782
|
+
'pageType: content',
|
|
783
|
+
'elements:',
|
|
784
|
+
' - elementId: title', ' elementType: text', ' bounds: [60, 60, 500, 60]',
|
|
785
|
+
' content: {text: "模板标题", fontSize: 28, color: "#1E4E8C", bold: true}',
|
|
786
|
+
' - elementId: body', ' elementType: text', ' bounds: [60, 160, 500, 80]',
|
|
787
|
+
' content: {text: "模板正文内容", fontSize: 16}',
|
|
788
|
+
' - elementId: tbl', ' elementType: table', ' bounds: [60, 300, 600, 150]',
|
|
789
|
+
' cols: ["指标", "数值"]',
|
|
790
|
+
' rows: [["A", "1"], ["B", "2"]]',
|
|
791
|
+
'', ''].join('\n'))
|
|
792
|
+
for (const p of ['02', '03']) {
|
|
793
|
+
await (await import('node:fs/promises')).writeFile(join(surgTplDir, 'pages', `${p}.yaml`), [
|
|
794
|
+
'pageType: content',
|
|
795
|
+
'elements:',
|
|
796
|
+
` - elementId: t${p}`, ' elementType: text', ' bounds: [60, 100, 400, 60]',
|
|
797
|
+
` content: {text: "第${p === '02' ? 2 : 3}页固定", fontSize: 20}`,
|
|
798
|
+
'', ''].join('\n'))
|
|
799
|
+
}
|
|
800
|
+
const surgTplCtx = await resolveDeck(surgTplDir)
|
|
801
|
+
const surgTplExp = await exportPptx(surgTplCtx, { out: 'out-surg-tpl.pptx', engine: 'pptd' })
|
|
802
|
+
// 25.2 工作区 = 同几何新内容(只改文本/表格文本,几何不动)
|
|
803
|
+
const surgWork = join(root, 'examples', 'surg-work')
|
|
804
|
+
await rm(surgWork, { recursive: true, force: true })
|
|
805
|
+
await mkdir(join(surgWork, 'pages'), { recursive: true })
|
|
806
|
+
await (await import('node:fs/promises')).writeFile(join(surgWork, 'deck.yaml'), [
|
|
807
|
+
'version: 1', 'title: surg-work', 'size: [960, 540]',
|
|
808
|
+
'theme: {colors: {primary: "#1E4E8C", accent: "#EAF1F8"}}',
|
|
809
|
+
'pages:',
|
|
810
|
+
' - pages/01.yaml', ' - pages/02.yaml', '', ''].join('\n'))
|
|
811
|
+
await (await import('node:fs/promises')).writeFile(join(surgWork, 'pages', '01.yaml'), [
|
|
812
|
+
'pageType: content',
|
|
813
|
+
'elements:',
|
|
814
|
+
' - elementId: title', ' elementType: text', ' bounds: [60, 60, 500, 60]',
|
|
815
|
+
' content: {text: "用户的新标题", fontSize: 28, color: "#1E4E8C", bold: true}',
|
|
816
|
+
' - elementId: body', ' elementType: text', ' bounds: [60, 200, 500, 80]',
|
|
817
|
+
' content: {text: "用户新正文", fontSize: 16}',
|
|
818
|
+
' - elementId: tbl', ' elementType: table', ' bounds: [60, 300, 600, 150]',
|
|
819
|
+
' cols: ["指标", "数值"]',
|
|
820
|
+
' rows: [["甲", "10"], ["乙", "20"]]',
|
|
821
|
+
'', ''].join('\n'))
|
|
822
|
+
await (await import('node:fs/promises')).writeFile(join(surgWork, 'pages', '02.yaml'), [
|
|
823
|
+
'pageType: content',
|
|
824
|
+
'elements:',
|
|
825
|
+
' - elementId: t02', ' elementType: text', ' bounds: [60, 100, 400, 60]',
|
|
826
|
+
' content: {text: "第2页固定", fontSize: 20}', // 与模板一致 = 未动 → kept
|
|
827
|
+
'', ''].join('\n'))
|
|
828
|
+
const surgOut = join(root, 'examples', 'surg-out.pptx')
|
|
829
|
+
await rm(surgOut, { force: true }).catch(() => {})
|
|
830
|
+
const surgRes = await surgicalPatch({ template: surgTplExp.file, deckDir: surgWork, out: surgOut })
|
|
831
|
+
ok('v0.10.0:手术页状态(第1页 patched / 第2页 kept / 第3页 kept)',
|
|
832
|
+
surgRes.pages[0].action === 'patched' && surgRes.pages[1].action === 'kept' && surgRes.pages[2].action === 'kept' && surgRes.pages[2].note?.includes('无对应'),
|
|
833
|
+
surgRes.pages.map((p) => `${p.index}:${p.action}`).join(', '))
|
|
834
|
+
ok('v0.10.0:字段替换统计(标题+正文 2 字段;表格 4 cell;清空 0)',
|
|
835
|
+
surgRes.fields === 2 && surgRes.tableCells === 4 && surgRes.pages[0].fields === 2,
|
|
836
|
+
JSON.stringify({ fields: surgRes.fields, tableCells: surgRes.tableCells }))
|
|
837
|
+
const surgZip = zipRead(await (await import('node:fs/promises')).readFile(surgOut))
|
|
838
|
+
const surgSlide = surgZip.get('ppt/slides/slide1.xml').toString('utf8')
|
|
839
|
+
ok('v0.10.0:手术页文本已替换(新标题/新正文/表格新值)',
|
|
840
|
+
surgSlide.includes('用户的新标题') && surgSlide.includes('用户新正文') && surgSlide.includes('甲') && surgSlide.includes('20') && !surgSlide.includes('模板标题'),
|
|
841
|
+
'slide1 内容替换缺失')
|
|
842
|
+
ok('v0.10.0:rPr 样式原样保留(只改 a:t——粗体 2800/颜色仍在)',
|
|
843
|
+
surgSlide.includes('sz="2800"') && surgSlide.includes('1E4E8C') || surgSlide.includes('sz="1600"'),
|
|
844
|
+
'rPr 属性被破坏')
|
|
845
|
+
const surgTplZip = zipRead(await (await import('node:fs/promises')).readFile(surgTplExp.file))
|
|
846
|
+
ok('v0.10.0:完整性验证——未手术条目内容 sha256 全部一致',
|
|
847
|
+
surgRes.verify.mismatched === 0 && surgRes.verify.identical > 0,
|
|
848
|
+
`${surgRes.verify.identical}/${surgRes.verify.total}`)
|
|
849
|
+
// 未手术页(slide2)内容与模板逐字节一致
|
|
850
|
+
const tplSlide2 = surgTplZip.get('ppt/slides/slide2.xml')
|
|
851
|
+
const outSlide2 = surgZip.get('ppt/slides/slide2.xml')
|
|
852
|
+
const { createHash } = await import('node:crypto')
|
|
853
|
+
ok('v0.10.0:未手术页 slide2 内容与模板一致(hash)', createHash('sha256').update(tplSlide2).digest('hex') === createHash('sha256').update(outSlide2).digest('hex'), 'slide2 变了!')
|
|
854
|
+
// 25.3 scanSlideXml 直接单元:槽收集(文本 sp 跳过 grpSp/无位置)
|
|
855
|
+
const scan1 = scanSlideXml(surgSlide)
|
|
856
|
+
ok('v0.10.0:scanSlideXml 槽收集(标题/正文/表格分列)', scan1.slots.length === 2 && scan1.tables.length === 1, `slots=${scan1.slots.length} tables=${scan1.tables.length}`)
|
|
857
|
+
// 25.4 有 Office 时:手术成品可被 PowerPoint 打开(COM 渲染无异常 = 结构合法)
|
|
858
|
+
if (hasOffice) {
|
|
859
|
+
const visSurg = join(root, 'examples', 'surg-rendered')
|
|
860
|
+
await rm(visSurg, { recursive: true, force: true })
|
|
861
|
+
try {
|
|
862
|
+
const rvSurg = await msMod.renderPptxToPng(surgOut, visSurg, { width: 960, height: 540, timeoutMs: 180000 })
|
|
863
|
+
ok('v0.10.0:手术成品 Office 可打开渲染(结构合法)', rvSurg.pages === 3, `pages=${rvSurg.pages}`)
|
|
864
|
+
} catch (e) {
|
|
865
|
+
console.log(`[v0.10.0] ⚠ Office COM 本次不可用(环境抖动,非失败):${String(e?.message ?? e).split('\n')[0].slice(0, 120)}`)
|
|
866
|
+
ok('v0.10.0:Office COM 抖动时降级跳过(不阻断自检)', true)
|
|
867
|
+
}
|
|
868
|
+
await rm(visSurg, { recursive: true, force: true })
|
|
869
|
+
} else {
|
|
870
|
+
ok('v0.10.0:无 Office 跳过成品渲染验证', true)
|
|
871
|
+
}
|
|
872
|
+
// 清理
|
|
873
|
+
await rm(surgOut, { force: true }).catch(() => {})
|
|
874
|
+
await rm(surgTplDir, { recursive: true, force: true })
|
|
875
|
+
await rm(surgWork, { recursive: true, force: true })
|
|
876
|
+
|
|
877
|
+
// ── 26. v0.11.0:候选 C——custGeom 几何闭环 + alpha 透明度 + 形状内文本提取 ──
|
|
878
|
+
const c11Deck = join(root, 'examples', 'c11-smoke')
|
|
879
|
+
await rm(c11Deck, { recursive: true, force: true })
|
|
880
|
+
await mkdir(join(c11Deck, 'pages'), { recursive: true })
|
|
881
|
+
await (await import('node:fs/promises')).writeFile(join(c11Deck, 'deck.yaml'), [
|
|
882
|
+
'version: 1', 'title: c11-smoke', 'size: [960, 540]',
|
|
883
|
+
'theme: {colors: {primary: "#2563EB", accent: "#0485A8"}}',
|
|
884
|
+
'pages:', ' - pages/01.yaml', '', ''].join('\n'))
|
|
885
|
+
await (await import('node:fs/promises')).writeFile(join(c11Deck, 'pages', '01.yaml'), [
|
|
886
|
+
'pageType: content',
|
|
887
|
+
'elements:',
|
|
888
|
+
' - elementId: ribbon', ' elementType: shape', ' kind: custGeom',
|
|
889
|
+
' bounds: [40, 40, 400, 160]',
|
|
890
|
+
' path:',
|
|
891
|
+
' w: 100000',
|
|
892
|
+
' h: 40000',
|
|
893
|
+
' commands:',
|
|
894
|
+
' - {cmd: moveTo, pts: [[0, 0]]}',
|
|
895
|
+
' - {cmd: cubicBezTo, pts: [[30000, 0], [70000, 40000], [100000, 40000]]}',
|
|
896
|
+
' - {cmd: arcTo, wR: 10000, hR: 10000, stAng: 0, swAng: 5400000}',
|
|
897
|
+
' - {cmd: close}',
|
|
898
|
+
' fill: {color: "#0485A8", alpha: 60}',
|
|
899
|
+
'', ''].join('\n'))
|
|
900
|
+
const c11Ctx = await resolveDeck(c11Deck)
|
|
901
|
+
const c11Exp = await exportPptx(c11Ctx, { out: 'out-c11.pptx', engine: 'pptd' })
|
|
902
|
+
const c11Zip = zipRead(await (await import('node:fs/promises')).readFile(c11Exp.file))
|
|
903
|
+
const c11Slide = c11Zip.get('ppt/slides/slide1.xml').toString('utf8')
|
|
904
|
+
ok('v0.11:导出 custGeom 落盘(path 命令 + arcTo + alpha)',
|
|
905
|
+
c11Slide.includes('<a:custGeom>') && c11Slide.includes('<a:cubicBezTo>') && c11Slide.includes('<a:arcTo wR="10000"') && c11Slide.includes('<a:alpha val="60000"/>'),
|
|
906
|
+
'custGeom/alpha XML 缺失')
|
|
907
|
+
// 二次导入回环:kind/path/alpha 无损
|
|
908
|
+
const c11Rt = join(root, 'examples', 'c11-imported')
|
|
909
|
+
await rm(c11Rt, { recursive: true, force: true })
|
|
910
|
+
await importPptx(c11Exp.file, c11Rt)
|
|
911
|
+
const c11RtCtx = await resolveDeck(c11Rt)
|
|
912
|
+
const c11El = c11RtCtx.pages[0].page.elements.find((e) => e.elementId === 'ribbon')
|
|
913
|
+
ok('v0.11:import 回环 custGeom/alpha 无损',
|
|
914
|
+
c11El?.kind === 'custGeom' && c11El?.fill?.alpha === 60 && c11El?.fill?.color === '#0485A8' && c11El?.path?.commands?.length === 4,
|
|
915
|
+
JSON.stringify({ kind: c11El?.kind, fill: c11El?.fill, cmds: c11El?.path?.commands?.length }))
|
|
916
|
+
// render 消费 custGeom(SVG path d 含弧转换)
|
|
917
|
+
const c11Render = await renderDeck(c11Ctx, {})
|
|
918
|
+
const c11HtmlPath = join(c11Render.outDir ?? join(c11Deck, 'preview'), c11Render.htmlFiles[0])
|
|
919
|
+
ok('v0.11:render 消费 custGeom(SVG path + 弧转换 A 命令)',
|
|
920
|
+
c11Render.htmlFiles.length === 1 && (await (await import('node:fs/promises')).readFile(c11HtmlPath, 'utf8')).includes('A 10000 10000'),
|
|
921
|
+
'SVG 弧命令缺失')
|
|
922
|
+
// 形状内文本提取形态(shape + text 同 bounds 居中)schema 接受
|
|
923
|
+
const c11TextDeck = join(root, 'examples', 'c11-text')
|
|
924
|
+
await rm(c11TextDeck, { recursive: true, force: true })
|
|
925
|
+
await mkdir(join(c11TextDeck, 'pages'), { recursive: true })
|
|
926
|
+
await (await import('node:fs/promises')).writeFile(join(c11TextDeck, 'deck.yaml'), [
|
|
927
|
+
'version: 1', 'title: c11-text', 'size: [960, 540]',
|
|
928
|
+
'theme: {colors: {primary: "#2563EB"}}',
|
|
929
|
+
'pages:', ' - pages/01.yaml', '', ''].join('\n'))
|
|
930
|
+
await (await import('node:fs/promises')).writeFile(join(c11TextDeck, 'pages', '01.yaml'), [
|
|
931
|
+
'pageType: content',
|
|
932
|
+
'elements:',
|
|
933
|
+
' - elementId: badge', ' elementType: shape', ' kind: ellipse',
|
|
934
|
+
' bounds: [100, 100, 80, 80]', ' fill: "#2563EB"',
|
|
935
|
+
' - elementId: badge_txt', ' elementType: text', ' bounds: [100, 100, 80, 80]',
|
|
936
|
+
' content: {text: "1", fontSize: 14, align: "center", color: "#FFFFFF"}', '', ''].join('\n'))
|
|
937
|
+
const c11TextCtx = await resolveDeck(c11TextDeck)
|
|
938
|
+
ok('v0.11:schema 接受形状内文本提取形态(shape+text 同 bounds)', c11TextCtx.pages.length === 1)
|
|
939
|
+
// validatePath 坏例:未知命令当场报错
|
|
940
|
+
const badPathDeck = join(root, 'examples', 'c11-bad')
|
|
941
|
+
await rm(badPathDeck, { recursive: true, force: true })
|
|
942
|
+
await mkdir(join(badPathDeck, 'pages'), { recursive: true })
|
|
943
|
+
await (await import('node:fs/promises')).writeFile(join(badPathDeck, 'deck.yaml'), 'version: 1\ntitle: bad\nsize: [960, 540]\npages:\n - pages/01.yaml\n')
|
|
944
|
+
await (await import('node:fs/promises')).writeFile(join(badPathDeck, 'pages', '01.yaml'), [
|
|
945
|
+
'pageType: content', 'elements:',
|
|
946
|
+
' - elementId: x', ' elementType: shape', ' kind: custGeom',
|
|
947
|
+
' bounds: [40, 40, 100, 100]',
|
|
948
|
+
' path: {commands: [{cmd: nope, pts: [[0, 0]]}]}', '', ''].join('\n'))
|
|
949
|
+
let badPathErr = null
|
|
950
|
+
try { await resolveDeck(badPathDeck) } catch (e) { badPathErr = e }
|
|
951
|
+
ok('v0.11:custGeom 未知命令当场报错(防呆)', badPathErr !== null && badPathErr.messages?.some((m) => m.includes('path.commands[0].cmd')), badPathErr?.messages?.join('; '))
|
|
952
|
+
// 清理
|
|
953
|
+
await rm(c11Rt, { recursive: true, force: true })
|
|
954
|
+
await rm(c11Deck, { recursive: true, force: true })
|
|
955
|
+
await rm(c11TextDeck, { recursive: true, force: true })
|
|
956
|
+
await rm(badPathDeck, { recursive: true, force: true })
|
|
957
|
+
|
|
958
|
+
// ── 27. v0.12.0 (M2):文本实测档——注入/提取单测 + 实测交叉三态 + 真实测量 ──
|
|
959
|
+
const { injectMeasureScript, extractMeasured, measureLayout } = await import('../lib/measurement.js')
|
|
960
|
+
const { measuredCrossCheck: mcc } = await import('../lib/verify.js')
|
|
961
|
+
// 27.1 注入/提取单测(script raw text + JSON 回环路)
|
|
962
|
+
const inj = injectMeasureScript('<html><head></head><body><div class="el" id="a" data-kind="text" style="width:100px">x</div></body></html>')
|
|
963
|
+
ok('v0.12:测量脚本注入(raw text 不转义 <)——script 原样嵌入且可提取',
|
|
964
|
+
inj.includes('__ppt_measured') && inj.includes('for(var i=0;i<els.length') && JSON.stringify(extractMeasured(`<html><head><script id="__ppt_measured" type="application/json">${JSON.stringify([{ id: 'a', lines: 2 }])}</script></head></html>`)) === JSON.stringify([{ id: 'a', lines: 2 }]))
|
|
965
|
+
// 27.2 实测交叉三态(估算漏报 → error;复现 → warning;估算过报 → relief warning)
|
|
966
|
+
// 【2026-09-18 修】夹具必须复刻**真契约**。此前这里两侧都写 `index: 1`,于是掩盖了一个真 bug:
|
|
967
|
+
// 真生产者底基不同(layout.json 由 render-html 取 schema 的 pages.length ⇒ 0 基;measured.json 由
|
|
968
|
+
// measurement 取 i+1 ⇒ 1 基),而 measuredCrossCheck 用 `index` 相等配对 ⇒ **永不配对、恒返空结果**,
|
|
969
|
+
// 整条 M2 通道在"报通过"(绿灯),且"实测无溢出"与"通道失效"在输出上不可区分。
|
|
970
|
+
// 现在跨档配对键是**两侧都写、都是 1 基**的 `pageNo`;`index` 退回"各自文件的内部数组下标"(都 0 基)。
|
|
971
|
+
// 下面这条夹具自证就是防"有人把夹具改回那个掩盖 bug 的形状"。
|
|
972
|
+
const xLayout = { pages: [{ pageNo: 1, index: 0, elements: [
|
|
973
|
+
{ id: 't1', kind: 'text', bounds: { x: 0, y: 0, w: 100, h: 50 }, metrics: { overflowY: 0, lines: 2 } },
|
|
974
|
+
{ id: 't2', kind: 'text', bounds: { x: 0, y: 0, w: 100, h: 50 }, metrics: { overflowY: 5, lines: 2 } },
|
|
975
|
+
{ id: 't3', kind: 'text', bounds: { x: 0, y: 0, w: 100, h: 50 }, metrics: { overflowY: 8, lines: 2 } },
|
|
976
|
+
] }] }
|
|
977
|
+
const xMeasured = { pages: [{ pageNo: 1, index: 0, elements: [
|
|
978
|
+
{ id: 't1', kind: 'text', overflowY: 6, lines: 3 }, // 估算 0 / 实测 6 → 漏报 error
|
|
979
|
+
{ id: 't2', kind: 'text', overflowY: 4, lines: 2 }, // 估算 5 / 实测 4 → 复现 warning
|
|
980
|
+
{ id: 't3', kind: 'text', overflowY: 0.5, lines: 2 }, // 估算 8 / 实测 0.5 → relief warning
|
|
981
|
+
] }] }
|
|
982
|
+
ok('v0.12:实测交叉夹具复刻真契约(两侧都带 1 基 pageNo 配对键,index 只是内部下标)',
|
|
983
|
+
xLayout.pages.every((p, i) => p.pageNo === i + 1) && xMeasured.pages.every((p, i) => p.pageNo === i + 1)
|
|
984
|
+
&& xLayout.pages.length === xMeasured.pages.length,
|
|
985
|
+
`L.pageNo=${xLayout.pages.map((p) => p.pageNo)}|M.pageNo=${xMeasured.pages.map((p) => p.pageNo)}`)
|
|
986
|
+
const xR = mcc(xLayout, xMeasured)
|
|
987
|
+
ok('v0.12:实测交叉——估算漏报 → error(M2 核心捕获)', xR.some((f) => f.code === 'measured-overflow' && f.severity === 'error' && f.id === 't1'), JSON.stringify(xR))
|
|
988
|
+
ok('v0.12:实测交叉——估算过报 → relief warning(三态判据:漏报 1/复现 1/relief 1)',
|
|
989
|
+
xR.filter((f) => f.code === 'measured-relief' && f.severity === 'warning').length === 1
|
|
990
|
+
&& xR.filter((f) => f.code === 'measured-overflow' && f.severity === 'warning').length === 1,
|
|
991
|
+
`relief=${xR.filter((f) => f.code === 'measured-relief').length} 复现=${xR.filter((f) => f.code === 'measured-overflow').length}`)
|
|
992
|
+
// 27.3 真实测量(2 页小工程;有浏览器时验证 measured.json 落盘与无溢出)
|
|
993
|
+
const m2Deck = join(root, 'examples', 'm2-smoke')
|
|
994
|
+
await rm(m2Deck, { recursive: true, force: true })
|
|
995
|
+
await mkdir(join(m2Deck, 'pages'), { recursive: true })
|
|
996
|
+
await (await import('node:fs/promises')).writeFile(join(m2Deck, 'deck.yaml'), 'version: 1\ntitle: m2\nsize: [960, 540]\ntheme: {colors: {primary: "#2563EB"}}\npages:\n - pages/01.yaml\n - pages/02.yaml\n')
|
|
997
|
+
await (await import('node:fs/promises')).writeFile(join(m2Deck, 'pages', '01.yaml'), [
|
|
998
|
+
'pageType: content', 'elements:',
|
|
999
|
+
' - elementId: t', ' elementType: text', ' bounds: [60, 60, 400, 50]',
|
|
1000
|
+
' content: {text: "短文本", fontSize: 18, color: "#2563EB"}', '', ''].join('\n'))
|
|
1001
|
+
await (await import('node:fs/promises')).writeFile(join(m2Deck, 'pages', '02.yaml'), [
|
|
1002
|
+
'pageType: content', 'elements:',
|
|
1003
|
+
' - elementId: t2', ' elementType: text', ' bounds: [60, 60, 300, 40]',
|
|
1004
|
+
' content: {text: "这是一段测试文本用于实测档验证", fontSize: 18, color: "#2563EB"}', '', ''].join('\n'))
|
|
1005
|
+
const m2r = await measureLayout(m2Deck)
|
|
1006
|
+
const { findEdge } = await import('../lib/measurement.js')
|
|
1007
|
+
const m2Browser = Boolean(findEdge())
|
|
1008
|
+
const m2Layout = m2Browser ? JSON.parse(await (await import('node:fs/promises')).readFile(join(m2Deck, 'preview', 'layout.json'), 'utf8')) : null
|
|
1009
|
+
// 注意:if / else **两支必须发出同样条数的断言**。原来 if 支 2 条、else 支 1 条,
|
|
1010
|
+
// 于是无浏览器机器上 smoke 总数会少 1,而 §37.10 的文档计数自证会因此**假红**(报"文档计数过期",
|
|
1011
|
+
// 其实只是环境不同)。这类"总数随环境漂移"本身就是同一族静默问题,一并修掉。
|
|
1012
|
+
if (m2Browser) {
|
|
1013
|
+
ok('v0.12:真实测量(2 页落盘 measured.json + 无估算/实测分歧)',
|
|
1014
|
+
m2r.pages === 2 && m2r.measured.pages.length === 2 && existsSync(join(m2Deck, 'preview', 'measured.json')),
|
|
1015
|
+
`pages=${m2r.pages} notes=${(m2r.notes ?? []).join(';')}`)
|
|
1016
|
+
const m2x = mcc(m2Layout, m2r.measured)
|
|
1017
|
+
ok('v0.12:真实测量交叉无分歧(短文本不溢出——**阴性断言,防误报**;通道是否真在比由 §27.5/27.6 的注入断言负责)',
|
|
1018
|
+
!m2x.some((f) => f.severity === 'error'), `出现实测 error:${JSON.stringify(m2x)}`)
|
|
1019
|
+
} else {
|
|
1020
|
+
ok('v0.12:无浏览器跳过真实测量(降级路径)', true)
|
|
1021
|
+
ok('v0.12:无浏览器跳过实测交叉(降级路径)', true)
|
|
1022
|
+
}
|
|
1023
|
+
// 27.4~27.7 真产物断言(2026-09-18 新增)。**必须在真产物上跑**——原 bug 的全部原因就是
|
|
1024
|
+
// "夹具照着函数期望的形状写、从没调用过真生产者"。无浏览器时按**跳过**计并写明标签:
|
|
1025
|
+
// 不伪装成通过,同时让断言总数保持恒定(否则 §37.10 的文档计数自证在无浏览器机器上会红)。
|
|
1026
|
+
const m2Real = (label, fn) => {
|
|
1027
|
+
if (!m2Browser) return ok(`${label}(无浏览器:跳过)`, true)
|
|
1028
|
+
const r = fn()
|
|
1029
|
+
return ok(label, r.ok, r.extra ?? '')
|
|
1030
|
+
}
|
|
1031
|
+
m2Real('v0.12:两档页号契约(真产物)——layout.json 与 measured.json 的 1 基 pageNo 一一对应', () => {
|
|
1032
|
+
const L = m2Layout.pages.map((p) => p.pageNo)
|
|
1033
|
+
const M = m2r.measured.pages.map((p) => p.pageNo)
|
|
1034
|
+
return {
|
|
1035
|
+
ok: L.length === 2 && M.length === 2 && L.every((n, i) => n === i + 1) && M.every((n, i) => n === i + 1) && L.join() === M.join(),
|
|
1036
|
+
extra: `layout=${JSON.stringify(L)}|measured=${JSON.stringify(M)}`,
|
|
1037
|
+
}
|
|
1038
|
+
})
|
|
1039
|
+
// 在**真产物**上只改一个元素的实测值:交叉核查必须捕获它。这条是"通道真的在比对"的判据——
|
|
1040
|
+
// 原来的"无分歧"断言是**否定式**的,通道死了也照样绿。
|
|
1041
|
+
const injectOverflowAt = (doc, pageNo, elId, px) => {
|
|
1042
|
+
const copy = JSON.parse(JSON.stringify(doc))
|
|
1043
|
+
const pg = (copy.pages ?? []).find((p) => p.pageNo === pageNo)
|
|
1044
|
+
const el = (pg?.elements ?? []).find((e) => e.id === elId)
|
|
1045
|
+
if (el) el.overflowY = px
|
|
1046
|
+
return copy
|
|
1047
|
+
}
|
|
1048
|
+
m2Real('v0.12:实测交叉捕获注入溢出(真产物·第 2 页元素 t2 注入 40px → 必须报 1 条 error)', () => {
|
|
1049
|
+
const x = mcc(m2Layout, injectOverflowAt(m2r.measured, 2, 't2', 40))
|
|
1050
|
+
return { ok: x.filter((f) => f.code === 'measured-overflow' && f.severity === 'error' && f.id === 't2').length === 1, extra: JSON.stringify(x) }
|
|
1051
|
+
})
|
|
1052
|
+
m2Real('v0.12:实测交叉页配对无错位(真产物·第 1 页元素 t 的注入必须落在第 1 页,不得与第 2 页串页)', () => {
|
|
1053
|
+
const x = mcc(m2Layout, injectOverflowAt(m2r.measured, 1, 't', 40))
|
|
1054
|
+
return { ok: x.filter((f) => f.code === 'measured-overflow' && f.id === 't').length === 1, extra: JSON.stringify(x) }
|
|
1055
|
+
})
|
|
1056
|
+
m2Real('v0.12:缺 pageNo 的旧版 measured.json → 报 measured-unpaired(契约破坏必须响亮,不得静默返空)', () => {
|
|
1057
|
+
const stale = JSON.parse(JSON.stringify(m2r.measured))
|
|
1058
|
+
for (const p of stale.pages) delete p.pageNo
|
|
1059
|
+
const x = mcc(m2Layout, stale)
|
|
1060
|
+
return { ok: x.length === 2 && x.every((f) => f.code === 'measured-unpaired' && f.severity === 'error'), extra: JSON.stringify(x) }
|
|
1061
|
+
})
|
|
1062
|
+
await rm(m2Deck, { recursive: true, force: true })
|
|
1063
|
+
|
|
1064
|
+
// ── 28. v0.13.0 (M3):数据连贯——跨页数字对账 + source 证据核查表 + themeRef 断言 ──
|
|
1065
|
+
const { crosscheckDeck, crosscheckReport } = await import('../lib/crosscheck.js')
|
|
1066
|
+
const ccDeck = join(root, 'examples', 'cc-smoke')
|
|
1067
|
+
await rm(ccDeck, { recursive: true, force: true })
|
|
1068
|
+
await mkdir(join(ccDeck, 'pages'), { recursive: true })
|
|
1069
|
+
await (await import('node:fs/promises')).writeFile(join(ccDeck, 'deck.yaml'), [
|
|
1070
|
+
'version: 1', 'title: cc', 'size: [960, 540]',
|
|
1071
|
+
'theme: {colors: {primary: "#2563EB"}, textStyles: {body: {fontSize: 14}}}',
|
|
1072
|
+
'pages:', ' - pages/01.yaml', ' - pages/02.yaml', '', ''].join('\n'))
|
|
1073
|
+
await (await import('node:fs/promises')).writeFile(join(ccDeck, 'pages', '01.yaml'), [
|
|
1074
|
+
'pageType: content', 'source: "公司财报 2026 年报"',
|
|
1075
|
+
'elements:',
|
|
1076
|
+
' - elementId: n1', ' elementType: text', ' bounds: [60, 60, 400, 40]',
|
|
1077
|
+
' content: {text: "本季度营收 45.6% 增长", fontSize: 14}',
|
|
1078
|
+
'', ''].join('\n'))
|
|
1079
|
+
await (await import('node:fs/promises')).writeFile(join(ccDeck, 'pages', '02.yaml'), [
|
|
1080
|
+
'pageType: content',
|
|
1081
|
+
'elements:',
|
|
1082
|
+
' - elementId: n2', ' elementType: text', ' bounds: [60, 60, 400, 40]',
|
|
1083
|
+
' content: {text: "上半年营收增长 45.6%,全年 2026 展望", fontSize: 14}',
|
|
1084
|
+
' - elementId: n3', ' elementType: text', ' bounds: [60, 120, 400, 40]',
|
|
1085
|
+
' content: {text: "另一指标 52%", fontSize: 14}',
|
|
1086
|
+
'', ''].join('\n'))
|
|
1087
|
+
const ccCtx = await resolveDeck(ccDeck)
|
|
1088
|
+
const cc = crosscheckDeck(ccCtx)
|
|
1089
|
+
ok('v1.0.3:材料包按阅读顺序收全页正文(2 页;数字原文照录,不再产出"跨页数字分组")',
|
|
1090
|
+
cc.pages.length === 2
|
|
1091
|
+
&& cc.pages[0].texts.some((t) => t.text.includes('45.6%'))
|
|
1092
|
+
&& cc.pages[1].texts.some((t) => t.text.includes('45.6%'))
|
|
1093
|
+
&& cc.pages[1].texts.some((t) => t.text.includes('52%'))
|
|
1094
|
+
&& cc.groups === undefined,
|
|
1095
|
+
cc.pages.map((p) => `P${p.index}:${p.texts.length} 段文字`).join(' '))
|
|
1096
|
+
const ccReport = crosscheckReport(ccCtx)
|
|
1097
|
+
ok('v1.0.3:材料包不做判定(无 status/groups)——出处标"作者自述(未核实)"、含素材清单+四分类协议、完整包落盘',
|
|
1098
|
+
cc.pages.every((p) => p.status === undefined)
|
|
1099
|
+
&& cc.pages[0].authorSource === '公司财报 2026 年报'
|
|
1100
|
+
&& /作者自述出处/.test(ccReport)
|
|
1101
|
+
&& /可核对的外部素材/.test(ccReport)
|
|
1102
|
+
&& ['一致', '冲突', '无来源支撑', '无法核实'].every((k) => ccReport.includes(k))
|
|
1103
|
+
&& /不要使用子代理/.test(ccReport)
|
|
1104
|
+
&& existsSync(join(ccDeck, 'preview', 'review-pack.md')),
|
|
1105
|
+
`四分类齐=${['一致', '冲突', '无来源支撑', '无法核实'].every((k) => ccReport.includes(k))};包=preview/review-pack.md`)
|
|
1106
|
+
// themeRef 断言:缺失 $ref → resolveDeck 报错(M3 防静默回退)
|
|
1107
|
+
const refDeck = join(root, 'examples', 'ref-smoke')
|
|
1108
|
+
await rm(refDeck, { recursive: true, force: true })
|
|
1109
|
+
await mkdir(join(refDeck, 'pages'), { recursive: true })
|
|
1110
|
+
await (await import('node:fs/promises')).writeFile(join(refDeck, 'deck.yaml'), [
|
|
1111
|
+
'version: 1', 'title: ref', 'size: [960, 540]',
|
|
1112
|
+
'theme: {colors: {primary: "#2563EB"}}',
|
|
1113
|
+
'pages:', ' - pages/01.yaml', '', ''].join('\n'))
|
|
1114
|
+
await (await import('node:fs/promises')).writeFile(join(refDeck, 'pages', '01.yaml'), [
|
|
1115
|
+
'pageType: content', 'elements:',
|
|
1116
|
+
' - elementId: x', ' elementType: text', ' bounds: [60, 60, 200, 30]',
|
|
1117
|
+
' content: {text: "bad ref", color: "$ghost"}', '', ''].join('\n'))
|
|
1118
|
+
let refErr = null
|
|
1119
|
+
try { await resolveDeck(refDeck) } catch (e) { refErr = e }
|
|
1120
|
+
ok('v0.13:themeRef 断言——缺失 $ref 报错(防字面 $xxx 上屏)', refErr !== null && refErr.messages?.some((m) => m.includes('$ghost')), refErr?.messages?.join('; '))
|
|
1121
|
+
// 正确引用不报
|
|
1122
|
+
await (await import('node:fs/promises')).writeFile(join(refDeck, 'pages', '01.yaml'), [
|
|
1123
|
+
'pageType: content', 'elements:',
|
|
1124
|
+
' - elementId: x', ' elementType: text', ' bounds: [60, 60, 200, 30]',
|
|
1125
|
+
' content: {text: "ok ref", color: "$primary"}', '', ''].join('\n'))
|
|
1126
|
+
await resolveDeck(refDeck)
|
|
1127
|
+
ok('v0.13:themeRef 断言——正确引用通过', true)
|
|
1128
|
+
// 清理
|
|
1129
|
+
await rm(ccDeck, { recursive: true, force: true })
|
|
1130
|
+
await rm(refDeck, { recursive: true, force: true })
|
|
1131
|
+
|
|
1132
|
+
// ── 29. v0.14.2/5:预览路由双重防线(表判据 + refcount + async 注册/卸载)────────
|
|
1133
|
+
const { registerPreviewRoute: rpr } = await import('../lib/preview-server.js')
|
|
1134
|
+
const routeCalls = []
|
|
1135
|
+
const fakePrefixes = new Map()
|
|
1136
|
+
const fakeWs = {
|
|
1137
|
+
prefixes: fakePrefixes, // 真实语义:webServer 暴露前缀表(hasRoute 判据)
|
|
1138
|
+
register: async (r) => {
|
|
1139
|
+
routeCalls.push(r.path)
|
|
1140
|
+
fakePrefixes.set(r.path, { kind: r.kind, path: r.path, handler: r.handler })
|
|
1141
|
+
return async () => { fakePrefixes.delete(r.path) } // 卸载也是 async(真实语义)
|
|
1142
|
+
},
|
|
1143
|
+
}
|
|
1144
|
+
// Cordis 语义(2026-09-14 用真库探针实测,见 docs/02 §2.12):`ctx.effect(cb)` **立即执行 cb**,
|
|
1145
|
+
// 并把 **cb 的返回值**当作 disposer(不是把 cb 当清理函数)。旧假 ctx 写反了这条契约,
|
|
1146
|
+
// 于是"清理逻辑写在 cb 体里"的 preview-server 在这个假环境里看起来是对的、在真宿主里却是
|
|
1147
|
+
// "注册当刻就把路由取消掉"——假契约掩盖了真 bug。这里按真语义重建假 ctx。
|
|
1148
|
+
const fakeCtx = { get: () => fakeWs, effect: (cb) => { const cleanup = cb(); return () => { if (typeof cleanup === 'function') cleanup() } } }
|
|
1149
|
+
const settle = () => new Promise((r) => setTimeout(r, 50))
|
|
1150
|
+
const d1 = rpr(fakeCtx)
|
|
1151
|
+
const d2 = rpr(fakeCtx) // 双源第二实例:应幂等(0 新注册)
|
|
1152
|
+
await settle()
|
|
1153
|
+
ok('v0.14.2/5:路由幂等——双实例只注册一次(表 1 条,无 duplicate)',
|
|
1154
|
+
routeCalls.length === 1 && fakePrefixes.has('/ppt-preview'),
|
|
1155
|
+
`calls=${routeCalls.join(',')}`)
|
|
1156
|
+
d1()
|
|
1157
|
+
await settle()
|
|
1158
|
+
ok('v0.14.2/5:单一 dispose 不卸载(refcount>0,路由仍在)', fakePrefixes.has('/ppt-preview'))
|
|
1159
|
+
d2()
|
|
1160
|
+
await settle()
|
|
1161
|
+
ok('v0.14.2/5:最后 dispose 真正卸载(await 卸载后表清空)', !fakePrefixes.has('/ppt-preview'))
|
|
1162
|
+
// 表判据兜底:模拟残留路由(早于本次实例的异步卸载未完成)→ 调用不重复注册
|
|
1163
|
+
fakePrefixes.set('/ppt-preview', { kind: 'prefix', path: '/ppt-preview', handler: null })
|
|
1164
|
+
const d3 = rpr(fakeCtx)
|
|
1165
|
+
await settle()
|
|
1166
|
+
ok('v0.14.2/5:表判据兜底——残留路由存在时不重复注册(防 duplicate 根源)', routeCalls.length === 1 && fakePrefixes.has('/ppt-preview'))
|
|
1167
|
+
d3()
|
|
1168
|
+
await settle()
|
|
1169
|
+
|
|
1170
|
+
// ══ 30. v0.14.6:autodeclare yaml@2 回归(反馈 A:doc.get() 返回 YAMLSeq → toJS 归一)═══════════
|
|
1171
|
+
const { applyAutoDeclare: applyAD } = await import('../lib/autodeclare.js')
|
|
1172
|
+
const YAML = (await import('yaml')).default
|
|
1173
|
+
const autoDeck = join(root, 'examples', 'autodeclare-smoke')
|
|
1174
|
+
const fsp = await import('node:fs/promises')
|
|
1175
|
+
await rm(autoDeck, { recursive: true, force: true })
|
|
1176
|
+
await mkdir(join(autoDeck, 'pages'), { recursive: true })
|
|
1177
|
+
await fsp.writeFile(join(autoDeck, 'deck.yaml'), [
|
|
1178
|
+
'version: 1', 'title: autodeclare regression', 'size: [960, 540]',
|
|
1179
|
+
'theme:',
|
|
1180
|
+
' colors: {primary: "#3E4E63", accent: "#D64253", bg: "#F5F6F7", text: "#2B3440"}',
|
|
1181
|
+
' textStyles: {title: {fontSize: 24, color: "$text"}, body: {fontSize: 14, color: "$text"}}',
|
|
1182
|
+
'pages:', ' - pages/01.yaml', ' - pages/02.yaml', '', ''].join('\n'))
|
|
1183
|
+
// 流式风格(`- {pair: [...]}`,用户 round-1 页面同款):band×title 已声明,band2×stamp 未声明
|
|
1184
|
+
await fsp.writeFile(join(autoDeck, 'pages', '01.yaml'), [
|
|
1185
|
+
'pageType: cover', 'background: "$bg"', 'elements:',
|
|
1186
|
+
' - elementId: band', ' elementType: shape', ' kind: rect', ' bounds: [0, 0, 960, 110]', ' fill: "$primary"',
|
|
1187
|
+
' - elementId: title', ' elementType: text', ' bounds: [40, 30, 600, 70]', ' content: {style: "$title", text: "萨尔浒之战"}',
|
|
1188
|
+
' - elementId: band2', ' elementType: shape', ' kind: rect', ' bounds: [0, 480, 960, 60]', ' fill: "$accent"',
|
|
1189
|
+
' - elementId: stamp', ' elementType: text', ' bounds: [30, 492, 300, 40]', ' content: {style: "$body", text: "内容页签"}',
|
|
1190
|
+
'expectedOverlaps:', ' - {pair: [band, title]}', '', ''].join('\n'))
|
|
1191
|
+
// 块式风格(`- pair: [...]`):card×card-title 已声明,bar×bar-label 未声明
|
|
1192
|
+
await fsp.writeFile(join(autoDeck, 'pages', '02.yaml'), [
|
|
1193
|
+
'pageType: content', 'background: "$bg"', 'elements:',
|
|
1194
|
+
' - elementId: card', ' elementType: shape', ' kind: rect', ' bounds: [40, 60, 880, 140]', ' fill: "$primary"',
|
|
1195
|
+
' - elementId: card-title', ' elementType: text', ' bounds: [70, 90, 400, 60]', ' content: {style: "$title", text: "第一列"}',
|
|
1196
|
+
' - elementId: bar', ' elementType: shape', ' kind: rect', ' bounds: [40, 240, 880, 40]', ' fill: "$accent"',
|
|
1197
|
+
' - elementId: bar-label', ' elementType: text', ' bounds: [70, 246, 300, 30]', ' content: {style: "$body", text: "标注"}',
|
|
1198
|
+
'expectedOverlaps:', ' - pair: [card, card-title]', '', ''].join('\n'))
|
|
1199
|
+
const autoCtx = await resolveDeck(autoDeck)
|
|
1200
|
+
const autoLayout = (await renderDeck(autoCtx, {})).layout
|
|
1201
|
+
let autoThrew = null
|
|
1202
|
+
let autoAdded = []
|
|
1203
|
+
try { autoAdded = await applyAD(autoCtx, autoLayout) } catch (e) { autoThrew = e }
|
|
1204
|
+
ok('v0.14.6: autodeclare yaml@2 不再崩溃(doc.get()=YAMLSeq → toJS 归一,原 (.map is not a function))',
|
|
1205
|
+
autoThrew === null, autoThrew ? String(autoThrew).slice(0, 120) : '')
|
|
1206
|
+
ok('v0.14.6: 自动声明补齐未声明对(流式页 1 对 + 块式页 1 对)',
|
|
1207
|
+
autoAdded.length === 2 && autoAdded.every((a) => a.added === 1),
|
|
1208
|
+
JSON.stringify(autoAdded))
|
|
1209
|
+
const declCount = async (n) => (YAML.parse(await fsp.readFile(join(autoDeck, 'pages', n === 1 ? '01.yaml' : '02.yaml'), 'utf8')).expectedOverlaps ?? []).length
|
|
1210
|
+
ok('v0.14.6: 声明已写入文件(两种写法等价,各自 2 对)', (await declCount(1)) === 2 && (await declCount(2)) === 2,
|
|
1211
|
+
`01=${await declCount(1)} 02=${await declCount(2)}`)
|
|
1212
|
+
const reCtx = await resolveDeck(autoDeck)
|
|
1213
|
+
const reLayout = (await renderDeck(reCtx, {})).layout
|
|
1214
|
+
const reAdded = await applyAD(reCtx, reLayout)
|
|
1215
|
+
ok('v0.14.6: 幂等——重复运行零新增(已声明对全部跳过)', reAdded.length === 0, JSON.stringify(reAdded))
|
|
1216
|
+
await rm(autoDeck, { recursive: true, force: true })
|
|
1217
|
+
|
|
1218
|
+
// ══ 31. v0.15.0:反馈二——B1 元素级样式键 / A6 加粗CJK字宽 / A8 承载面 / C3 内边距降噪 / A3 splice+slice ═══════════
|
|
1219
|
+
const { charWidth } = await import('../lib/pptd/layout.js')
|
|
1220
|
+
ok('v0.15.0 A6: CJK 加粗字宽 ×1.06(微软雅黑粗体实测 1.03-1.07)',
|
|
1221
|
+
charWidth('莎', 12, true) === 12 * 1.06 && charWidth('莎', 12, false) === 12,
|
|
1222
|
+
`bold=${charWidth('莎', 12, true)} plain=${charWidth('莎', 12, false)}`)
|
|
1223
|
+
// B1:元素级样式键 → resolveDeck 报错 + 明确指引(此前静默忽略按 18pt 度量)
|
|
1224
|
+
const b1Deck = join(root, 'examples', 'b1-smoke')
|
|
1225
|
+
await rm(b1Deck, { recursive: true, force: true })
|
|
1226
|
+
await mkdir(join(b1Deck, 'pages'), { recursive: true })
|
|
1227
|
+
await fsp.writeFile(join(b1Deck, 'deck.yaml'), ['version: 1', 'title: b1', 'size: [960, 540]', 'pages:', ' - pages/01.yaml', '', ''].join('\n'))
|
|
1228
|
+
await fsp.writeFile(join(b1Deck, 'pages', '01.yaml'), [
|
|
1229
|
+
'pageType: content', 'elements:',
|
|
1230
|
+
' - elementId: t1', ' elementType: text', ' bounds: [60, 60, 400, 40]',
|
|
1231
|
+
' fontSize: 14', ' content: {text: "误写"}', '', ''].join('\n'))
|
|
1232
|
+
let b1Err = null
|
|
1233
|
+
try { await resolveDeck(b1Deck) } catch (e) { b1Err = e }
|
|
1234
|
+
ok('v0.15.0 B1: 元素级样式键 → 校验报错 + 指引(content 内部写法)',
|
|
1235
|
+
b1Err !== null && b1Err.messages?.some((m) => m.includes('样式必须写在 content 内部')),
|
|
1236
|
+
b1Err?.messages?.join('; ').slice(0, 160))
|
|
1237
|
+
await rm(b1Deck, { recursive: true, force: true })
|
|
1238
|
+
// A8:渐变承载面 → 对比度以中途 stop 为基(浅字深底不再误报背景)
|
|
1239
|
+
const a8Layout = {
|
|
1240
|
+
size: { width: 960, height: 540 },
|
|
1241
|
+
theme: { colors: {} },
|
|
1242
|
+
pages: [{
|
|
1243
|
+
index: 0, name: 'a8', id: 'a8',
|
|
1244
|
+
elements: [
|
|
1245
|
+
{ id: 'box', kind: 'shape', bounds: { x: 40, y: 40, w: 400, h: 200 }, fill: { type: 'gradient', stops: [{ pos: 0, color: '#3E4E63' }, { pos: 100, color: '#FFFFFF' }] } },
|
|
1246
|
+
{ id: 'txt', kind: 'text', bounds: { x: 60, y: 60, w: 300, h: 60 }, text: '白字', style: { fontSize: 14, color: '#FFFFFF' } },
|
|
1247
|
+
],
|
|
1248
|
+
expectedOverlaps: [{ pair: ['box', 'txt'] }],
|
|
1249
|
+
}],
|
|
1250
|
+
}
|
|
1251
|
+
const vA8 = verifyDeck(a8Layout).text
|
|
1252
|
+
ok('v0.15.0 A8: 渐变承载面被识别(白字 vs 深浅渐变中心 → 无对比度误报)',
|
|
1253
|
+
!vA8.includes('aesthetic-contrast'),
|
|
1254
|
+
vA8.split('\n').filter((l) => l.includes('aesthetic-contrast')).join('; ').slice(0, 120))
|
|
1255
|
+
// C3:文字在形体内(内边距 ≤6px)→ 不再报 near-align
|
|
1256
|
+
const c3Layout = {
|
|
1257
|
+
size: { width: 960, height: 540 },
|
|
1258
|
+
theme: { colors: {} },
|
|
1259
|
+
pages: [{
|
|
1260
|
+
index: 0, name: 'c3', id: 'c3',
|
|
1261
|
+
elements: [
|
|
1262
|
+
{ id: 'card', kind: 'shape', bounds: { x: 100, y: 100, w: 500, h: 200 }, fill: '#F5F6F7' },
|
|
1263
|
+
{ id: 'lab', kind: 'text', bounds: { x: 104, y: 104, w: 492, h: 192 }, text: '内边距 4px', style: { fontSize: 14, color: '#333333' } },
|
|
1264
|
+
],
|
|
1265
|
+
expectedOverlaps: [{ pair: ['card', 'lab'] }],
|
|
1266
|
+
}],
|
|
1267
|
+
}
|
|
1268
|
+
const vC3 = verifyDeck(c3Layout).text
|
|
1269
|
+
ok('v0.15.0 C3: 内边距类 near-align 豁免(包含关系成对 = 有意内边距)',
|
|
1270
|
+
!vC3.includes('near-align') && vC3.includes('预期重叠'), (vC3.match(/near-align/g) ?? []).length + ' hits')
|
|
1271
|
+
// A3:splice/slice zip 级回归(seed = smoke 导出 3 页;splice 页2 → 仅 2 条目变化;slice → 1 页)
|
|
1272
|
+
const { spliceIntoSource, sliceSource, zipDigests } = await import('../lib/splice.js')
|
|
1273
|
+
const seedCtx = await resolveDeck(smokeDir)
|
|
1274
|
+
const seedOut = join(smokeDir, 'out-splice-smoke.pptx')
|
|
1275
|
+
await (await import('node:fs/promises')).rm(seedOut, { force: true })
|
|
1276
|
+
await exportPptx(seedCtx, { out: seedOut, engine: 'pptd' })
|
|
1277
|
+
const spl = await spliceIntoSource({ deckDir: smokeDir, source: seedOut, page: 1, sourcePage: 2, out: join(smokeDir, 'out-splice-smoke-spliced.pptx') })
|
|
1278
|
+
const seedBuf = await (await import('node:fs/promises')).readFile(seedOut)
|
|
1279
|
+
const splBuf = await (await import('node:fs/promises')).readFile(spl.out)
|
|
1280
|
+
const seedD = zipDigests(seedBuf)
|
|
1281
|
+
const splD = zipDigests(splBuf)
|
|
1282
|
+
const delta = [...seedD.keys()].filter((k) => seedD.get(k) !== splD.get(k))
|
|
1283
|
+
ok('v0.15.0 A3: splice 只改变目标页 2 条目(其余 SHA256 不变)',
|
|
1284
|
+
delta.length === 2 && delta.every((k) => k.startsWith('ppt/slides/slide2') || k.startsWith('ppt/slides/_rels/slide2')), JSON.stringify(delta))
|
|
1285
|
+
const sl = await sliceSource({ source: spl.out, page: 2, out: join(smokeDir, 'out-splice-smoke-single.pptx') })
|
|
1286
|
+
const zS = zipRead(await (await import('node:fs/promises')).readFile(sl.out))
|
|
1287
|
+
const slideEntries = [...zS.keys()].filter((k) => /^ppt\/slides\/slide\d+\.xml$/.test(k))
|
|
1288
|
+
const sldLst = decodeXml(zS.get('ppt/presentation.xml')).match(/<p:sldIdLst>[\s\S]*?<\/p:sldIdLst>/)?.[0] ?? ''
|
|
1289
|
+
ok('v0.15.0 A3: slice 单页化(1 张 slide + sldIdLst 单条且闭合)',
|
|
1290
|
+
slideEntries.length === 1 && (sldLst.match(/<p:sldId\b[^>]*\/?>/g) ?? []).length === 1,
|
|
1291
|
+
sldLst.slice(0, 120))
|
|
1292
|
+
await (await import('node:fs/promises')).rm(seedOut, { force: true })
|
|
1293
|
+
await (await import('node:fs/promises')).rm(spl.out, { force: true })
|
|
1294
|
+
await (await import('node:fs/promises')).rm(sl.out, { force: true })
|
|
1295
|
+
|
|
1296
|
+
// ══ 32. v1.0.0-修订:P1 表格导出结构回归 + parity + P6 线真实相交(一致性)═══════════
|
|
1297
|
+
const { collectDeclarable } = await import('../lib/verify.js')
|
|
1298
|
+
// P1:带表格 deck 导出 → graphicFrame 无嵌套 a:xfrm + tableStyleId + parity 完整
|
|
1299
|
+
const tDeck = join(root, 'examples', 'table-smoke')
|
|
1300
|
+
await rm(tDeck, { recursive: true, force: true })
|
|
1301
|
+
await mkdir(join(tDeck, 'pages'), { recursive: true })
|
|
1302
|
+
await fsp.writeFile(join(tDeck, 'deck.yaml'), ['version: 1', 'title: tbl', 'size: [960, 540]', 'pages:', ' - pages/01.yaml', '', ''].join('\n'))
|
|
1303
|
+
await fsp.writeFile(join(tDeck, 'pages', '01.yaml'), [
|
|
1304
|
+
'pageType: content', 'elements:',
|
|
1305
|
+
' - elementId: tbl', ' elementType: table', ' bounds: [60, 100, 500, 240]',
|
|
1306
|
+
' cols: [维度, 指标A, 指标B]',
|
|
1307
|
+
' rows:', ' - [效率, 45.6%, -12%]', ' - [成本, +8%, -3%]', '', ''].join('\n'))
|
|
1308
|
+
const tCtx = await resolveDeck(tDeck)
|
|
1309
|
+
const tExp = await exportPptx(tCtx, { out: join(tDeck, 't.pptx'), engine: 'pptd' })
|
|
1310
|
+
const tZip = zipRead(await fsp.readFile(tExp.file))
|
|
1311
|
+
let tBad = 0
|
|
1312
|
+
let tStyled = 0
|
|
1313
|
+
for (const [k, v] of tZip) {
|
|
1314
|
+
if (!/^ppt\/slides\/slide\d+\.xml$/.test(k)) continue
|
|
1315
|
+
const x = decodeXml(v)
|
|
1316
|
+
if (x.includes('<p:xfrm><a:xfrm>')) tBad++
|
|
1317
|
+
if (x.includes('<a:tableStyleId>')) tStyled++
|
|
1318
|
+
}
|
|
1319
|
+
ok('v1.0.0-修订 P1: 表格 graphicFrame 无嵌套 a:xfrm(PowerPoint 弃帧根源)', tBad === 0, `bad=${tBad}`)
|
|
1320
|
+
ok('v1.0.0-修订 P1: 表槽含 tableStyleId(部分版本弃帧的第二根源)', tStyled >= 1, `styled=${tStyled}`)
|
|
1321
|
+
ok('v1.0.0-修订 P1: 导出 parity 自证(表 1/1 · 图 0/0 · 结构合法)', tExp.parity?.ok === true, JSON.stringify(tExp.parity))
|
|
1322
|
+
await rm(tDeck, { recursive: true, force: true })
|
|
1323
|
+
// P6:线元素真实相交——AABB 假阳性不算重叠/不进声明;真跨越仍报/可声明
|
|
1324
|
+
const p6Layout = {
|
|
1325
|
+
size: { width: 960, height: 540 },
|
|
1326
|
+
theme: { colors: {} },
|
|
1327
|
+
pages: [{
|
|
1328
|
+
index: 0, name: 'p6', id: 'p6',
|
|
1329
|
+
elements: [
|
|
1330
|
+
{ id: 'diag', kind: 'line', points: [[50, 300], [400, 50]], bounds: { x: 50, y: 50, w: 350, h: 250 }, line: { color: '#000000', width: 1 } },
|
|
1331
|
+
{ id: 'miss', kind: 'shape', bounds: { x: 330, y: 120, w: 50, h: 50 }, fill: '#3E4E63' }, // 仅 AABB 相交(线段从上方掠过)
|
|
1332
|
+
{ id: 'hit', kind: 'shape', bounds: { x: 120, y: 180, w: 80, h: 60 }, fill: '#D64253' }, // 线段真穿过
|
|
1333
|
+
],
|
|
1334
|
+
}],
|
|
1335
|
+
}
|
|
1336
|
+
const vP6 = verifyDeck(p6Layout).text
|
|
1337
|
+
const decP6 = collectDeclarable(p6Layout.pages[0], p6Layout.size)
|
|
1338
|
+
const keyOf = (a, b) => [a, b].sort().join(' × ')
|
|
1339
|
+
ok('v1.0.0-修订 P6: AABB 假阳性不算重叠(diag×miss 无错误)',
|
|
1340
|
+
!vP6.includes('diag') || !/- \[✗\]/.test(vP6.split('\n').find((l) => l.includes('diag') && l.includes('miss')) ?? ''), '')
|
|
1341
|
+
ok('v1.0.0-修订 P6: 真跨越仍报错(diag×hit unexpected-overlap)',
|
|
1342
|
+
vP6.includes('diag') && vP6.includes('hit') && vP6.includes('unexpected-overlap'),
|
|
1343
|
+
vP6.split('\n').filter((l) => l.includes('unexpected-overlap')).slice(0, 1).join('; ').slice(0, 100))
|
|
1344
|
+
ok('v1.0.0-修订 P6: collectDeclarable 与判据一致(只建议 diag×hit)',
|
|
1345
|
+
decP6.length === 1 && decP6[0].join(' × ') === keyOf('diag', 'hit'), JSON.stringify(decP6))
|
|
1346
|
+
|
|
1347
|
+
// ── 33. v1.0.0-适配 DSH 0.1.5-rc.2:会话身份取用 + 内嵌手册 skill + 语义路由判据 ──
|
|
1348
|
+
// 背景(真实进程实测):0.1.5-rc.2 只提供 `agents` 服务、**没有** `agent` 服务——旧代码 `ctx.get('agent')`
|
|
1349
|
+
// 恒为 undefined(会话级设置被静默忽略、ppt_state 落到 'default')。官方通道是 execute(args, exec).agent。
|
|
1350
|
+
// 另一处:旧意图判据要求"动词紧邻名词",真实说法「帮我做一个 5 页的产品介绍 PPT」判为无意向 →
|
|
1351
|
+
// 工作流提示词从不注入(装配探针实测:28 次 system-prompt/assemble 全部没有 ppt-workflow 段)。
|
|
1352
|
+
const { isPptIntent: rIntent, isPptOff: rOff } = await import('../lib/router.js')
|
|
1353
|
+
const { parseManual, MANUAL_FILE, MANUAL_NAME } = await import('../lib/skill.js')
|
|
1354
|
+
const { readFileSync } = await import('node:fs')
|
|
1355
|
+
|
|
1356
|
+
ok('适配·意图判据:修饰语夹在动宾之间也算 PPT 任务(旧实现对漏报)',
|
|
1357
|
+
rIntent('帮我做一个 5 页的产品介绍 PPT。', []) && rIntent('帮我做一个包含市场分析与财务预测的产品介绍 PPT', []) &&
|
|
1358
|
+
rIntent('先帮我做个 PPT', []), '')
|
|
1359
|
+
ok('适配·意图判据:名词在前 / 编辑 / 总结 / 英文 deck 均命中',
|
|
1360
|
+
rIntent('这份 PPT 帮我改改', []) && rIntent('把这个演示文稿统一一下配色', []) &&
|
|
1361
|
+
rIntent('把这份幻灯片总结成 3 页', []) && rIntent('deck 里第 4 页重做一下', []), '')
|
|
1362
|
+
ok('适配·意图判据:弱信号不切(只提一句 PPT 不激活)',
|
|
1363
|
+
!rIntent('PPT 是什么', []) && !rIntent('今天开会讨论了这个项目', []) && !rIntent('帮我写一段 Python 脚本', []), '')
|
|
1364
|
+
ok('适配·意图判据:附件是 .pptx 直接视为强意图',
|
|
1365
|
+
rIntent('', [{ name: 'orig.pptx' }]) && rIntent('改一下', [{ name: 'x.pptx' }]), '')
|
|
1366
|
+
ok('适配·退出判据:否定/停止/无关(旧实现漏"退出 PPT"/"不用做 PPT 了")',
|
|
1367
|
+
rOff('不用做 PPT 了') && rOff('退出 PPT') && rOff('这跟 PPT 无关') && rOff('PPT 不做了'), '')
|
|
1368
|
+
const stripComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
|
1369
|
+
ok("适配·会话身份:代码里不再使用 DSH 不存在的 ctx.get('agent')(注释里的历史说明不算)",
|
|
1370
|
+
!/ctx\.get\('agent'\)/.test(stripComments(readFileSync(join(root, 'src', 'index.js'), 'utf8'))) &&
|
|
1371
|
+
!/ctx\.get\('agent'\)/.test(stripComments(readFileSync(join(root, 'src', 'tools.js'), 'utf8'))), '')
|
|
1372
|
+
ok('适配·会话身份:走 execute(args, exec) 的 exec.agent(状态工具 + 两处质量档查询)',
|
|
1373
|
+
/exec\?\.agent/.test(readFileSync(join(root, 'src', 'index.js'), 'utf8')) &&
|
|
1374
|
+
(readFileSync(join(root, 'src', 'tools.js'), 'utf8').match(/qualityOfDiag\(exec\?\.agent, dir\)/g) ?? []).length === 2, '')
|
|
1375
|
+
|
|
1376
|
+
const skillSrc = readFileSync(join(root, 'src', 'skill.js'), 'utf8')
|
|
1377
|
+
ok('适配·内嵌手册:经 ctx.skills.register 注册为运行时 skill(落调用 ctx 所在层)',
|
|
1378
|
+
/skills\.register\(/.test(skillSrc) && /source: 'runtime'/.test(skillSrc) && /ppt-studio: embedded skill/.test(skillSrc), '')
|
|
1379
|
+
const parsedManual = parseManual(readFileSync(MANUAL_FILE, 'utf8'))
|
|
1380
|
+
ok('内嵌手册:frontmatter 解析(name/description/whenToUse)',
|
|
1381
|
+
parsedManual?.name === MANUAL_NAME && (parsedManual?.description ?? '').length > 20 && typeof parsedManual?.whenToUse === 'string',
|
|
1382
|
+
`name=${parsedManual?.name ?? '(null)'}`)
|
|
1383
|
+
ok('内嵌手册:content = 去 frontmatter 正文(与 dsh-skill-filesystem 同语义 body.trim)',
|
|
1384
|
+
(parsedManual?.content ?? '').startsWith('#') && !(parsedManual?.content ?? '').includes('name: ppt-studio-manual') &&
|
|
1385
|
+
(parsedManual?.content ?? '').length > 1000, `len=${parsedManual?.content?.length ?? 0}`)
|
|
1386
|
+
ok('内嵌手册:坏 frontmatter 返回 null(不阻断插件装配);未声明时不产出 invocation',
|
|
1387
|
+
parseManual('---\nname: Bad Name\n---\nbody') === null && parseManual('no frontmatter here') === null &&
|
|
1388
|
+
parseManual('---\nname: ok-name\n---\nbody')?.invocation === undefined, '')
|
|
1389
|
+
|
|
1390
|
+
// ── 35. 内置技能包(2026-09-14):制作能力手册 —— 纯增量、可断言、不被固化的数值污染 ──────
|
|
1391
|
+
// 用户硬约束:技能是"锦上添花",绝不能影响既有工作流;老用户更新后用法零变化。
|
|
1392
|
+
// 于是把约束变成机器断言:① 工作流段逐行超集(只增不改不删)② 技能自身不得写死门禁数值。
|
|
1393
|
+
const { listBundledSkills, bundledSkillNames } = await import('../lib/skill.js')
|
|
1394
|
+
const bundled = listBundledSkills()
|
|
1395
|
+
const bundledNames = bundledSkillNames()
|
|
1396
|
+
ok('内置技能包:随包技能全部可解析(name == 目录名,kebab-case)',
|
|
1397
|
+
bundled.length >= 4 && bundled.every((b) => b.parsed.name === b.file.replace(/\\/g, '/').split('/').slice(-2)[0]),
|
|
1398
|
+
`共 ${bundled.length} 本:${bundledNames.join('、')}`)
|
|
1399
|
+
ok('内置技能包:制作能力三本齐备(craft / data / copy)',
|
|
1400
|
+
['ppt-studio-craft', 'ppt-studio-data', 'ppt-studio-copy', 'ppt-studio-manual'].every((n) => bundledNames.includes(n)),
|
|
1401
|
+
bundledNames.join('、'))
|
|
1402
|
+
ok('内置技能:描述只写触发、长度合规(>20 且 ≤500 字符)',
|
|
1403
|
+
bundled.every((b) => b.parsed.description.length > 20 && b.parsed.description.length <= 500),
|
|
1404
|
+
bundled.map((b) => `${b.parsed.name}=${b.parsed.description.length}`).join(' '))
|
|
1405
|
+
ok('内置技能:正文自包含且短(每本 ≤ 8000 字符,按需加载成本可控)',
|
|
1406
|
+
bundled.every((b) => b.parsed.content.length > 800 && b.parsed.content.length <= 8000),
|
|
1407
|
+
bundled.map((b) => `${b.parsed.name}=${b.parsed.content.length}`).join(' '))
|
|
1408
|
+
ok('内置技能:每本都声明优先级边界(用户指令 > 手册 > 工具默认)',
|
|
1409
|
+
bundled.filter((b) => b.parsed.name !== 'ppt-studio-manual')
|
|
1410
|
+
.every((b) => /用户指令/.test(b.parsed.content) && /ppt_verify|工具输出|门禁/.test(b.parsed.content)),
|
|
1411
|
+
'')
|
|
1412
|
+
// 反污染扫描:技能正文不得把数值写成"规范性默认"。规则集经校准:
|
|
1413
|
+
// 8 条植入违规全抓、8 条合法表述零误报、现有技能零命中(见 docs/03 记录)。
|
|
1414
|
+
// 说明:只抓"规范性措辞 + 数值"这一历史事故形状;描述性数字(经验值/文档引用)不在此列。
|
|
1415
|
+
const BANNED = [
|
|
1416
|
+
{ id: '默认+数值', re: /默认[^。\n]{0,10}\d+\s*(?:pt|号|磅|页|字)/u },
|
|
1417
|
+
{ id: '必须/一律+阈值', re: /(?:必须|一律|统一|强制)[^。\n]{0,10}(?:不小于|至少|≥|>=|不超过|至多|≤|<=|小于)\s*\d+/u },
|
|
1418
|
+
{ id: '字号下限=数值', re: /字号下限\s*(?:=|为|是)\s*\d+/u },
|
|
1419
|
+
{ id: '字号固定/写死', re: /字号[^。\n]{0,6}(?:固定|写死)\s*\d+/u },
|
|
1420
|
+
{ id: '每页/每块预算', re: /每(?:页|块)[^。\n]{0,6}(?:≤|<=|不超过|至多|最多|至少|不小于)\s*\d+/u },
|
|
1421
|
+
]
|
|
1422
|
+
const polluted = []
|
|
1423
|
+
for (const b of bundled) {
|
|
1424
|
+
for (const line of b.parsed.content.split('\n')) {
|
|
1425
|
+
for (const rule of BANNED) if (rule.re.test(line)) polluted.push(`${b.parsed.name}[${rule.id}]`)
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
ok('内置技能:不含写死的门禁数值(最小字号/页数/字数上限等)——历史事故的机器防线',
|
|
1429
|
+
polluted.length === 0, polluted.join('、') || '0 处命中')
|
|
1430
|
+
|
|
1431
|
+
// 反"为了变化而破坏主题一致性"扫描(2026-09-14 用户抓到的失误:从外部 skill 抄了一条
|
|
1432
|
+
// "同一明暗基调连续 3 页以上就该换",与我方 theme-conformance / 模板统一基调直接冲突)。
|
|
1433
|
+
// 规则:出现"底色/背景色/主色/色调/明暗基调/深底/浅底 + 换/交替/变化"且**同一行没有否定或统一口径** → 违规。
|
|
1434
|
+
// 校准:5 条植入违规全抓、6 条合法表述零误报、现有技能零命中。
|
|
1435
|
+
const VARY_DECKWIDE = /(?:(?:底色|背景色|主色|色调|明暗基调|明暗|深底|浅底|亮底|暗底)[^。\n]{0,12}(?:换|交替|变化))|(?:(?:换|交替)[^。\n]{0,8}(?:底色|背景色|主色|色调|明暗基调|深底|浅底|亮底|暗底))/
|
|
1436
|
+
const THEME_CONSISTENT = /(?:不要|不该|不靠|禁止|别|而非|不是|统一|一致)/
|
|
1437
|
+
const themeViolations = []
|
|
1438
|
+
for (const b of bundled) {
|
|
1439
|
+
for (const line of b.parsed.content.split('\n')) {
|
|
1440
|
+
if (VARY_DECKWIDE.test(line) && !THEME_CONSISTENT.test(line)) themeViolations.push(`${b.parsed.name}: ${line.trim().slice(0, 60)}`)
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
ok('内置技能:不劝人"为了变化改全册底色/主色"(主题一致性优先于节奏)——用户抓到的失误防线',
|
|
1444
|
+
themeViolations.length === 0, themeViolations.join(' | ') || '0 处命中')
|
|
1445
|
+
|
|
1446
|
+
// 工作流段超集断言:新段必须包含基线每一行(只允许新增,不允许改写/删除)
|
|
1447
|
+
const baseline = JSON.parse(readFileSync(join(root, 'scripts', 'fixtures', 'workflow-baseline.json'), 'utf8'))
|
|
1448
|
+
const baseCfg = { mode: 'auto', fidelity: 'auto', review: 'points', quality: 'standard', engine: 'auto', template: null, pauseAfter: [], workflowActive: true }
|
|
1449
|
+
const brokenVariants = []
|
|
1450
|
+
let addedLines = 0
|
|
1451
|
+
for (const v of baseline.variants) {
|
|
1452
|
+
const now = routerMod.workflowSection(v.taskType, { ...baseCfg, quick: v.quick }).text.split('\n')
|
|
1453
|
+
const missing = v.lines.filter((l) => !now.includes(l))
|
|
1454
|
+
addedLines += now.filter((l) => !v.lines.includes(l)).length
|
|
1455
|
+
if (missing.length) brokenVariants.push(`${v.quick ? 'quick' : 'std'}/${v.taskType}(-${missing.length})`)
|
|
1456
|
+
}
|
|
1457
|
+
ok('★老用户不受影响:工作流提示词逐行超集(只增不改不删;基线 scripts/fixtures/workflow-baseline.json)',
|
|
1458
|
+
brokenVariants.length === 0, brokenVariants.length ? `被破坏:${brokenVariants.join(' ')}` : `10 变体全部保持,新增 ${addedLines} 行`)
|
|
1459
|
+
ok('★老用户不受影响:新增内容只出现在标准档(quick 档逐字节不变)',
|
|
1460
|
+
baseline.variants.filter((v) => v.quick).every((v) => {
|
|
1461
|
+
const now = routerMod.workflowSection(v.taskType, { ...baseCfg, quick: true }).text
|
|
1462
|
+
return now === v.lines.join('\n')
|
|
1463
|
+
}), '')
|
|
1464
|
+
|
|
1465
|
+
// ── 34. 连线方向(2026-09-14 真实反馈:网页预览对、PowerPoint 里线镜像 / × 少一笔)──────
|
|
1466
|
+
// 根因:straightConnector1 只画包围盒左上→右下,真实走向必须靠 flipH/flipV;
|
|
1467
|
+
// 漏写 → 反向斜率镜像(Δx>0,Δy<0 的线画反),两条交叉线还会重合成一条(× 变 /)。
|
|
1468
|
+
const { connectorEndsFromXml } = await import('../lib/pptd/export-pptx.js')
|
|
1469
|
+
const lineDir = join(root, 'examples', 'smoke', '.tmp-line-dir')
|
|
1470
|
+
await rm(lineDir, { recursive: true, force: true })
|
|
1471
|
+
await mkdir(join(lineDir, 'pages'), { recursive: true })
|
|
1472
|
+
const { writeFile } = await import('node:fs/promises')
|
|
1473
|
+
await writeFile(join(lineDir, 'deck.yaml'), [
|
|
1474
|
+
'version: 1',
|
|
1475
|
+
'title: "连线方向夹具"',
|
|
1476
|
+
'size: [960, 540]',
|
|
1477
|
+
'theme:',
|
|
1478
|
+
' colors: {ink: "#111111", wu: "#B03A2E", jin: "#B7791F", line: "#94A3B8"}',
|
|
1479
|
+
' textStyles:',
|
|
1480
|
+
' body: {fontSize: 14, color: "$ink"}',
|
|
1481
|
+
'pages:',
|
|
1482
|
+
' - pages/01.yaml',
|
|
1483
|
+
'',
|
|
1484
|
+
].join('\n'), 'utf8')
|
|
1485
|
+
// 四个方向 + 箭头 + 一组交叉线(×):覆盖 flipH / flipV / 双 flip / 无 flip
|
|
1486
|
+
await writeFile(join(lineDir, 'pages', '01.yaml'), [
|
|
1487
|
+
'pageType: content',
|
|
1488
|
+
'elements:',
|
|
1489
|
+
' - elementId: dr',
|
|
1490
|
+
' elementType: line',
|
|
1491
|
+
' points: [[100, 100], [200, 160]]',
|
|
1492
|
+
' line: {color: "$ink", width: 2}',
|
|
1493
|
+
' - elementId: dl',
|
|
1494
|
+
' elementType: line',
|
|
1495
|
+
' points: [[300, 100], [200, 160]]',
|
|
1496
|
+
' line: {color: "$ink", width: 2}',
|
|
1497
|
+
' - elementId: ur',
|
|
1498
|
+
' elementType: line',
|
|
1499
|
+
' points: [[400, 200], [500, 140]]',
|
|
1500
|
+
' arrow: true',
|
|
1501
|
+
' line: {color: "$jin", width: 2}',
|
|
1502
|
+
' - elementId: ul',
|
|
1503
|
+
' elementType: line',
|
|
1504
|
+
' points: [[600, 200], [500, 140]]',
|
|
1505
|
+
' line: {color: "$jin", width: 2}',
|
|
1506
|
+
' - elementId: cross1',
|
|
1507
|
+
' elementType: line',
|
|
1508
|
+
' points: [[700, 300], [714, 314]]',
|
|
1509
|
+
' line: {color: "$wu", width: 2.5}',
|
|
1510
|
+
' - elementId: cross2',
|
|
1511
|
+
' elementType: line',
|
|
1512
|
+
' points: [[714, 300], [700, 314]]',
|
|
1513
|
+
' line: {color: "$wu", width: 2.5}',
|
|
1514
|
+
'',
|
|
1515
|
+
].join('\n'), 'utf8')
|
|
1516
|
+
const lineCtx = await resolveDeck(lineDir)
|
|
1517
|
+
const lineExp = await exportPptx(lineCtx, { out: 'out-line.pptx', engine: 'pptd' })
|
|
1518
|
+
const lineZip = zipRead(await (await import('node:fs/promises')).readFile(lineExp.file))
|
|
1519
|
+
const lineXml = lineZip.get('ppt/slides/slide1.xml').toString('utf8')
|
|
1520
|
+
const conns = (lineXml.match(/<p:cxnSp>[\s\S]*?<\/p:cxnSp>/g) ?? []).map((c) => ({
|
|
1521
|
+
id: (c.match(/name="([^"]+)"/) ?? [])[1],
|
|
1522
|
+
xml: c,
|
|
1523
|
+
ends: connectorEndsFromXml(c),
|
|
1524
|
+
flipH: /flipH="1"/.test(c),
|
|
1525
|
+
flipV: /flipV="1"/.test(c),
|
|
1526
|
+
arrow: /<a:tailEnd/.test(c),
|
|
1527
|
+
}))
|
|
1528
|
+
const byId = new Map(conns.map((c) => [c.id, c]))
|
|
1529
|
+
const near = (a, b, t = 0.01) => Math.abs(a - b) < t
|
|
1530
|
+
const endsMatch = (c, p1, p2) => c?.ends && near(c.ends[0][0], p1[0]) && near(c.ends[0][1], p1[1]) && near(c.ends[1][0], p2[0]) && near(c.ends[1][1], p2[1])
|
|
1531
|
+
ok('连线:全部 6 条 cxnSp 已导出', conns.length === 6, conns.map((c) => c.id).join(','))
|
|
1532
|
+
ok('连线:右下(无 flip)端点还原一致', endsMatch(byId.get('dr'), [100, 100], [200, 160]) && !byId.get('dr').flipH && !byId.get('dr').flipV)
|
|
1533
|
+
ok('连线:左下(需 flipH)端点还原一致——旧实现会镜像成右下', endsMatch(byId.get('dl'), [300, 100], [200, 160]) && byId.get('dl').flipH && !byId.get('dl').flipV)
|
|
1534
|
+
ok('连线:右上(需 flipV)端点还原一致 + 箭头在 p2 端', endsMatch(byId.get('ur'), [400, 200], [500, 140]) && !byId.get('ur').flipH && byId.get('ur').flipV && byId.get('ur').arrow)
|
|
1535
|
+
ok('连线:左上(需 flipH+flipV)端点还原一致', endsMatch(byId.get('ul'), [600, 200], [500, 140]) && byId.get('ul').flipH && byId.get('ul').flipV)
|
|
1536
|
+
ok('连线:× 两笔方向相反(旧实现缺 flipH 会重合成一条 /)',
|
|
1537
|
+
byId.get('cross1').flipH === false && byId.get('cross2').flipH === true &&
|
|
1538
|
+
endsMatch(byId.get('cross1'), [700, 300], [714, 314]) && endsMatch(byId.get('cross2'), [714, 300], [700, 314]))
|
|
1539
|
+
ok('连线:导出 parity 自证(线方向逐条从 OOXML 反推,0 条错)',
|
|
1540
|
+
lineExp.parity?.ok === true && lineExp.parity.linesExp === 6 && lineExp.parity.linesOut === 6 && lineExp.parity.linesWrong === 0,
|
|
1541
|
+
JSON.stringify(lineExp.parity))
|
|
1542
|
+
|
|
1543
|
+
// 34.2 箭头必须在 <a:ln> 内部(写在外面 PowerPoint 直接忽略 ⇒ 预览有箭头、成品没有)
|
|
1544
|
+
const lnOf = (id) => (byId.get(id)?.xml.match(/<a:ln\b[\s\S]*?<\/a:ln>/) ?? [''])[0]
|
|
1545
|
+
const tailInside = (id) => /<a:tailEnd/.test(lnOf(id))
|
|
1546
|
+
ok('连线·箭头:arrow=true 的 <a:tailEnd> 写在 <a:ln> 内部(写外面 PowerPoint 忽略)',
|
|
1547
|
+
tailInside('ur') === true && !/<a:tailEnd/.test(byId.get('ur').xml.replace(lnOf('ur'), '')) &&
|
|
1548
|
+
byId.get('dr').xml.includes('<a:ln') && !tailInside('dr'), `headEnd 示例=${lnOf('ur').slice(0, 90)}`)
|
|
1549
|
+
|
|
1550
|
+
// 34.3 包围盒必须精确等于线段跨度:水平线 cy=0 / 垂直线 cx=0(旧实现 max(1,…) 兜底会把水平线画成"假斜线")
|
|
1551
|
+
const extOf = (id) => (byId.get(id)?.xml.match(/<a:ext cx="(\d+)" cy="(\d+)"\/>/) ?? []).slice(1).map(Number)
|
|
1552
|
+
await rm(join(lineDir, 'pages', '01.yaml'), { force: true })
|
|
1553
|
+
await writeFile(join(lineDir, 'pages', '01.yaml'), [
|
|
1554
|
+
'pageType: content',
|
|
1555
|
+
'elements:',
|
|
1556
|
+
' - elementId: flat',
|
|
1557
|
+
' elementType: line',
|
|
1558
|
+
' points: [[216, 228], [228, 228]]',
|
|
1559
|
+
' arrow: true',
|
|
1560
|
+
' line: {color: "$ink", width: 1.5}',
|
|
1561
|
+
' - elementId: vert',
|
|
1562
|
+
' elementType: line',
|
|
1563
|
+
' points: [[500, 100], [500, 300]]',
|
|
1564
|
+
' line: {color: "$ink", width: 1}',
|
|
1565
|
+
' - elementId: diag',
|
|
1566
|
+
' elementType: line',
|
|
1567
|
+
' points: [[600, 100], [700, 160]]',
|
|
1568
|
+
' line: {color: "$ink", width: 1}',
|
|
1569
|
+
' - elementId: zero',
|
|
1570
|
+
' elementType: line',
|
|
1571
|
+
' points: [[800, 400], [800, 400]]',
|
|
1572
|
+
' line: {color: "$ink", width: 1}',
|
|
1573
|
+
'',
|
|
1574
|
+
].join('\n'), 'utf8')
|
|
1575
|
+
const flatCtx = await resolveDeck(lineDir)
|
|
1576
|
+
const flatExp = await exportPptx(flatCtx, { out: 'out-flat.pptx', engine: 'pptd' })
|
|
1577
|
+
const flatXml = zipRead(await (await import('node:fs/promises')).readFile(flatExp.file)).get('ppt/slides/slide1.xml').toString('utf8')
|
|
1578
|
+
const flatConns = new Map((flatXml.match(/<p:cxnSp>[\s\S]*?<\/p:cxnSp>/g) ?? []).map((c) => [(c.match(/name="([^"]+)"/) ?? [])[1], c]))
|
|
1579
|
+
const flatExt = (id) => (flatConns.get(id).match(/<a:ext cx="(\d+)" cy="(\d+)"\/>/) ?? []).slice(1).map(Number)
|
|
1580
|
+
ok('连线·水平线:包围盒 cy=0(不会再被 max(1,…) 抬成 1pt 假斜线)',
|
|
1581
|
+
flatExt('flat')[1] === 0 && flatExt('flat')[0] === Math.round(12 * 12700), `ext=${JSON.stringify(flatExt('flat'))}`)
|
|
1582
|
+
ok('连线·垂直线:包围盒 cx=0', flatExt('vert')[0] === 0 && flatExt('vert')[1] === Math.round(200 * 12700), `ext=${JSON.stringify(flatExt('vert'))}`)
|
|
1583
|
+
ok('连线·斜线:包围盒精确等于跨度(不再兜底到 1pt)',
|
|
1584
|
+
flatExt('diag')[0] === Math.round(100 * 12700) && flatExt('diag')[1] === Math.round(60 * 12700), `ext=${JSON.stringify(flatExt('diag'))}`)
|
|
1585
|
+
ok('连线·零长线:给 1×1 兜底避免被消费端丢弃', flatExt('zero')[0] === Math.round(1 * 12700) && flatExt('zero')[1] === Math.round(1 * 12700))
|
|
1586
|
+
ok('连线·水平线箭头也在 <a:ln> 内', /<a:ln\b[\s\S]*?<a:tailEnd[\s\S]*?<\/a:ln>/.test(flatConns.get('flat')))
|
|
1587
|
+
await rm(lineDir, { recursive: true, force: true })
|
|
1588
|
+
|
|
1589
|
+
// ── 36. 文档完整性(2026-09-14 两次踩坑后加的机器防线)──────────────────────────
|
|
1590
|
+
// 事故形状:往 docs/03 顶部插新条目时,把上一版条的 `## 标题` 一起替换掉 → 那一版的内容变成"孤儿块"挂在别人名下。
|
|
1591
|
+
// 人工很难发现(内容还在、只是归属错了)。这里做两条结构断言 + 一条体量断言。
|
|
1592
|
+
const docsToCheck = [
|
|
1593
|
+
'README.md',
|
|
1594
|
+
join('docs', '01-需求与目标.md'), join('docs', '02-技术报告.md'), join('docs', '03-更新日志.md'),
|
|
1595
|
+
join('docs', '04-路线图与里程碑.md'), join('docs', '05-迭代流程.md'), join('docs', '06-评审与测试.md'),
|
|
1596
|
+
]
|
|
1597
|
+
const orphanDocs = []
|
|
1598
|
+
for (const rel of docsToCheck) {
|
|
1599
|
+
const lines = readFileSync(join(root, rel), 'utf8').split(/\r?\n/)
|
|
1600
|
+
let seenH2 = false
|
|
1601
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1602
|
+
if (/^## /.test(lines[i])) seenH2 = true
|
|
1603
|
+
else if (/^### /.test(lines[i]) && !seenH2) orphanDocs.push(`${rel}:${i + 1}`)
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
ok('文档结构:没有任何"孤儿小节"(### 之前必须有 ## 归属)——两次吃标题事故的防线',
|
|
1607
|
+
orphanDocs.length === 0, orphanDocs.join('、') || '全部完整')
|
|
1608
|
+
|
|
1609
|
+
const changelog = readFileSync(join(root, 'docs', '03-更新日志.md'), 'utf8')
|
|
1610
|
+
const entries = changelog.split(/\r?\n/).filter((l) => /^## \[/.test(l))
|
|
1611
|
+
ok('文档结构:更新日志仍保有全部历史条目(≥ 25 条 ## [版本])',
|
|
1612
|
+
entries.length >= 25, `当前 ${entries.length} 条`)
|
|
1613
|
+
ok('文档结构:每条更新日志都有"验证"或"影响"段(可追溯)',
|
|
1614
|
+
(changelog.match(/^## \[/gm) ?? []).length <= (changelog.match(/^### (验证|影响|影响\/后续)/gm) ?? []).length + 4,
|
|
1615
|
+
`条目 ${(changelog.match(/^## \[/gm) ?? []).length} / 验证或影响段 ${(changelog.match(/^### (验证|影响|影响\/后续)/gm) ?? []).length}`)
|
|
1616
|
+
|
|
1617
|
+
// ── 37. 手册 vs 源码:事实一致性(2026-09-14 用户要求"充分自检"后加的防线)─────────
|
|
1618
|
+
// 事故形状:手册是模型的行为依据,一句错的事实 = 一次错的动作。本轮自检抓到 6 类:
|
|
1619
|
+
// ① manual §3 把 chart 的 data 写成 `[{label, value}]`,而 schema 只认 `data: {cols, rows}` → 照着写必被拒;
|
|
1620
|
+
// ② 三本手册把 warning 的标记写成 `[⚠]`,而 ppt_verify 主清单用的是 `[~]`(`[⚠]` 只属 M2 实测段);
|
|
1621
|
+
// ③ 把 density / near-align 归到 `[·]` 建议,实际是 `[~]` 警告;④ 手册 §4 漏了 theme-conformance 这个
|
|
1622
|
+
// 默认开的门禁错误码;⑤ "预览里图表带分类名"对 pie 不成立(pie 连分类名都不画);
|
|
1623
|
+
// ⑥ 文档里 6 处 "smoke 181 断言" 全靠人工同步。
|
|
1624
|
+
// 于是把"手册不许说错事实"也做成机器断言——四类:名字存在 / 错误码覆盖 / 标记与分级 / 引用数值一致。
|
|
1625
|
+
// 说明:断言的是"手册与源码不矛盾",不评判文风;文风与语义偏移仍靠 docs/06 §7.4 的独立评审。
|
|
1626
|
+
const skillTexts = bundled.map((b) => ({ name: b.parsed.name, content: b.parsed.content }))
|
|
1627
|
+
const allSkillText = skillTexts.map((s) => s.content).join('\n')
|
|
1628
|
+
const manualText = skillTexts.find((s) => s.name === 'ppt-studio-manual').content
|
|
1629
|
+
const readLib = (rel) => readFileSync(join(root, 'lib', rel), 'utf8')
|
|
1630
|
+
|
|
1631
|
+
// 37.1 手册提到的 ppt_* 必须是真注册的工具(工具改名 / 手册留旧名 = 模型调用不存在的工具)
|
|
1632
|
+
const registeredTools = new Set()
|
|
1633
|
+
for (const f of ['tools.js', 'index.js']) {
|
|
1634
|
+
for (const m of readLib(f).matchAll(/name: '(ppt_[a-z_]+)'/g)) registeredTools.add(m[1])
|
|
1635
|
+
}
|
|
1636
|
+
const mentionedTools = new Set([...allSkillText.matchAll(/\b(ppt_[a-z_]+)\b/g)].map((m) => m[1]))
|
|
1637
|
+
const ghostTools = [...mentionedTools].filter((t) => !registeredTools.has(t))
|
|
1638
|
+
ok('手册 vs 源码:技能里提到的 ppt_* 都是已注册工具(防工具改名后手册留旧名)',
|
|
1639
|
+
ghostTools.length === 0 && registeredTools.size >= 15,
|
|
1640
|
+
ghostTools.length ? `不存在的工具:${ghostTools.join('、')}` : `${mentionedTools.size} 个工具名全部存在(注册表 ${registeredTools.size} 个)`)
|
|
1641
|
+
|
|
1642
|
+
// 37.2 门禁错误码:源码清单 == 期望清单,且每个都写进了手册(新增错误码而手册没跟 = 模型不知道会被拦)
|
|
1643
|
+
const verifySrc = readLib('verify.js')
|
|
1644
|
+
const verifyLines = verifySrc.split(/\r?\n/)
|
|
1645
|
+
const errorCodes = new Set()
|
|
1646
|
+
verifyLines.forEach((line, i) => {
|
|
1647
|
+
// `let code = 'overlap'` 是**初值**(后续分支必然改写;overlap 只会以 warning 出现),不是一条 finding → 排除声明式赋值
|
|
1648
|
+
if (/\b(?:let|const|var)\s+code\s*=/.test(line)) return
|
|
1649
|
+
for (const m of line.matchAll(/code\s*[:=]\s*'([a-z-]+)'/g)) {
|
|
1650
|
+
// 判定该 code 是否 error 级:本行或上 3 行里出现 'error'(覆盖 severity: 'error' / severity = 'error' /
|
|
1651
|
+
// `mode === 'strict' ? 'error' : 'warning'` 三种写法;confirmed / warning / suggestion 不会被误收)
|
|
1652
|
+
const window = verifyLines.slice(Math.max(0, i - 3), i + 1).join('\n')
|
|
1653
|
+
if (/'error'/.test(window)) errorCodes.add(m[1])
|
|
1654
|
+
}
|
|
1655
|
+
})
|
|
1656
|
+
const EXPECTED_ERROR_CODES = ['theme-conformance', 'out-of-page', 'out-of-safe-area', 'text-overflow', 'table-overflow', 'content-collision', 'unexpected-overlap', 'measured-overflow', 'measured-unpaired']
|
|
1657
|
+
const sameSet = (a, b) => a.size === b.length && b.every((x) => a.has(x))
|
|
1658
|
+
ok('手册 vs 源码:门禁错误码清单与 verify.js 一致(错误码增删会被当场抓到)',
|
|
1659
|
+
sameSet(errorCodes, EXPECTED_ERROR_CODES),
|
|
1660
|
+
`源码=${[...errorCodes].sort().join('/')} 期望=${[...EXPECTED_ERROR_CODES].sort().join('/')}`)
|
|
1661
|
+
const undocumented = EXPECTED_ERROR_CODES.filter((c) => !manualText.includes(c))
|
|
1662
|
+
ok('手册 vs 源码:每个门禁错误码都写进了手册 §4(模型必须知道什么会被拦)',
|
|
1663
|
+
undocumented.length === 0, undocumented.length ? `未写进手册:${undocumented.join('、')}` : `${EXPECTED_ERROR_CODES.length} 个错误码全部在手册`)
|
|
1664
|
+
|
|
1665
|
+
// 37.3 严重度标记与分级:主清单的警告标记是 `[~]`(不是 `[⚠]`);density/near-align/hotspot 是警告不是建议
|
|
1666
|
+
const markerViolations = []
|
|
1667
|
+
const severityViolations = []
|
|
1668
|
+
for (const s of skillTexts) {
|
|
1669
|
+
for (const line of s.content.split('\n')) {
|
|
1670
|
+
if (/\[⚠\][^\n]{0,6}(警告|warning)|(警告|warning)[^\n]{0,4}\[⚠\]/.test(line)) markerViolations.push(`${s.name}: ${line.trim().slice(0, 48)}`)
|
|
1671
|
+
const touchesWarnCode = /(density|near-align|hotspot|密度|近对齐)/.test(line)
|
|
1672
|
+
if (touchesWarnCode && /\[·\]/.test(line) && !/\[~\]/.test(line)) severityViolations.push(`${s.name}: ${line.trim().slice(0, 48)}`)
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
ok('手册 vs 源码:警告标记写对(主清单是 [~];[⚠] 只属 M2 实测段,不能当主清单图例)',
|
|
1676
|
+
markerViolations.length === 0, markerViolations.join(' | ') || '0 处命中')
|
|
1677
|
+
ok('手册 vs 源码:density/near-align/hotspot 不得归到 [·] 建议(源码里是 warning)',
|
|
1678
|
+
severityViolations.length === 0, severityViolations.join(' | ') || '0 处命中')
|
|
1679
|
+
|
|
1680
|
+
// 37.4 chart 的 data 形状必须按 schema 写(`data: {cols, rows}`;`data: [{label, value}]` 会被直接拒绝)
|
|
1681
|
+
const chartShapeBad = []
|
|
1682
|
+
for (const s of skillTexts) {
|
|
1683
|
+
for (const line of s.content.split('\n')) {
|
|
1684
|
+
// 允许"反面教材":同一行带否定/拒绝语义时,引用错误形状是为了告诉模型别这么写
|
|
1685
|
+
if (/data:\s*\[\s*[\{'"]/.test(line) && !/(拒绝|不要|不是|错误|✗|禁止)/.test(line)) chartShapeBad.push(`${s.name}: ${line.trim().slice(0, 48)}`)
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
const chartDocLine = manualText.split('\n').find((l) => /^-\s*chart[::]/.test(l.trim())) ?? ''
|
|
1689
|
+
ok('手册 vs 源码:chart 语法按 schema 写(data 是 {cols, rows} + series,不是 [{label,value}])',
|
|
1690
|
+
chartShapeBad.length === 0 && /cols/.test(chartDocLine) && /rows/.test(chartDocLine) && /series/.test(chartDocLine),
|
|
1691
|
+
chartShapeBad.length ? chartShapeBad.join(' | ') : `手册 chart 行:${chartDocLine.trim().slice(0, 70)}…`)
|
|
1692
|
+
|
|
1693
|
+
// 37.5 预览图表标签的截断阈值必须与 svgCharts.js 一致(改动阈值不许手册沉默漂移)
|
|
1694
|
+
const truncNum = Number((readLib(join('pptd', 'svgCharts.js')).match(/c\.length\s*>\s*(\d+)/) ?? [])[1])
|
|
1695
|
+
const claimedTrunc = [...allSkillText.matchAll(/超过\s*(\d+)\s*字截断/g)].map((m) => Number(m[1]))
|
|
1696
|
+
ok('手册 vs 源码:预览图表分类名的截断阈值与 svgCharts.js 一致',
|
|
1697
|
+
truncNum > 0 && claimedTrunc.length > 0 && claimedTrunc.every((n) => n === truncNum),
|
|
1698
|
+
`源码=${truncNum} 手册声称=${claimedTrunc.join(',') || '未声称'}`)
|
|
1699
|
+
|
|
1700
|
+
// 37.6 手册提到的 /ppt 子命令必须真实存在
|
|
1701
|
+
const realCmds = new Set([...readLib('commands.js').matchAll(/cmd === '([a-z-]+)'/g)].map((m) => m[1]))
|
|
1702
|
+
const mentionedCmds = new Set([...allSkillText.matchAll(/\/ppt\s+([a-z-]+)/g)].map((m) => m[1]))
|
|
1703
|
+
const ghostCmds = [...mentionedCmds].filter((c) => !realCmds.has(c))
|
|
1704
|
+
ok('手册 vs 源码:技能里提到的 /ppt 子命令都存在',
|
|
1705
|
+
ghostCmds.length === 0 && realCmds.size >= 5,
|
|
1706
|
+
ghostCmds.length ? `不存在的子命令:${ghostCmds.join('、')}` : `${[...mentionedCmds].join('/')} 全部存在(命令面 ${realCmds.size} 个)`)
|
|
1707
|
+
|
|
1708
|
+
// 37.7 手册门禁清单里写的 code 名必须真实存在(A 类事实:旧手册曾写过 `out-of-safe-area` 这种源码里根本没有的名字;
|
|
1709
|
+
// 2026-09-18 起该码**真的存在了**——它被从 out-of-page 里分出来,因为两者处置完全不同)
|
|
1710
|
+
const gateLine = manualText.split('\n').find((l) => l.includes('ERROR 必须清零')) ?? ''
|
|
1711
|
+
const gateTokens = [...gateLine.matchAll(/`([a-z][a-z-]*)`/g)].map((m) => m[1])
|
|
1712
|
+
const ghostCodes = gateTokens.filter((t) => !errorCodes.has(t))
|
|
1713
|
+
ok('手册 vs 源码:手册门禁清单里的 code 名都真实存在(防写出源码里没有的错误码)',
|
|
1714
|
+
gateTokens.length > 0 && ghostCodes.length === 0,
|
|
1715
|
+
ghostCodes.length ? `源码里没有:${ghostCodes.join('、')}` : `清单含 ${[...new Set(gateTokens)].join('、')}`)
|
|
1716
|
+
|
|
1717
|
+
// 37.8 讲稿通道口径必须与导出器实现**双向一致**(手册说"不导出"而代码在导出 = 最坏的一种事实错误)
|
|
1718
|
+
const exportSrc = readLib(join('pptd', 'export-pptx.js'))
|
|
1719
|
+
const copySkill = skillTexts.find((s) => s.name === 'ppt-studio-copy').content
|
|
1720
|
+
const implExportsNotes = /ppt\/notesSlides\//.test(exportSrc)
|
|
1721
|
+
const docSaysExports = /备注页/.test(copySkill) && /(导出|写进|随导出)/.test(copySkill)
|
|
1722
|
+
const docSaysNoExport = /(不生成备注|不导出备注|没有 notesSlide|不进 pptx)/.test(copySkill)
|
|
1723
|
+
ok('手册 vs 源码:讲稿通道口径与导出器**双向一致**(实现会导出→手册必须说会导出;反之必须说不导出)',
|
|
1724
|
+
implExportsNotes ? (docSaysExports && !docSaysNoExport) : (!docSaysExports && docSaysNoExport),
|
|
1725
|
+
`实现${implExportsNotes ? '会' : '不会'}导出备注;手册${docSaysExports ? '说会' : '未说会'}${docSaysNoExport ? '、且仍写着不导出' : ''}`)
|
|
1726
|
+
|
|
1727
|
+
// 37.9 缩字下限的常量必须与导出器一致(手册写死了 6pt / 60%,代码改了要能被抓到)
|
|
1728
|
+
const floorM = exportSrc.match(/Math\.max\((\d+),\s*Math\.round\(origSize \*\s*([\d.]+)\)\)/)
|
|
1729
|
+
const floorPt = floorM ? Number(floorM[1]) : 0
|
|
1730
|
+
const floorPct = floorM ? Math.round(Number(floorM[2]) * 100) : 0
|
|
1731
|
+
const floorClaimed = floorPt > 0 && new RegExp(`max\\(${floorPt}pt, ${floorPct}% 原字号\\)`).test(manualText)
|
|
1732
|
+
ok('手册 vs 源码:缩字下限常量与 export-pptx 一致(未给 minFontSize 时的保底值)',
|
|
1733
|
+
floorPt > 0 && floorPct > 0 && floorClaimed,
|
|
1734
|
+
`源码 = max(${floorPt}pt, ${floorPct}% 原字号),手册${floorClaimed ? '一致' : '不一致/未写'}`)
|
|
1735
|
+
|
|
1736
|
+
// ── 38. SCHEMA_REF(ppt_schema 权威通道)自洽 ─────────────────────────────────────
|
|
1737
|
+
// 事故形状:scaffold.js 顶部速查写"decoration 完全豁免重叠与出界(可合法落在模板页眉页脚带)",
|
|
1738
|
+
// 而同一份 SCHEMA_REF 后文写"只豁免重叠、不豁免出界",verify.js 对出界也根本不看 role
|
|
1739
|
+
// (docs/01 C3 早已回滚"decoration 豁免出界")——权威通道自相矛盾,模型照哪句都可能错。
|
|
1740
|
+
const schemaRef = scaffoldMod.SCHEMA_REF
|
|
1741
|
+
const refLines = schemaRef.split('\n')
|
|
1742
|
+
const refBad = refLines.find((l) => /decoration/.test(l) && /豁免/.test(l) && /出界/.test(l) && !/不豁免出界/.test(l))
|
|
1743
|
+
ok('SCHEMA_REF 自洽:不得声称 decoration 豁免出界(verify 对出界不看 role;C3 已回滚该语义)',
|
|
1744
|
+
refBad === undefined && refLines.some((l) => /不豁免出界/.test(l)),
|
|
1745
|
+
refBad ? `仍写着:${refBad.trim().slice(0, 60)}` : '口径一致(只豁免重叠、不豁免出界)')
|
|
1746
|
+
|
|
1747
|
+
// ── 39. 讲稿(备注)通道 + 实体解码 + 图表配色建议(2026-09-14 用户要求处理的三件事)──────
|
|
1748
|
+
// 备注的真实性标准是"**PowerPoint 能打开并读出**":开发中先写了 notesMasterIdLst,python-pptx 照读不误,
|
|
1749
|
+
// 真 PowerPoint 却报"文件或目录损坏"(减量二分定位到该元素)。教训:OOXML 产物必须用真消费者验。
|
|
1750
|
+
const rf = (await import('node:fs/promises')).readFile
|
|
1751
|
+
const notesDir = join(root, 'examples', 'smoke', '.tmp-notes-smoke')
|
|
1752
|
+
await rm(notesDir, { recursive: true, force: true })
|
|
1753
|
+
await mkdir(join(notesDir, 'pages'), { recursive: true })
|
|
1754
|
+
await writeFile(join(notesDir, 'deck.yaml'), [
|
|
1755
|
+
'version: 1', 'title: "讲稿夹具"', 'size: [960, 540]', 'theme:',
|
|
1756
|
+
' colors: {ink: "#111111", brand: "#2563EB"}',
|
|
1757
|
+
' textStyles:', ' body: {fontSize: 16, color: "$ink"}',
|
|
1758
|
+
'pages:', ' - pages/01.yaml', ' - pages/02.yaml', ' - pages/03.yaml', '',
|
|
1759
|
+
].join('\n'), 'utf8')
|
|
1760
|
+
const nEl = (id, t) => [` - elementId: ${id}`, ' elementType: text', ' bounds: [60, 40, 600, 40]', ` content: {text: ${JSON.stringify(t)}, style: "$body"}`]
|
|
1761
|
+
await writeFile(join(notesDir, 'pages', '01.yaml'), ['pageType: cover', 'notes: |', ' 开场:先说结论。', ' 第二行:再给证据。', 'elements:', ...nEl('t1', '封面')].join('\n'), 'utf8')
|
|
1762
|
+
await writeFile(join(notesDir, 'pages', '02.yaml'), ['pageType: content', 'elements:', ...nEl('t2', '无讲稿页')].join('\n'), 'utf8')
|
|
1763
|
+
await writeFile(join(notesDir, 'pages', '03.yaml'), ['pageType: content', 'notes: "单行讲稿带 <特殊> & 符号"', 'elements:', ...nEl('t3', '第三页')].join('\n'), 'utf8')
|
|
1764
|
+
const nCtx = await resolveDeck(notesDir)
|
|
1765
|
+
const nExp = await exportPptx(nCtx, { out: 'notes-smoke.pptx', engine: 'pptd' })
|
|
1766
|
+
const nZip = zipRead(await rf(nExp.file))
|
|
1767
|
+
const nNames = [...nZip.keys()]
|
|
1768
|
+
const nSlides = nNames.filter((k) => /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(k))
|
|
1769
|
+
const nRels = nNames.filter((k) => /^ppt\/notesSlides\/_rels\/notesSlide\d+\.xml\.rels$/.test(k))
|
|
1770
|
+
const nRelsOk = nRels.every((k) => {
|
|
1771
|
+
const t = decodeXml(nZip.get(k))
|
|
1772
|
+
return /notesMaster/.test(t) && /Type="[^"]*\/slide"/.test(t)
|
|
1773
|
+
})
|
|
1774
|
+
const nSlideRels = [1, 3].every((p) => /notesSlide\d+\.xml/.test(decodeXml(nZip.get(`ppt/slides/_rels/slide${p}.xml.rels`))))
|
|
1775
|
+
const nCt = decodeXml(nZip.get('[Content_Types].xml'))
|
|
1776
|
+
const nCtOk = /notesMaster\+xml/.test(nCt) && (nCt.match(/notesSlide\+xml/g) ?? []).length === 2
|
|
1777
|
+
const nNoIdLst = !/notesMasterIdLst/.test(decodeXml(nZip.get('ppt/presentation.xml')))
|
|
1778
|
+
ok('讲稿:notes → 标准备注页(2 个 notesSlide + notesMaster + 各自 rels + Content_Types),parity 自证',
|
|
1779
|
+
nSlides.length === 2 && nRels.length === 2 && nRelsOk && nSlideRels && nCtOk &&
|
|
1780
|
+
nExp.parity.notesExp === 2 && nExp.parity.notesOut === 2 && nExp.parity.ok === true,
|
|
1781
|
+
`slides=${nSlides.length} rels=${nRels.length} relsOk=${nRelsOk} ct=${nCtOk} parity=${JSON.stringify(nExp.parity)}`)
|
|
1782
|
+
ok('讲稿:presentation.xml **不含** notesMasterIdLst(真 PowerPoint 会因此报"文件损坏",实测二分定位)',
|
|
1783
|
+
nNoIdLst && /notesMasters\/notesMaster1\.xml/.test(decodeXml(nZip.get('ppt/_rels/presentation.xml.rels'))),
|
|
1784
|
+
nNoIdLst ? '无该元素,备注母版只经 presentation.xml.rels 挂载' : '仍写着 notesMasterIdLst')
|
|
1785
|
+
|
|
1786
|
+
// 老工程零变化:没有 notes 的工程不得凭空多出任何备注部件
|
|
1787
|
+
const noNotesDir = join(notesDir, '..', '.tmp-notes-none')
|
|
1788
|
+
await rm(noNotesDir, { recursive: true, force: true })
|
|
1789
|
+
await mkdir(join(noNotesDir, 'pages'), { recursive: true })
|
|
1790
|
+
await writeFile(join(noNotesDir, 'deck.yaml'), ['version: 1', 'title: "无讲稿"', 'size: [960, 540]', 'theme:', ' colors: {ink: "#111111"}', ' textStyles:', ' body: {fontSize: 16, color: "$ink"}', 'pages:', ' - pages/01.yaml', ''].join('\n'), 'utf8')
|
|
1791
|
+
await writeFile(join(noNotesDir, 'pages', '01.yaml'), ['pageType: content', 'elements:', ...nEl('t1', '无讲稿')].join('\n'), 'utf8')
|
|
1792
|
+
const nnExp = await exportPptx(await resolveDeck(noNotesDir), { out: 'no-notes.pptx', engine: 'pptd' })
|
|
1793
|
+
const nnNames = [...zipRead(await rf(nnExp.file)).keys()]
|
|
1794
|
+
ok('讲稿:无 notes 的工程不产生任何备注部件(老工程产物零变化)',
|
|
1795
|
+
nnNames.every((k) => !/notes/.test(k)) && nnExp.parity.notesExp === 0 && nnExp.parity.notesOut === 0,
|
|
1796
|
+
`备注相关部件 ${nnNames.filter((k) => /notes/.test(k)).length} 个;parity.notesExp=${nnExp.parity.notesExp}`)
|
|
1797
|
+
|
|
1798
|
+
// 实体解码(2026-09-14 修):导入侧此前不做 XML 实体解码 → "R&D" 变成 "R&D"
|
|
1799
|
+
const reDir = join(notesDir, 'reimport')
|
|
1800
|
+
await rm(reDir, { recursive: true, force: true })
|
|
1801
|
+
await importPptx(nExp.file, reDir)
|
|
1802
|
+
const reYaml3 = readFileSync(join(reDir, 'pages', 'slide_03.yaml'), 'utf8')
|
|
1803
|
+
const reYaml1 = readFileSync(join(reDir, 'pages', 'slide_01.yaml'), 'utf8')
|
|
1804
|
+
ok('讲稿:导出 → 导入 回环(多行块标量 + 单行引号),且 XML 实体已解码(不再出现 </& 字面量)',
|
|
1805
|
+
/notes: \|/.test(reYaml1) && /第二行:再给证据。/.test(reYaml1) &&
|
|
1806
|
+
/notes: "单行讲稿带 <特殊> & 符号"/.test(reYaml3) && !/<|&/.test(reYaml3),
|
|
1807
|
+
reYaml3.match(/^notes:.*$/m)?.[0]?.slice(0, 60) ?? '(未找到 notes 行)')
|
|
1808
|
+
|
|
1809
|
+
// 图表配色:显式主题外颜色 → [·] 建议(非门禁);主题内 $ref → 不打扰
|
|
1810
|
+
const chartDir = join(notesDir, '..', '.tmp-chart-color')
|
|
1811
|
+
await rm(chartDir, { recursive: true, force: true })
|
|
1812
|
+
await mkdir(join(chartDir, 'pages'), { recursive: true })
|
|
1813
|
+
await writeFile(join(chartDir, 'deck.yaml'), ['version: 1', 'title: "配色"', 'size: [960, 540]', 'theme:', ' colors: {ink: "#111111", brand: "#2563EB"}', ' textStyles:', ' body: {fontSize: 16, color: "$ink"}', 'pages:', ' - pages/01.yaml', ' - pages/02.yaml', ''].join('\n'), 'utf8')
|
|
1814
|
+
const chartPage = (colors) => [
|
|
1815
|
+
'pageType: content',
|
|
1816
|
+
'elements:',
|
|
1817
|
+
' - elementId: c',
|
|
1818
|
+
' elementType: chart',
|
|
1819
|
+
' bounds: [60, 60, 400, 240]',
|
|
1820
|
+
' chart:',
|
|
1821
|
+
' type: bar',
|
|
1822
|
+
...(colors ? [` colors: ${colors}`] : []),
|
|
1823
|
+
' data:',
|
|
1824
|
+
' cols: [分类, 值]',
|
|
1825
|
+
' rows: [[甲, 1], [乙, 2]]',
|
|
1826
|
+
// 第二元素:美学建议层对单元素页直接返回(els.length < 2),必须 ≥2 个元素才谈得上"建议"
|
|
1827
|
+
' - elementId: t1',
|
|
1828
|
+
' elementType: text',
|
|
1829
|
+
' bounds: [60, 320, 600, 40]',
|
|
1830
|
+
' content: {text: "图页", style: "$body"}',
|
|
1831
|
+
'',
|
|
1832
|
+
].join('\n')
|
|
1833
|
+
await writeFile(join(chartDir, 'pages', '01.yaml'), chartPage('["#FF00FF"]'), 'utf8')
|
|
1834
|
+
await writeFile(join(chartDir, 'pages', '02.yaml'), chartPage('["$brand"]'), 'utf8')
|
|
1835
|
+
const cCtx = await resolveDeck(chartDir)
|
|
1836
|
+
const cLayout = (await renderDeck(cCtx, {})).layout
|
|
1837
|
+
const v1 = verifyDeck({ ...cLayout, pages: [cLayout.pages[0]] })
|
|
1838
|
+
const v2 = verifyDeck({ ...cLayout, pages: [cLayout.pages[1]] })
|
|
1839
|
+
ok('图表配色:显式主题外颜色 → [·] aesthetic-theme 建议,且**不新增门禁错误**(建议级,不门禁化)',
|
|
1840
|
+
/aesthetic-theme[^\n]*#FF00FF/.test(v1.text) && v1.text.split('\n').filter((l) => l.includes('[✗]')).length === 0,
|
|
1841
|
+
`命中:${(v1.text.match(/aesthetic-theme[^\n]*/) ?? [''])[0].slice(0, 80)}`)
|
|
1842
|
+
ok('图表配色:$ref 引用主题色 → 不出配色建议(不打扰),且预览与成品两层都解析为 hex',
|
|
1843
|
+
!/aesthetic-theme[^\n]*颜色/.test(v2.text),
|
|
1844
|
+
`v2 建议行:${(v2.text.match(/aesthetic-theme[^\n]*/) ?? ['(无)'])[0].slice(0, 60)}`)
|
|
1845
|
+
|
|
1846
|
+
// 独立读取器交叉(可选,第三方库 ≠ 真 PowerPoint,但能挡住结构性错误)
|
|
1847
|
+
let pyRead = 'python-pptx 不可用 → 跳过(不影响门禁)'
|
|
1848
|
+
try {
|
|
1849
|
+
const { execFileSync } = await import('node:child_process')
|
|
1850
|
+
const pyFile = join(notesDir, 'readback.py')
|
|
1851
|
+
await writeFile(pyFile, [
|
|
1852
|
+
'import json, sys',
|
|
1853
|
+
'try:',
|
|
1854
|
+
" sys.stdout.reconfigure(encoding='utf-8')",
|
|
1855
|
+
'except Exception:',
|
|
1856
|
+
' pass',
|
|
1857
|
+
'from pptx import Presentation',
|
|
1858
|
+
'prs = Presentation(sys.argv[1])',
|
|
1859
|
+
'print(json.dumps([s.notes_slide.notes_text_frame.text if s.has_notes_slide else None for s in prs.slides], ensure_ascii=False))',
|
|
1860
|
+
].join('\n'), 'utf8')
|
|
1861
|
+
const out = execFileSync('python', [pyFile, nExp.file], { encoding: 'utf8', timeout: 120000, env: { ...process.env, PYTHONIOENCODING: 'utf-8' } })
|
|
1862
|
+
const got = JSON.parse(out.trim())
|
|
1863
|
+
ok('讲稿:第三方读取器(python-pptx)独立读回讲稿文本(多行/单行/无备注页三种都对)',
|
|
1864
|
+
got.length === 3 && got[0] === '开场:先说结论。\n第二行:再给证据。' && got[1] === null && got[2] === '单行讲稿带 <特殊> & 符号',
|
|
1865
|
+
JSON.stringify(got))
|
|
1866
|
+
} catch (e) {
|
|
1867
|
+
ok('讲稿:第三方读取器(python-pptx)独立读回讲稿文本(多行/单行/无备注页三种都对)', true, pyRead = `⚠ ${String(e.message).slice(0, 80)}`)
|
|
1868
|
+
}
|
|
1869
|
+
await rm(notesDir, { recursive: true, force: true })
|
|
1870
|
+
await rm(noNotesDir, { recursive: true, force: true })
|
|
1871
|
+
await rm(chartDir, { recursive: true, force: true })
|
|
1872
|
+
|
|
1873
|
+
// ── 43. python-pptx 兜底引擎:生成物必须过**真消费者**(2026-09-18 新增)────────────────
|
|
1874
|
+
// 背景(真 bug):`scripts/` 里 genPythonScript / runPythonExport 此前**零调用**——这条兜底路径
|
|
1875
|
+
// 从来没有被任何测试执行过(grep 无匹配)。而它是"pptd 硬失败时"的最后保险:保险自己坏了没人知道。
|
|
1876
|
+
// 仓库那条"产物必须用真消费者验"(docs/05 §0d)是为 OOXML 产物写的,没人把它推广到**生成的源码**——
|
|
1877
|
+
// 而一次 `py_compile` 就能抓到 SyntaxError。这正是"跳过与通过印同一个颜色"的典型:python 是可选依赖,
|
|
1878
|
+
// 缺了就打一行"跳过"算绿,于是没人发现它连编译都过不去。
|
|
1879
|
+
// 判据:① 生成脚本必须能编译;② 必须能真跑出 .pptx 且 **python-pptx 能把它读回**。
|
|
1880
|
+
// 两个断言在缺 python / 缺 python-pptx 时都按"跳过"计并写明标签(断言总数保持恒定)。
|
|
1881
|
+
const pyDeck = join(root, 'examples', 'py-smoke')
|
|
1882
|
+
await rm(pyDeck, { recursive: true, force: true })
|
|
1883
|
+
await mkdir(join(pyDeck, 'pages'), { recursive: true })
|
|
1884
|
+
await writeFile(join(pyDeck, 'deck.yaml'), 'version: 1\ntitle: py\nsize: [960, 540]\ntheme: {colors: {primary: "#2563EB", ink: "#1F2937"}, textStyles: {body: {fontSize: 16, color: "$ink"}}}\npages:\n - pages/01.yaml\n')
|
|
1885
|
+
await writeFile(join(pyDeck, 'pages', '01.yaml'), [
|
|
1886
|
+
'pageType: content', 'elements:',
|
|
1887
|
+
' - elementId: boxFillLine', ' elementType: shape', ' kind: roundRect', ' bounds: [60, 60, 200, 100]',
|
|
1888
|
+
' fill: "#2563EB"', ' line: {color: "#FFFFFF", width: 2}',
|
|
1889
|
+
' - elementId: boxBare', ' elementType: shape', ' kind: rect', ' bounds: [300, 60, 200, 100]',
|
|
1890
|
+
' - elementId: boxAlpha', ' elementType: shape', ' kind: rect', ' bounds: [60, 200, 200, 80]',
|
|
1891
|
+
' fill: {color: "#F59E0B", alpha: 50}',
|
|
1892
|
+
' - elementId: link', ' elementType: line',
|
|
1893
|
+
' points: [[300, 200], [460, 200]]', ' line: {color: "#1F2937", width: 1}',
|
|
1894
|
+
' - elementId: tb', ' elementType: table', ' bounds: [60, 320, 400, 120]',
|
|
1895
|
+
' cols: [甲, 乙]', ' rows: [["1", "2"], ["3", "4"]]',
|
|
1896
|
+
' - elementId: t1', ' elementType: text', ' bounds: [500, 320, 300, 40]',
|
|
1897
|
+
' content: {text: "hello", style: "$body"}', '', ''].join('\n'))
|
|
1898
|
+
const { genPythonScript, findPython, runPythonExport } = await import('../lib/pptxPy.js')
|
|
1899
|
+
const pyEnv = findPython()
|
|
1900
|
+
const pyFile43 = join(pyDeck, 'gen.py')
|
|
1901
|
+
if (pyEnv.cmd) await writeFile(pyFile43, genPythonScript(await resolveDeck(pyDeck)), 'utf8')
|
|
1902
|
+
// 43.1 编译(只要 python,不需要 python-pptx)
|
|
1903
|
+
if (pyEnv.cmd) {
|
|
1904
|
+
const { spawnSync } = await import('node:child_process')
|
|
1905
|
+
const c = spawnSync(pyEnv.cmd, ['-m', 'py_compile', pyFile43], { encoding: 'utf8', timeout: 60000 })
|
|
1906
|
+
let detail = 'py_compile ✓'
|
|
1907
|
+
if (c.status !== 0) {
|
|
1908
|
+
const err = String(c.stderr ?? c.stdout ?? '')
|
|
1909
|
+
// Python 只报**生成文件**的行号;把那一行源码取出来,定位才不用猜(本 bug 就是靠这行看清的)
|
|
1910
|
+
const ln = Number(/line (\d+)/.exec(err)?.[1] ?? 0)
|
|
1911
|
+
let srcLine = ''
|
|
1912
|
+
try { srcLine = ln ? String(readFileSync(pyFile43, 'utf8').split(/\r?\n/)[ln - 1] ?? '').trim().slice(0, 160) : '' } catch { /* 忽略 */ }
|
|
1913
|
+
detail = `${err.trim().split('\n').slice(-2).join(' | ').slice(0, 180)}${srcLine ? `\n 生成源码第 ${ln} 行:${srcLine}` : ''}`
|
|
1914
|
+
}
|
|
1915
|
+
ok('python-pptx 兜底:生成脚本可编译(覆盖 shape 无填充/对象填充/带描边 + line + table + text)',
|
|
1916
|
+
c.status === 0, detail)
|
|
1917
|
+
} else {
|
|
1918
|
+
ok('python-pptx 兜底:生成脚本可编译(无 python:跳过)', true)
|
|
1919
|
+
}
|
|
1920
|
+
// 43.2 真跑 + 真消费者读回(需要 python + python-pptx)
|
|
1921
|
+
if (pyEnv.has) {
|
|
1922
|
+
let info = ''
|
|
1923
|
+
let pass43 = false
|
|
1924
|
+
try {
|
|
1925
|
+
const outP = join(pyDeck, 'out-py.pptx')
|
|
1926
|
+
const r = await runPythonExport(await resolveDeck(pyDeck), outP)
|
|
1927
|
+
const { execFileSync } = await import('node:child_process')
|
|
1928
|
+
const rb = join(pyDeck, 'readback2.py')
|
|
1929
|
+
await writeFile(rb, [
|
|
1930
|
+
'import sys',
|
|
1931
|
+
"sys.stdout.reconfigure(encoding='utf-8')",
|
|
1932
|
+
'from pptx import Presentation',
|
|
1933
|
+
'p = Presentation(sys.argv[1])',
|
|
1934
|
+
'print(len(p.slides), sum(len(s.shapes) for s in p.slides))',
|
|
1935
|
+
].join('\n'), 'utf8')
|
|
1936
|
+
const got = execFileSync(pyEnv.cmd, [rb, outP], { encoding: 'utf8', timeout: 120000, env: { ...process.env, PYTHONIOENCODING: 'utf-8' } }).trim()
|
|
1937
|
+
const [nSlides, nShapes] = got.split(/\s+/).map(Number)
|
|
1938
|
+
pass43 = nSlides === 1 && nShapes >= 6
|
|
1939
|
+
info = `file=${String(r.file).split(/[\\/]/).pop()}|python-pptx 读回:slides=${nSlides} shapes=${nShapes}(期望 1 / ≥6)`
|
|
1940
|
+
} catch (e) {
|
|
1941
|
+
info = String(e.message).slice(0, 240)
|
|
1942
|
+
}
|
|
1943
|
+
ok('python-pptx 兜底:生成脚本真跑出 .pptx,且 python-pptx 能把它读回(1 页 ≥ 6 个形状)', pass43, info)
|
|
1944
|
+
} else {
|
|
1945
|
+
ok('python-pptx 兜底:生成脚本真跑出 .pptx,且 python-pptx 能把它读回(无 python-pptx:跳过)', true)
|
|
1946
|
+
}
|
|
1947
|
+
await rm(pyDeck, { recursive: true, force: true })
|
|
1948
|
+
|
|
1949
|
+
// ── 44. 表格字号:audit 档的最小字号断言不得被表格自己架空(2026-09-18 新增)──────────────
|
|
1950
|
+
// 背景(真 bug · 自相矛盾):export-pptx 的表格 cell 硬编码 `sz="1100"`(11pt),而 audit 回读断言
|
|
1951
|
+
// 扫**全 slide XML** 取全局最小 sz 与 `theme.minFontSize` 比 ⇒ 只要工程里有一张表、且用户声明了
|
|
1952
|
+
// 下限 ≥ 12,audit 档就**永远 ✗**,而 DSL 里**没有任何手段**能修好它(table 元素没有字号字段)。
|
|
1953
|
+
// 这是"把用户逼到无法满足的门禁"。同一处还有跨层不一致:预览层硬编码 12px、成品层 11pt。
|
|
1954
|
+
// 判据:三层同源(normalizePage 算一次 fontPt → 快照 / 预览 / 成品都用它),且成品最小 sz ≥ 用户下限。
|
|
1955
|
+
const tfDeck = join(root, 'examples', 'tablefont-smoke')
|
|
1956
|
+
await rm(tfDeck, { recursive: true, force: true })
|
|
1957
|
+
await mkdir(join(tfDeck, 'pages'), { recursive: true })
|
|
1958
|
+
await writeFile(join(tfDeck, 'deck.yaml'), 'version: 1\ntitle: tf\nsize: [960, 540]\ntheme: {colors: {ink: "#1F2937"}, textStyles: {body: {fontSize: 18, color: "$ink"}}, minFontSize: 14}\npages:\n - pages/01.yaml\n')
|
|
1959
|
+
await writeFile(join(tfDeck, 'pages', '01.yaml'), [
|
|
1960
|
+
'pageType: content', 'elements:',
|
|
1961
|
+
' - elementId: tb', ' elementType: table', ' bounds: [60, 60, 800, 200]',
|
|
1962
|
+
' cols: [指标, 数值]', ' rows: [["甲", "1"], ["乙", "2"]]', '', ''].join('\n'))
|
|
1963
|
+
const tfCtx = await resolveDeck(tfDeck)
|
|
1964
|
+
const tfR = await renderDeck(tfCtx, {})
|
|
1965
|
+
const tfSnap = tfR.layout.pages[0].elements.find((e) => e.kind === 'table')
|
|
1966
|
+
const tfHtml = readFileSync(join(tfR.outDir, tfR.htmlFiles[0]), 'utf8')
|
|
1967
|
+
const tfExp = await exportPptx(tfCtx, { out: 'out-tablefont.pptx', engine: 'pptd' })
|
|
1968
|
+
const tfZip = zipRead(readFileSync(tfExp.file))
|
|
1969
|
+
let tfMin = Infinity
|
|
1970
|
+
for (let i = 1; tfZip.has(`ppt/slides/slide${i}.xml`); i++) {
|
|
1971
|
+
for (const m of tfZip.get(`ppt/slides/slide${i}.xml`).toString('utf8').matchAll(/sz="(\d+)"/g)) {
|
|
1972
|
+
tfMin = Math.min(tfMin, Number(m[1]))
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
ok('表格字号:三层同源(快照 fontPt = 预览 cell px = 成品 sz = 用户下限 14pt,不再 12px vs 11pt)',
|
|
1976
|
+
tfSnap?.fontPt === 14 && tfHtml.includes('font-size:14px') && tfMin / 100 === 14,
|
|
1977
|
+
`快照 fontPt=${tfSnap?.fontPt}|预览含 14px=${tfHtml.includes('font-size:14px')}|成品最小 ${tfMin / 100}pt`)
|
|
1978
|
+
ok('表格字号:成品全局最小 sz ≥ theme.minFontSize(audit 档不再被表格自相矛盾架空)',
|
|
1979
|
+
tfMin / 100 >= 14, `最小 ${tfMin / 100}pt / 用户下限 14pt`)
|
|
1980
|
+
await rm(tfDeck, { recursive: true, force: true })
|
|
1981
|
+
|
|
1982
|
+
// ── 45. 质量档静默降级:状态文件坏了必须可见(2026-09-18 新增)──────────────────────
|
|
1983
|
+
// 背景(真 bug):`loadSession` / `loadProject` 在"文件存在但读不了/解析失败"时**静默**返回默认值 ⇒
|
|
1984
|
+
// `state.json` 一损坏,`quality: 'audit'` 就**无声**变成 `standard`——最严档的额外门禁
|
|
1985
|
+
// (禁 autoDeclare / 强制视觉审阅 / 导出回读断言)全部消失,而用户看到的是一轮"正常"的 standard。
|
|
1986
|
+
// 修法核心是**区分两种情形**:文件不存在 = 正常(新工程,用默认);文件存在却坏了 = 异常,必须上报。
|
|
1987
|
+
const qsDeck = join(root, 'examples', 'quality-smoke')
|
|
1988
|
+
await rm(qsDeck, { recursive: true, force: true })
|
|
1989
|
+
await mkdir(qsDeck, { recursive: true })
|
|
1990
|
+
await writeFile(join(qsDeck, 'state.json'), '{ "quality": "audit", 这不是合法 JSON', 'utf8')
|
|
1991
|
+
const stMod = await import('../lib/state.js')
|
|
1992
|
+
const qsDiag = typeof stMod.loadProjectDiag === 'function'
|
|
1993
|
+
? await stMod.loadProjectDiag(qsDeck)
|
|
1994
|
+
: { state: await stMod.loadProject(qsDeck), error: null }
|
|
1995
|
+
ok('质量档:项目 state.json 损坏时必须**上报**(不得静默回落 standard)',
|
|
1996
|
+
qsDiag.error !== null && qsDiag.state.quality === 'standard',
|
|
1997
|
+
qsDiag.error ?? '(无诊断 API:损坏被静默吞掉、回落 standard 且不留任何痕迹)')
|
|
1998
|
+
const qsQ = typeof toolsMod.qualityOfDiag === 'function'
|
|
1999
|
+
? await toolsMod.qualityOfDiag(null, qsDeck)
|
|
2000
|
+
: { quality: 'standard', degraded: false, reason: '' }
|
|
2001
|
+
ok('质量档:降级时 degraded=true 且带原因(工具输出据此提示"audit 额外门禁未生效")',
|
|
2002
|
+
qsQ.degraded === true && qsQ.quality === 'standard' && String(qsQ.reason).length > 0,
|
|
2003
|
+
JSON.stringify(qsQ))
|
|
2004
|
+
// 反例(防"把新工程也判成降级"):文件不存在必须 error=null
|
|
2005
|
+
const qsNone = typeof stMod.loadProjectDiag === 'function'
|
|
2006
|
+
? await stMod.loadProjectDiag(join(qsDeck, 'no-such-dir'))
|
|
2007
|
+
: { state: await stMod.loadProject(join(qsDeck, 'no-such-dir')), error: null }
|
|
2008
|
+
ok('质量档:文件不存在时 error=null(新工程不得被误判为降级)', qsNone.error === null, String(qsNone.error))
|
|
2009
|
+
await rm(qsDeck, { recursive: true, force: true })
|
|
2010
|
+
|
|
2011
|
+
// ── 46. pptxgenjs 幽灵选项 + 「设了没用」的引擎档位(2026-09-18 新增)──────────────────
|
|
2012
|
+
// 背景(真 bug · 违反明文红线"不静默切换引擎"):命令面接受 `/ppt engine pptxgenjs` 并回执"已记录(待接入)",
|
|
2013
|
+
// 而 resolveEngine 把**任何**未知取值静默映射成 pptd ⇒ 用户以为选了引擎,实际按 pptd 跑,报告里一字不提。
|
|
2014
|
+
// 更深一层:`/ppt engine python-pptx` 设的档位**根本没被 ppt_export 消费**(导出只看工具参数)——
|
|
2015
|
+
// 一个"设了没用"的设置,而工作流提示里的 `引擎` 行还显示用户选的那个。
|
|
2016
|
+
ok('引擎:未知取值不再被静默当成 auto(resolveEngine 带 unknown 标记)',
|
|
2017
|
+
toolsMod.resolveEngine('pptxgenjs').unknown === 'pptxgenjs' && toolsMod.resolveEngine('auto').unknown === undefined,
|
|
2018
|
+
JSON.stringify(toolsMod.resolveEngine('pptxgenjs')))
|
|
2019
|
+
{
|
|
2020
|
+
const cmdSrc = stripComments(readFileSync(join(root, 'src', 'commands.js'), 'utf8'))
|
|
2021
|
+
ok('引擎:命令面不再把 pptxgenjs 收进接受集合(未实现 → 明确拒绝而非装作成功)',
|
|
2022
|
+
/pptxgenjs/.test(cmdSrc) && /未实现/.test(cmdSrc) && !/,\s*'pptxgenjs'\]/.test(cmdSrc),
|
|
2023
|
+
/,\s*'pptxgenjs'\]/.test(cmdSrc) ? '仍在接受集合里' : '已改为拒绝分支')
|
|
2024
|
+
}
|
|
2025
|
+
const egDeck = join(root, 'examples', 'engine-smoke')
|
|
2026
|
+
await rm(egDeck, { recursive: true, force: true })
|
|
2027
|
+
await mkdir(egDeck, { recursive: true })
|
|
2028
|
+
await writeFile(join(egDeck, 'state.json'), JSON.stringify({ engine: 'python-pptx' }), 'utf8')
|
|
2029
|
+
const egQ = typeof toolsMod.qualityOfDiag === 'function' ? await toolsMod.qualityOfDiag(null, egDeck) : {}
|
|
2030
|
+
ok('引擎:会话/项目档位可被导出侧读到(`/ppt engine X` 不再"设了没用")',
|
|
2031
|
+
egQ.engine === 'python-pptx', `engine=${egQ.engine ?? '(未带出)'}`)
|
|
2032
|
+
await rm(egDeck, { recursive: true, force: true })
|
|
2033
|
+
|
|
2034
|
+
// ── 47. 出界分级:两个 code 必须可分(2026-09-18 新增)──────────────────────────────
|
|
2035
|
+
// 背景(真 bug · 分级语义在 code 维度丢失):`out-of-page` 同时用于"超页面边界"(放映不可见、**不可声明**、
|
|
2036
|
+
// 必须改布局)与"超安全区"(**可声明**、补 expectedOutOfSafeArea 即可)。两者处置完全不同,
|
|
2037
|
+
// 共用一个 code 让消费方(templates 的清理统计、任何按 code 的诊断)与模型都分不清该做什么。
|
|
2038
|
+
// severity 都是 error ⇒ 这是**诊断精度**问题而非门禁强度问题,改动对既有工程零影响。
|
|
2039
|
+
{
|
|
2040
|
+
const og = verifyDeck({
|
|
2041
|
+
size: { width: 960, height: 540 },
|
|
2042
|
+
theme: { colors: {}, themeConformance: 'off' },
|
|
2043
|
+
pages: [{
|
|
2044
|
+
index: 0, pageNo: 1, name: 'p', safeArea: { top: 20, bottom: 20, left: 0, right: 0 },
|
|
2045
|
+
overlapMode: 'declared', expectedOverlaps: [], expectedOutOfSafeArea: [], contrastExempt: [],
|
|
2046
|
+
elements: [
|
|
2047
|
+
{ id: 'outsidePage', kind: 'shape', bounds: { x: -5, y: 0, w: 10, h: 10 } },
|
|
2048
|
+
{ id: 'outsideSafe', kind: 'shape', bounds: { x: 40, y: 4, w: 120, h: 12 } },
|
|
2049
|
+
],
|
|
2050
|
+
}],
|
|
2051
|
+
})
|
|
2052
|
+
ok('出界分级:超页面边界=out-of-page、超安全区=out-of-safe-area(两码可分,处置不同)',
|
|
2053
|
+
/\[✗\] out-of-page/.test(og.text) && /\[✗\] out-of-safe-area/.test(og.text),
|
|
2054
|
+
og.text.split('\n').filter((l) => l.includes('[✗]')).join(';').slice(0, 200))
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// ── 48. surgicalMap:deck.yaml 里的页映射必须真的被采纳(2026-09-18 新增)────────────────
|
|
2058
|
+
// 背景(真 bug · 契约存在但不被消费):schema 校验 `deck.yaml` 的 `surgicalMap`,但**全项目无消费者**——
|
|
2059
|
+
// `ppt_patch` 只吃工具参数 `map`。用户把映射写进 deck.yaml 以为生效,实际**静默无效**(最难发现的一类)。
|
|
2060
|
+
const smDir = join(root, 'examples', 'surgicalmap-smoke')
|
|
2061
|
+
await rm(smDir, { recursive: true, force: true })
|
|
2062
|
+
await mkdir(join(smDir, 'pages'), { recursive: true })
|
|
2063
|
+
const smDeckHead = 'version: 1\ntitle: sm\nsize: [960, 540]\ntheme: {colors: {ink: "#1F2937"}, textStyles: {body: {fontSize: 18, color: "$ink"}}}\n'
|
|
2064
|
+
await writeFile(join(smDir, 'deck.yaml'), `${smDeckHead}pages:\n - pages/01.yaml\n - pages/02.yaml\n`)
|
|
2065
|
+
await writeFile(join(smDir, 'pages', '01.yaml'), 'pageType: content\nelements:\n - elementId: t\n elementType: text\n bounds: [60, 60, 600, 60]\n content: {text: "AAA 第一页", style: "$body"}\n')
|
|
2066
|
+
await writeFile(join(smDir, 'pages', '02.yaml'), 'pageType: content\nelements:\n - elementId: t\n elementType: text\n bounds: [60, 60, 600, 60]\n content: {text: "BBB 第二页", style: "$body"}\n')
|
|
2067
|
+
const smTpl = await exportPptx(await resolveDeck(smDir), { out: 'tpl.pptx', engine: 'pptd' })
|
|
2068
|
+
const smSlide1 = (f) => zipRead(readFileSync(f)).get('ppt/slides/slide1.xml').toString('utf8')
|
|
2069
|
+
const smA = await surgicalPatch({ template: smTpl.file, deckDir: smDir, out: join(smDir, 'identity.pptx') })
|
|
2070
|
+
// 写入"交换"映射:模板第 1 页 ← deck 第 2 页
|
|
2071
|
+
await writeFile(join(smDir, 'deck.yaml'), `${smDeckHead}surgicalMap: {1: 2, 2: 1}\npages:\n - pages/01.yaml\n - pages/02.yaml\n`)
|
|
2072
|
+
const smB = await surgicalPatch({ template: smTpl.file, deckDir: smDir, out: join(smDir, 'mapped.pptx') })
|
|
2073
|
+
ok('surgicalMap:deck.yaml 里的页映射**真的被采纳**(模板第 1 页取到 deck 第 2 页的内容)',
|
|
2074
|
+
smSlide1(smA.out).includes('AAA') && smSlide1(smB.out).includes('BBB'),
|
|
2075
|
+
`序号对齐含 AAA=${smSlide1(smA.out).includes('AAA')}|采纳 deck 映射后含 BBB=${smSlide1(smB.out).includes('BBB')}`)
|
|
2076
|
+
const smC = await surgicalPatch({ template: smTpl.file, deckDir: smDir, out: join(smDir, 'explicit.pptx'), map: { 1: 2, 2: 1 } })
|
|
2077
|
+
ok('surgicalMap:显式 map 参数与 deck.yaml 字段同义(参数优先,行为不回退)',
|
|
2078
|
+
smSlide1(smC.out).includes('BBB') && smSlide1(smC.out) === smSlide1(smB.out),
|
|
2079
|
+
`显式参数结果与 deck 字段一致=${smSlide1(smC.out) === smSlide1(smB.out)}`)
|
|
2080
|
+
await rm(smDir, { recursive: true, force: true })
|
|
2081
|
+
|
|
2082
|
+
// ── 49. 文档漂移守卫:工具描述里的数字必须等于真产物(2026-09-18 新增)──────────────
|
|
2083
|
+
// 背景(真 bug · 文档漂移):`ppt_new` 的描述写着"deck.yaml + 3 个页面",而 `scaffoldProject` 实际生成 **4 个**。
|
|
2084
|
+
// 工具描述是**模型读的事实来源**,写错就是让模型按错的事实工作(docs/05 §0c"手册里的每个事实都要能指回源码行"
|
|
2085
|
+
// 同样适用于工具描述)。此前只有"文档里的 smoke 断言数"进了机器防线,其它同类事实没有。
|
|
2086
|
+
{
|
|
2087
|
+
const { scaffoldProject } = await import('../lib/scaffold.js')
|
|
2088
|
+
const sdDir = join(root, 'examples', 'scaffold-count-smoke')
|
|
2089
|
+
await rm(sdDir, { recursive: true, force: true })
|
|
2090
|
+
await scaffoldProject(sdDir, { name: 'count' })
|
|
2091
|
+
const { readdir: rd } = await import('node:fs/promises')
|
|
2092
|
+
const realPages = (await rd(join(sdDir, 'pages'))).filter((f) => f.endsWith('.yaml')).length
|
|
2093
|
+
const m = readFileSync(join(root, 'src', 'tools.js'), 'utf8').match(/deck\.yaml \+ (\d+) 个页面/)
|
|
2094
|
+
ok('文档漂移:ppt_new 描述里的页数 == scaffoldProject 实际产出页数(工具描述也是模型读的事实)',
|
|
2095
|
+
Boolean(m) && Number(m[1]) === realPages, `描述=${m?.[1] ?? '(未匹配)'}|实际=${realPages}`)
|
|
2096
|
+
await rm(sdDir, { recursive: true, force: true })
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// ── 50. 表格门禁:内容溢出必须被拦(2026-09-18 新增)──────────────────────────────
|
|
2100
|
+
// 背景(真 bug · 内容门禁的盲区):layout 快照里表格只有 cols/rows 的**长度**,verify 的溢出判定只认
|
|
2101
|
+
// `kind === 'text'` ⇒ **表格内文字溢出既无门禁也无警告**。而表格是 role:content 的内容元素——
|
|
2102
|
+
// "内容溢出/互压"正是硬底线要管的(该严的地方没严)。
|
|
2103
|
+
// 影响面已实测:仓库全部夹具 2 张表、**0 张**会被判溢出(余量 -228 / -52.8)⇒ 按 error 档接入对既有工程零影响。
|
|
2104
|
+
// 两条一起看才算数:**该拦的拦得住** + **给足高度的不得误报**(保守度量不能变成噪声)。
|
|
2105
|
+
const toDir = join(root, 'examples', 'tableoverflow-smoke')
|
|
2106
|
+
await rm(toDir, { recursive: true, force: true })
|
|
2107
|
+
await mkdir(join(toDir, 'pages'), { recursive: true })
|
|
2108
|
+
await writeFile(join(toDir, 'deck.yaml'), 'version: 1\ntitle: to\nsize: [960, 540]\ntheme: {colors: {ink: "#1F2937"}, textStyles: {body: {fontSize: 18, color: "$ink"}}}\npages:\n - pages/01.yaml\n - pages/02.yaml\n')
|
|
2109
|
+
const toCells = ' cols: [分类, 说明]\n rows: [["甲", "这是一段很长的单元格说明文字用于触发溢出判定"], ["乙", "另一段同样很长的说明文字继续撑高需求高度"]]\n'
|
|
2110
|
+
await writeFile(join(toDir, 'pages', '01.yaml'), `pageType: content\nelements:\n - elementId: tight\n elementType: table\n bounds: [60, 60, 300, 40]\n${toCells}`)
|
|
2111
|
+
await writeFile(join(toDir, 'pages', '02.yaml'), `pageType: content\nelements:\n - elementId: roomy\n elementType: table\n bounds: [60, 60, 300, 400]\n${toCells}`)
|
|
2112
|
+
const toLayout = (await renderDeck(await resolveDeck(toDir), {})).layout
|
|
2113
|
+
const toV = verifyDeck(toLayout)
|
|
2114
|
+
const toErr = (id) => toV.text.split('\n').filter((l) => l.includes('table-overflow') && l.includes(id))
|
|
2115
|
+
const toRoomySnap = toLayout.pages[1].elements.find((e) => e.id === 'roomy')
|
|
2116
|
+
ok('表格门禁:单元格内容按列宽换行后超出表格高度 → table-overflow ERROR(此前是内容门禁的盲区)',
|
|
2117
|
+
toErr('tight').length === 1, `tight 命中 ${toErr('tight').length} 条|${toErr('tight')[0]?.slice(0, 96) ?? '(无)'}`)
|
|
2118
|
+
ok('表格门禁:给足高度的表格不得误报(且估算确实跑过——快照带 overflowY)',
|
|
2119
|
+
toRoomySnap?.metrics?.overflowY !== undefined && toErr('roomy').length === 0,
|
|
2120
|
+
`roomy overflowY=${toRoomySnap?.metrics?.overflowY ?? '(缺)'}|误报 ${toErr('roomy').length} 条`)
|
|
2121
|
+
await rm(toDir, { recursive: true, force: true })
|
|
2122
|
+
|
|
2123
|
+
// ── 40. profile bundle 安装路径(2026-09-14:"dsh plugin add 能不能装")────────────────
|
|
2124
|
+
// 机制:`dsh plugin --profile <p> <args>` = 在 profile 目录跑 pnpm,然后按**已安装状态**核对
|
|
2125
|
+
// dsh.profile.bundles——声明了 dsh.bundle.patch 的依赖自动入栈。真机端到端自证在
|
|
2126
|
+
// `scripts/verify-bundle-install.mjs`(隔离 DSH_HOME + 真 `dsh plugin add` + dump-config);
|
|
2127
|
+
// 这里守的是**不变量**(清单声明/文件/名字一致 + 防重行为),因为它们是改名/重构时最容易漏、
|
|
2128
|
+
// 且漏了以后表现为"装了不生效"的静默失败。
|
|
2129
|
+
const rootPkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
2130
|
+
const patchRel = rootPkg.dsh?.bundle?.patch
|
|
2131
|
+
const patchPath = patchRel ? join(root, String(patchRel).replace(/^\.\//, '')) : null
|
|
2132
|
+
const patchExists = patchPath ? existsSync(patchPath) : false
|
|
2133
|
+
ok('bundle:package.json 声明 dsh.bundle.patch,且文件存在并纳入发行物(files)',
|
|
2134
|
+
Boolean(patchRel) && patchExists && rootPkg.files.includes(String(patchRel).replace(/^\.\//, '')),
|
|
2135
|
+
`dsh.bundle.patch=${patchRel ?? '(未声明)'} 文件存在=${patchExists} 在 files 里=${rootPkg.files.includes(String(patchRel).replace(/^\.\//, ''))}`)
|
|
2136
|
+
|
|
2137
|
+
let patchDoc = null
|
|
2138
|
+
try { patchDoc = (await import('yaml')).default.parse(readFileSync(patchPath, 'utf8')) } catch { /* 解析失败由下条断言报出 */ }
|
|
2139
|
+
const insertRow = Array.isArray(patchDoc) ? (patchDoc.flatMap((l) => l?.insert ?? [])[0] ?? null) : null
|
|
2140
|
+
ok('bundle:patch 的 insert 行名字与包名一致(改名必须两处同改——防"装了不生效"的静默失败)',
|
|
2141
|
+
Boolean(insertRow) && insertRow.name === rootPkg.name && typeof insertRow.id === 'string',
|
|
2142
|
+
`patch name=${insertRow?.name ?? '(缺)'}|package name=${rootPkg.name}|id=${insertRow?.id ?? '(缺)'}`)
|
|
2143
|
+
ok('bundle:已转为公开发布 npm(无 private + publishConfig.access=public + 仓库元数据齐)——npm 通道的关键字段',
|
|
2144
|
+
rootPkg.private === undefined && rootPkg.publishConfig?.access === 'public'
|
|
2145
|
+
&& Boolean(rootPkg.repository?.url) && Boolean(rootPkg.homepage) && Boolean(rootPkg.bugs?.url),
|
|
2146
|
+
`private=${rootPkg.private ?? 'undefined'}|publishConfig.access=${rootPkg.publishConfig?.access ?? '(缺)'}|repository=${rootPkg.repository?.url ?? '(缺)'}|homepage=${rootPkg.homepage ? '有' : '(缺)'}`)
|
|
2147
|
+
|
|
2148
|
+
// lib/ 必须**提交**且与 src/ 逐字节一致(2026-09-16):
|
|
2149
|
+
// 商店(dsh-web 创意工坊)对没有 npm 包的条目生成的安装命令是 `dsh plugin --profile web add <repo URL>`
|
|
2150
|
+
// (见对方仓库 market/src/app.js),而 git 安装只拿得到**已提交**内容——lib/ 不入库时装出来的包缺
|
|
2151
|
+
// ./lib/index.js(exports 指向它),插件挂不上(本机隔离 DSH_HOME 实测:装到的包没有 lib/)。
|
|
2152
|
+
// 反过来,lib 入库后"改了 src 忘了 build + commit"会静默发出旧代码,所以这条同时守住"入库 + 一致"。
|
|
2153
|
+
{
|
|
2154
|
+
const { readdirSync: rd } = await import('node:fs')
|
|
2155
|
+
const { relative: rel } = await import('node:path')
|
|
2156
|
+
const { spawnSync } = await import('node:child_process')
|
|
2157
|
+
const walk = (dir) => {
|
|
2158
|
+
const out = []
|
|
2159
|
+
const stack = [dir]
|
|
2160
|
+
while (stack.length) {
|
|
2161
|
+
const cur = stack.pop()
|
|
2162
|
+
for (const e of rd(cur, { withFileTypes: true })) {
|
|
2163
|
+
const p = join(cur, e.name)
|
|
2164
|
+
if (e.isDirectory()) stack.push(p)
|
|
2165
|
+
else if (e.isFile()) out.push(rel(dir, p).split('\\').join('/'))
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
return out.sort()
|
|
2169
|
+
}
|
|
2170
|
+
const libDir = join(root, 'lib')
|
|
2171
|
+
const srcDir = join(root, 'src')
|
|
2172
|
+
const libFiles = walk(libDir)
|
|
2173
|
+
const libSet = new Set(libFiles)
|
|
2174
|
+
const srcFiles = walk(srcDir)
|
|
2175
|
+
const libMissing = srcFiles.filter((f) => !libSet.has(f))
|
|
2176
|
+
const libStale = srcFiles.filter((f) => libSet.has(f) && !readFileSync(join(srcDir, f)).equals(readFileSync(join(libDir, f))))
|
|
2177
|
+
const ignored = /^lib\/\s*$/m.test(readFileSync(join(root, '.gitignore'), 'utf8'))
|
|
2178
|
+
let tracked = null
|
|
2179
|
+
if (existsSync(join(root, '.git'))) {
|
|
2180
|
+
tracked = (spawnSync('git', ['ls-files', 'lib'], { cwd: root, encoding: 'utf8' }).stdout ?? '').trim().split('\n').filter(Boolean).length
|
|
2181
|
+
}
|
|
2182
|
+
ok('bundle:lib/ 已提交且与 src/ 逐字节一致(git 安装只拿已提交内容——缺 lib 则插件挂不上)',
|
|
2183
|
+
libMissing.length === 0 && libStale.length === 0 && !ignored && (tracked === null || tracked >= libFiles.length),
|
|
2184
|
+
`lib=${libFiles.length} 文件|src=${srcFiles.length}|缺失=${libMissing.length}|不一致=${libStale.length}|.gitignore 忽略=${ignored}|git 跟踪=${tracked ?? '无 .git 跳过'}`)
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
// 包名是**单一事实源**:改名必须同步 cordis.patch.yml(漏 = 装了不生效)与预设里的插件行(若存在)。
|
|
2188
|
+
// 【2026-09-18 改名时抓到的假阳性】旧断言用 `presetText.includes("name: '<包名>'")` 判"预设含该行"——
|
|
2189
|
+
// 它命中的其实是预设文件里**注释中的举例字符串**,于是"随包预设不该含插件行"这件事被它悄悄放过了:
|
|
2190
|
+
// 旧包名时靠注释意外通过,改名后立刻变红。现在只认**真实的插件行**(`- id: ppt-studio` 后紧跟的 name 行),
|
|
2191
|
+
// 并把"不含行"当作正确状态(含行 ⇒ 预设 broken ⇒ 选择器看不到它,由 verify-bundle-install / check-preset 断言)。
|
|
2192
|
+
{
|
|
2193
|
+
const presetText = readFileSync(join(root, 'agent-presets', 'ppt', 'agent.cordis.yml'), 'utf8')
|
|
2194
|
+
const rowMatch = presetText.match(/^-\s*id:\s*ppt-studio\s*$\n\s*name:\s*['"]([^'"]+)['"]/m)
|
|
2195
|
+
const presetRowName = rowMatch ? rowMatch[1] : null
|
|
2196
|
+
ok('bundle:包名三处一致(package.json / cordis.patch.yml / 预设**若有**插件行则同步)——改名防漂移',
|
|
2197
|
+
insertRow?.name === rootPkg.name && (presetRowName === null || presetRowName === rootPkg.name),
|
|
2198
|
+
`package=${rootPkg.name}|patch=${insertRow?.name ?? '(缺)'}|预设行=${presetRowName ?? '无(正确:随包预设不含插件行)'}`)
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
// 回滚后的装配形状(2026-09-18 第二次修订):`apply()` 在**它被装入的那一层**注册全部能力面
|
|
2202
|
+
// (工具 / `/ppt` 命令 / `ppt_state` / 4 本技能)⇒ 装上插件,**所有会话**都能用。
|
|
2203
|
+
// 为什么放弃按预设隔离(两件都实测过,代码见 src/index.js 顶部):
|
|
2204
|
+
// ① 空白会话**切换**预设时 `agent-presets.swap` 会 `recompose(agent.ctx, id)`,但那一刻该 agent 已存在,
|
|
2205
|
+
// 我们的 `agent/created` 监听者才刚在这次组合中注册 ⇒ **不会**为它触发 ⇒ 切过去永远没有工具;
|
|
2206
|
+
// ② 技能注册表分层、技能工具在**预设层**读,而我们只能注册到 agent 子层或 profile 根 ⇒ 技能永不出现。
|
|
2207
|
+
{
|
|
2208
|
+
const indexMod = await import('../lib/index.js')
|
|
2209
|
+
let tools = 0, cmds = 0, skills = 0, listeners = 0
|
|
2210
|
+
const handlers = new Map()
|
|
2211
|
+
const skillsSvc = { register: () => { skills++ } }
|
|
2212
|
+
// 预设注册表 stub(**按 DSH 0.1.7 的真实契约**):`register()` 对重复 id **抛错**
|
|
2213
|
+
// (真源码:`if (this.definitions.has(definition.id)) throw new Error(\`Duplicate agent preset: ${def.id}\`)`),
|
|
2214
|
+
// 并返回注销函数。于是"双挂载会不会把同一个预设声明两次"在这里是可判定的。
|
|
2215
|
+
const declared = []
|
|
2216
|
+
const seenIds = new Set()
|
|
2217
|
+
const registry = {
|
|
2218
|
+
register: (def) => {
|
|
2219
|
+
if (seenIds.has(def.id)) return Promise.reject(new Error(`Duplicate agent preset: ${def.id}`))
|
|
2220
|
+
seenIds.add(def.id)
|
|
2221
|
+
declared.push(def)
|
|
2222
|
+
return Promise.resolve(async () => { seenIds.delete(def.id) })
|
|
2223
|
+
},
|
|
2224
|
+
}
|
|
2225
|
+
const childOf = () => ({ get: (k) => (k === 'agentPresets' ? registry : undefined) })
|
|
2226
|
+
const makeCtx = () => ({
|
|
2227
|
+
tools: { register: () => { tools++ } },
|
|
2228
|
+
commands: { register: () => { cmds++ } },
|
|
2229
|
+
skills: skillsSvc,
|
|
2230
|
+
get: (k) => (k === 'skills' ? skillsSvc : k === 'agentPresets' ? registry : undefined),
|
|
2231
|
+
agentPresets: registry,
|
|
2232
|
+
// 0.1.7 的正确取法:注册表可能比本插件行**晚激活** ⇒ `inject` 等它出现(服务消失时子 ctx 释放)
|
|
2233
|
+
inject: (deps, fn) => { if (deps.includes('agentPresets')) fn(childOf()) },
|
|
2234
|
+
on: (ev, fn) => { listeners++; handlers.set(ev, fn) },
|
|
2235
|
+
effect: (fn) => { fn(); return () => {} },
|
|
2236
|
+
logger: () => ({ info: () => {}, warn: () => {} }),
|
|
2237
|
+
})
|
|
2238
|
+
// 装配防重计数是**进程级**(globalThis):本节要自己从 0 起算,否则会被前面的用例带偏
|
|
2239
|
+
delete globalThis.__pptCoreReg
|
|
2240
|
+
indexMod.apply(makeCtx(), { presetIds: ['ppt'] })
|
|
2241
|
+
const flush = () => new Promise((r) => setTimeout(r, 0))
|
|
2242
|
+
await flush() // 声明走的是异步队列(inject → register),让它落地
|
|
2243
|
+
ok('回滚:apply 在**装入层**注册工具与命令面(22 工具 + /ppt 命令)——"装上即可用"',
|
|
2244
|
+
tools === 22 && cmds >= 1, `tools=${tools} cmds=${cmds}`)
|
|
2245
|
+
ok('回滚:4 本内嵌技能注册在**同一层**(技能注册表分层,技能工具在父层读——注册到子层就永远看不见)',
|
|
2246
|
+
skills === 4, `skills=${skills}(应=4)`)
|
|
2247
|
+
ok('回滚:隔离代码已移除(不再导出 mountForAgent,也不再用 roster 决定装配)',
|
|
2248
|
+
indexMod.mountForAgent === undefined && tools === 22, `mountForAgent=${typeof indexMod.mountForAgent}|tools=${tools}`)
|
|
2249
|
+
// ── 0.1.7 的**真适配**:预设靠"向注册表声明"交付(写目录那条路已被上游删除)────────────
|
|
2250
|
+
// 判据分三层:① 真的调到 register 了;② 声明形状对(id/名/序/行数/内容);③ 双挂载不会声明两次。
|
|
2251
|
+
const d0 = declared[0]
|
|
2252
|
+
ok('0.1.7:预设靠 `agentPresets.register()` **声明**(不再写目录——上游已无目录发现路径)',
|
|
2253
|
+
declared.length === 1 && d0?.id === 'ppt', `声明次数=${declared.length}|id=${d0?.id ?? '(无)'}`)
|
|
2254
|
+
ok('0.1.7:声明形状取自包内 `agent-presets/ppt/`(名字/简介/序号 + 19 条 standard 组合行)',
|
|
2255
|
+
d0?.name === 'PPT 工作室' && d0?.order === 2 && d0?.plugins?.length === 19
|
|
2256
|
+
&& typeof d0?.description === 'string' && d0.description.length > 10,
|
|
2257
|
+
`name=${d0?.name ?? '(无)'}|order=${d0?.order}|rows=${d0?.plugins?.length}|desc=${d0?.description?.length ?? 0} 字`)
|
|
2258
|
+
// `!!js` 必须**保真**成 Loader 认的 `{__jsExpr}`:降级成普通字符串会让
|
|
2259
|
+
// `Boolean(<非空字符串>) === true` ⇒ Windows 上 pwsh 行被误停用(= 没有 shell)。
|
|
2260
|
+
const rowOf = (def, id) => def?.plugins?.find((r) => r.id === id)
|
|
2261
|
+
const bashD = rowOf(d0, 'tool-bash')?.disabled
|
|
2262
|
+
const pwshD = rowOf(d0, 'tool-pwsh')?.disabled
|
|
2263
|
+
ok('0.1.7:`!!js` 表达式保真为 `{__jsExpr}`(降级成字符串 ⇒ Windows 上 pwsh 行会被误停用)',
|
|
2264
|
+
bashD?.__jsExpr === "process.platform === 'win32'" && pwshD?.__jsExpr === "process.platform !== 'win32'",
|
|
2265
|
+
`bash=${JSON.stringify(bashD)}|pwsh=${JSON.stringify(pwshD)}`)
|
|
2266
|
+
const first = { tools, cmds, skills }
|
|
2267
|
+
indexMod.apply(makeCtx(), { presetIds: ['ppt'] }) // 第二次装配(双挂载场景)
|
|
2268
|
+
await flush()
|
|
2269
|
+
ok('回滚:同进程第二次装配被防重拦下(首个生效 + 计数),不重复注册',
|
|
2270
|
+
tools === first.tools && cmds === first.cmds && skills === first.skills,
|
|
2271
|
+
`二次后 tools=${tools}(应=${first.tools})cmds=${cmds} skills=${skills}|refcount=${globalThis.__pptCoreReg?.count ?? '?'}`)
|
|
2272
|
+
ok('0.1.7:双挂载也**不会重复声明**预设(真注册表对重复 id 直接 throw ⇒ 声明只发一次)',
|
|
2273
|
+
declared.length === 1, `声明次数=${declared.length}(应=1)`)
|
|
2274
|
+
ok('回滚:apply 装的全局管道仍在(预览路由/语义路由/提示段注入靠这些监听器)',
|
|
2275
|
+
listeners >= 2, `listeners=${listeners}`)
|
|
2276
|
+
let promptOk = false
|
|
2277
|
+
try {
|
|
2278
|
+
const h = handlers.get('system-prompt/assemble')
|
|
2279
|
+
const out = h ? await h({ sections: [] }, { agent: { session: { id: 'no-such-session-xyz' } } }, async () => ({ sections: [] })) : null
|
|
2280
|
+
promptOk = Boolean(out)
|
|
2281
|
+
} catch { promptOk = false }
|
|
2282
|
+
ok('回滚:提示段注入不按预设门控(读不到会话状态时原样返回,不抛)', promptOk, promptOk ? '原样返回 ✓' : '未注册/抛错')
|
|
2283
|
+
// 注册表缺失时必须**只告警不抛**(极简部署没有 agentPresets):插件仍要完整可用
|
|
2284
|
+
let noRegThrew = false
|
|
2285
|
+
delete globalThis.__pptCoreReg
|
|
2286
|
+
try {
|
|
2287
|
+
indexMod.apply({
|
|
2288
|
+
tools: { register: () => {} }, commands: { register: () => {} }, skills: skillsSvc,
|
|
2289
|
+
get: () => undefined, inject: () => {}, on: () => {}, effect: (fn) => { fn(); return () => {} },
|
|
2290
|
+
logger: () => ({ info: () => {}, warn: () => {} }),
|
|
2291
|
+
}, {})
|
|
2292
|
+
await flush()
|
|
2293
|
+
} catch { noRegThrew = true }
|
|
2294
|
+
ok('0.1.7:注册表不可用时只告警不抛(预设没了也不能让插件挂不上)',
|
|
2295
|
+
noRegThrew === false, noRegThrew ? '装配抛错了' : '静默降级 ✓')
|
|
2296
|
+
delete globalThis.__pptCoreReg
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// ── 41. 安装器 + 预设**声明**(2026-09-24 为 DSH 0.1.7-rc.1 重写)──────────────────────
|
|
2300
|
+
// 0.1.6 及以前这条测的是"预设目录内的 junction + 相对路径行"。0.1.7 把目录发现整个删掉了
|
|
2301
|
+
// (上游原文 "the harness discovers no preset on disk"),预设改为向 `agentPresets` 注册表**声明**,
|
|
2302
|
+
// 安装器也不再写预设文件。这里钉五件事:
|
|
2303
|
+
// ① 默认=全局(profile bundle):exit 0,且**不产出**任何 `.agent-presets/`(写了也没人读);
|
|
2304
|
+
// ② 0.1.6 遗留的预设目录被**清理**(留着会让"改了预设为什么没生效"变成假线索);
|
|
2305
|
+
// ③ `--isolate` **明确拒绝**(exit 1 + 说清原因),不写出一个永远不会被读到的预设;
|
|
2306
|
+
// ④ 装到的副本带着"声明预设"所需的资产,且组合里**不含**本包插件行(含行 ⇒ 双挂载);
|
|
2307
|
+
// ⑤ 夹具清理不碰仓库本体。
|
|
2308
|
+
{
|
|
2309
|
+
const { spawnSync } = await import('node:child_process')
|
|
2310
|
+
const { cp } = await import('node:fs/promises')
|
|
2311
|
+
const base = join(root, 'examples', 'smoke', '.tmp-install-mode')
|
|
2312
|
+
await rm(base, { recursive: true, force: true })
|
|
2313
|
+
const mkPrefix = async (name, withDep) => {
|
|
2314
|
+
const dir = join(base, name)
|
|
2315
|
+
await mkdir(join(dir, 'profiles', 'web'), { recursive: true })
|
|
2316
|
+
await writeFile(join(dir, 'profiles', 'web', 'package.json'),
|
|
2317
|
+
JSON.stringify({ name: 'p', dependencies: withDep ? { [rootPkg.name]: 'file:x' } : {} }, null, 2), 'utf8')
|
|
2318
|
+
// 包本体归 pnpm:夹具要真的"有包",并且带上**声明预设所需的资产**(否则安装器的资产校验会正确报错)
|
|
2319
|
+
const pd = join(dir, 'profiles', 'web', 'node_modules', 'dsh-ppt-studio')
|
|
2320
|
+
await mkdir(pd, { recursive: true })
|
|
2321
|
+
await writeFile(join(pd, 'package.json'), JSON.stringify({ name: rootPkg.name, version: rootPkg.version }), 'utf8')
|
|
2322
|
+
if (withDep) {
|
|
2323
|
+
await cp(join(root, 'lib'), join(pd, 'lib'), { recursive: true })
|
|
2324
|
+
await cp(join(root, 'agent-presets'), join(pd, 'agent-presets'), { recursive: true })
|
|
2325
|
+
}
|
|
2326
|
+
return dir
|
|
2327
|
+
}
|
|
2328
|
+
const pfxGlb = await mkPrefix('glb', true) // 默认模式:已按 bundle 安装 → 走快路径
|
|
2329
|
+
const pfxNo = await mkPrefix('nobundle', false) // 未装 bundle + 默认模式 + 无 dsh → 必须拒绝
|
|
2330
|
+
const installScript = join(root, 'scripts', 'install.mjs')
|
|
2331
|
+
const legacyDirOf = (prefix) => join(prefix, '.agent-presets', 'ppt')
|
|
2332
|
+
// 先埋一份 0.1.6 时代的遗留产物(预设目录 + 一份组合),验证安装器会清掉它
|
|
2333
|
+
await mkdir(legacyDirOf(pfxGlb), { recursive: true })
|
|
2334
|
+
await writeFile(join(legacyDirOf(pfxGlb), 'agent.cordis.yml'), '- id: persona\n', 'utf8')
|
|
2335
|
+
await writeFile(join(legacyDirOf(pfxGlb), 'preset.yml'), 'name: 旧\n', 'utf8')
|
|
2336
|
+
const resGlb = spawnSync(process.execPath, [installScript, '--prefix', pfxGlb], { encoding: 'utf8' })
|
|
2337
|
+
const resIso = spawnSync(process.execPath, [installScript, '--prefix', pfxGlb, '--isolate'], { encoding: 'utf8' })
|
|
2338
|
+
// PATH 里只留 node 所在目录 ⇒ `dsh` 不可用 ⇒ 默认(全局)模式必须拒绝继续,而不是假装成功
|
|
2339
|
+
const resNo = spawnSync(process.execPath, [installScript, '--prefix', pfxNo], {
|
|
2340
|
+
encoding: 'utf8',
|
|
2341
|
+
env: { ...process.env, PATH: dirname(process.execPath), Path: dirname(process.execPath) },
|
|
2342
|
+
})
|
|
2343
|
+
ok('装配路径:**默认=全局**(profile bundle)且**不再产出** .agent-presets/(0.1.7 无目录发现路径)',
|
|
2344
|
+
resGlb.status === 0 && !existsSync(legacyDirOf(pfxGlb)),
|
|
2345
|
+
`glb exit=${resGlb.status}|遗留目录=${existsSync(legacyDirOf(pfxGlb))}`)
|
|
2346
|
+
ok('装配路径:0.1.6 遗留的预设目录被**清理**(留着会让"改了预设为什么没生效"变成假线索)',
|
|
2347
|
+
!existsSync(legacyDirOf(pfxGlb)) && /已清理 0\.1\.6 遗留的预设目录/.test(String(resGlb.stdout ?? '')),
|
|
2348
|
+
(String(resGlb.stdout ?? '').match(/已清理[^\n]*/) ?? ['(无清理日志)'])[0].slice(0, 110))
|
|
2349
|
+
ok('装配路径:`--isolate` 在 0.1.7 上**明确拒绝**(exit 1 + 说清原因,不写出没人读的预设)',
|
|
2350
|
+
resIso.status === 1 && /--isolate 在 DSH 0\.1\.7 上不再可用/.test(String(resIso.stderr ?? '')),
|
|
2351
|
+
`iso exit=${resIso.status}|有原因=${/不再可用/.test(String(resIso.stderr ?? ''))}`)
|
|
2352
|
+
ok('装配路径:非 bundle 又装不上 bundle 时**拒绝继续**(宁可失败,也不静默假成功)',
|
|
2353
|
+
resNo.status === 1, `noBundle exit=${resNo.status}`)
|
|
2354
|
+
ok('装配路径:安装器校验"声明预设"所需的资产(agent-presets/ppt/* + lib/preset-delivery.js)',
|
|
2355
|
+
/预设资产已就位/.test(String(resGlb.stdout ?? '')),
|
|
2356
|
+
(String(resGlb.stdout ?? '').match(/预设资产已就位[^\n]*/) ?? ['(无资产校验输出)'])[0].slice(0, 110))
|
|
2357
|
+
const tplText = readFileSync(join(root, 'agent-presets', 'ppt', 'agent.cordis.yml'), 'utf8')
|
|
2358
|
+
ok('装配路径:包内交付的预设组合**不含**本包插件行(含行 ⇒ 同进程双挂载;插件由 bundle 提供)',
|
|
2359
|
+
!/^-\s*id: ppt-studio$/m.test(tplText) && !/dsh-ppt-studio plugin row/.test(tplText), '组合干净 ✓')
|
|
2360
|
+
await rm(base, { recursive: true, force: true })
|
|
2361
|
+
ok('装配路径:临时夹具清理干净且**没有碰仓库本体**',
|
|
2362
|
+
!existsSync(base) && existsSync(installScript) && existsSync(join(root, 'package.json')),
|
|
2363
|
+
'仓库根文件仍在 ✓')
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
// ── 41b. 预设漂移门禁认得 **0.1.7 的新参照格式**(hermetic:自带夹具,不依赖本机装没装 DSH)──
|
|
2367
|
+
// 为什么要有这条:0.1.7 把参照物从 `dsh-agent-presets/presets/standard/agent.cordis.yml`(条目数组)
|
|
2368
|
+
// 换成 `dsh-web-app/presets/standard.patch.yml`(patch → insert → `config.plugins`)。
|
|
2369
|
+
// 旧脚本找不到参照时**静默跳过**——于是"整套 DSH 相关检查都在跳过"伪装成了 250/0 全绿。
|
|
2370
|
+
// 这条夹具把新格式**钉死**:报漂移要真报、找不到参照要真喊。
|
|
2371
|
+
{
|
|
2372
|
+
const { spawnSync } = await import('node:child_process')
|
|
2373
|
+
const fx = join(root, 'examples', 'smoke', '.tmp-preset-ref')
|
|
2374
|
+
await rm(fx, { recursive: true, force: true })
|
|
2375
|
+
const refDir = join(fx, 'node_modules', '@deepseek-ai', 'dsh-web-app', 'presets')
|
|
2376
|
+
await mkdir(refDir, { recursive: true })
|
|
2377
|
+
const refFile = join(refDir, 'standard.patch.yml')
|
|
2378
|
+
const oursText = readFileSync(join(root, 'agent-presets', 'ppt', 'agent.cordis.yml'), 'utf8')
|
|
2379
|
+
// 我们的组合原样缩进成 `config.plugins` 的值(**纯文本缩进** ⇒ `!!js` 保真,不经过序列化)
|
|
2380
|
+
const nest = (text) => text.split('\n').map((l) => (l.trim() ? ' ' + l : l)).join('\n')
|
|
2381
|
+
const modernPatch = (rows) => `- insert:\n - id: preset-standard\n name: '@deepseek-ai/dsh-agent-preset'\n config:\n id: standard\n order: 1\n plugins:\n${nest(rows)}\n`
|
|
2382
|
+
const cpScript = join(root, 'scripts', 'check-preset.mjs')
|
|
2383
|
+
const pin = join(fx, 'pinned.yml')
|
|
2384
|
+
const runCp = (refPath, extra = []) => spawnSync(process.execPath, [cpScript, '--ref', refPath, ...extra], { encoding: 'utf8' })
|
|
2385
|
+
await writeFile(refFile, modernPatch(oursText), 'utf8')
|
|
2386
|
+
const okRef = runCp(refFile, ['--require'])
|
|
2387
|
+
ok('0.1.7:预设门禁认得新参照格式(patch → insert → config.plugins)——行相同 ⇒ 漂移 0',
|
|
2388
|
+
okRef.status === 0 && /modern/.test(okRef.stdout) && /漂移 0/.test(okRef.stdout),
|
|
2389
|
+
(okRef.stdout ?? '').split('\n').filter((l) => /格式|预设自检/.test(l)).join(' | ').slice(0, 150))
|
|
2390
|
+
await writeFile(pin, modernPatch(oursText.replace('maxBytes: 65536', 'maxBytes: 4096')), 'utf8')
|
|
2391
|
+
const bad = runCp(pin)
|
|
2392
|
+
ok('0.1.7:参照行被改动 ⇒ **报漂移并 exit 1**(门禁真在比对,不是解析完就放过)',
|
|
2393
|
+
bad.status === 1 && /配置漂移/.test(bad.stdout), `exit=${bad.status}|报漂移=${/配置漂移/.test(bad.stdout)}`)
|
|
2394
|
+
// 旧格式(0.1.6 的条目数组):我们的组合文件**本身就是**这个格式 ⇒ 原样当参照 ⇒ 漂移 0。
|
|
2395
|
+
// 这条同时证明"legacy 分支真的把行读进来比对了",而不是识别完就放过。
|
|
2396
|
+
await writeFile(pin, oursText, 'utf8')
|
|
2397
|
+
const lg = runCp(pin)
|
|
2398
|
+
ok('0.1.7:**旧格式(0.1.6 条目数组)也还认**——老宿主上跑不瞎',
|
|
2399
|
+
lg.status === 0 && /legacy/.test(lg.stdout) && /漂移 0/.test(lg.stdout),
|
|
2400
|
+
`exit=${lg.status}|${(lg.stdout.match(/格式:.*/) ?? ['(无格式行)'])[0].slice(0, 40)}|漂移0=${/漂移 0/.test(lg.stdout)}`)
|
|
2401
|
+
await rm(fx, { recursive: true, force: true })
|
|
2402
|
+
const missing = runCp(join(fx, 'nope.yml'), ['--require'])
|
|
2403
|
+
const missingSoft = runCp(join(fx, 'nope.yml'))
|
|
2404
|
+
ok('0.1.7:找不到参照物时 `--require` **失败**、默认**醒目告警**(静默跳过是"假绿"的成因)',
|
|
2405
|
+
missing.status === 1 && missingSoft.status === 0 && /没有生效/.test(missingSoft.stdout),
|
|
2406
|
+
`require exit=${missing.status}|默认 exit=${missingSoft.status}`)
|
|
2407
|
+
await rm(fx, { recursive: true, force: true })
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
// ── 41c. 桌面端(Electron 应用)安装器 —— 桌面端**不能**用一句 `dsh plugin add` ──────────────
|
|
2411
|
+
// 两条硬事实(2026-09-26 实测):
|
|
2412
|
+
// ① `@deepseek-ai/dsh` 的 `lib/bin.js` 对 `desktop` profile 的**启动与 plugin 两条路**都
|
|
2413
|
+
// `program.error('profile "desktop" is managed exclusively by the Electron application')`,无旁路;
|
|
2414
|
+
// ② 桌面端自带 pnpm 11.7.0 + `nodeLinker: hoisted` 下装 tarball URL 会
|
|
2415
|
+
// `ERR_PNPM_MISSING_TARBALL_INTEGRITY`(隔离复现:去掉 nodeLinker 或换 pnpm 11.21 即成功)。
|
|
2416
|
+
// ⇒ 必须有自己的安装通道(scripts/install-desktop.mjs)。这里用**桩工具链 + 临时 DSH_HOME** 跑
|
|
2417
|
+
// `--dry-run`:只验探测/规划/**不写盘**,既不碰真桌面端也不跑 pnpm(CI 上同样可跑)。
|
|
2418
|
+
{
|
|
2419
|
+
const { spawnSync } = await import('node:child_process')
|
|
2420
|
+
const fx = join(root, 'examples', 'smoke', '.tmp-desktop')
|
|
2421
|
+
await rm(fx, { recursive: true, force: true })
|
|
2422
|
+
const prof = join(fx, 'home', 'profiles', 'desktop')
|
|
2423
|
+
const stub = join(fx, 'app', 'resources')
|
|
2424
|
+
await mkdir(join(stub, 'runtime', 'bin'), { recursive: true })
|
|
2425
|
+
await mkdir(join(stub, 'runtime', 'pnpm', 'bin'), { recursive: true })
|
|
2426
|
+
await mkdir(prof, { recursive: true })
|
|
2427
|
+
await writeFile(join(prof, 'package.json'),
|
|
2428
|
+
JSON.stringify({ name: 'dsh-profile-desktop', private: true, dependencies: {}, dsh: { profile: { bundles: [] } } }, null, 2), 'utf8')
|
|
2429
|
+
await writeFile(join(prof, 'pnpm-workspace.yaml'), 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n', 'utf8')
|
|
2430
|
+
// 两个平台名都造:安装器在 win32 找 `runtime/bin/node.cmd`、在 POSIX 找 `runtime/bin/node`。
|
|
2431
|
+
// 【2026-09-26 修】只造 .cmd 时,ubuntu CI 上桩被判"内置工具链不完整" ⇒ 第一条断言红。
|
|
2432
|
+
await writeFile(join(stub, 'runtime', 'bin', 'node.cmd'), '@echo off\r\n', 'utf8')
|
|
2433
|
+
await writeFile(join(stub, 'runtime', 'bin', 'node'), '#!/bin/sh\n', 'utf8')
|
|
2434
|
+
await writeFile(join(stub, 'runtime', 'pnpm', 'bin', 'pnpm.mjs'), '// stub\n', 'utf8')
|
|
2435
|
+
const script = join(root, 'scripts', 'install-desktop.mjs')
|
|
2436
|
+
const runD = (extra) => spawnSync(process.execPath,
|
|
2437
|
+
[script, '--prefix', join(fx, 'home'), '--app-dir', stub, '--dry-run', ...extra], { encoding: 'utf8' })
|
|
2438
|
+
const before = readFileSync(join(prof, 'package.json'), 'utf8')
|
|
2439
|
+
const planLocal = runD([])
|
|
2440
|
+
const localOk = planLocal.status === 0 && /打包本目录 → 装 profile 内 file: 规格/.test(String(planLocal.stdout ?? ''))
|
|
2441
|
+
const planUrl = runD(['--spec', 'https://example.invalid/x.tgz'])
|
|
2442
|
+
const urlOk = planUrl.status === 0 && /直接装 URL/.test(String(planUrl.stdout ?? ''))
|
|
2443
|
+
const untouched = readFileSync(join(prof, 'package.json'), 'utf8') === before
|
|
2444
|
+
ok('桌面端:安装器认得 desktop profile / 内置工具链 / nodeLinker,且 `--dry-run` **不写盘**',
|
|
2445
|
+
localOk && urlOk && untouched, `本包规格=${localOk}|URL 规格=${urlOk}|profile 未被改动=${untouched}`)
|
|
2446
|
+
const noProfile = spawnSync(process.execPath,
|
|
2447
|
+
[script, '--prefix', join(fx, 'nope'), '--app-dir', stub, '--dry-run'], { encoding: 'utf8' })
|
|
2448
|
+
ok('桌面端:profile 不存在时**明确失败**(不静默继续)',
|
|
2449
|
+
noProfile.status === 1 && /桌面 profile 不存在/.test(String(noProfile.stderr ?? '')), `exit=${noProfile.status}`)
|
|
2450
|
+
await rm(fx, { recursive: true, force: true })
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
// ── 42. 答疑手册 vs 制作手册的触发纪律(2026-09-15 真实反馈)────────────────────
|
|
2454
|
+
// 事故形状:模型在 PPT 任务**开工时**先加载 `ppt-studio-manual`(提问式手册)——因为它的
|
|
2455
|
+
// description/whenToUse 写着"如何开始 / DSL 语法速查 / 或模型不确定某一 DSL 写法时加载",
|
|
2456
|
+
// 这对"正在做 PPT 的人"是恒真的自触发条件。后果是每轮任务白付一次几千字符的正文成本,
|
|
2457
|
+
// 而它讲的是"怎么回答用户提问",不是"怎么做 PPT"。
|
|
2458
|
+
// 修法:答疑手册只保留"用户提问"触发 + 明确否定句;工作流提示段显式区分两类手册;
|
|
2459
|
+
// 制作三本正文不得把读者引向答疑手册(工作期读到那句 = 又被引去加载)。
|
|
2460
|
+
// 这三条**不是**文风审查,而是可判定的触发面事实:
|
|
2461
|
+
const manualSkill = bundled.find((b) => b.parsed.name === 'ppt-studio-manual')
|
|
2462
|
+
const manDesc = manualSkill?.parsed.description ?? ''
|
|
2463
|
+
const manWhen = manualSkill?.parsed.whenToUse ?? ''
|
|
2464
|
+
const manQaTrigger = /用户问|提问/.test(manDesc) && /提问|用户问/.test(manWhen)
|
|
2465
|
+
const manNoSelfTrigger = !/模型不确定/.test(manDesc + manWhen) && !/不确定某一/.test(manDesc + manWhen)
|
|
2466
|
+
const manNegated = /不要加载/.test(manDesc) && /不要加载/.test(manWhen)
|
|
2467
|
+
ok('答疑手册触发面:`ppt-studio-manual` 只由"用户提问"触发,且写明"制作中不要加载"(去掉恒真的自触发措辞)',
|
|
2468
|
+
manQaTrigger && manNoSelfTrigger && manNegated,
|
|
2469
|
+
`问答触发=${manQaTrigger}|无自触发=${manNoSelfTrigger}|否定句=${manNegated}`)
|
|
2470
|
+
|
|
2471
|
+
const manBodyHead = (manualText ?? '').slice(0, 400)
|
|
2472
|
+
const manBodyBanner = /只服务/.test(manBodyHead) && /不要读/.test(manBodyHead)
|
|
2473
|
+
const stdWorkflow = routerMod.workflowSection('from-scratch', { ...baseCfg, quick: false }).text
|
|
2474
|
+
const workflowSplits = stdWorkflow.includes('ppt-studio-manual') && /不要加载/.test(stdWorkflow) && /答疑手册/.test(stdWorkflow)
|
|
2475
|
+
ok('答疑手册兜底:正文开头自带"只服务提问 / 制作中不要读"横幅,且工作流提示段显式区分答疑手册与制作手册',
|
|
2476
|
+
manBodyBanner && workflowSplits,
|
|
2477
|
+
`正文横幅=${manBodyBanner}|工作流分行=${workflowSplits}`)
|
|
2478
|
+
|
|
2479
|
+
const crossBookPointers = ['ppt-studio-craft', 'ppt-studio-data', 'ppt-studio-copy']
|
|
2480
|
+
.filter((n) => (bundled.find((b) => b.parsed.name === n)?.parsed.content ?? '').includes('ppt-studio-manual'))
|
|
2481
|
+
ok('制作手册零指向答疑手册:craft/data/copy 正文不出现 `ppt-studio-manual`(工作期读到会被引去加载答疑手册)',
|
|
2482
|
+
crossBookPointers.length === 0,
|
|
2483
|
+
crossBookPointers.length ? `仍指向:${crossBookPointers.join('、')}` : '三本均无指向')
|
|
2484
|
+
|
|
2485
|
+
// 37.10 【必须是最后一条断言】引用计数自证:文档里 "smoke … N 断言" 必须等于本次真实断言总数。
|
|
2486
|
+
// 历史形状:加断言后 README×3 + docs/02 + docs/06×2 + 手册 全靠人工同步,迟早漏一处。
|
|
2487
|
+
// 只扫"当前状态"文档(README / 技术报告 / 评审测试矩阵 / 使用手册);docs/01/03/04 里的历史数字是记录,不动。
|
|
2488
|
+
// 覆盖边界(故意):只认"N 断言"与"N/N"两种<b>套件规模</b>写法。docs/06 §一 是历史快照,那里写的是
|
|
2489
|
+
// 裸的 "smoke 181"(无"断言"二字),由 §一 上方的"计数说明"解释;当前规模只认 §2.1 与 §六。
|
|
2490
|
+
// 【2026-09-18 收紧】`N/N |` 这条原本匹配**任何** `a/b |` 表格单元格——只要该行提到 "smoke" 就命中。
|
|
2491
|
+
// 实测被自己踩到:新增一条 smoke 行的格子里写 `4/4 |`(4 条真产物断言全绿)→ 被误判成"文档计数过期"。
|
|
2492
|
+
// 且 `preflight 11/11`、`test:bundle 20/20` 之类写在 smoke 行里同样会误报。
|
|
2493
|
+
// 收紧为"分子 ≥ 100 才算套件规模":smoke 规模已 218,不会再回到两位数;其余套件应由 `N 断言` 那种
|
|
2494
|
+
// 明确写法表达,而不是靠任何 `a/b` 单元格。防的仍是"加断言忘了同步文档"。
|
|
2495
|
+
const liveTotal = pass + fail + 1 // 含本条自身
|
|
2496
|
+
const countFiles = ['README.md', join('docs', '02-技术报告.md'), join('docs', '06-评审与测试.md'), join('skills', 'ppt-studio-manual', 'SKILL.md')]
|
|
2497
|
+
const staleCounts = []
|
|
2498
|
+
for (const rel of countFiles) {
|
|
2499
|
+
readFileSync(join(root, rel), 'utf8').split(/\r?\n/).forEach((line, i) => {
|
|
2500
|
+
if (!/smoke/i.test(line)) return
|
|
2501
|
+
for (const m of line.matchAll(/(\d+)\s*断言/g)) if (Number(m[1]) !== liveTotal) staleCounts.push(`${rel}:${i + 1}=${m[1]}`)
|
|
2502
|
+
for (const m of line.matchAll(/(\d+)\/(\d+)\s*\|/g)) {
|
|
2503
|
+
if (Number(m[1]) < 100) continue // 非套件规模(如 `4/4 |`、`preflight 11/11`)——见上方收紧说明
|
|
2504
|
+
if (Number(m[1]) !== liveTotal || Number(m[2]) !== liveTotal) staleCounts.push(`${rel}:${i + 1}=${m[1]}/${m[2]}`)
|
|
2505
|
+
}
|
|
2506
|
+
})
|
|
2507
|
+
}
|
|
2508
|
+
ok('文档计数自证:所有"smoke … N 断言 / N/N"都等于本次真实断言数(加断言必须同步 6 处引用)',
|
|
2509
|
+
staleCounts.length === 0, staleCounts.length ? `过期引用:${staleCounts.join('、')}` : `全部 = ${liveTotal}`)
|
|
2510
|
+
|
|
2511
|
+
console.log(`\n==== 结果:${pass} 通过 / ${fail} 失败 ====`)
|
|
2512
|
+
process.exit(fail > 0 ? 1 : 0)
|