dsh-ppt-studio 1.1.5 → 1.2.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/README.md +4 -1
- package/docs/00-/350/256/276/350/256/241/346/272/220.md +4 -0
- package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +114 -1
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +149 -0
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +23 -0
- package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +2 -1
- package/docs/07-/345/244/215/346/235/202/345/233/276/350/203/275/345/212/233/347/253/213/351/241/271/344/270/216/351/243/216/351/231/251/347/231/273/350/256/260.md +319 -0
- package/docs/08-/351/230/266/346/256/265A/350/256/276/350/256/241/350/257/264/346/230/216-/347/273/223/346/236/204/345/205/263/347/263/273/344/270/216/345/233/276/344/270/223/345/261/236/346/240/241/351/252/214.md +306 -0
- package/docs/09-/344/273/243/347/240/201/345/234/260/345/233/276/344/270/216/346/250/241/345/235/227/350/201/214/350/264/243.md +148 -0
- package/docs/10-/345/256/277/344/270/273/350/200/246/345/220/210/347/202/271/346/270/205/345/215/225.md +49 -0
- package/docs/11-/346/234/257/350/257/255/350/241/250.md +42 -0
- package/docs/12-/345/244/215/346/235/202/345/233/276/351/200/232/350/267/257/347/232/204/351/200/211/346/213/251/344/270/216/351/252/214/346/224/266.md +65 -0
- package/docs/13-/345/217/221/347/211/210/345/207/206/345/244/207/350/256/241/345/210/222.md +79 -0
- package/docs/14-/345/217/221/345/270/203/346/270/205/345/215/225/357/274/210/350/215/211/347/250/277/357/274/211.md +37 -0
- package/docs/17-/346/211/213/347/224/27313/351/241/265/350/255/246/345/221/212/351/200/220/346/235/241/345/244/204/347/275/256.md +102 -0
- package/examples/fx/fx-pro/preview/layout.json +12 -0
- package/examples/fx/fx-pro/preview/measured.json +1 -1
- package/examples/fx/fx-pro.pptx +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/imported/preview-rt/layout.json +3 -0
- package/examples/smoke/imported/source.pptx +0 -0
- package/examples/smoke/out-smoke.pptx +0 -0
- package/examples/smoke/preview/layout.json +3 -0
- package/lib/group-render.js +177 -0
- package/lib/msrender.js +27 -12
- package/lib/pptd/blocks.js +52 -0
- package/lib/pptd/export-pptx.js +266 -11
- package/lib/pptd/layout.js +54 -3
- package/lib/pptd/line-rules.js +259 -0
- package/lib/pptd/line-text-rule.js +90 -0
- package/lib/pptd/relations.js +615 -0
- package/lib/pptd/render-html.js +19 -1
- package/lib/pptd/route-geometry.js +120 -0
- package/lib/pptd/schema.js +146 -11
- package/lib/pptd/style-profile.js +133 -0
- package/lib/scaffold.js +54 -1
- package/lib/tools.js +20 -2
- package/lib/verify.js +69 -1
- package/package.json +20 -3
- package/scripts/audit-manual-facts.mjs +15 -3
- package/scripts/bench-diagram-scale.mjs +165 -0
- package/scripts/check-generators.mjs +22 -0
- package/scripts/check-lib-sync.mjs +33 -0
- package/scripts/check-line-text.mjs +30 -0
- package/scripts/check-package.mjs +60 -0
- package/scripts/check-removed-traces.mjs +37 -0
- package/scripts/check-syntax.mjs +44 -0
- package/scripts/check-unknown-keys.mjs +114 -0
- package/scripts/fixtures/handdrawn-baseline.json +177 -0
- package/scripts/fixtures/prompt-cost-baseline.json +178 -0
- package/scripts/handdrawn-option-probe.mjs +78 -0
- package/scripts/probe-prompt-cost.mjs +127 -0
- package/scripts/red-probe.mjs +75 -0
- package/scripts/render-group.mjs +72 -0
- package/scripts/smoke.mjs +183 -12
- package/scripts/test-fast.mjs +108 -0
- package/scripts/test-map.mjs +162 -0
- package/scripts/test-release.mjs +64 -0
- package/scripts/verify-diagram-relations.mjs +405 -0
- package/scripts/verify-handdrawn.mjs +115 -0
- package/scripts/verify-handwritten.mjs +203 -0
- package/scripts/verify-primitives.mjs +714 -0
- package/scripts/verify-products.mjs +37 -0
- package/scripts/verify-shape.mjs +84 -0
- package/scripts/visual-pair-batch.mjs +77 -0
- package/skills/diagram-drawing/SKILL.md +23 -0
- package/skills/diagram-drawing/capability-matrix.md +22 -0
- package/skills/diagram-drawing/composition.md +22 -0
- package/skills/diagram-drawing/mechanisms.md +141 -0
- package/skills/diagram-drawing/pitfalls.md +89 -0
- package/skills/diagram-drawing/routing.md +17 -0
- package/skills/diagram-drawing/text-and-spacing.md +16 -0
- package/skills/diagram-drawing/verify.md +50 -0
- package/skills/diagrams/compare.md +24 -0
- package/skills/diagrams/flow.md +24 -0
- package/skills/diagrams/funnel.md +25 -0
- package/skills/diagrams/layers.md +18 -0
- package/skills/diagrams/loop.md +25 -0
- package/skills/diagrams/matrix.md +23 -0
- package/skills/diagrams/sequence.md +17 -0
- package/skills/diagrams/state.md +34 -0
- package/skills/diagrams/steps-ring.md +24 -0
- package/skills/diagrams/swimlane.md +23 -0
- package/skills/diagrams/timeline.md +24 -0
- package/skills/diagrams/tree.md +24 -0
- package/skills/ppt-studio-manual/SKILL.md +2 -2
- package/templates/academic-white/preview.png +0 -0
- package/templates/business-blue/preview.png +0 -0
- package/templates/pitch-bold/preview.png +0 -0
- package/templates/tech-dark/preview.png +0 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 红样本探针(G4 验收证据)
|
|
4
|
+
* 红:故意造缺陷,断言门禁必须抓到。
|
|
5
|
+
* 绿:断言引擎**真实契约** —— 2 点斜向直连线保持直线(cxnSp);3 点折线的斜段被正交化。
|
|
6
|
+
* 用法:node scripts/red-probe.mjs
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { resolveDeck } from '../src/pptd/schema.js'
|
|
11
|
+
import { renderDeck } from '../src/pptd/render-html.js'
|
|
12
|
+
import { verifyDeck } from '../src/verify.js'
|
|
13
|
+
|
|
14
|
+
const root = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
|
|
15
|
+
const dir = join(root, 'examples', 'red-probe')
|
|
16
|
+
mkdirSync(join(dir, 'pages'), { recursive: true })
|
|
17
|
+
writeFileSync(join(dir, 'deck.yaml'), [
|
|
18
|
+
'version: 1', 'title: 红样本探针', 'size: [960, 540]', 'theme:', ' colors:',
|
|
19
|
+
' primary: "#7C3AED"', ' accent: "#E11D48"', ' soft: "#EDE9FE"',
|
|
20
|
+
' text: "#2E1065"', ' bg: "#FAF5FF"', 'pages:', ' - pages/red.yaml', '',
|
|
21
|
+
].join('\n'))
|
|
22
|
+
|
|
23
|
+
const L = ['pageType: content', 'elements:']
|
|
24
|
+
L.push(' - elementId: title', ' elementType: text', ' bounds: [60, 34, 400, 30]',
|
|
25
|
+
" content: { text: '红样本探针', fontSize: 22, color: '#2E1065', bold: true }")
|
|
26
|
+
// 红①:线穿文字
|
|
27
|
+
L.push(' - elementId: lab', ' elementType: text', ' bounds: [300, 240, 120, 24]',
|
|
28
|
+
" content: { text: '残饵画面', fontSize: 12, color: '#334155' }")
|
|
29
|
+
L.push(' - elementId: life', ' elementType: line', ' points: [[360, 180], [360, 330]]',
|
|
30
|
+
" line: { color: '#94A3B8', width: 1.2 }", ' arrow: false')
|
|
31
|
+
// 红②:箭头前直段 7px(L 形:守卫不修 ⇒ 门禁报)
|
|
32
|
+
L.push(' - elementId: short', ' elementType: line', ' points: [[100, 430], [200, 430], [200, 437]]',
|
|
33
|
+
" line: { color: '#7C3AED', width: 2 }", ' arrow: true')
|
|
34
|
+
// 红③:端点离目标 10px
|
|
35
|
+
L.push(' - elementId: tgtGap', ' elementType: shape', ' kind: roundRect', ' bounds: [570, 400, 120, 60]',
|
|
36
|
+
" fill: '#EDE9FE'")
|
|
37
|
+
L.push(' - elementId: gap', ' elementType: line', ' points: [[470, 430], [560, 430]]',
|
|
38
|
+
" line: { color: '#7C3AED', width: 2 }", ' arrow: true')
|
|
39
|
+
// 红④:箭头尖端插进图形
|
|
40
|
+
L.push(' - elementId: tgtIn', ' elementType: shape', ' kind: roundRect', ' bounds: [730, 380, 120, 60]',
|
|
41
|
+
" fill: '#EDE9FE'")
|
|
42
|
+
L.push(' - elementId: inside', ' elementType: line', ' points: [[660, 410], [760, 410]]',
|
|
43
|
+
" line: { color: '#E11D48', width: 2 }", ' arrow: true')
|
|
44
|
+
// 绿①:2 点斜向直连线 —— 契约要求保持直线(cxnSp)
|
|
45
|
+
L.push(' - elementId: diag2', ' elementType: line', ' points: [[100, 140], [220, 200]]',
|
|
46
|
+
" line: { color: '#64748B', width: 1.5 }", ' arrow: false')
|
|
47
|
+
// 绿②:3 点折线含斜段 —— 契约要求被正交化
|
|
48
|
+
L.push(' - elementId: poly', ' elementType: line', ' points: [[600, 120], [700, 180], [760, 180]]',
|
|
49
|
+
" line: { color: '#64748B', width: 1.5 }", ' arrow: false')
|
|
50
|
+
writeFileSync(join(dir, 'pages', 'red.yaml'), L.join('\n') + '\n')
|
|
51
|
+
|
|
52
|
+
const ctx = await resolveDeck(dir)
|
|
53
|
+
await renderDeck(ctx, { out: 'preview' })
|
|
54
|
+
const layout = JSON.parse(readFileSync(join(dir, 'preview', 'layout.json'), 'utf8'))
|
|
55
|
+
const pages = Array.isArray(layout.pages) ? layout.pages : Object.values(layout.pages)
|
|
56
|
+
const v = verifyDeck({ pages: [pages[0]] })
|
|
57
|
+
const codes = new Set([...v.errors, ...v.warns].map((m) => m.code))
|
|
58
|
+
const get = (id) => (pages[0].elements ?? []).find((e) => e.id === id)?.points ?? []
|
|
59
|
+
const axisOf = (pts) => pts.every((p, i) => i === 0 || Math.abs(p[0] - pts[i - 1][0]) < 0.5 || Math.abs(p[1] - pts[i - 1][1]) < 0.5)
|
|
60
|
+
const diag2 = get('diag2')
|
|
61
|
+
const poly = get('poly')
|
|
62
|
+
const endOff = ['line-end-off-edge', 'arrow-end-gap'].some((c) => codes.has(c))
|
|
63
|
+
const rows = [
|
|
64
|
+
['line-cross-text', codes.has('line-cross-text'), '红①:线×文字穿越(G1 新增检查)'],
|
|
65
|
+
['arrow-end-short-run', codes.has('arrow-end-short-run'), '红②:箭头前直段仅 7px'],
|
|
66
|
+
['arrow-tip-inside-shape', codes.has('arrow-tip-inside-shape'), '红④:尖端插进图形'],
|
|
67
|
+
['line-end-off-edge/gap', endOff, '红③:端点离目标 10px'],
|
|
68
|
+
['2点斜连线保持直线', diag2.length === 2 && !axisOf(diag2), '绿①:cxnSp 契约 points=' + JSON.stringify(diag2)],
|
|
69
|
+
['3点折线斜段被正交化', poly.length >= 4 && axisOf(poly), '绿②:折线契约 points=' + JSON.stringify(poly)],
|
|
70
|
+
]
|
|
71
|
+
let bad = 0
|
|
72
|
+
console.log('红样本门禁:错' + v.errors.length + ' / 警' + v.warns.length + '|codes = ' + [...codes].sort().join(', '))
|
|
73
|
+
for (const [name, ok, desc] of rows) { if (!ok) bad++; console.log(' ' + (ok ? '✓' : '✗') + ' ' + name.padEnd(24) + ' ← ' + desc) }
|
|
74
|
+
console.log('==== 抓取断言:' + (bad === 0 ? '全部命中' : bad + ' 项未命中') + ' ====')
|
|
75
|
+
process.exit(bad === 0 ? 0 : 1)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 组级裁剪渲染(CLI)。
|
|
3
|
+
*
|
|
4
|
+
* 取景 / 裁剪语义**全部来自 `src/group-render.js`**;本文件只负责:参数解析 → 导出 pptx →
|
|
5
|
+
* PowerPoint COM 出图 → 报告。**不要在这里重复取景逻辑**——重复会让"工具看到的小图"与
|
|
6
|
+
* "CLI 出的小图"不一致,审阅结论就会自相矛盾(这正是拆模块的原因)。
|
|
7
|
+
*
|
|
8
|
+
* 用法:
|
|
9
|
+
* node scripts/render-group.mjs <deck目录> --group <组id> [--page N] [--pad 16] [--out 目录]
|
|
10
|
+
* node scripts/render-group.mjs <deck目录> --id <元素id> 上面的参数同样适用
|
|
11
|
+
* --page-render:额外再出「整页版(带上下文)」PNG
|
|
12
|
+
*/
|
|
13
|
+
import { basename, join } from 'node:path'
|
|
14
|
+
import { findPowerPoint, renderPptxToPng } from '../lib/msrender.js'
|
|
15
|
+
import { resolveDeck } from '../lib/pptd/schema.js'
|
|
16
|
+
import { renderDeck } from '../lib/pptd/render-html.js'
|
|
17
|
+
import { exportPptx } from '../lib/pptd/export-pptx.js'
|
|
18
|
+
import { isolateGroup } from '../lib/group-render.js'
|
|
19
|
+
|
|
20
|
+
const argv = process.argv.slice(2)
|
|
21
|
+
const dir = argv[0]
|
|
22
|
+
if (!dir) {
|
|
23
|
+
console.error('用法:node scripts/render-group.mjs <deck目录> --group <组id> [--page N] [--pad 16] [--out 目录] [--page-render]')
|
|
24
|
+
process.exit(2)
|
|
25
|
+
}
|
|
26
|
+
const flag = (name, def = undefined) => {
|
|
27
|
+
const i = argv.indexOf(`--${name}`)
|
|
28
|
+
if (i < 0) return def
|
|
29
|
+
const v = argv[i + 1]
|
|
30
|
+
return v === undefined || v.startsWith('--') ? true : v
|
|
31
|
+
}
|
|
32
|
+
const pageNo = Number(flag('page', '1'))
|
|
33
|
+
const pad = Number(flag('pad', '16'))
|
|
34
|
+
const wantPage = argv.includes('--page-render')
|
|
35
|
+
const outDir = flag('out', join(dir, '.group-render'))
|
|
36
|
+
const groupId = flag('group')
|
|
37
|
+
const oneId = flag('id')
|
|
38
|
+
|
|
39
|
+
const ctx = await resolveDeck(dir)
|
|
40
|
+
const r = await isolateGroup(dir, { page: pageNo, group: groupId, id: oneId, pad, outDir })
|
|
41
|
+
|
|
42
|
+
console.log('==== 组级裁剪渲染 ====')
|
|
43
|
+
console.log(`来源:${r.sourceLabel}|成员 ${r.memberCount} 个|bbox = [${Math.round(r.bbox.x)}, ${Math.round(r.bbox.y)}, ${Math.round(r.bbox.w)}×${Math.round(r.bbox.h)}](已外扩 pad=${pad})`)
|
|
44
|
+
console.log(`**这是第 ${pageNo} 页的某个组合的局部图,不是整页**(整页缩略图会掩盖线落边/压字/留白这类小尺寸缺陷)`)
|
|
45
|
+
for (const n of r.notes) console.log(` · 提示:${n}`)
|
|
46
|
+
|
|
47
|
+
const iso = await exportPptx(r.ctxIso, { out: join(r.work, 'isolated.pptx') })
|
|
48
|
+
await renderDeck(r.ctxIso, { out: 'preview' })
|
|
49
|
+
console.log(`隔离版:${r.sub.length} 个元素 → ${basename(iso.file)}|parity.ok = ${iso.parity?.ok}|预览 HTML:${join(r.work, 'preview', 'index.html')}`)
|
|
50
|
+
|
|
51
|
+
if (findPowerPoint()) {
|
|
52
|
+
const shot = await renderPptxToPng(iso.file, join(r.work, 'shots'), {
|
|
53
|
+
pages: [1],
|
|
54
|
+
width: Math.max(640, Math.round(r.bbox.w * 2)),
|
|
55
|
+
height: Math.max(360, Math.round(r.bbox.h * 2)),
|
|
56
|
+
})
|
|
57
|
+
console.log(`隔离版 PNG:${shot.files?.[0] ?? '(无)'}`)
|
|
58
|
+
} else {
|
|
59
|
+
console.log('隔离版 PNG:本机没有 PowerPoint ⇒ 跳过(预览 HTML 仍可用;桌面端可用 PowerPoint COM 出图)')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (wantPage) {
|
|
63
|
+
const full = await exportPptx(ctx, { out: join(outDir, 'page.pptx') })
|
|
64
|
+
if (findPowerPoint()) {
|
|
65
|
+
const shot = await renderPptxToPng(full.file, join(outDir, 'page-shots'), { pages: [pageNo] })
|
|
66
|
+
console.log(`整页版 PNG(在上下文):${shot.files?.[0]}`)
|
|
67
|
+
} else {
|
|
68
|
+
await renderDeck(ctx, { out: join(outDir, 'page-preview') })
|
|
69
|
+
console.log(`整页版:本机没有 PowerPoint ⇒ 已生成预览 HTML:${join(outDir, 'page-preview', 'index.html')}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
console.log(`产物目录:${outDir}`)
|
package/scripts/smoke.mjs
CHANGED
|
@@ -427,7 +427,12 @@ ok('启发B:输出行由报告生成(lossLine === lossAuditLine(落盘 JSON)
|
|
|
427
427
|
bandImp.lossLine === lossAuditLine(lossAuditJson), String(bandImp.lossLine).slice(0, 90))
|
|
428
428
|
ok('v0.4:theme 聚合建议块写入 deck.yaml', bandDeckYaml.includes('# 建议主题') && bandDeckYaml.includes('textStyles'))
|
|
429
429
|
const mnErr = validatePage({ elements: [{ elementId: 'l', elementType: 'line', points: [[10, 10], [100, 10], [200, 10]] }] }, 'test.yaml')
|
|
430
|
-
|
|
430
|
+
// 阶段 A(2026-09-28)行为变更:>2 点折线**支持**(导出为 custGeom 开放路径,docs/08 §6);
|
|
431
|
+
// 非法点列(<2 点)仍显式报错。原来那条"多点必须报错"的断言已按新事实改写(断言总数不变)。
|
|
432
|
+
const mnBad = validatePage({ elements: [{ elementId: 'l', elementType: 'line', points: [[10, 10]] }] }, 'test.yaml')
|
|
433
|
+
ok('v0.4 + 阶段A:3 点折线被接受、1 点折线显式报错(不再是"多点一律拒绝")',
|
|
434
|
+
mnErr === null && mnBad !== null && mnBad.messages.some((m) => m.includes('至少 2 点')),
|
|
435
|
+
`3点=${mnErr ? `报错(${mnErr.messages?.join('; ')})` : '通过'}|1点=${mnBad ? '报错' : '通过'}`)
|
|
431
436
|
const { analyzePage, aestheticSuggestions } = await import('../lib/verify.js')
|
|
432
437
|
const size960 = { width: 960, height: 540 }
|
|
433
438
|
const naPage = { elements: [
|
|
@@ -1676,6 +1681,9 @@ const errorCodes = new Set()
|
|
|
1676
1681
|
verifyLines.forEach((line, i) => {
|
|
1677
1682
|
// `let code = 'overlap'` 是**初值**(后续分支必然改写;overlap 只会以 warning 出现),不是一条 finding → 排除声明式赋值
|
|
1678
1683
|
if (/\b(?:let|const|var)\s+code\s*=/.test(line)) return
|
|
1684
|
+
// 阶段 A(2026-09-28):本行**显式**写了非 error 的 severity(如 `severity: 'warning', code: 'declared-stale'`)
|
|
1685
|
+
// ⇒ 它不是错误码,不能因为上 3 行里出现过 'error' 被误收——本守卫的语义是"门禁**错误**码清单"。
|
|
1686
|
+
if (/severity\s*[:=]\s*'(?:warning|confirmed|suggestion|info)'/.test(line)) return
|
|
1679
1687
|
for (const m of line.matchAll(/code\s*[:=]\s*'([a-z-]+)'/g)) {
|
|
1680
1688
|
// 判定该 code 是否 error 级:本行或上 3 行里出现 'error'(覆盖 severity: 'error' / severity = 'error' /
|
|
1681
1689
|
// `mode === 'strict' ? 'error' : 'warning'` 三种写法;confirmed / warning / suggestion 不会被误收)
|
|
@@ -3013,16 +3021,179 @@ ok('npm 发布通道:发布的是**下载下来的 Release 资产**(等 .tgz
|
|
|
3013
3021
|
await rm(work57, { recursive: true, force: true })
|
|
3014
3022
|
}
|
|
3015
3023
|
|
|
3016
|
-
//
|
|
3017
|
-
//
|
|
3018
|
-
//
|
|
3019
|
-
//
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3024
|
+
// ── 58. 阶段 A:结构声明与复杂图地基(2026-09-28)──────────────────────────────
|
|
3025
|
+
// 守三件事:① 新字段的 schema 校验(含"顺序无关的引用检查");② **三处白名单都要透传**
|
|
3026
|
+
// (layout.js normalizePage / render-html.js snap / 导出层)——第一版正是漏在 snap() 上,
|
|
3027
|
+
// 造成"性能数字好看、结构关系全为 0";③ 结构豁免**真的**免掉门禁错误,且**没用新字段的页面行为不变**(门控)。
|
|
3028
|
+
{
|
|
3029
|
+
const work58 = join(smokeDir, '.tmp-diagram-basics')
|
|
3030
|
+
await rm(work58, { recursive: true, force: true })
|
|
3031
|
+
await mkdir(join(work58, 'pages'), { recursive: true })
|
|
3032
|
+
await writeFile(join(work58, 'deck.yaml'), ['version: 1', 'title: diagram-basics', 'size: [960, 540]', 'theme:',
|
|
3033
|
+
' colors: {primary: "#2563EB", text: "#1F2937", bg: "#F1F5F9"}', ' textStyles:',
|
|
3034
|
+
' body: {fontSize: 16, color: "$text"}', 'pages:', ' - pages/01.yaml', ''].join('\n'), 'utf8')
|
|
3035
|
+
const page58 = ['pageType: content',
|
|
3036
|
+
'groups:',
|
|
3037
|
+
' - id: g1', ' label: 输入层', ' members: [zone, a]',
|
|
3038
|
+
'expectedOverlaps:',
|
|
3039
|
+
' - pair: [chip, zone]', ' reason: "标题胶囊压在容器顶边(设计意图)"',
|
|
3040
|
+
'elements:',
|
|
3041
|
+
' - elementId: zone', ' elementType: shape', ' kind: roundRect', ' bounds: [40, 120, 880, 300]',
|
|
3042
|
+
' fill: "#F1F5F9"', ' role: background', ' contains: [a, t1, poly, dash]',
|
|
3043
|
+
' - elementId: a', ' elementType: shape', ' kind: rect', ' bounds: [80, 200, 160, 80]', ' fill: "#2563EB"',
|
|
3044
|
+
' - elementId: t1', ' elementType: text', ' bounds: [260, 200, 200, 40]', ' content: {text: "节点", style: "$body"}',
|
|
3045
|
+
' - elementId: chip', ' elementType: shape', ' kind: roundRect', ' bounds: [300, 106, 120, 30]',
|
|
3046
|
+
' fill: "#1F2937"', ' badgeOf: zone', ' roleReason: "标题胶囊"',
|
|
3047
|
+
' - elementId: poly', ' elementType: line', ' points: [[600, 200], [660, 240], [720, 200]]', ' arrow: both',
|
|
3048
|
+
' - elementId: dash', ' elementType: line', ' points: [[250, 300], [300, 260]]', ' arrow: end',
|
|
3049
|
+
' line: {color: "#1F2937", width: 1, dash: dash}',
|
|
3050
|
+
' attach: {from: {ref: a, side: right}, to: {ref: t1, side: left}}', ''].join('\n')
|
|
3051
|
+
await writeFile(join(work58, 'pages', '01.yaml'), page58, 'utf8')
|
|
3052
|
+
|
|
3053
|
+
// ① schema:合法结构声明通过;attach 放错元素/非法 side 报错(顺序无关的引用检查也在自证脚本里钉着)
|
|
3054
|
+
const ok58 = validatePage(await resolveDeck(work58).then((c) => c.pages[0].page), 's58.yaml')
|
|
3055
|
+
ok('§58 结构声明:合法 deck(groups/contains/badgeOf/attach/roleReason/expectedOverlaps.reason)schema 全通过',
|
|
3056
|
+
ok58 === null, ok58?.messages?.join('; ') ?? '')
|
|
3057
|
+
const bad58 = validatePage({ elements: [{ elementId: 'x', elementType: 'shape', kind: 'rect', bounds: [0, 0, 9, 9], attach: { from: { ref: 'x', side: 'up' } } }] }, 's58b.yaml')
|
|
3058
|
+
ok('§58 结构声明:attach 放在非 line 元素上、且 side 非法 ⇒ 均当场报错(不静默忽略)',
|
|
3059
|
+
bad58 !== null && /只对 elementType: line 有效/.test(bad58.messages.join('; ')),
|
|
3060
|
+
bad58?.messages?.join('; ') ?? '竟然通过')
|
|
3061
|
+
|
|
3062
|
+
// ② 预览与门禁共用 layout.json ⇒ 新字段必须**都**在里面(白名单漏字段 = 结构关系在门禁层凭空消失)
|
|
3063
|
+
await renderDeck(await resolveDeck(work58), { out: 'preview58' })
|
|
3064
|
+
const layout58 = JSON.parse(await (await import('node:fs/promises')).readFile(join(work58, 'preview58', 'layout.json'), 'utf8'))
|
|
3065
|
+
const el58 = (id) => layout58.pages[0].elements.find((e) => e.id === id) ?? {}
|
|
3066
|
+
ok('§58 三处白名单透传:contains/badgeOf/roleReason/attach/arrow 都进了 layout.json(防 normalizePage/snap 式漏字段复发)',
|
|
3067
|
+
Array.isArray(el58('zone').contains) && el58('zone').contains.length === 4
|
|
3068
|
+
&& el58('chip').badgeOf === 'zone' && el58('chip').roleReason === '标题胶囊'
|
|
3069
|
+
&& el58('dash').attach?.from?.ref === 'a' && el58('poly').arrow === 'both',
|
|
3070
|
+
`zone.contains=${JSON.stringify(el58('zone').contains)}|chip.badgeOf=${el58('chip').badgeOf}|poly.arrow=${el58('poly').arrow}`)
|
|
3071
|
+
ok('§58 groups 随 layout.json 落盘(门禁吃 layout.json,页面级结构不能只在 DSL 里)',
|
|
3072
|
+
Array.isArray(layout58.pages[0].groups) && layout58.pages[0].groups.length === 1 && layout58.pages[0].groups[0].id === 'g1')
|
|
3073
|
+
|
|
3074
|
+
// ④ A-④ 集成:手写坐标写错时由 attach 纠正(**不是**静默覆盖——必须留 attachNotes 并在门禁里可见)
|
|
3075
|
+
const dash58 = el58('dash')
|
|
3076
|
+
const a58 = el58('a'); const t1_58 = el58('t1')
|
|
3077
|
+
const onEdge = (pt, r, side) => side === 'right' ? Math.abs(pt[0] - (r.bounds.x + r.bounds.w)) < 2 : Math.abs(pt[0] - r.bounds.x) < 2
|
|
3078
|
+
ok('§58 attach 解析:手写坐标错误时被纠到 a.right / t1.left 上,且 attachNotes 记录了两端改写',
|
|
3079
|
+
onEdge(dash58.points[0], a58, 'right') && onEdge(dash58.points[dash58.points.length - 1], t1_58, 'left')
|
|
3080
|
+
&& Array.isArray(dash58.attachNotes) && dash58.attachNotes.length === 2,
|
|
3081
|
+
`points=${JSON.stringify(dash58.points)}|notes=${dash58.attachNotes?.length}`)
|
|
3082
|
+
|
|
3083
|
+
// ③ 结构豁免真的生效 + 报告两段;门控:不用新字段的页行为不变
|
|
3084
|
+
const v58 = verifyDeck(layout58)
|
|
3085
|
+
ok('§58 结构豁免:容器/子元素/徽章/附着线的重叠全部被结构关系豁免 ⇒ 0 错误',
|
|
3086
|
+
v58.errors.length === 0, v58.errors.map((e) => `${e.code}:${e.id}`).join('|') || '无')
|
|
3087
|
+
ok('§58 报告形态:「结构关系」计数行 + 「设计声明复核」段都在(豁免必须可见)',
|
|
3088
|
+
/· 结构关系:组 1|包含 4|附着 2|徽章 1|有意重叠 1/.test(v58.text) && v58.text.includes('· 设计声明复核:'),
|
|
3089
|
+
v58.text.split('\n').filter((l) => l.includes('结构关系') || l.includes('声明复核')).join('|'))
|
|
3090
|
+
ok('§58 attach 改写可见:门禁报告出现 attach-override 警告("attach 优先"可以,静默覆盖不行)',
|
|
3091
|
+
v58.warns.some((w) => w.code === 'attach-override'),
|
|
3092
|
+
v58.warns.map((w) => w.code).join(','))
|
|
3093
|
+
// 门控对照:同一页把阶段 A 特性**全部降级**(新字段删掉、折线退回 2 点、groups 清空)——
|
|
3094
|
+
// 注意 `usesStructure` 把">2 点折线"也算新特性,只删字段不退回点数会测不到门控(第一次就踩了这个)
|
|
3095
|
+
const plain58 = {
|
|
3096
|
+
...layout58.pages[0], groups: [],
|
|
3097
|
+
elements: (layout58.pages[0].elements ?? []).map((el) => {
|
|
3098
|
+
const { contains, badgeOf, roleReason, attach, ...rest } = el
|
|
3099
|
+
return Array.isArray(rest.points) && rest.points.length > 2 ? { ...rest, points: rest.points.slice(0, 2) } : rest
|
|
3100
|
+
}),
|
|
3101
|
+
}
|
|
3102
|
+
const vp58 = analyzePage(plain58, layout58.size ?? { width: 960, height: 540 })
|
|
3103
|
+
ok('§58 门控(非回归):降级掉阶段 A 特性的同一页,容器压内容仍按未声明重叠报错,且不出结构两段/无新增 code',
|
|
3104
|
+
vp58.some((f) => f.code === 'unexpected-overlap')
|
|
3105
|
+
&& vp58.every((f) => !['relation-invalid', 'declared-stale', 'duplicate-element', 'line-through-box', 'arrow-not-on-edge'].includes(f.code))
|
|
3106
|
+
&& !verifyDeck({ pages: [plain58], size: layout58.size }).text.includes('· 结构关系:'),
|
|
3107
|
+
vp58.map((f) => f.code).join(','))
|
|
3108
|
+
|
|
3109
|
+
// ④ 导出基元:折线一帧 custGeom、双箭头两端都在、虚线 prstDash;直线仍走 straightConnector1
|
|
3110
|
+
const r58 = await exportPptx(await resolveDeck(work58), { out: join(work58, 'out.pptx') })
|
|
3111
|
+
const z58 = zipRead(await (await import('node:fs/promises')).readFile(r58.file))
|
|
3112
|
+
const x58 = z58.get('ppt/slides/slide1.xml').toString('utf8')
|
|
3113
|
+
const cnt = (re) => (x58.match(re) ?? []).length
|
|
3114
|
+
ok('§58 导出:>2 点折线 = 一帧 p:sp + custGeom(不是 N 条 cxnSp 拼的直线段)',
|
|
3115
|
+
cnt(/<a:custGeom>/g) === 1 && cnt(/<p:cxnSp>/g) === 1 && cnt(/prst="straightConnector1"/g) === 1,
|
|
3116
|
+
`custGeom=${cnt(/<a:custGeom>/g)}|cxnSp=${cnt(/<p:cxnSp>/g)}|直线=${cnt(/prst="straightConnector1"/g)}`)
|
|
3117
|
+
ok("§58 导出:arrow:'both' ⇒ headEnd 与 tailEnd **都在** <a:ln> 内;dash ⇒ prstDash 出现",
|
|
3118
|
+
cnt(/<a:headEnd/g) === 1 && cnt(/<a:tailEnd/g) === 2 && cnt(/<a:prstDash/g) === 1,
|
|
3119
|
+
`headEnd=${cnt(/<a:headEnd/g)}|tailEnd=${cnt(/<a:tailEnd/g)}|prstDash=${cnt(/<a:prstDash/g)}`)
|
|
3120
|
+
// 口径(导出侧队友明确):折线单独计入 polyLinesExp/Out,**不再进 linesExp/linesOut**;
|
|
3121
|
+
// 故集成断言要用 polyLines*(而非 lines*),且 polyLinesWrong 是**数字**不是数组。
|
|
3122
|
+
ok('§58 图进 parity:折线条数单独进 parity(polyLinesExp/Out 对得上且不为 0,wrong=0)',
|
|
3123
|
+
(r58.parity?.polyLinesExp ?? 0) === 1 && r58.parity?.polyLinesOut === 1 && r58.parity?.polyLinesWrong === 0,
|
|
3124
|
+
`polyLines ${r58.parity?.polyLinesOut}/${r58.parity?.polyLinesExp}|wrong=${JSON.stringify(r58.parity?.polyLinesWrong ?? null)}`)
|
|
3125
|
+
ok('§58 图进 parity:附着线单独计数,且**导出侧独立复核**端点落在被引用元素的对应边上(0 wrong)',
|
|
3126
|
+
r58.parity?.attachedLinesExp === 1 && r58.parity?.attachedLinesOut === 1 && (r58.parity?.attachedLinesWrong ?? ['x']).length === 0,
|
|
3127
|
+
`attached ${r58.parity?.attachedLinesOut}/${r58.parity?.attachedLinesExp}|wrong=${JSON.stringify(r58.parity?.attachedLinesWrong ?? null)}`)
|
|
3128
|
+
// ⑤ 能力可发现:能力写进 SCHEMA_REF(ppt_schema 是模型的权威通道)才算"上线",否则等于藏起来
|
|
3129
|
+
const vocab58 = ['groups:', 'contains:', 'badgeOf:', 'roleReason:', 'attach:', '折线', "arrow: 'both'", 'line.dash', 'notchedRightArrow', 'expectedOverlaps:']
|
|
3130
|
+
const missing58 = vocab58.filter((k) => !scaffoldMod.SCHEMA_REF.includes(k))
|
|
3131
|
+
ok('§58 能力可发现:ppt_schema(SCHEMA_REF)写进了阶段 A 全部新词汇(含折线/双箭头/虚线/notchedRightArrow)——不写则模型不知道能用',
|
|
3132
|
+
missing58.length === 0, missing58.length ? `缺:${missing58.join('、')}` : `${vocab58.length} 个词汇齐备`)
|
|
3133
|
+
await rm(work58, { recursive: true, force: true })
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
// ── F4:§59(族物化断言)已停用;意图由手画侧覆盖(verify:handdrawn / redprobe / linetext / optionprobe)
|
|
3137
|
+
// 原文保留为注释,供族时代对照(tag family-era-final 可运行)。
|
|
3138
|
+
// // 守四件事:① 语法可发现(ppt_schema 里有 diagram);② 物化真的把 IR 展开成普通元素 + 结构关系;
|
|
3139
|
+
// // ③ 引擎产出**零冲突且零声明**(不靠 expectedOverlaps 掩盖自己的几何问题);
|
|
3140
|
+
// // ④ 没有 diagram 的页一个字都不多(阶段 A 的门控不被引擎绕过)。
|
|
3141
|
+
// {
|
|
3142
|
+
// await rm(work59, { recursive: true, force: true })
|
|
3143
|
+
// await mkdir(join(work59, 'pages'), { recursive: true })
|
|
3144
|
+
// await writeFile(join(work59, 'deck.yaml'), ['version: 1', 'title: ir', 'size: [960, 540]', 'theme:',
|
|
3145
|
+
// ' colors: {primary: "#2563EB", accent: "#F59E0B", text: "#1F2937", bg: "#F8FAFC"}', ' textStyles:',
|
|
3146
|
+
// ' body: {fontSize: 14, color: "$text"}', ' spacing: {base: 28}', ' safeArea: {top: 40, bottom: 40, left: 40, right: 40}',
|
|
3147
|
+
// 'pages:', ' - pages/01.yaml', ''].join('\n'), 'utf8')
|
|
3148
|
+
// ' nodes:', ' - {id: n1, label: 采集}', ' - {id: n2, label: 清洗}', ' - {id: n3, label: 应用, emphasis: accent}',
|
|
3149
|
+
// ' edges:', ' - {from: n1, to: n2, label: 实时}', ' - {from: n2, to: n3, style: dashed}',
|
|
3150
|
+
// ' groups:', ' - {id: s1, label: 第一段, members: [n1, n2]}', ' - {id: s2, label: 第二段, members: [n3]}', ''].join('\n'), 'utf8')
|
|
3151
|
+
// const ctx59 = await resolveDeck(work59)
|
|
3152
|
+
// const page59 = ctx59.pages[0].page
|
|
3153
|
+
// ok('§59 能力可发现:ppt_schema 写进了手画通路的机制词汇(attach / contains / role:decoration / roleReason / custGeom / 18px / 手画是正式路径)——不写模型不知道能用',
|
|
3154
|
+
// ['attach', 'contains', 'role: decoration', 'roleReason', 'custGeom', '18px', '受支持的正式路径'].every((k) => scaffoldMod.SCHEMA_REF.includes(k)))
|
|
3155
|
+
// && page59.elements.some((e) => Array.isArray(e.contains) && e.contains.length >= 2),
|
|
3156
|
+
// `元素 ${page59.elements.length}|组 ${(page59.groups ?? []).length}`)
|
|
3157
|
+
// await renderDeck(ctx59, { out: 'preview59' })
|
|
3158
|
+
// const layout59 = JSON.parse(await (await import('node:fs/promises')).readFile(join(work59, 'preview59', 'layout.json'), 'utf8'))
|
|
3159
|
+
// const v59 = verifyDeck(layout59)
|
|
3160
|
+
// const decl59 = (page59.expectedOverlaps ?? []).length + (page59.expectedOutOfSafeArea ?? []).length
|
|
3161
|
+
// ok('§59 引擎产出零冲突:真实门禁 0 错误,且页面**零声明**(不靠声明掩盖几何问题)',
|
|
3162
|
+
// v59.errors.length === 0 && decl59 === 0,
|
|
3163
|
+
// `错误 ${v59.errors.length}|声明 ${decl59}|${v59.errors.slice(0, 3).map((e) => e.code).join(',')}`)
|
|
3164
|
+
// ok('§59 引擎产出的关系在门禁里可见(结构关系行 + 附着线计入 parity 的那种 attach)',
|
|
3165
|
+
// /· 结构关系:组 2|包含 \d+|附着 4/.test(v59.text) && !v59.warns.some((w) => w.code === 'attach-override'),
|
|
3166
|
+
// v59.text.split('\n').find((l) => l.includes('结构关系'))?.trim())
|
|
3167
|
+
// // 无 diagram 的页:物化不碰它(纪律:既有工程逐字节不变)
|
|
3168
|
+
// await writeFile(join(work59, 'pages', '02.yaml'), ['pageType: content', 'elements:',
|
|
3169
|
+
// ' - elementId: t1', ' elementType: text', ' bounds: [40, 40, 400, 40]', ' content: {text: "手写页", style: "$body"}', ''].join('\n'), 'utf8')
|
|
3170
|
+
// await writeFile(join(work59, 'deck.yaml'), ['version: 1', 'title: ir', 'size: [960, 540]', 'theme:',
|
|
3171
|
+
// ' colors: {primary: "#2563EB", accent: "#F59E0B", text: "#1F2937", bg: "#F8FAFC"}', ' textStyles:',
|
|
3172
|
+
// ' body: {fontSize: 14, color: "$text"}', ' spacing: {base: 28}', 'pages:', ' - pages/01.yaml', ' - pages/02.yaml', ''].join('\n'), 'utf8')
|
|
3173
|
+
// const ctx59b = await resolveDeck(work59)
|
|
3174
|
+
// const plain59 = ctx59b.pages[1].page
|
|
3175
|
+
// ok('§59 无 diagram 的页一个字都不多(元素原样、无 groups、无 notes)',
|
|
3176
|
+
// plain59.elements.length === 1 && plain59.elements[0].elementId === 't1' && plain59.groups === undefined && plain59.diagramNotes === undefined,
|
|
3177
|
+
// `元素 ${plain59.elements.length}|groups ${plain59.groups === undefined ? 'undefined' : plain59.groups.length}`)
|
|
3178
|
+
// await rm(work59, { recursive: true, force: true })
|
|
3179
|
+
// }
|
|
3180
|
+
|
|
3181
|
+
// // 37.10 【必须是最后一条断言】引用计数自证:文档里 "smoke … N 断言" 必须等于本次真实断言总数。
|
|
3182
|
+
// // 历史形状:加断言后 README×3 + docs/02 + docs/06×2 + 手册 全靠人工同步,迟早漏一处。
|
|
3183
|
+
// // 只扫"当前状态"文档(README / 技术报告 / 评审测试矩阵 / 使用手册);docs/01/03/04 里的历史数字是记录,不动。
|
|
3184
|
+
// // 覆盖边界(故意):只认"N 断言"与"N/N"两种<b>套件规模</b>写法。docs/06 §一 是历史快照,那里写的是
|
|
3185
|
+
// // 裸的 "smoke 181"(无"断言"二字),由 §一 上方的"计数说明"解释;当前规模只认 §2.1 与 §六。
|
|
3186
|
+
// // 【2026-09-18 收紧】`N/N |` 这条原本匹配**任何** `a/b |` 表格单元格——只要该行提到 "smoke" 就命中。
|
|
3187
|
+
// // 实测被自己踩到:新增一条 smoke 行的格子里写 `4/4 |`(4 条真产物断言全绿)→ 被误判成"文档计数过期"。
|
|
3188
|
+
// // 且 `preflight 11/11`、`test:bundle 20/20` 之类写在 smoke 行里同样会误报。
|
|
3189
|
+
// // 收紧为"分子 ≥ 100 才算套件规模":smoke 规模已 218,不会再回到两位数;其余套件应由 `N 断言` 那种
|
|
3190
|
+
// // 明确写法表达,而不是靠任何 `a/b` 单元格。防的仍是"加断言忘了同步文档"。
|
|
3191
|
+
// ── F4 补偿:手画侧能力可发现性,逐条对应被停用的族断言(M = N)──
|
|
3192
|
+
ok('F4 手画能力可发现:SCHEMA_REF 写进 attach —— 不写模型不知道能用', scaffoldMod.SCHEMA_REF.includes('attach'))
|
|
3193
|
+
ok('F4 手画能力可发现:SCHEMA_REF 写进 contains —— 不写模型不知道能用', scaffoldMod.SCHEMA_REF.includes('contains'))
|
|
3194
|
+
ok('F4 手画能力可发现:SCHEMA_REF 写进 role: decoration —— 不写模型不知道能用', scaffoldMod.SCHEMA_REF.includes('role: decoration'))
|
|
3195
|
+
ok('F4 手画能力可发现:SCHEMA_REF 写进 roleReason —— 不写模型不知道能用', scaffoldMod.SCHEMA_REF.includes('roleReason'))
|
|
3196
|
+
ok('F4 手画能力可发现:SCHEMA_REF 写进 custGeom —— 不写模型不知道能用', scaffoldMod.SCHEMA_REF.includes('custGeom'))
|
|
3026
3197
|
const liveTotal = pass + fail + 1 // 含本条自身
|
|
3027
3198
|
const countFiles = ['README.md', join('docs', '02-技术报告.md'), join('docs', '06-评审与测试.md'), join('skills', 'ppt-studio-manual', 'SKILL.md')]
|
|
3028
3199
|
const staleCounts = []
|
|
@@ -3046,4 +3217,4 @@ if (PREV_SMOKE_HOME === undefined) delete process.env.DSH_HOME
|
|
|
3046
3217
|
else process.env.DSH_HOME = PREV_SMOKE_HOME
|
|
3047
3218
|
|
|
3048
3219
|
console.log(`\n==== 结果:${pass} 通过 / ${fail} 失败 ====`)
|
|
3049
|
-
process.exit(fail > 0 ? 1 : 0)
|
|
3220
|
+
process.exit(fail > 0 ? 1 : 0)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 快测档(`npm run test:fast`,2026-09-27 新增)——**每条改动**默认跑这一档。
|
|
3
|
+
*
|
|
4
|
+
* 设计(用户 2026-09-27 拍板):
|
|
5
|
+
* ① 廉价跨切面守卫**永远全跑**——它们抓的正是"改 A 文件、断言在 B 文件里"这类子集化最容易漏的错
|
|
6
|
+
* (真实案例:改技能正文撞 ≤8000 字上限;改 schema 的 chart 字段撞"手册 vs 源码事实审计")。
|
|
7
|
+
* ② 影响面由 `scripts/test-map.mjs` 从 `git diff` 推出,`--explain` 可解释每一步选择。
|
|
8
|
+
* ③ **fail-safe**:改动文件没映射到任何规则 ⇒ 视为影响面未知 ⇒ 自动升级为全量 smoke。
|
|
9
|
+
* ④ 重活(真渲染/真装包/真跑 python)默认不进快测,`--with-heavy` 才带。
|
|
10
|
+
*
|
|
11
|
+
* 用法:
|
|
12
|
+
* node scripts/test-fast.mjs # 按 git diff 自动判定
|
|
13
|
+
* node scripts/test-fast.mjs --files=a.js,b.js
|
|
14
|
+
* node scripts/test-fast.mjs --full # 强制全量(等价 npm test)
|
|
15
|
+
* node scripts/test-fast.mjs --with-heavy # 带上重节(仍按影响面选面)
|
|
16
|
+
* node scripts/test-fast.mjs --explain # 只解释不执行
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from 'node:child_process'
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { explain, root, sectionsFromSmoke } from './test-map.mjs'
|
|
22
|
+
|
|
23
|
+
const argv = process.argv.slice(2)
|
|
24
|
+
const has = (f) => argv.includes(f)
|
|
25
|
+
const valOf = (name) => { const a = argv.find((x) => x.startsWith(`${name}=`)); return a ? a.slice(name.length + 1) : null }
|
|
26
|
+
|
|
27
|
+
function sh(cmd, extraEnv = {}) {
|
|
28
|
+
const t0 = Date.now()
|
|
29
|
+
const r = spawnSync(cmd, { shell: true, cwd: root, encoding: 'utf8', env: { ...process.env, ...extraEnv } })
|
|
30
|
+
const sec = ((Date.now() - t0) / 1000).toFixed(1)
|
|
31
|
+
const out = `${r.stdout ?? ''}${r.stderr ?? ''}`
|
|
32
|
+
return { code: r.status ?? 1, sec, out }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function changedFiles() {
|
|
36
|
+
const explicit = valOf('--files')
|
|
37
|
+
if (explicit) return explicit.split(',').map((s) => s.trim()).filter(Boolean)
|
|
38
|
+
// CI(干净检出、无本地改动)通道:由 workflow 把 PR 的改动清单写进文件,路径放这个环境变量里。
|
|
39
|
+
// 没有它、且干净检出 ⇒ 文件数为 0 ⇒ 调用方 fail-safe 跑全量(不假装"没有影响面")。
|
|
40
|
+
const fromEnv = process.env.TEST_FAST_FILES
|
|
41
|
+
if (fromEnv) {
|
|
42
|
+
try {
|
|
43
|
+
const text = existsSync(fromEnv) ? readFileSync(fromEnv, 'utf8') : fromEnv
|
|
44
|
+
return [...new Set(text.split(/[\r\n,]+/).map((s) => s.trim()).filter(Boolean))]
|
|
45
|
+
} catch { /* 落到 git 通道 */ }
|
|
46
|
+
}
|
|
47
|
+
const r = spawnSync('git', ['-C', root, 'status', '--porcelain=v1'], { encoding: 'utf8' })
|
|
48
|
+
const files = []
|
|
49
|
+
for (const line of String(r.stdout ?? '').split(/\r?\n/)) {
|
|
50
|
+
if (!line.trim()) continue
|
|
51
|
+
const p = line.slice(3).trim()
|
|
52
|
+
// 改名 `old -> new` 取新路径
|
|
53
|
+
files.push(p.includes('->') ? p.split('->')[1].trim() : p)
|
|
54
|
+
}
|
|
55
|
+
// 已提交但尚未发版的改动也要算(与上一个 tag 比)
|
|
56
|
+
const tag = spawnSync('git', ['-C', root, 'describe', '--tags', '--abbrev=0'], { encoding: 'utf8' })
|
|
57
|
+
if ((tag.status ?? 1) === 0) {
|
|
58
|
+
const since = spawnSync('git', ['-C', root, 'diff', '--name-only', `${String(tag.stdout).trim()}..HEAD`], { encoding: 'utf8' })
|
|
59
|
+
for (const f of String(since.stdout ?? '').split(/\r?\n/)) if (f.trim()) files.push(f.trim())
|
|
60
|
+
}
|
|
61
|
+
return [...new Set(files)]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const files = changedFiles()
|
|
65
|
+
const ex = explain(files, { includeHeavy: has('--with-heavy') })
|
|
66
|
+
const guarded = ex.unmapped.length > 0 || files.length === 0
|
|
67
|
+
const docsOnly = !guarded && ex.files.every((f) => f.startsWith('docs/') || f.startsWith('skills/') || f === 'README.md')
|
|
68
|
+
|
|
69
|
+
console.log('==== 快测档(test:fast)====')
|
|
70
|
+
console.log(`改动文件 ${files.length} 个|标签 = ${[...ex.tags].join(',') || '(无)'}|选中分节 ${ex.sections.length}/${sectionsFromSmoke().length}`)
|
|
71
|
+
if (files.length) console.log(` 选中:${ex.sections.slice(0, 14).map((s) => s.id).join(' ')}${ex.sections.length > 14 ? ` …(+${ex.sections.length - 14})` : ''}`)
|
|
72
|
+
for (const n of ex.notes) console.log(` ℹ ${n}`)
|
|
73
|
+
if (has('--explain')) { console.log('(--explain:只解释,不执行)'); process.exit(0) }
|
|
74
|
+
|
|
75
|
+
const results = []
|
|
76
|
+
const run = (name, cmd, env) => {
|
|
77
|
+
const r = sh(cmd, env)
|
|
78
|
+
results.push({ name, ...r })
|
|
79
|
+
console.log(`${r.code === 0 ? '✓' : '✗'} ${name}(${r.sec}s)`)
|
|
80
|
+
if (r.code !== 0) console.log(r.out.split(/\r?\n/).filter((l) => /✗|Error|错误/.test(l)).slice(0, 6).join('\n'))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ① 廉价跨切面守卫:永远跑(这几条全部加起来的代价约 6s,是快测的固定底座)
|
|
84
|
+
run('build(src → lib)', 'node scripts/build.mjs')
|
|
85
|
+
run('语法体检(src/lib/scripts 全覆盖)', 'node scripts/check-syntax.mjs')
|
|
86
|
+
run('LF 守卫', 'node scripts/check-tracked-lf.mjs')
|
|
87
|
+
run('lib 新鲜度', 'node scripts/check-lib-freshness.mjs')
|
|
88
|
+
run('预设门禁', 'node scripts/check-preset.mjs')
|
|
89
|
+
run('手册事实审计', 'node scripts/audit-manual-facts.mjs')
|
|
90
|
+
|
|
91
|
+
// ② 主体:按影响面决定
|
|
92
|
+
if (has('--full') || guarded) {
|
|
93
|
+
const why = has('--full') ? '显式 --full' : (files.length === 0 ? '没有改动文件' : '有未映射文件 ⇒ fail-safe')
|
|
94
|
+
console.log(`▶ 跑**全量 smoke**(${why})——注意:这一档约 5.2 分钟`)
|
|
95
|
+
run('smoke 全量', 'node scripts/smoke.mjs')
|
|
96
|
+
} else if (docsOnly) {
|
|
97
|
+
console.log('▶ 只改了文档/技能 ⇒ **不跑 smoke**(运行时零改动)。若你改了 smoke 自身或断言数量,请用 --full。')
|
|
98
|
+
} else {
|
|
99
|
+
console.log('▶ 运行时可改:本档默认**不跑 smoke 全量**(重节与深回归留到批次边界/发版)。')
|
|
100
|
+
console.log(` 受影响分节(供你判断是否需要 --full):${ex.sections.map((s) => s.id).join(' ') || '(无)'}`)
|
|
101
|
+
if (has('--with-heavy')) console.log(' (--with-heavy:真正需要真渲染/真装包那类重节时,用 `npm test` 或 `npm run test:release`)')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const total = results.reduce((a, r) => a + Number(r.sec), 0).toFixed(1)
|
|
105
|
+
const failed = results.filter((r) => r.code !== 0)
|
|
106
|
+
console.log(`==== 快测结果:${results.length - failed.length}/${results.length} 通过|用时 ${total}s ====`)
|
|
107
|
+
if (failed.length) { console.log(`✗ 失败:${failed.map((f) => f.name).join('、')}`); process.exit(1) }
|
|
108
|
+
console.log('提示:批次边界与发版前请跑 `npm test`(全量);发版用 `npm run test:release`。')
|