返回 oh-my-ppt
layout-catalog.test.ts
根目录 / tests / unit / prompt / layout-catalog.test.ts
1 import { readFileSync } from 'fs'
2 import path from 'path'
3 import { describe, expect, it } from 'vitest'
4
5 const projectRoot = process.cwd()
6
7 const readProjectFile = (filePath: string) =>
8 readFileSync(path.join(projectRoot, filePath), 'utf-8')
9
10 const layoutDir = 'resources/skills/oh-my-ppt-layout'
11
12 describe('layout skill catalog structure', () => {
13 it('catalog.md declares itself advisory and style-agnostic', () => {
14 const catalog = readProjectFile(`${layoutDir}/references/catalog.md`)
15
16 expect(catalog).toContain('advisory')
17 expect(catalog.toLowerCase()).toContain('structure choice')
18 expect(catalog).toContain('style-swap self-check')
19 expect(catalog).toContain('not visual styles')
20 })
21
22 it('catalog.md gives canonical 1600x900 zone skeletons for common half-empty failures', () => {
23 const catalog = readProjectFile(`${layoutDir}/references/catalog.md`)
24
25 expect(catalog).toContain('Canonical 1600×900 zone skeletons')
26 for (const skeleton of [
27 'full-height-two-zone',
28 'vertical-timeline-lanes',
29 'kpi-dashboard-balanced',
30 'chart-plus-insight-stack'
31 ]) {
32 expect(catalog).toContain(skeleton)
33 }
34
35 expect(catalog).toContain('all real modules sit in the top half')
36 expect(catalog).toContain('both zones must visibly participate in the middle of the canvas')
37 expect(catalog).toContain('timeline cards should not all sit in one top row')
38 expect(catalog).toContain('a metric dashboard needs a designed middle')
39 expect(catalog).toContain('chart frame around 240px')
40 })
41
42 it('catalog.md contains all 22 named patterns across 9 intents', () => {
43 const catalog = readProjectFile(`${layoutDir}/references/catalog.md`)
44
45 const patterns = [
46 'hero-title-center',
47 'hero-title-asymmetric',
48 'hero-big-number',
49 'section-divider',
50 'hero-quote',
51 'summary-takeaways',
52 'executive-brief',
53 'kpi-hero',
54 'metric-band',
55 'trend-exhibit',
56 'chart-annotated',
57 'compare-two-zone',
58 'compare-options',
59 'decision-matrix',
60 'concept-center-satellites',
61 'framework-2x2',
62 'framework-pyramid',
63 'process-linear',
64 'process-loop',
65 'timeline-strip',
66 'asset-image-hero',
67 'asset-text-visual-split'
68 ]
69
70 for (const pattern of patterns) {
71 expect(catalog).toContain(pattern)
72 }
73 expect(patterns).toHaveLength(22)
74 })
75
76 it('catalog.md marks structural gap patterns while keeping executive-brief controlled', () => {
77 const catalog = readProjectFile(`${layoutDir}/references/catalog.md`)
78 const headings = catalog.split('\n').filter((line) => line.startsWith('### `'))
79
80 const executiveHeading = headings.find((line) => line.includes('`executive-brief`'))
81 expect(executiveHeading).toContain('controlled high-density')
82
83 // Structural expansion patterns can still be marked as filling a catalog gap,
84 // but the high-density executive brief should not be the default density cue.
85 for (const pattern of [
86 'decision-matrix',
87 'framework-2x2',
88 'framework-pyramid',
89 'process-loop'
90 ]) {
91 const heading = headings.find((line) => line.includes(`\`${pattern}\``))
92 expect(heading, `heading for expanded pattern ${pattern}`).toBeDefined()
93 expect(heading).toMatch(/fills a gap/i)
94 }
95 })
96
97 it('catalog.md documents the stackable composition techniques', () => {
98 const catalog = readProjectFile(`${layoutDir}/references/catalog.md`)
99
100 expect(catalog).toContain('Stackable composition techniques')
101 for (const technique of [
102 'unequal-zones',
103 'overlap-layering',
104 'bento-grid',
105 'split-tone',
106 'floating-cards',
107 'staircase',
108 'hero-band',
109 'diagonal-accent',
110 'asymmetric-whitespace'
111 ]) {
112 expect(catalog).toContain(technique)
113 }
114 })
115
116 it('every catalog pattern block carries all four parts of the definition', () => {
117 const catalog = readProjectFile(`${layoutDir}/references/catalog.md`)
118
119 // Split into per-pattern blocks by the `### `name`` heading, and assert each
120 // block has all four parts — not just that the headers exist somewhere.
121 const blocks = catalog.split(/\n(?=### `)/).filter((b) => /^### `/.test(b))
122 expect(blocks.length, 'at least the 22 named patterns').toBeGreaterThanOrEqual(22)
123
124 const parts = ['**Input shape**', '**Structure recipe**', '**Budget rule**', '**Failure signs**']
125 for (const block of blocks) {
126 const name = block.match(/^### `([^`]+)`/)?.[1] ?? 'unknown'
127 for (const part of parts) {
128 expect(block, `pattern \`${name}\` is missing ${part}`).toContain(part)
129 }
130 }
131 })
132 })
133
134 describe('layout skill cross-file wiring', () => {
135 it('SKILL.md points to catalog.md, layout.md, and checklist.md', () => {
136 const skill = readProjectFile(`${layoutDir}/SKILL.md`)
137
138 expect(skill).toContain('references/catalog.md')
139 expect(skill).toContain('references/layout.md')
140 expect(skill).toContain('references/checklist.md')
141 })
142
143 it('SKILL.md keeps the per-page decision path and preflight shape', () => {
144 const skill = readProjectFile(`${layoutDir}/SKILL.md`)
145
146 expect(skill).toContain('per-page decision path')
147 expect(skill).toContain('pattern: trend-exhibit')
148 expect(skill).toContain('skeleton: chart-plus-insight-stack')
149 expect(skill).toContain('image policy: standard mode, no image request slot')
150 })
151
152 it('layout.md no longer duplicates catalog-owned or SKILL-owned sections', () => {
153 const layout = readProjectFile(`${layoutDir}/references/layout.md`)
154
155 // Composition patterns and Creative techniques moved into catalog.md.
156 expect(layout).not.toContain('## Composition patterns')
157 expect(layout).not.toContain('## Creative layout techniques')
158 // Density rules and title readability live in SKILL.md, not here.
159 expect(layout).not.toContain('## Density levels')
160 expect(layout).not.toContain('## Title placement')
161 // The kept deep-dive sections remain.
162 expect(layout).toContain('Collision avoidance')
163 expect(layout).toContain('Height budget walkthrough')
164 })
165 })
166
167 describe('layout skill checklist levels', () => {
168 it('checklist.md has P0/P1/P2 and the structural P0 items', () => {
169 const checklist = readProjectFile(`${layoutDir}/references/checklist.md`)
170
171 expect(checklist).toContain('P0 — not deliverable')
172 expect(checklist).toContain('P1 — should fix')
173 expect(checklist).toContain('P2 — consider optimizing')
174
175 // P0 mirrors the project hard rules.
176 expect(checklist).toContain('data-img-slot')
177 expect(checklist).toContain('below 18px')
178 expect(checklist).toContain('heading is below 24px')
179 expect(checklist).toContain('data-ppt-text-role="auxiliary"')
180 expect(checklist).toContain('two-row bottom card grid')
181 expect(checklist).toContain('exceeds 1600×900')
182 expect(checklist).toContain('top-heavy')
183 expect(checklist).toContain('220–280px')
184 })
185 })
186
187 const readFourLayoutFiles = () =>
188 [
189 readProjectFile(`${layoutDir}/SKILL.md`),
190 readProjectFile(`${layoutDir}/references/catalog.md`),
191 readProjectFile(`${layoutDir}/references/layout.md`),
192 readProjectFile(`${layoutDir}/references/checklist.md`)
193 ].join('\n')
194
195 describe('layout skill height calc — tell the canvas, let the model compute', () => {
196 it('SKILL.md gives a height-budget calc method, not preset module sizes', () => {
197 const skill = readProjectFile(`${layoutDir}/SKILL.md`)
198 // The original writing: a step-by-step calc the model runs per page.
199 expect(skill).toContain('calculate the height budget in order')
200 expect(skill).toContain('Remaining = maximum space')
201 // Step 5 must direct the chart/modules to use the canvas without forcing
202 // dense fill.
203 expect(skill).toMatch(/intentional whitespace/i)
204 })
205
206 it('SKILL.md tells the model both canvas dimensions', () => {
207 const skill = readProjectFile(`${layoutDir}/SKILL.md`)
208 expect(skill).toMatch(/1600px wide.*900px tall/s)
209 })
210
211 it('SKILL.md mirrors the chart skill role ranges and keeps breathing-room guidance', () => {
212 const skill = readProjectFile(`${layoutDir}/SKILL.md`)
213 // Role ranges mirror the chart skill's height calc, while preserving real
214 // presentation density.
215 expect(skill).toMatch(/380.?560px/i)
216 expect(skill).toMatch(/standard 280.?360px/i)
217 expect(skill).toMatch(/compact support 220.?280px/i)
218 // The old "should not blindly consume all leftover height" caveat biased the
219 // model toward small charts; it stays removed.
220 expect(skill).not.toMatch(/blindly consume all leftover/i)
221 expect(skill).toMatch(/breathing room/i)
222 })
223
224 it('SKILL.md §7 treats accidental under-fill as the failure, not whitespace itself', () => {
225 const skill = readProjectFile(`${layoutDir}/SKILL.md`)
226 expect(skill).toMatch(/failure is not whitespace itself/i)
227 expect(skill).toMatch(/accidental under-fill/i)
228 })
229
230 it('checklist.md flags accidental empty bands without requiring dense fill', () => {
231 const checklist = readProjectFile(`${layoutDir}/references/checklist.md`)
232 expect(checklist).toMatch(/accidentally under-filled|empty band/i)
233 expect(checklist).toMatch(/do not make the whole page dense/i)
234 })
235
236 it('catalog.md budget rules describe balance relationships, not pixel budgets', () => {
237 const catalog = readProjectFile(`${layoutDir}/references/catalog.md`)
238 expect(catalog).toMatch(/preserving whitespace/i)
239 expect(catalog).toMatch(/not forced into a dense dashboard/i)
240 })
241
242 it('the preflight is a zone sketch + balance check, not a rigid template', () => {
243 const skill = readProjectFile(`${layoutDir}/SKILL.md`)
244 const block = skill.match(/```text\n([\s\S]*?)```/)
245 expect(block, 'preflight block present').toBeDefined()
246 const preflight = block![1]
247 expect(preflight).toContain('pattern: trend-exhibit')
248 expect(preflight).toContain('image policy: standard mode, no image request slot')
249 // The model sketches zones and checks balance before writing HTML.
250 expect(preflight).toMatch(/zones:/i)
251 expect(preflight).toMatch(/balance check|breathing room/i)
252 // No preset per-module pixel sum to copy.
253 expect(preflight).not.toMatch(/height budget:.*\d{2,3}\s*\+/)
254 // Framed as a creativity-preserving thinking aid, not a fixed template.
255 expect(skill).toMatch(/thinking aid, not a template/i)
256 })
257 })
258
259 describe('layout skill forbidden-phrase guard across all four files', () => {
260 it('no banned phrasing leaks into catalog/layout/checklist (extends the SKILL guard)', () => {
261 const combined = readFourLayoutFiles()
262
263 expect(combined).not.toContain('cut content')
264 expect(combined).not.toContain('move support modules to another slide')
265 expect(combined).not.toContain('split the content')
266 expect(combined).not.toContain('放不下就减模块')
267 })
268
269 it('does not tell the model to move content to another slide (resolve within the page instead)', () => {
270 // Deck outline / page count is planned upstream; the page agent must not
271 // reassign information to a different slide. When content overflows,
272 // condense, regroup, convert to compact forms, or switch pattern in-page.
273 const combined = readFourLayoutFiles()
274
275 expect(combined).not.toMatch(/next slide/i)
276 expect(combined).not.toMatch(/follow-?up slide/i)
277 // "a new slide" is legitimate in "Creating a new slide" (When-to-use);
278 // reassignment uses another / dedicated / separate / its own.
279 expect(combined).not.toMatch(/(its own|another|a dedicated|a separate) slide/i)
280 expect(combined).not.toMatch(/break (?:it |them )?across/i)
281 expect(combined).not.toMatch(/across (?:two|2|multiple|\d+) slides/i)
282 expect(combined).not.toMatch(/\bmoves? to\b[^.\n]*\b(?:slide|page|notes)\b/i)
283 expect(combined).not.toMatch(/\bbelongs? on\b[^.\n]*\b(?:slide|page)\b/i)
284 })
285
286 it('does not suggest discarding information (fold / compress / relegate in-page instead)', () => {
287 // "drop the extra detail" reads as permission to lose info, which collides
288 // with the no-cut-content guard. Resolve by folding/compressing/relegating.
289 // remove/trim are intentionally NOT banned — they have legit structural
290 // uses (flatten wrappers, trim length), written as "flatten" in these files.
291 const combined = readFourLayoutFiles()
292
293 expect(combined).not.toMatch(
294 /\b(drops?|dropped|dropping|omits?|discards?|deletes?|shed|shedding)\b/i
295 )
296 })
297 })
298
298 lines TYPESCRIPT