| 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 |