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