返回 oh-my-ppt
html-utils.test.ts
根目录 / tests / unit / tools / html-utils.test.ts
1 import { describe, expect, it } from 'vitest'
2
3 import { validateHtmlContent, validatePersistedPageHtml } from '../../../src/main/presentation/html/html-utils'
4 import {
5 DATA_ANIM_SKILL_NAME,
6 formatSkillUsageRequirement,
7 } from '../../../src/main/product-skills/contract'
8
9 describe('validateHtmlContent animation validation', () => {
10 it('allows declarative data-anim stagger delay', () => {
11 const result = validateHtmlContent(`
12 <div>
13 <div data-anim="fade-up" data-anim-delay="stagger(100)">A</div>
14 <div data-anim="fade-up" data-anim-delay='stagger(120)'>B</div>
15 </div>
16 `)
17
18 expect(result.errors).not.toContain(
19 `检测到未命名空间的动画调用(animate/stagger/createTimeline);修改动画前请先 ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`
20 )
21 })
22
23 it('still rejects unqualified stagger calls in scripts', () => {
24 const result = validateHtmlContent(`
25 <div>Card</div>
26 <script>
27 stagger(100)
28 </script>
29 `)
30
31 expect(result.errors).toContain(
32 `检测到未命名空间的动画调用(animate/stagger/createTimeline);修改动画前请先 ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`
33 )
34 })
35 })
36
37 describe('validateHtmlContent chart height marker validation', () => {
38 it('rejects chart frames whose height class does not match @ppt-chart-height', () => {
39 const result = validateHtmlContent(`
40 <div>
41 <!-- height calc @ppt-chart-height=420: chart height = hero/main = 420 -->
42 <div class="ppt-chart-frame relative h-[240px] w-full overflow-hidden">
43 <canvas id="chart" class="h-full w-full"></canvas>
44 </div>
45 </div>
46 `)
47
48 expect(result.valid).toBe(false)
49 expect(result.errors.some((error) => error.includes('@ppt-chart-height=420'))).toBe(true)
50 expect(result.errors.some((error) => error.includes('h-[240px]'))).toBe(true)
51 })
52
53 it('rejects mismatch when the marker is attached to a one-chart wrapper', () => {
54 const result = validateHtmlContent(`
55 <div>
56 <!-- height calc @ppt-chart-height=400: chart area slot = 542; chart height = 400 -->
57 <div class="bg-white p-4">
58 <h3>关键指标变化幅度对比</h3>
59 <div class="ppt-chart-frame relative h-[240px] w-full overflow-hidden">
60 <canvas id="chart" class="h-full w-full"></canvas>
61 </div>
62 </div>
63 </div>
64 `)
65
66 expect(result.valid).toBe(false)
67 expect(result.errors.some((error) => error.includes('@ppt-chart-height=400'))).toBe(true)
68 expect(result.errors.some((error) => error.includes('h-[240px]'))).toBe(true)
69 })
70
71 it('uses a whole-page marker only when the page has a single chart', () => {
72 const result = validateHtmlContent(`
73 <div>
74 <!-- height calc @ppt-chart-height=400: single chart page, chart height = 400 -->
75 <section>
76 <div class="ppt-chart-frame relative h-[240px] w-full overflow-hidden">
77 <canvas id="chart" class="h-full w-full"></canvas>
78 </div>
79 </section>
80 </div>
81 `)
82
83 expect(result.valid).toBe(false)
84 expect(result.errors.some((error) => error.includes('@ppt-chart-height=400'))).toBe(true)
85 })
86
87 it('does not globally apply one marker to multiple charts', () => {
88 const result = validateHtmlContent(`
89 <div>
90 <!-- height calc @ppt-chart-height=400: ambiguous multi-chart page -->
91 <header>Two charts below</header>
92 <section>
93 <div class="ppt-chart-frame relative h-[240px] w-full overflow-hidden">
94 <canvas id="chart-a" class="h-full w-full"></canvas>
95 </div>
96 <div class="ppt-chart-frame relative h-[240px] w-full overflow-hidden">
97 <canvas id="chart-b" class="h-full w-full"></canvas>
98 </div>
99 </section>
100 </div>
101 `)
102
103 expect(result.errors.some((error) => error.includes('图表高度标记 @ppt-chart-height'))).toBe(
104 false
105 )
106 })
107
108 it('allows matching chart height marker and frame class', () => {
109 const result = validateHtmlContent(`
110 <div>
111 <!-- height calc @ppt-chart-height=420: chart height = hero/main = 420 -->
112 <div class="ppt-chart-frame relative h-[420px] w-full overflow-hidden">
113 <canvas id="chart" class="h-full w-full"></canvas>
114 </div>
115 </div>
116 `)
117
118 expect(result.errors.some((error) => error.includes('图表高度标记 @ppt-chart-height'))).toBe(
119 false
120 )
121 })
122
123 it('rejects visible chart height marker text outside HTML comments', () => {
124 const result = validateHtmlContent(`
125 <div>
126 <div class="ppt-chart-frame relative h-[420px] w-full overflow-hidden">
127 @ppt-chart-height=420
128 <canvas id="chart" class="h-full w-full"></canvas>
129 </div>
130 </div>
131 `)
132
133 expect(result.valid).toBe(false)
134 expect(result.errors.some((error) => error.includes('必须写在 HTML 注释中'))).toBe(true)
135 })
136
137 it('does not reject a marker when the chart frame has no fixed height class yet', () => {
138 const result = validateHtmlContent(`
139 <div>
140 <!-- height calc @ppt-chart-height=420: chart height = hero/main = 420 -->
141 <div class="ppt-chart-frame relative w-full overflow-hidden">
142 <canvas id="chart" class="h-full w-full"></canvas>
143 </div>
144 </div>
145 `)
146
147 expect(result.errors.some((error) => error.includes('图表高度标记 @ppt-chart-height'))).toBe(
148 false
149 )
150 })
151
152 it('flags an out-of-range class height against the marker (no range filtering on the class side)', () => {
153 const result = validateHtmlContent(`
154 <div>
155 <!-- height calc @ppt-chart-height=120: chart height = compact = 120 -->
156 <div class="ppt-chart-frame relative h-[100px] w-full overflow-hidden">
157 <canvas id="chart" class="h-full w-full"></canvas>
158 </div>
159 </div>
160 `)
161
162 // h-[100px] is below the chart-frame MIN, but the contract is "marker must
163 // equal the class". 120 ≠ 100, so this is a mismatch — not silently treated
164 // as "no fixed height class".
165 expect(result.valid).toBe(false)
166 expect(result.errors.some((error) => error.includes('@ppt-chart-height=120'))).toBe(true)
167 expect(result.errors.some((error) => error.includes('h-[100px]'))).toBe(true)
168 })
169 })
170
171 describe('validateHtmlContent chart label validation', () => {
172 it('rejects Chart.js labels that contain HTML markup', () => {
173 const result = validateHtmlContent(`
174 <div>
175 <!-- height calc @ppt-chart-height=360: chart slot = 360 -->
176 <div class="ppt-chart-frame relative h-[360px]">
177 <canvas id="chart" class="h-full w-full"></canvas>
178 </div>
179 <script>
180 document.addEventListener('DOMContentLoaded', function() {
181 PPT.createChart(document.getElementById('chart'), {
182 type: 'bar',
183 data: {
184 labels: ['AI调校师<br><span>约80→1,400</span>'],
185 datasets: [{ label: '变化率', data: [1650] }]
186 },
187 options: { responsive: true, maintainAspectRatio: false }
188 })
189 })
190 </script>
191 </div>
192 `)
193
194 expect(result.valid).toBe(false)
195 expect(result.errors.some((error) => error.includes('图表 labels 包含 HTML 标签'))).toBe(true)
196 })
197
198 it('allows Chart.js string-array labels for multi-line categories', () => {
199 const result = validateHtmlContent(`
200 <div>
201 <!-- height calc @ppt-chart-height=360: chart slot = 360 -->
202 <div class="ppt-chart-frame relative h-[360px]">
203 <canvas id="chart" class="h-full w-full"></canvas>
204 </div>
205 <script>
206 document.addEventListener('DOMContentLoaded', function() {
207 PPT.createChart(document.getElementById('chart'), {
208 type: 'bar',
209 data: {
210 labels: [['AI调校师', '约80→1,400'], ['中割/补间', '9,300→5,600']],
211 datasets: [{ label: '2026人数', data: [1400, 5600] }]
212 },
213 options: { responsive: true, maintainAspectRatio: false }
214 })
215 })
216 </script>
217 </div>
218 `)
219
220 expect(result.errors.some((error) => error.includes('图表 labels 包含 HTML 标签'))).toBe(false)
221 })
222 })
223
224 describe('validatePersistedPageHtml chart validation', () => {
225 const pageWithChartFrame = (frameClass: string): string => `
226 <html>
227 <body>
228 <section class="ppt-page-root" data-ppt-guard-root="1">
229 <main class="ppt-page-content">
230 <div class="ppt-chart-frame relative ${frameClass}">
231 <canvas id="chart" class="h-full w-full"></canvas>
232 </div>
233 </main>
234 </section>
235 </body>
236 </html>
237 `
238
239 const pageWithBrokenChartScript = (): string => `
240 <html>
241 <body>
242 <section class="ppt-page-root" data-ppt-guard-root="1">
243 <main class="ppt-page-content">
244 <div class="ppt-chart-frame relative h-[360px]">
245 <canvas id="chart" class="h-full w-full"></canvas>
246 </div>
247 <script>
248 document.addEventListener('DOMContentLoaded', function() {
249 PPT.createChart(document.getElementById('chart'), {
250 type: 'bar',
251 data: { labels: ['A'], datasets: [{ data: [1] }] },
252 options: {
253 responsive: true,
254 maintainAspectRatio: false,
255 animation: {
256 duration: 800,
257 easing: '</script></main></section>
258 </main>
259 </section>
260 </body>
261 </html>
262 `
263
264 it.each(['h-[240px]', 'h-64'])(
265 'accepts supported Tailwind chart height class %s',
266 (frameClass) => {
267 const result = validatePersistedPageHtml(pageWithChartFrame(frameClass), 'page-1')
268
269 expect(result.valid).toBe(true)
270 }
271 )
272
273 it('rejects persisted pages with malformed inline chart scripts', () => {
274 const result = validatePersistedPageHtml(pageWithBrokenChartScript(), 'page-1')
275
276 expect(result.valid).toBe(false)
277 expect(result.errors.some((error) => error.includes('内联 script 语法错误'))).toBe(true)
278 })
279
280 it('rejects persisted pages whose chart height marker disagrees with the frame class', () => {
281 const result = validatePersistedPageHtml(
282 `
283 <html>
284 <body>
285 <section class="ppt-page-root" data-ppt-guard-root="1">
286 <main class="ppt-page-content">
287 <!-- height calc @ppt-chart-height=560: chart slot = 560 -->
288 <div class="ppt-chart-frame relative h-[240px]">
289 <canvas id="chart" class="h-full w-full"></canvas>
290 </div>
291 </main>
292 </section>
293 </body>
294 </html>
295 `,
296 'page-1'
297 )
298
299 expect(result.valid).toBe(false)
300 expect(result.errors.some((error) => error.includes('@ppt-chart-height=560'))).toBe(true)
301 expect(result.errors.some((error) => error.includes('h-[240px]'))).toBe(true)
302 })
303
304 it('rejects persisted pages with visible chart height marker text', () => {
305 const result = validatePersistedPageHtml(
306 `
307 <html>
308 <body>
309 <section class="ppt-page-root" data-ppt-guard-root="1">
310 <main class="ppt-page-content">
311 <div class="ppt-chart-frame relative h-[560px]">
312 @ppt-chart-height=560
313 <canvas id="chart" class="h-full w-full"></canvas>
314 </div>
315 </main>
316 </section>
317 </body>
318 </html>
319 `,
320 'page-1'
321 )
322
323 expect(result.valid).toBe(false)
324 expect(result.errors.some((error) => error.includes('必须写在 HTML 注释中'))).toBe(true)
325 })
326
327 it('rejects persisted pages whose chart labels contain HTML markup', () => {
328 const result = validatePersistedPageHtml(
329 `
330 <html>
331 <body>
332 <section class="ppt-page-root" data-ppt-guard-root="1">
333 <main class="ppt-page-content">
334 <div class="ppt-chart-frame relative h-[360px]">
335 <canvas id="chart" class="h-full w-full"></canvas>
336 </div>
337 <script>
338 document.addEventListener('DOMContentLoaded', function() {
339 PPT.createChart(document.getElementById('chart'), {
340 type: 'bar',
341 data: {
342 labels: ['中割/补间<br><span>9,300→5,600</span>'],
343 datasets: [{ data: [-39.8] }]
344 },
345 options: { responsive: true, maintainAspectRatio: false }
346 })
347 })
348 </script>
349 </main>
350 </section>
351 </body>
352 </html>
353 `,
354 'page-1'
355 )
356
357 expect(result.valid).toBe(false)
358 expect(result.errors.some((error) => error.includes('图表 labels 包含 HTML 标签'))).toBe(true)
359 })
360 })
361
361 lines TYPESCRIPT