dsh-ppt-studio 1.1.5 → 1.2.1
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 +159 -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 +57 -0
- package/docs/15-1.2.0 /345/217/221/345/270/203/346/212/245/345/221/212.md" +46 -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 +191 -20
- 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-preset-scope.mjs +1 -1
- 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/ppt-studio-diagram/SKILL.md +30 -0
- package/skills/ppt-studio-diagram/capability-matrix.md +22 -0
- package/skills/ppt-studio-diagram/composition.md +22 -0
- package/skills/ppt-studio-diagram/mechanisms.md +141 -0
- package/skills/ppt-studio-diagram/pitfalls.md +97 -0
- package/skills/ppt-studio-diagram/routing.md +17 -0
- package/skills/ppt-studio-diagram/text-and-spacing.md +16 -0
- package/skills/ppt-studio-diagram/verify.md +50 -0
- package/skills/ppt-studio-diagram-compare/SKILL.md +38 -0
- package/skills/ppt-studio-diagram-flow/SKILL.md +38 -0
- package/skills/ppt-studio-diagram-funnel/SKILL.md +39 -0
- package/skills/ppt-studio-diagram-layers/SKILL.md +32 -0
- package/skills/ppt-studio-diagram-loop/SKILL.md +39 -0
- package/skills/ppt-studio-diagram-matrix/SKILL.md +37 -0
- package/skills/ppt-studio-diagram-sequence/SKILL.md +31 -0
- package/skills/ppt-studio-diagram-state/SKILL.md +48 -0
- package/skills/ppt-studio-diagram-steps-ring/SKILL.md +38 -0
- package/skills/ppt-studio-diagram-swimlane/SKILL.md +37 -0
- package/skills/ppt-studio-diagram-timeline/SKILL.md +38 -0
- package/skills/ppt-studio-diagram-tree/SKILL.md +38 -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,405 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 结构关系自证(阶段 A 地基,2026-09-28):`node scripts/verify-diagram-relations.mjs`
|
|
4
|
+
*
|
|
5
|
+
* 为什么单独一个脚本:`src/pptd/relations.js` 是本阶段最容易"看起来对、实际宽松"的地方
|
|
6
|
+
* (判据一松就变成假绿)。所以这里把**每条关系的正例 + 反例**、组结构合法性、确定性、
|
|
7
|
+
* "不改输入"、"豁免只来自有效关系"全部钉成断言,退出码 0/1,可随时复跑。
|
|
8
|
+
*
|
|
9
|
+
* 覆盖(对应 docs/08 §2/§3):
|
|
10
|
+
* 1 正例:合法结构 ⇒ 0 错误、关系全部 valid、豁免集合正确、统计数字正确
|
|
11
|
+
* 2 反例:contains 不成立 / attach 未落边 / badge 面积比超限与不完全覆盖 / 声明两端是内容 / 声明已失效
|
|
12
|
+
* 3 组结构:重复成员、成员不存在、一元素属两组、嵌套成环
|
|
13
|
+
* 4 图专属检查:duplicate-element / text-over-text / line-through-box / arrow-not-on-edge(有 attach ⇒ 错误)
|
|
14
|
+
* 5 三条硬纪律:确定性(两次深度相等)、不改输入、豁免不落盘(page 上没有新增字段)
|
|
15
|
+
*/
|
|
16
|
+
import { deriveRelations, checkDiagram, summaryLine, rectOf, contains, onSide, segmentCrossesRect, resolveAttach } from '../lib/pptd/relations.js'
|
|
17
|
+
|
|
18
|
+
let pass = 0
|
|
19
|
+
let fail = 0
|
|
20
|
+
const failures = []
|
|
21
|
+
function ok(name, cond, detail = '') {
|
|
22
|
+
if (cond) { pass++; console.log(`✓ ${name}${detail ? ` — ${detail}` : ''}`) }
|
|
23
|
+
else { fail++; failures.push(name); console.log(`✗ ${name}${detail ? ` — ${detail}` : ''}`) }
|
|
24
|
+
}
|
|
25
|
+
const H = (t) => console.log(`\n=== ${t} ===`)
|
|
26
|
+
|
|
27
|
+
/** 造一个合法页面:容器含两子元素 + 一条两端附着且带箭头的线 + 一个徽章 + 一条有意重叠声明。 */
|
|
28
|
+
function goodPage() {
|
|
29
|
+
return {
|
|
30
|
+
index: 0,
|
|
31
|
+
groups: [
|
|
32
|
+
{ id: 'g_in', label: '输入层', members: ['in_box'] },
|
|
33
|
+
{ id: 'g_core', label: '内核', members: ['m1', 'g_loop'] },
|
|
34
|
+
{ id: 'g_loop', label: '反馈小块', members: ['e1'] },
|
|
35
|
+
],
|
|
36
|
+
elements: [
|
|
37
|
+
{ id: 'zone', elementType: 'shape', bounds: { x: 40, y: 120, w: 880, h: 300 }, contains: ['in_box', 'chip'], role: 'background' },
|
|
38
|
+
{ id: 'in_box', elementType: 'shape', bounds: { x: 70, y: 200, w: 180, h: 90 } },
|
|
39
|
+
{ id: 'm1', elementType: 'shape', bounds: { x: 400, y: 200, w: 300, h: 60 } },
|
|
40
|
+
{ id: 'chip', elementType: 'shape', bounds: { x: 300, y: 124, w: 120, h: 34 }, badgeOf: 'zone', role: 'background', roleReason: '标题胶囊' },
|
|
41
|
+
{ id: 'e1', elementType: 'line', points: [[250, 245], [400, 230]], attach: { from: { ref: 'in_box', side: 'right' }, to: { ref: 'm1', side: 'left' } }, arrow: 'both' },
|
|
42
|
+
],
|
|
43
|
+
expectedOverlaps: [{ pair: ['chip', 'zone'], reason: '标题胶囊压在容器顶边' }],
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── 1. 正例 ────────────────────────────────────────────────────────────────
|
|
48
|
+
H('1. 正例:合法结构')
|
|
49
|
+
{
|
|
50
|
+
const page = goodPage()
|
|
51
|
+
const r = deriveRelations(page)
|
|
52
|
+
ok('无结构错误', r.errors.length === 0, r.errors.map((e) => e.detail).join('|'))
|
|
53
|
+
ok('无失效/不成立的关系', r.invalid.length === 0, JSON.stringify(r.invalid))
|
|
54
|
+
ok('关系全部 valid(包含 2 + 附着 2 + 徽章 1 + 有意重叠 1 = 6)', r.relations.length === 6 && r.relations.every((x) => x.valid), `关系数=${r.relations.length}|invalid=${JSON.stringify(r.invalid)}`)
|
|
55
|
+
ok('统计数字正确(组 3 / 包含 2 / 附着 2 / 徽章 1 / 有意重叠 1)',
|
|
56
|
+
r.stats.groups === 3 && r.stats.contains === 2 && r.stats.attach === 2 && r.stats.badge === 1 && r.stats.overlapDecl === 1,
|
|
57
|
+
JSON.stringify(r.stats))
|
|
58
|
+
ok('豁免集合 = {in_box × zone, chip × zone}(去重后 2 个)',
|
|
59
|
+
r.exempt.length === 2 && r.exempt.includes('chip × zone') && r.exempt.includes('in_box × zone'), r.exempt.join(','))
|
|
60
|
+
ok('组深度正确(g_loop 嵌在 g_core 内 ⇒ depth 1)', r.groups.find((g) => g.id === 'g_loop')?.depth === 1)
|
|
61
|
+
ok('图专属检查在合法页上静默', (() => { const c = checkDiagram(page); return c.errors.length === 0 && c.warnings.length === 0 })(),
|
|
62
|
+
JSON.stringify(checkDiagram(page)))
|
|
63
|
+
ok('summaryLine 形态稳定', /^结构关系:组 3|包含 2|附着 2|徽章 1|有意重叠 1(自动豁免重叠 2 处,未落盘)$/.test(summaryLine(r)), summaryLine(r))
|
|
64
|
+
// 1c) 包含关系的**传递闭包**:A ⊇ B ⊇ C ⇒ A×C 也豁免(嵌套组合的关键;外层不必枚举深后代)
|
|
65
|
+
const chain = {
|
|
66
|
+
elements: [
|
|
67
|
+
{ id: 'A', elementType: 'shape', bounds: { x: 0, y: 0, w: 400, h: 300 }, contains: ['B'] },
|
|
68
|
+
{ id: 'B', elementType: 'shape', bounds: { x: 20, y: 20, w: 300, h: 200 }, contains: ['C'] },
|
|
69
|
+
{ id: 'C', elementType: 'text', bounds: { x: 40, y: 40, w: 100, h: 30 } },
|
|
70
|
+
],
|
|
71
|
+
}
|
|
72
|
+
const rc = deriveRelations(chain)
|
|
73
|
+
ok('包含传递闭包:A ⊇ B ⊇ C ⇒ exempt 含 A × C(嵌套组合不必枚举深后代)',
|
|
74
|
+
rc.exempt.includes('A × B') && rc.exempt.includes('B × C') && rc.exempt.includes('A × C') && rc.stats.closurePairs >= 1,
|
|
75
|
+
`exempt=${rc.exempt.join(',')}|closurePairs=${rc.stats.closurePairs}`)
|
|
76
|
+
ok('传递闭包只沿**有效**边传播:把 B 的 contains 声明改成不成立 ⇒ A × C 不再豁免',
|
|
77
|
+
(() => {
|
|
78
|
+
const bad = JSON.parse(JSON.stringify(chain))
|
|
79
|
+
bad.elements[1].bounds = { x: 20, y: 20, w: 50, h: 40 } // B 装不下 C ⇒ B→C 不成立
|
|
80
|
+
const rb = deriveRelations(bad)
|
|
81
|
+
return !rb.exempt.includes('A × C') && rb.invalid.some((x) => x.type === 'contains' && x.from === 'B')
|
|
82
|
+
})())
|
|
83
|
+
}
|
|
84
|
+
H('2. 反例(宽松判据会在这里漏掉)')
|
|
85
|
+
{
|
|
86
|
+
// contains 不成立:容器缩小到装不下子元素
|
|
87
|
+
const p1 = goodPage()
|
|
88
|
+
p1.elements[0].bounds = { x: 40, y: 120, w: 200, h: 120 }
|
|
89
|
+
const r1 = deriveRelations(p1)
|
|
90
|
+
ok('contains 不成立 ⇒ 进 invalid 且产生点名错误',
|
|
91
|
+
r1.invalid.some((x) => x.type === 'contains') && r1.errors.some((e) => e.code === 'relation-invalid' && /contains/.test(e.detail)),
|
|
92
|
+
r1.errors.map((e) => e.detail).join('|'))
|
|
93
|
+
ok('contains 不成立 ⇒ 该对被移出豁免集合', !r1.exempt.includes('in_box × zone'), r1.exempt.join(','))
|
|
94
|
+
|
|
95
|
+
// attach 未落边:终点偏离 6px
|
|
96
|
+
const p2 = goodPage()
|
|
97
|
+
p2.elements[4].points = [[250, 245], [406, 230]]
|
|
98
|
+
const r2 = deriveRelations(p2)
|
|
99
|
+
ok('attach 未落边 ⇒ 点名错误', r2.errors.some((e) => /attach 声明不成立/.test(e.detail)), r2.errors.map((e) => e.detail).join('|'))
|
|
100
|
+
ok('attach 未落边 ⇒ 不影响其它关系(contains/badge 仍 valid)', r2.relations.filter((x) => x.type === 'contains').every((x) => x.valid))
|
|
101
|
+
|
|
102
|
+
// badge 面积比超限
|
|
103
|
+
const p3 = goodPage()
|
|
104
|
+
p3.elements[3].bounds = { x: 300, y: 124, w: 700, h: 280 } // 面积接近容器 ⇒ 更像遮罩
|
|
105
|
+
const r3 = deriveRelations(p3)
|
|
106
|
+
ok('badge 面积比超限 ⇒ 不成立并说明"更像遮罩"', r3.invalid.some((x) => x.type === 'badge' && /遮罩/.test(x.why)), JSON.stringify(r3.invalid))
|
|
107
|
+
|
|
108
|
+
// badge 完全覆盖(chip 比 owner 还大 ⇒ 应作为"遮罩"被拒)
|
|
109
|
+
// 注意语义:这一对被 badgeOf 拒了,但**同时**有一条显式 expectedOverlaps 声明 ⇒ 它仍应在豁免集里
|
|
110
|
+
//("声明"与"结构关系"相互独立:显式声明能豁免重叠,但不能把一条不成立的 badge 声明变成合法)
|
|
111
|
+
const p4 = goodPage()
|
|
112
|
+
p4.elements[3].bounds = { x: 30, y: 110, w: 920, h: 320 }
|
|
113
|
+
const r4 = deriveRelations(p4)
|
|
114
|
+
const badgeRel = r4.relations.find((x) => x.type === 'badge')
|
|
115
|
+
ok('badge 完全覆盖 ⇒ 该结构关系不成立(按遮罩处理)',
|
|
116
|
+
r4.invalid.some((x) => x.type === 'badge') && badgeRel?.valid === false,
|
|
117
|
+
JSON.stringify({ invalid: r4.invalid.map((x) => x.type), badgeValid: badgeRel?.valid }))
|
|
118
|
+
ok('但它仍被**显式声明**豁免(声明与结构关系相互独立)',
|
|
119
|
+
r4.exempt.includes('chip × zone'), r4.exempt.join(','))
|
|
120
|
+
|
|
121
|
+
// 声明两端都是内容元素 ⇒ 永不可声明
|
|
122
|
+
const p5 = goodPage()
|
|
123
|
+
p5.elements.push({ id: 't1', elementType: 'text', bounds: { x: 500, y: 500, w: 200, h: 40 }, content: { text: '甲' } })
|
|
124
|
+
p5.elements.push({ id: 't2', elementType: 'text', bounds: { x: 560, y: 505, w: 200, h: 40 }, content: { text: '乙' } })
|
|
125
|
+
p5.expectedOverlaps.push({ pair: ['t1', 't2'], reason: '故意压字' })
|
|
126
|
+
const r5 = deriveRelations(p5)
|
|
127
|
+
ok('内容 × 内容 的声明被拒(不可声明)', r5.errors.some((e) => e.code === 'declared-content-collision'), r5.errors.map((e) => e.code).join(','))
|
|
128
|
+
|
|
129
|
+
// 声明已失效(两端不再相交)⇒ 只算 ℹ,不算错误
|
|
130
|
+
const p6 = goodPage()
|
|
131
|
+
p6.elements[3].bounds = { x: 300, y: 600, w: 120, h: 34 } // chip 移出 zone 之外
|
|
132
|
+
const r6 = deriveRelations(p6)
|
|
133
|
+
ok('失效声明进 invalid 但**不**产生错误(ℹ 级)',
|
|
134
|
+
r6.invalid.some((x) => x.type === 'overlapDecl') && !r6.errors.some((e) => /overlapDecl 声明不成立/.test(e.detail)),
|
|
135
|
+
JSON.stringify({ invalid: r6.invalid.map((x) => x.type), errors: r6.errors.map((e) => e.code) }))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── 3. 组结构合法性 ────────────────────────────────────────────────────────
|
|
139
|
+
H('3. groups 结构校验')
|
|
140
|
+
{
|
|
141
|
+
const cases = [
|
|
142
|
+
['成员重复', (p) => { p.groups[0].members = ['in_box', 'in_box'] }, 'group-duplicate-member'],
|
|
143
|
+
['成员不存在', (p) => { p.groups[0].members = ['nope'] }, 'group-member-missing'],
|
|
144
|
+
['一元素属两个直接组', (p) => { p.groups[1].members.push('in_box') }, 'group-member-multi-parent'],
|
|
145
|
+
['嵌套成环', (p) => { p.groups.push({ id: 'g_cycle', members: ['g_core'] }); p.groups[1].members.push('g_cycle') }, 'group-cycle'],
|
|
146
|
+
['组 id 与元素同名', (p) => { p.groups.push({ id: 'zone', members: ['in_box'] }) }, 'group-id-collides-element'],
|
|
147
|
+
]
|
|
148
|
+
for (const [name, mutate, code] of cases) {
|
|
149
|
+
const p = goodPage()
|
|
150
|
+
mutate(p)
|
|
151
|
+
const r = deriveRelations(p)
|
|
152
|
+
ok(`组校验:${name} ⇒ ${code}`, r.errors.some((e) => e.code === code), r.errors.map((e) => e.code).join(','))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── 4. 图专属检查 ─────────────────────────────────────────────────────────
|
|
157
|
+
H('4. 图专属机械检查')
|
|
158
|
+
{
|
|
159
|
+
const dup = goodPage()
|
|
160
|
+
dup.elements.push({ id: 'in_box_copy', elementType: 'shape', bounds: { x: 71, y: 200, w: 180, h: 90 } })
|
|
161
|
+
ok('duplicate-element:几乎重合的两元素被点名', checkDiagram(dup).errors.some((e) => e.code === 'duplicate-element'))
|
|
162
|
+
|
|
163
|
+
const tot = goodPage()
|
|
164
|
+
tot.elements.push({ id: 't1', elementType: 'text', bounds: { x: 600, y: 500, w: 200, h: 40 }, content: { text: '甲' } })
|
|
165
|
+
tot.elements.push({ id: 't2', elementType: 'text', bounds: { x: 620, y: 505, w: 200, h: 40 }, content: { text: '乙' } })
|
|
166
|
+
ok('text-over-text:文本互压报错(不可声明)', checkDiagram(tot).errors.some((e) => e.code === 'text-over-text'))
|
|
167
|
+
|
|
168
|
+
const cross = goodPage()
|
|
169
|
+
cross.elements.push({ id: 'box_mid', elementType: 'shape', bounds: { x: 280, y: 235, w: 60, h: 40 } })
|
|
170
|
+
ok('line-through-box:线穿过非端点盒子报错', checkDiagram(cross).errors.some((e) => e.code === 'line-through-box'), JSON.stringify(checkDiagram(cross).errors))
|
|
171
|
+
|
|
172
|
+
const containerOk = goodPage()
|
|
173
|
+
ok('容器不算"被穿过的盒子"(线在容器内部正常)',
|
|
174
|
+
!checkDiagram(containerOk).errors.some((e) => e.code === 'line-through-box' && /zone/.test(e.detail)))
|
|
175
|
+
|
|
176
|
+
const arrowOff = goodPage()
|
|
177
|
+
arrowOff.elements[4].points = [[250, 245], [380, 230]] // 箭头端悬空
|
|
178
|
+
delete arrowOff.elements[4].attach
|
|
179
|
+
const cOff = checkDiagram(arrowOff)
|
|
180
|
+
ok('arrow-not-on-edge:无 attach ⇒ 警告级', cOff.warnings.some((e) => e.code === 'arrow-not-on-edge') && !cOff.errors.some((e) => e.code === 'arrow-not-on-edge'))
|
|
181
|
+
|
|
182
|
+
const arrowOff2 = goodPage()
|
|
183
|
+
arrowOff2.elements[4].points = [[250, 245], [380, 230]]
|
|
184
|
+
const cOff2 = checkDiagram(arrowOff2)
|
|
185
|
+
ok('arrow-not-on-edge:带 attach ⇒ 升级为错误', cOff2.errors.some((e) => e.code === 'arrow-not-on-edge'))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── 5. 三条硬纪律 ─────────────────────────────────────────────────────────
|
|
189
|
+
H('5. 硬纪律:确定性 / 不改输入 / 豁免不落盘')
|
|
190
|
+
{
|
|
191
|
+
const page = goodPage()
|
|
192
|
+
const before = JSON.stringify(page)
|
|
193
|
+
const a = JSON.stringify(deriveRelations(page))
|
|
194
|
+
const b = JSON.stringify(deriveRelations(goodPage()))
|
|
195
|
+
ok('确定性:同输入两次推导结果深度相等', a === b)
|
|
196
|
+
ok('不改输入:page 未被原地修改', JSON.stringify(page) === before)
|
|
197
|
+
ok('豁免不落盘:page 上没有被写入 exempt/relations 等字段',
|
|
198
|
+
!('exempt' in page) && !('relations' in page) && !('_relations' in page))
|
|
199
|
+
|
|
200
|
+
// 关系顺序稳定(按 type|from|to 排序)
|
|
201
|
+
const r = deriveRelations(goodPage())
|
|
202
|
+
const keys = r.relations.map((x) => `${x.type}|${x.from}|${x.to}`)
|
|
203
|
+
ok('关系顺序稳定(字典序)', keys.join(',') === [...keys].sort().join(','), keys.join(','))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── 6. 底层几何谓词(直接单测,防"上层看着对、谓词实际宽松") ────────────────
|
|
207
|
+
H('6. 几何谓词单测')
|
|
208
|
+
{
|
|
209
|
+
const P = { x: 0, y: 0, w: 100, h: 50, right: 100, bottom: 50 }
|
|
210
|
+
ok('contains:完全包含为真', contains(P, { x: 10, y: 10, w: 20, h: 20, right: 30, bottom: 30 }))
|
|
211
|
+
ok('contains:溢出 1px 为假', !contains(P, { x: 10, y: 10, w: 95, h: 20, right: 105, bottom: 30 }))
|
|
212
|
+
ok('onSide:right 命中(含容差)', onSide('right', [100.5, 25], P) && !onSide('right', [103, 25], P))
|
|
213
|
+
ok('onSide:纵向超界不算命中', !onSide('right', [100, 80], P))
|
|
214
|
+
ok('segmentCrossesRect:穿过内部为真', segmentCrossesRect([-10, 25], [110, 25], P))
|
|
215
|
+
ok('segmentCrossesRect:只贴边不算穿过', !segmentCrossesRect([0, 0], [0, 50], P))
|
|
216
|
+
ok('rectOf 兼容数组与对象两种 bounds', rectOf({ bounds: [1, 2, 3, 4] }).right === 4 && rectOf({ bounds: { x: 1, y: 2, w: 3, h: 4 } }).bottom === 6)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── 7. schema 层:结构字段校验(A-①) ─────────────────────────────────────
|
|
220
|
+
H('7. schema:结构字段的类型与引用校验(validatePage)')
|
|
221
|
+
{
|
|
222
|
+
const { validatePage } = await import('../lib/pptd/schema.js')
|
|
223
|
+
const msg = (p) => { try { const e = validatePage(p, 'x.yaml'); return e ? (e.messages ?? []).join('|') : '' } catch (err) { return String(err?.message ?? err) } }
|
|
224
|
+
const shape = (id, extra = {}) => ({ elementId: id, elementType: 'shape', kind: 'rect', bounds: [0, 0, 40, 20], ...extra })
|
|
225
|
+
const line = (extra = {}) => ({ elementId: 'l', elementType: 'line', points: [[0, 0], [40, 20]], ...extra })
|
|
226
|
+
|
|
227
|
+
ok('合法:attach + arrow both + dash + groups 全通过',
|
|
228
|
+
msg({ elements: [shape('a'), line({ attach: { from: { ref: 'a', side: 'right' } }, arrow: 'both', line: { dash: 'dash' } })], groups: [{ id: 'g', label: '组合', members: ['a', 'l'] }] }) === '')
|
|
229
|
+
ok('attach.side 非法 ⇒ 报错', /attach\.from\.side/.test(msg({ elements: [shape('a'), line({ attach: { from: { ref: 'a', side: 'up' } } })] })))
|
|
230
|
+
// 顺序无关(延后校验):引用**写了但被引用元素在后文**⇒ 合法;引用真不存在 ⇒ 报错
|
|
231
|
+
const laterRef = { elements: [line({ attach: { to: { ref: 'later', side: 'left' } } }), shape('later')] }
|
|
232
|
+
const missingRef = { elements: [line({ attach: { to: { ref: 'ghost', side: 'left' } } })] }
|
|
233
|
+
ok('attach.ref 顺序无关:引用后文元素合法、引用不存在元素报错',
|
|
234
|
+
msg(laterRef) === '' && /不是本页元素 id/.test(msg(missingRef)),
|
|
235
|
+
`后文引用=${msg(laterRef) || '通过'}|不存在=${msg(missingRef) || '竟然通过'}`)
|
|
236
|
+
ok('attach 放在非 line 元素上 ⇒ 报错', /只对 elementType: line 有效/.test(msg({ elements: [shape('a', { attach: { from: { ref: 'a', side: 'top' } } })] })))
|
|
237
|
+
ok("arrow 非法值报错、'both' 合法", /arrow/.test(msg({ elements: [line({ arrow: 'front' })] })) && !/arrow/.test(msg({ elements: [line({ arrow: 'both' })] })))
|
|
238
|
+
ok('line.dash 非法值 ⇒ 报错', /line\.dash/.test(msg({ elements: [line({ line: { dash: 'wavy' } })] })))
|
|
239
|
+
ok('contains 指向不存在 / 指向自己 ⇒ 各自报错',
|
|
240
|
+
/不是本页元素 id/.test(msg({ elements: [shape('a', { contains: ['nope'] })] })) && /不能包含自己/.test(msg({ elements: [shape('a', { contains: ['a'] })] })))
|
|
241
|
+
ok('badgeOf 指向不存在 ⇒ 报错', /不是本页元素 id/.test(msg({ elements: [shape('a', { badgeOf: 'ghost' })] })))
|
|
242
|
+
ok('roleReason 空串 ⇒ 报错', /roleReason/.test(msg({ elements: [{ elementId: 't', elementType: 'text', bounds: [0, 0, 80, 20], content: { text: 'x' }, role: 'background', roleReason: ' ' }] })))
|
|
243
|
+
|
|
244
|
+
ok('groups:成员不存在 ⇒ 报错', /既不是元素也不是组 id/.test(msg({ elements: [shape('a')], groups: [{ id: 'g', members: ['ghost'] }] })))
|
|
245
|
+
ok('groups:嵌套成环 ⇒ 报错', /嵌套成环/.test(msg({ elements: [shape('a')], groups: [{ id: 'g1', members: ['g2'] }, { id: 'g2', members: ['g1'] }] })))
|
|
246
|
+
ok('groups:一个元素属两个直接组 ⇒ 报错', /只允许一个直接父组/.test(msg({ elements: [shape('a')], groups: [{ id: 'g1', members: ['a'] }, { id: 'g2', members: ['a'] }] })))
|
|
247
|
+
ok('groups:id 与元素同名 / 重复组 id ⇒ 报错', /共用同一命名空间/.test(msg({ elements: [shape('a')], groups: [{ id: 'a', members: ['a'] }] })) && /duplicate/.test(msg({ elements: [shape('a')], groups: [{ id: 'g', members: ['a'] }, { id: 'g', members: ['a'] }] })))
|
|
248
|
+
|
|
249
|
+
ok('expectedOverlaps.reason 类型错 ⇒ 报错;**缺失不报错**(docs/08:缺失仅警告)',
|
|
250
|
+
/reason/.test(msg({ elements: [shape('a'), shape('b')], expectedOverlaps: [{ pair: ['a', 'b'], reason: 123 }] }))
|
|
251
|
+
&& !/reason/.test(msg({ elements: [shape('a'), shape('b')], expectedOverlaps: [{ pair: ['a', 'b'] }] })))
|
|
252
|
+
|
|
253
|
+
// 非回归:既有的"只写 2 点 + 无结构字段"的 deck 行为完全不变
|
|
254
|
+
ok('非回归:无新字段的页面校验结果与从前一致(无错误)', msg({ elements: [shape('a'), line({ arrow: true })] }) === '')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── 8. verify 集成(A-③):结构豁免真的进声明集;报告段出现;且**门控**保证普通页不受影响 ──
|
|
258
|
+
H('8. verify 集成:结构豁免 / 报告形态 / 门控(非回归)')
|
|
259
|
+
{
|
|
260
|
+
const { analyzePage, verifyDeck } = await import('../lib/verify.js')
|
|
261
|
+
const size = { width: 960, height: 540 }
|
|
262
|
+
const B = (x, y, w, h) => ({ x, y, w, h })
|
|
263
|
+
const container = (extra = {}) => ({ id: 'zone', type: 'shape', kind: 'roundRect', bounds: B(40, 120, 880, 300), ...extra })
|
|
264
|
+
const textInside = { id: 't1', type: 'text', kind: 'text', bounds: B(100, 200, 200, 40), content: { text: '标签' } }
|
|
265
|
+
const wrap = (page) => ({ index: 0, name: 'p', safeArea: null, overlapMode: 'declared', elements: [], expectedOverlaps: [], ...page })
|
|
266
|
+
|
|
267
|
+
// 无结构声明:容器(background) 压住文本(content) ⇒ declared 模式下未声明重叠 = 错误
|
|
268
|
+
const plain = wrap({ elements: [container(), textInside] })
|
|
269
|
+
const plainF = analyzePage(plain, size)
|
|
270
|
+
ok('门控对照:**没有**结构声明的页,容器压文本仍是 unexpected-overlap 错误',
|
|
271
|
+
plainF.some((f) => f.code === 'unexpected-overlap' && f.severity === 'error'),
|
|
272
|
+
plainF.map((f) => `${f.severity}:${f.code}`).join(','))
|
|
273
|
+
|
|
274
|
+
// 有 contains 且几何成立:同一对重叠被**结构关系豁免**(转为 confirmed 预期重叠,不再是错误)
|
|
275
|
+
const struct = wrap({ elements: [container({ contains: ['t1'] }), textInside] })
|
|
276
|
+
const structF = analyzePage(struct, size)
|
|
277
|
+
ok('结构豁免生效:contains 成立 ⇒ 该对不再报 unexpected-overlap,而是 confirmed 预期重叠',
|
|
278
|
+
!structF.some((f) => f.code === 'unexpected-overlap') && structF.some((f) => f.code === 'expected-overlap' && f.severity === 'confirmed'),
|
|
279
|
+
structF.map((f) => `${f.severity}:${f.code}`).join(','))
|
|
280
|
+
|
|
281
|
+
// contains 声明不成立(容器装不下子元素)⇒ 点名错误,且不再豁免
|
|
282
|
+
const bad = wrap({ elements: [container({ contains: ['t1'], bounds: B(40, 120, 80, 40) }), textInside] })
|
|
283
|
+
const badF = analyzePage(bad, size)
|
|
284
|
+
ok('声明不成立 ⇒ 出现 relation-invalid 错误(豁免被撤回)',
|
|
285
|
+
badF.some((f) => f.code === 'relation-invalid' && f.severity === 'error'),
|
|
286
|
+
badF.map((f) => `${f.severity}:${f.code}`).join(','))
|
|
287
|
+
|
|
288
|
+
// 已失效的预期重叠声明 ⇒ 可见警告(声明复核)
|
|
289
|
+
// 注意:本路径**受门控**——只有"用了结构声明"的页才启用阶段 A 的检查与复核段(保证普通页行为不变)。
|
|
290
|
+
// 因此夹具里必须同时有结构声明(下面的 contains)才能观察到 declared-stale。
|
|
291
|
+
const stale = wrap({
|
|
292
|
+
elements: [
|
|
293
|
+
container({ contains: ['inside'] }),
|
|
294
|
+
{ ...textInside, id: 'inside', bounds: B(100, 200, 200, 40) },
|
|
295
|
+
{ ...textInside, id: 'far', bounds: B(900, 500, 40, 20) },
|
|
296
|
+
],
|
|
297
|
+
expectedOverlaps: [{ pair: ['zone', 'far'], reason: '曾经的意图' }],
|
|
298
|
+
})
|
|
299
|
+
const staleF = analyzePage(stale, size)
|
|
300
|
+
ok('失效声明 ⇒ warning declared-stale(不复用错误级,也不静默)',
|
|
301
|
+
staleF.some((f) => f.code === 'declared-stale' && f.severity === 'warning'),
|
|
302
|
+
staleF.map((f) => `${f.severity}:${f.code}`).join(','))
|
|
303
|
+
|
|
304
|
+
// 报告形态:用了结构 ⇒ 结构关系行 + 声明复核段;没用 ⇒ 两段都不出现(门控)
|
|
305
|
+
const structText = verifyDeck({ size, theme: null, pages: [struct] }).text
|
|
306
|
+
const plainText = verifyDeck({ size, theme: null, pages: [plain] }).text
|
|
307
|
+
ok('报告:用了结构的页有「结构关系」行', /· 结构关系:组 \d+|包含 \d+|附着 \d+|徽章 \d+|有意重叠 \d+(自动豁免重叠 \d+ 处,未落盘)/.test(structText), structText.split('\n').filter((l) => l.includes('结构关系')).join('|'))
|
|
308
|
+
ok('报告:声明复核段固定存在(豁免必须可见)', structText.includes('· 设计声明复核:'), structText.split('\n').filter((l) => l.includes('声明复核')).join('|'))
|
|
309
|
+
ok('门控(非回归):没用新字段的页**不出现**这两段,报告与从前一致',
|
|
310
|
+
!plainText.includes('· 结构关系:') && !plainText.includes('· 设计声明复核:'))
|
|
311
|
+
ok('门控(非回归):普通页 findings 里没有本次新增的任何 code',
|
|
312
|
+
!plainF.some((f) => ['relation-invalid', 'declared-stale', 'duplicate-element', 'line-through-box', 'arrow-not-on-edge', 'group-cycle'].includes(f.code)),
|
|
313
|
+
plainF.map((f) => f.code).join(','))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── 9. attach → 坐标解析(A-④ 集成):attach 优先于手写 points,且**改写必须可见** ──
|
|
317
|
+
H('9. resolveAttach:锚点解析与"改写可见"')
|
|
318
|
+
{
|
|
319
|
+
const page = {
|
|
320
|
+
elements: [
|
|
321
|
+
{ id: 'A', elementType: 'shape', bounds: { x: 100, y: 100, w: 100, h: 60 } }, // right 边 x=200,y∈[100,160]
|
|
322
|
+
{ id: 'B', elementType: 'shape', bounds: { x: 400, y: 300, w: 100, h: 60 } }, // left 边 x=400,y∈[300,360]
|
|
323
|
+
],
|
|
324
|
+
}
|
|
325
|
+
const line = (extra) => ({ id: 'l', elementType: 'line', ...extra })
|
|
326
|
+
|
|
327
|
+
// ① 无手写 points:两端锚点直接造出坐标(attach 足以定义一条线)
|
|
328
|
+
const r1 = resolveAttach(line({ attach: { from: { ref: 'A', side: 'right' }, to: { ref: 'B', side: 'left' } } }), page)
|
|
329
|
+
ok('无手写 points:attach 两端直接解析出坐标,且落在对应边上',
|
|
330
|
+
r1.points?.length === 2 && onSide('right', r1.points[0], rectOf(page.elements[0])) && onSide('left', r1.points[1], rectOf(page.elements[1])),
|
|
331
|
+
JSON.stringify(r1.points))
|
|
332
|
+
|
|
333
|
+
// ② 手写 points 已正确 ⇒ 不改写、无提示(不能"每次都报覆盖")
|
|
334
|
+
const ok2 = [[200, 130], [400, 330]]
|
|
335
|
+
const r2 = resolveAttach(line({ points: ok2, attach: { from: { ref: 'A', side: 'right' }, to: { ref: 'B', side: 'left' } } }), page)
|
|
336
|
+
ok('手写坐标已正确 ⇒ 不改写、notes 为空(不产生噪音提示)',
|
|
337
|
+
r2.overridden === 0 && r2.notes.length === 0 && JSON.stringify(r2.points) === JSON.stringify(ok2), JSON.stringify(r2))
|
|
338
|
+
|
|
339
|
+
// ③ 手写 points 明显错误(偏 40px)⇒ attach 覆盖 + **必须提示**
|
|
340
|
+
const r3 = resolveAttach(line({ points: [[240, 130], [400, 370]], attach: { from: { ref: 'A', side: 'right' }, to: { ref: 'B', side: 'left' } } }), page)
|
|
341
|
+
ok('手写坐标错误 ⇒ attach 覆盖、overridden=2 且 notes 各一条(静默覆盖是不可接受的)',
|
|
342
|
+
r3.overridden === 2 && r3.notes.length === 2 && onSide('right', r3.points[0], rectOf(page.elements[0])) && onSide('left', r3.points[1], rectOf(page.elements[1])),
|
|
343
|
+
`overridden=${r3.overridden}|notes=${r3.notes.length}`)
|
|
344
|
+
|
|
345
|
+
// ④ 锚点取"最近点并夹紧":另一端远超边范围 ⇒ 锚点仍落在边上(不飞出去)
|
|
346
|
+
const r4 = resolveAttach(line({ attach: { from: { ref: 'A', side: 'right' }, to: { ref: 'B', side: 'left' } } }), page)
|
|
347
|
+
ok('锚点夹紧:另一端在边范围之外时,锚点仍严格落在该边线段上(不飞到延长线)',
|
|
348
|
+
r4.points[0][1] >= 100 && r4.points[0][1] <= 160 && r4.points[0][0] === 200,
|
|
349
|
+
JSON.stringify(r4.points[0]))
|
|
350
|
+
|
|
351
|
+
// ⑤ 非法 side / ref 不存在 ⇒ 不产坏坐标(保留原 points 并提示)
|
|
352
|
+
const bad = resolveAttach(line({ points: [[10, 10], [20, 20]], attach: { from: { ref: 'ghost', side: 'right' }, to: { ref: 'B', side: 'up' } } }), page)
|
|
353
|
+
ok('非法 side / ref 不存在 ⇒ 不解析(坐标原样保留、无 NaN)',
|
|
354
|
+
JSON.stringify(bad.points) === JSON.stringify([[10, 10], [20, 20]]) && bad.points.every((p) => p.every(Number.isFinite)),
|
|
355
|
+
JSON.stringify(bad))
|
|
356
|
+
|
|
357
|
+
// ⑥ 纯函数:不改输入
|
|
358
|
+
const src = line({ points: [[240, 130], [400, 370]], attach: { from: { ref: 'A', side: 'right' } } })
|
|
359
|
+
const before = JSON.stringify(src)
|
|
360
|
+
resolveAttach(src, page)
|
|
361
|
+
ok('纯函数:不改输入元素(points 不被原地改写)', JSON.stringify(src) === before)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── 8. 斜边预设:attach 锚点必须落在**真实轮廓**上(R1/R4c)──────────────
|
|
365
|
+
H('8. 斜边预设的 attach 锚点(真实轮廓,而非包围盒边)')
|
|
366
|
+
{
|
|
367
|
+
const { outlineAnchor, slantRect, onSide } = await import('../lib/pptd/relations.js')
|
|
368
|
+
const cases = [
|
|
369
|
+
['parallelogram', [770, 180, 150, 130], 'left', [740, 275], 778.75],
|
|
370
|
+
['parallelogram', [50, 180, 150, 130], 'right', [232, 245], 183.75],
|
|
371
|
+
['triangle', [100, 100, 200, 120], 'left', [80, 160], 150],
|
|
372
|
+
['triangle', [100, 100, 200, 120], 'right', [300, 160], 250],
|
|
373
|
+
['diamond', [100, 100, 200, 120], 'left', [80, 160], 100],
|
|
374
|
+
['diamond', [100, 100, 200, 120], 'right', [300, 160], 300],
|
|
375
|
+
// S1 扩面:trapezoid / chevron / hexagon(左右边为斜边)
|
|
376
|
+
// 梯形 [100,100,200,120]、y=160(中点):off=0.25×120=30 ⇒ 左边 100+30×0.5=115、右边 285
|
|
377
|
+
['trapezoid', [100, 100, 200, 120], 'left', [80, 160], 115],
|
|
378
|
+
['trapezoid', [100, 100, 200, 120], 'right', [320, 160], 285],
|
|
379
|
+
['trapezoid', [100, 100, 200, 120], 'left', [80, 100], 130], // 上边处(y=y0):off×1 ⇒ 100+30
|
|
380
|
+
// 雪佛龙:x1=0.5×120=60 ⇒ 中点(y=160)左边界是**缺口尖** 100+60=160、右边最外 100+200=300;
|
|
381
|
+
// 上边(y=100)左边界回到顶点 100、右边 100+200−60=240
|
|
382
|
+
['chevron', [100, 100, 200, 120], 'left', [80, 160], 160],
|
|
383
|
+
['chevron', [100, 100, 200, 120], 'right', [320, 160], 300],
|
|
384
|
+
['chevron', [100, 100, 200, 120], 'right', [320, 100], 240],
|
|
385
|
+
// 六边形:x1=0.25×120=30 ⇒ 中点处最宽(左 100、右 300);上边处内收 30 ⇒ 左 130
|
|
386
|
+
['hexagon', [100, 100, 200, 120], 'left', [80, 160], 100],
|
|
387
|
+
['hexagon', [100, 100, 200, 120], 'right', [320, 160], 300],
|
|
388
|
+
['hexagon', [100, 100, 200, 120], 'left', [80, 100], 130],
|
|
389
|
+
]
|
|
390
|
+
for (const [kind, bounds, side, from, want] of cases) {
|
|
391
|
+
const ref = { kind, bounds }
|
|
392
|
+
const a = outlineAnchor(ref, side, from)
|
|
393
|
+
const okAnchor = !!a && Math.abs(a[0] - want) < 1.5
|
|
394
|
+
const okVerify = okAnchor ? onSide(side, a, slantRect(ref, side, a), 1.5) : false
|
|
395
|
+
ok(`${kind}.${side}:锚点在真实轮廓上(x≈${want})且能过反验证`, okAnchor && okVerify, `x=${a ? a[0].toFixed(2) : 'null'}`)
|
|
396
|
+
}
|
|
397
|
+
// 快照命名差异(kind='shape' + shape=<预设名>)也必须认——这里踩过坑,固化成断言
|
|
398
|
+
const snapRef = { kind: 'shape', shape: 'parallelogram', bounds: { x: 770, y: 180, w: 150, h: 130 } }
|
|
399
|
+
const sa = outlineAnchor(snapRef, 'left', [740, 275])
|
|
400
|
+
ok('快照命名(kind="shape" + shape="parallelogram")同样解析到真实轮廓',
|
|
401
|
+
!!sa && Math.abs(sa[0] - 778.75) < 1.5, `x=${sa ? sa[0].toFixed(2) : 'null'}`)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
console.log(`\n==== verify-diagram-relations 结果:${pass} 通过 / ${fail} 失败 ====`)
|
|
405
|
+
if (fail) { console.log(`失败项:${failures.join(';')}`); process.exit(1) }
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 手画 13 页回归基线(F0 冻结)—— 用手画的**自动化回归**代替族时代的断言。
|
|
4
|
+
*
|
|
5
|
+
* 比对三项**与元数据无关**的不变量(不依赖 PowerPoint、不需要导出):
|
|
6
|
+
* ① layout 摘要:每页「元素 id + 类型 + 圆整后的 bounds/points」规范化 JSON 的 sha256
|
|
7
|
+
* ② 每页门禁 错/警 计数与警 code 集合
|
|
8
|
+
* ③ 13 张命名 PNG 的 sha256(若文件存在)
|
|
9
|
+
*
|
|
10
|
+
* 用法:
|
|
11
|
+
* node scripts/verify-handdrawn.mjs --capture # 落基线(只在冻结时用)
|
|
12
|
+
* node scripts/verify-handdrawn.mjs # 与基线比对(CI/回归用)
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { execSync } from 'node:child_process'
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { resolveDeck } from '../src/pptd/schema.js'
|
|
19
|
+
import { renderDeck } from '../src/pptd/render-html.js'
|
|
20
|
+
import { verifyDeck } from '../src/verify.js'
|
|
21
|
+
|
|
22
|
+
const ROOT = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
|
|
23
|
+
const SRC = join(ROOT, 'examples', 'hand-drawn')
|
|
24
|
+
const OUT = join(ROOT, '..', 'ppt-deliverable', '手画12+1')
|
|
25
|
+
const BASE = join(ROOT, 'scripts', 'fixtures', 'handdrawn-baseline.json')
|
|
26
|
+
const ORDER = ['01','02','03','04','05','06','07','08','09','10','11','12','13']
|
|
27
|
+
const NAMES = { '01':'01-层级树(手画).png','02':'02-矩阵(手画).png','03':'03-时间轴(手画).png','04':'04-泳道(手画).png','05':'05-左右对比(手画).png','06':'06-闭环(手画).png','07':'07-漏斗(手画).png','08':'08-步骤环(手画).png','09':'09-时序(手画).png','10':'10-状态机(手画).png','11':'11-流程图(手画).png','12':'12-分层架构(手画).png','13':'13-平台架构(手画).png' }
|
|
28
|
+
const r2 = (n) => Math.round(n * 100) / 100
|
|
29
|
+
const normEl = (e) => {
|
|
30
|
+
const b = e.bounds
|
|
31
|
+
const bounds = Array.isArray(b) ? b.map(r2) : (b && typeof b === 'object' ? [r2(b.x), r2(b.y), r2(b.w), r2(b.h)] : null)
|
|
32
|
+
const pts = Array.isArray(e.points) ? e.points.map((p) => [r2(p[0]), r2(p[1])]) : null
|
|
33
|
+
return { id: String(e.id ?? e.elementId ?? '?'), t: String(e.kind ?? e.type ?? '?'), bounds, pts }
|
|
34
|
+
}
|
|
35
|
+
const sha = (s) => createHash('sha256').update(s).digest('hex')
|
|
36
|
+
|
|
37
|
+
const ctx = await resolveDeck(SRC)
|
|
38
|
+
await renderDeck(ctx, { out: 'preview' })
|
|
39
|
+
const layout = JSON.parse(readFileSync(join(SRC, 'preview', 'layout.json'), 'utf8'))
|
|
40
|
+
const pages = Array.isArray(layout.pages) ? layout.pages : Object.values(layout.pages)
|
|
41
|
+
|
|
42
|
+
const perPage = pages.map((p, i) => {
|
|
43
|
+
const v = verifyDeck({ pages: [p] })
|
|
44
|
+
const els = (p.elements ?? []).map(normEl).sort((a, b) => a.id.localeCompare(b.id))
|
|
45
|
+
return {
|
|
46
|
+
page: ORDER[i],
|
|
47
|
+
els: els.length,
|
|
48
|
+
errors: v.errors.length,
|
|
49
|
+
errorCodes: [...new Set(v.errors.map((e) => e.code))].sort(),
|
|
50
|
+
warns: v.warns.length,
|
|
51
|
+
warnCodes: [...new Set(v.warns.map((w) => w.code))].sort(),
|
|
52
|
+
digest: sha(JSON.stringify(els)),
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
const digest = sha(JSON.stringify(perPage.map((p) => [p.page, p.els, p.digest])))
|
|
56
|
+
const pngs = {}
|
|
57
|
+
for (const code of ORDER) {
|
|
58
|
+
const f = join(OUT, NAMES[code])
|
|
59
|
+
if (existsSync(f)) pngs[NAMES[code]] = sha(readFileSync(f))
|
|
60
|
+
}
|
|
61
|
+
const cur = { digest, perPage, pngs }
|
|
62
|
+
|
|
63
|
+
if (process.argv.includes('--capture')) {
|
|
64
|
+
mkdirSync(dirname(BASE), { recursive: true })
|
|
65
|
+
const head = execSync('git rev-parse HEAD', { cwd: ROOT, encoding: 'utf8' }).trim()
|
|
66
|
+
// 保留更新历史:不再整文件覆写(否则上一次的更新原因会丢——实测踩过)
|
|
67
|
+
const prev = existsSync(BASE) ? JSON.parse(readFileSync(BASE, 'utf8')) : {}
|
|
68
|
+
const reasonIdx = process.argv.indexOf('--reason')
|
|
69
|
+
const reason = reasonIdx >= 0 ? (process.argv[reasonIdx + 1] ?? '(未注明)') : '(未注明,见提交信息)'
|
|
70
|
+
const history = [...(prev.history ?? [])]
|
|
71
|
+
if (prev.digest && prev.digest !== cur.digest) history.push({ at: new Date().toISOString(), from: prev.digest, to: cur.digest, reason })
|
|
72
|
+
else if (prev.digest && prev.digest === cur.digest) history.push({ at: new Date().toISOString(), from: cur.digest, to: cur.digest, reason: '摘要未变(重采): ' + reason })
|
|
73
|
+
writeFileSync(BASE, JSON.stringify({ savedAt: new Date().toISOString(), head, ...cur, history }, null, 2) + '\n')
|
|
74
|
+
console.log('✓ 基线已落盘 scripts/fixtures/handdrawn-baseline.json')
|
|
75
|
+
console.log(' layout 摘要 = ' + digest)
|
|
76
|
+
console.log(' 逐页 = ' + perPage.map((p) => p.page + ':错' + p.errors + '/警' + p.warns).join(' '))
|
|
77
|
+
console.log(' PNG 记录 = ' + Object.keys(pngs).length + ' 张')
|
|
78
|
+
process.exit(0)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!existsSync(BASE)) { console.log('✗ 无基线文件,请先 --capture'); process.exit(1) }
|
|
82
|
+
const base = JSON.parse(readFileSync(BASE, 'utf8'))
|
|
83
|
+
// history 断言(P2-3):基线必须留有**变更沿革**,且每条都要写明原因(不许静默改基线)
|
|
84
|
+
{
|
|
85
|
+
const h = base.history
|
|
86
|
+
const bad = !Array.isArray(h) || h.length === 0 || h.some((e) => !e || typeof e.reason !== 'string' || !e.reason.trim())
|
|
87
|
+
if (bad) bad.push('基线 history 缺失或条目无 reason ⇒ 请用: node scripts/verify-handdrawn.mjs --capture --reason "原因"')
|
|
88
|
+
if (bad !== false && bad) { console.log('✗ ' + (Array.isArray(bad) ? bad[bad.length - 1] : bad)); process.exit(1) }
|
|
89
|
+
}
|
|
90
|
+
const bad = []
|
|
91
|
+
// PNG sha:次要不变量(像素级)。渲染依赖 PowerPoint 版本/环境 ⇒ 差异只警告不失败(硬不变量是 layout 摘要);
|
|
92
|
+
// 交付目录不存在(如全新 clone)则整体跳过,保证 CI 可移植。
|
|
93
|
+
let pngSame = 0
|
|
94
|
+
const pngWarn = []
|
|
95
|
+
let pngMiss = 0
|
|
96
|
+
for (const [name, h] of Object.entries(base.pngs ?? {})) {
|
|
97
|
+
if (!(name in cur.pngs)) { pngMiss++; continue }
|
|
98
|
+
if (cur.pngs[name] === h) pngSame++
|
|
99
|
+
else pngWarn.push(name)
|
|
100
|
+
}
|
|
101
|
+
if (base.digest !== cur.digest) bad.push('layout 摘要变化:' + base.digest.slice(0, 12) + ' → ' + cur.digest.slice(0, 12))
|
|
102
|
+
for (const p of cur.perPage) {
|
|
103
|
+
const b = (base.perPage ?? []).find((x) => x.page === p.page)
|
|
104
|
+
if (!b) { bad.push('基线缺页 ' + p.page); continue }
|
|
105
|
+
if (b.digest !== p.digest) bad.push('[' + p.page + '] 布局摘要变化')
|
|
106
|
+
if (b.errors !== p.errors) bad.push('[' + p.page + '] 错误数 ' + b.errors + ' → ' + p.errors)
|
|
107
|
+
if (b.warns !== p.warns) bad.push('[' + p.page + '] 警告数 ' + b.warns + ' → ' + p.warns)
|
|
108
|
+
}
|
|
109
|
+
const errorsTotal = cur.perPage.reduce((a, p) => a + p.errors, 0)
|
|
110
|
+
const warnsTotal = cur.perPage.reduce((a, p) => a + p.warns, 0)
|
|
111
|
+
console.log('手画 13 页回归:' + cur.perPage.length + ' 页|错 ' + errorsTotal + '|警 ' + warnsTotal + '|layout 摘要 ' + cur.digest.slice(0, 12))
|
|
112
|
+
console.log(' 产物对照: PNG 相同 ' + pngSame + ' 张|缺失跳过 ' + pngMiss + ' 张|差异 ' + pngWarn.length + ' 张' + (pngWarn.length ? '(' + pngWarn.slice(0, 3).join('; ') + ')—— 像素差异属环境/渲染差异, 硬不变量是 layout 摘要' : ''))
|
|
113
|
+
if (errorsTotal > 0) bad.push('存在门禁错误')
|
|
114
|
+
if (bad.length) { console.log('✗ 与基线不一致:'); bad.forEach((b) => console.log(' · ' + b)); process.exit(1) }
|
|
115
|
+
console.log('✓ 与基线一致(layout 摘要逐字节相同 + 错警计数相同)')
|