返回 oh-my-ppt
layout-budget.test.ts
根目录 / tests / unit / prompt / layout-budget.test.ts
1 import { readFileSync } from 'fs'
2 import path from 'path'
3 import { describe, expect, it } from 'vitest'
4 import { buildCanvasConstraints } from '../../../src/main/agent-runtime/prompt'
5 import { resolveSlideSize } from '../../../src/shared/slide-size'
6
7 const projectRoot = process.cwd()
8
9 const readProjectFile = (filePath: string) =>
10 readFileSync(path.join(projectRoot, filePath), 'utf-8')
11
12 describe('layout prompt budget guardrails', () => {
13 it('keeps fullscreen backgrounds separate from conservative content budget', () => {
14 const sharedPrompt = buildCanvasConstraints(resolveSlideSize({ id: 'wide-16-9' }))
15 const layoutSkill = readProjectFile('resources/skills/oh-my-ppt-layout/SKILL.md')
16
17 expect(sharedPrompt).toContain('背景可铺满 1600×900')
18 expect(sharedPrompt).toContain('四边留 24-40px')
19 expect(layoutSkill).toContain('Full-bleed backgrounds may use the entire 1600×900 canvas')
20 expect(layoutSkill).toContain('24-40px spare height')
21 })
22
23 it('prevents overpacked chart slides with two-row support grids', () => {
24 const layoutSkill = readProjectFile('resources/skills/oh-my-ppt-layout/SKILL.md')
25 const chartSkill = readProjectFile('resources/skills/oh-my-ppt-chart/SKILL.md')
26 const chartReference = readProjectFile('resources/skills/oh-my-ppt-chart/references/chart.md')
27
28 expect(layoutSkill).toContain('Overpacked chart slide guardrails')
29 expect(layoutSkill).toContain('Do not create two-row bottom card grids below a tall chart')
30 expect(layoutSkill).toContain('support modules are capped at 1-2 compact blocks')
31 expect(layoutSkill).toContain('Content expansion does not override density')
32 expect(chartSkill).toContain('Do not pair a standard/tall chart with a two-row bottom card grid')
33 expect(chartReference).toContain('Never place a two-row bottom card grid under a standard/tall chart')
34 expect(chartReference).toContain('0-2 compact blocks')
35 })
36
37 it('budgets axis-heavy charts instead of squeezing labels into nearby modules', () => {
38 const chartSkill = readProjectFile('resources/skills/oh-my-ppt-chart/SKILL.md')
39 const chartReference = readProjectFile('resources/skills/oh-my-ppt-chart/references/chart.md')
40 const checklist = readProjectFile('resources/skills/oh-my-ppt-layout/references/checklist.md')
41 const combined = [chartSkill, chartReference, checklist].join('\n')
42
43 expect(combined).toContain('Axis-heavy')
44 expect(combined).toContain('6+ categories')
45 expect(combined).toContain('negative+positive')
46 expect(combined).toContain('40-60px')
47 expect(combined).toContain('layout.padding.bottom')
48 expect(checklist).toContain('axis-heavy chart has no tick/label reserve')
49 })
50
51 it('keeps chart data semantically valid and interpreted', () => {
52 const chartSkill = readProjectFile('resources/skills/oh-my-ppt-chart/SKILL.md')
53 const chartReference = readProjectFile('resources/skills/oh-my-ppt-chart/references/chart.md')
54 const combined = [chartSkill, chartReference].join('\n')
55
56 expect(combined).toContain('one value axis = one unit/meaning')
57 expect(combined).toContain('Do not mix counts, percentages, money')
58 expect(combined).toContain('Do not put HTML')
59 expect(combined).toContain('string-array labels')
60 expect(combined).toContain('Chart slides need interpretation')
61 expect(combined).toContain('one visible takeaway sentence')
62 expect(combined).toContain('Do not repeat every category as equal-weight cards')
63 })
64
65 it('checklist gates the slide thesis at delivery time', () => {
66 const checklist = readProjectFile('resources/skills/oh-my-ppt-layout/references/checklist.md')
67 // Soul delivery gate: the self-check asks for the one memorable sentence,
68 // so the thesis decided at planning is re-tested before the page ships.
69 expect(checklist).toContain('single memorable message')
70 expect(checklist).toContain('load-bearing structure')
71 })
72
73 it('delivery guard blocks top-heavy half-screen layouts across rewrite-capable paths', () => {
74 const scenarioPrompt = readProjectFile('src/main/agent-runtime/prompt/composers/canvas-scenario.ts')
75 const deckSystem = readProjectFile('src/main/agent-runtime/prompt/composers/deck-system.ts')
76 const generationUser = readProjectFile('src/main/agent-runtime/prompt/composers/generation-user.ts')
77 const editSystem = readProjectFile('src/main/agent-runtime/prompt/composers/edit-system.ts')
78
79 expect(scenarioPrompt).toContain('buildCanvasScenarioDeliveryGuard')
80 expect(scenarioPrompt).toContain('形服务于魂')
81 expect(scenarioPrompt).toContain('3 秒可读的主旨')
82 expect(scenarioPrompt).toContain('正文不能全部停在上半屏')
83 expect(scenarioPrompt).toContain('220–280px')
84 expect(scenarioPrompt).toContain('视觉重心可以略高于几何中心')
85 expect(scenarioPrompt).toContain('首屏必须有吸引点')
86 expect(scenarioPrompt).toContain('收藏价值')
87
88 expect(deckSystem).toContain('buildCanvasScenarioDeliveryGuard')
89 expect(generationUser).toContain('buildCanvasScenarioDeliveryGuard')
90
91 const containerEdit = editSystem.slice(
92 editSystem.indexOf('function buildContainerEditPrompt('),
93 editSystem.indexOf('function buildSelectorEditPrompt(')
94 )
95 const selectorEdit = editSystem.slice(
96 editSystem.indexOf('function buildSelectorEditPrompt('),
97 editSystem.indexOf('function buildSinglePageEditPrompt(')
98 )
99 const singlePageEdit = editSystem.slice(
100 editSystem.indexOf('function buildSinglePageEditPrompt('),
101 editSystem.indexOf('function buildDeckEditPrompt(')
102 )
103 const deckEdit = editSystem.slice(editSystem.indexOf('function buildDeckEditPrompt('))
104
105 expect(singlePageEdit).toContain('buildCanvasScenarioDeliveryGuard')
106 expect(deckEdit).toContain('buildCanvasScenarioDeliveryGuard')
107 expect(selectorEdit).not.toContain('buildCanvasScenarioDeliveryGuard')
108 expect(containerEdit).not.toContain('buildCanvasScenarioDeliveryGuard')
109 })
110
111 it('keeps layout guidance density-driven and requires a pre-write size self-check', () => {
112 const sharedPrompt = readProjectFile('src/main/agent-runtime/prompt/composers/shared.ts')
113 const scenarioPrompt = readProjectFile('src/main/agent-runtime/prompt/composers/canvas-scenario.ts')
114 const layoutSkill = readProjectFile('resources/skills/oh-my-ppt-layout/SKILL.md')
115 const chartSkill = readProjectFile('resources/skills/oh-my-ppt-chart/SKILL.md')
116 const chartReference = readProjectFile('resources/skills/oh-my-ppt-chart/references/chart.md')
117
118 expect(layoutSkill).toContain('Self-check width/height')
119 expect(layoutSkill).toContain('Width must fit 1600px and height must fit 900px')
120 expect(layoutSkill).toContain('Do not mechanically reuse the same card grid')
121 // Designer-mentality sketch (focal point / balance / intentional whitespace) +
122 // the mechanics canvas block (distribute EXISTING content, no fill, density by content).
123 expect(sharedPrompt).toContain('观众先看哪')
124 expect(sharedPrompt).toContain('让版面协调')
125 expect(sharedPrompt).toContain('对应逻辑画布宽 ${slideSize.width}px、高 ${slideSize.height}px')
126 expect(sharedPrompt).toContain('不为填满而新增')
127 expect(sharedPrompt).toContain('密度由内容决定')
128 expect(sharedPrompt).toContain('内容够了就不扩展')
129 expect(scenarioPrompt).toContain('过密先自我总结')
130 expect(sharedPrompt).toContain('内容过多先总结再布局')
131 expect(sharedPrompt).toContain('不要靠缩小字号、增加卡片、堆更多行')
132 expect(layoutSkill).toContain('presentation-like breathing room')
133 expect(layoutSkill).toContain('Source material is evidence; the slide is an edited message')
134 expect(layoutSkill).toContain('not by mirroring the document')
135 expect(layoutSkill).toContain('avoid repeating the same fact again as equal-weight summary cards')
136 expect(layoutSkill).toContain('pause before writing HTML and self-summarize')
137 expect(layoutSkill).toContain('do not turn every fact into a visible card')
138 expect(layoutSkill).toContain('Before expanding, decide whether the page is truly sparse')
139 expect(layoutSkill).toMatch(/do \*\*not\*\* add more cards or a second summary layer/i)
140 // Chart height marker contract stays in the always-on canvas block; the
141 // column-width / chart-slot calc detail lives in the chart skill (asserted below).
142 expect(sharedPrompt).toContain('@ppt-chart-height=N')
143 expect(layoutSkill).toMatch(/Pair a modest chart with right-sized support/i)
144 expect(layoutSkill).toContain('Supplement lightly from the source material')
145 expect(layoutSkill).toContain('columns share width, not height')
146 expect(layoutSkill).toContain('@ppt-chart-height=N')
147 expect(chartSkill).toContain('redesign the chart/support relationship')
148 expect(chartSkill).toContain('do not subtract a left metric rail from a right-column chart height')
149 expect(chartSkill).toContain('Never put `@ppt-chart-height=...` as visible text')
150 expect(chartSkill).toContain('Do not calculate a 600+ slot and then choose 340px')
151 expect(chartSkill).toContain('@ppt-chart-height=N')
152 expect(chartReference).toContain('redesign the chart/support relationship')
153 expect(chartReference).toContain('do not subtract a left metric rail from a right-column chart height')
154 expect(chartReference).toContain('Never put `@ppt-chart-height=...` as visible text')
155 expect(chartReference).toContain('Do not calculate a 600+ slot and then choose 340px')
156 expect(chartReference).toContain('@ppt-chart-height=N')
157
158 const combinedPrompt = [sharedPrompt, layoutSkill, chartSkill, chartReference].join('\n')
159 expect(combinedPrompt).not.toContain('cut content')
160 expect(combinedPrompt).not.toContain('move support modules to another slide')
161 expect(combinedPrompt).not.toContain('split the content')
162 expect(combinedPrompt).not.toContain('放不下就减模块')
163 expect(combinedPrompt).not.toContain('每个模块(图表/表格/卡片行/列表)都要填满')
164 })
165
166 it('chart skill avoids tiny charts without forcing dense support content', () => {
167 const chartSkill = readProjectFile('resources/skills/oh-my-ppt-chart/SKILL.md')
168 const chartReference = readProjectFile('resources/skills/oh-my-ppt-chart/references/chart.md')
169 const combined = [chartSkill, chartReference].join('\n')
170 // The old calc told the model to cap the chart at the role range and
171 // "leave the spare space empty" — that is what produced half-empty chart
172 // pages (e.g. a 692px slot capped at 400px, 292px left empty).
173 expect(combined).not.toMatch(/simply leave it empty/i)
174 expect(combined).not.toMatch(/min\([^)]*cap\)/i)
175 expect(combined).not.toMatch(/340.?420/)
176 // Hero/main charts still have room to be dominant.
177 expect(combined).toMatch(/380.?560/)
178 expect(combined).toMatch(/primary evidence/i)
179 expect(combined).toContain('support the content actually needs')
180 expect(combined).toContain('0-2 support items')
181 // Two distinct terms: content slot (chart + support area) vs chart slot
182 // (content slot − support). The final h-[Npx] must equal the chart slot, so
183 // the model does not fill the content slot and then add support on top.
184 expect(combined).toMatch(/content slot/i)
185 expect(combined).toMatch(/chart slot/i)
186 expect(combined).toMatch(/equal the chart slot/i)
187 // Real failure observed in generated pages: the comment calculated a hero
188 // chart height, but the actual frame class was written back to h-[240px].
189 expect(combined).toMatch(/chart height = 420/i)
190 expect(combined).toMatch(/h-\[240px\]/i)
191 })
192
193 it('keeps body and heading font floors semantic while exempting auxiliary text', () => {
194 const sharedPrompt = readProjectFile('src/main/agent-runtime/prompt/composers/shared.ts')
195 const layoutSkill = readProjectFile('resources/skills/oh-my-ppt-layout/SKILL.md')
196 const checklist = readProjectFile('resources/skills/oh-my-ppt-layout/references/checklist.md')
197 const combined = [sharedPrompt, layoutSkill, checklist].join('\n')
198
199 expect(combined).toContain('text-lg')
200 expect(combined).toContain('18px')
201 expect(combined).toContain('text-2xl')
202 expect(combined).toContain('24px')
203 expect(combined).toContain('data-ppt-text-role="auxiliary"')
204 expect(layoutSkill).toContain('this is a floor, not a fixed heading size')
205 expect(layoutSkill).toContain('Decorative chips, badges, status tags')
206 expect(sharedPrompt).toContain('标题仍可按层级放大')
207 expect(checklist).toContain('may be 12–17px')
208 expect(combined).toContain('auxiliary text below 12px')
209 expect(combined).not.toContain('text-base(16px)')
210 expect(combined).not.toContain('text-base` (16px) is the floor')
211 })
212
213 it('every canonical copy-this chart example carries @ppt-chart-height matching its h-[Npx]', () => {
214 const chartSkill = readProjectFile('resources/skills/oh-my-ppt-chart/SKILL.md')
215 const chartReference = readProjectFile('resources/skills/oh-my-ppt-chart/references/chart.md')
216
217 // The model copies these examples verbatim, so a canonical example that omits
218 // the marker (or whose marker disagrees with the class) silently breaks the
219 // marker↔class contract the validator enforces. Match a marker comment that
220 // sits directly above an h-[Npx] frame, and require marker N == class N.
221 const cases: Array<[string, string]> = [
222 ['SKILL.md', chartSkill],
223 ['chart.md', chartReference]
224 ]
225 for (const [name, source] of cases) {
226 const match = source.match(
227 /@ppt-chart-height=(\d+)[^\n]*\n\s*<div class="ppt-chart-frame[^"]*h-\[(\d+)px\]/
228 )
229 expect(
230 match,
231 `${name} canonical example must carry @ppt-chart-height directly above its h-[Npx] frame`
232 ).not.toBeNull()
233 expect(
234 match![1],
235 `${name} canonical example marker value must equal its h-[Npx] class`
236 ).toBe(match![2])
237 }
238 })
239 })
240
240 lines TYPESCRIPT