返回 oh-my-ppt
browser-scripts-animation.test.ts
根目录 / tests / unit / html-pptx / browser-scripts-animation.test.ts
1 /**
2 * @vitest-environment happy-dom
3 */
4 import { describe, expect, it } from 'vitest'
5 import fs from 'fs'
6 import path from 'path'
7 import {
8 COLLECT_PPTX_ANIMATION_TRACES_SCRIPT,
9 buildMarkPptxExtractedTextForBackgroundScript,
10 FREEZE_PAGE_FOR_PPTX_SCRIPT,
11 HAS_DECLARED_PPTX_ANIMATION_SCRIPT,
12 HIDE_FOR_PPTX_BACKGROUND_SCRIPT
13 } from '../../../src/main/io/html-pptx/browser-scripts'
14
15 const rect = (left: number, top: number, width: number, height: number) => ({
16 x: left,
17 y: top,
18 left,
19 top,
20 width,
21 height,
22 right: left + width,
23 bottom: top + height
24 })
25
26 const assignRect = (selector: string, left: number, top: number, width = 120, height = 48) => {
27 const el = document.querySelector(selector)
28 if (!el) throw new Error(`Missing test node: ${selector}`)
29 Object.defineProperty(el, 'getBoundingClientRect', {
30 value: () => rect(left, top, width, height),
31 configurable: true
32 })
33 return el
34 }
35
36 const collectTraces = () =>
37 new Function(`return ${COLLECT_PPTX_ANIMATION_TRACES_SCRIPT.trim()}`)() as Array<
38 Record<string, number | string>
39 >
40
41 describe('PPTX animation browser scripts', () => {
42 it('uses a slide transition instead of emitting unreliable element timing', () => {
43 expect(FREEZE_PAGE_FOR_PPTX_SCRIPT).not.toContain('data-pptx-native-anim')
44 expect(HIDE_FOR_PPTX_BACKGROUND_SCRIPT).not.toContain('[data-pptx-native-anim]')
45 const rendererSource = fs.readFileSync(
46 path.resolve('src/main/io/html-pptx/renderer.ts'),
47 'utf8'
48 )
49 expect(rendererSource).toContain("animationMode = 'slide-transition'")
50 expect(rendererSource).toContain("animationMode === 'slide-transition'")
51 expect(rendererSource).toContain("slide.transitionType = 'fade'")
52 expect(rendererSource).not.toContain("animationMode === 'native-element'")
53 expect(rendererSource).not.toContain('slide.animationTraces = traces')
54 })
55
56 it('detects source animation for a slide-level transition without using element timing', () => {
57 document.body.innerHTML = '<div class="ppt-page-root"><p>Static</p></div>'
58 const hasAnimation = () =>
59 new Function(`return ${HAS_DECLARED_PPTX_ANIMATION_SCRIPT.trim()}`)() as boolean
60
61 expect(hasAnimation()).toBe(false)
62 document.querySelector('.ppt-page-root')?.setAttribute('data-anim', 'fade-up')
63 expect(hasAnimation()).toBe(true)
64
65 document.querySelector('.ppt-page-root')?.setAttribute('data-anim', 'none')
66 expect(hasAnimation()).toBe(false)
67 })
68
69 it('removes extracted text even when its source container is animated', () => {
70 expect(HIDE_FOR_PPTX_BACKGROUND_SCRIPT).toContain(
71 '[data-pptx-extracted-shape]:not([data-pptx-static-background])'
72 )
73 expect(HIDE_FOR_PPTX_BACKGROUND_SCRIPT).toContain(
74 '[data-pptx-extracted-text], [data-pptx-extracted-text] *, [data-pptx-background-text-match]'
75 )
76 expect(HIDE_FOR_PPTX_BACKGROUND_SCRIPT).not.toContain(
77 'body *:not(canvas):not(svg):not(svg *):not([data-pptx-extracted-image])'
78 )
79 expect(HIDE_FOR_PPTX_BACKGROUND_SCRIPT).toContain('[data-pptx-has-before]::before')
80 expect(HIDE_FOR_PPTX_BACKGROUND_SCRIPT).toContain('[data-pptx-has-after]::after')
81 expect(HIDE_FOR_PPTX_BACKGROUND_SCRIPT).toContain(
82 "'[data-pptx-extracted-image] { opacity: 0 !important; visibility: hidden !important; }'"
83 )
84 expect(HIDE_FOR_PPTX_BACKGROUND_SCRIPT).toContain('const horizontalTolerance = 2;')
85 expect(HIDE_FOR_PPTX_BACKGROUND_SCRIPT).toContain('const verticalTolerance = 2;')
86 })
87
88 it('does not invent native effects for legacy opacity markers', () => {
89 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).not.toContain(
90 "collectTrace(el, 'fade-up', 'load', 'bottom', 560, index * 45"
91 )
92 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).not.toContain('legacyTargets')
93 })
94
95 it('adds an all-descendants text mask from exported PPT text boxes', () => {
96 const script = buildMarkPptxExtractedTextForBackgroundScript([{ x: 1, y: 2, w: 3, h: 0.5 }])
97
98 expect(script).toContain('data-pptx-background-text-match')
99 expect(script).toContain('every descendant text fragment maps to a')
100 expect(script).toContain('"x":1')
101 expect(script).toContain('overlap / rectArea >= 0.85')
102 expect(script).not.toContain('centerInside')
103 })
104
105 it('maps text boxes against the requested 4:3 slide size', () => {
106 const script = buildMarkPptxExtractedTextForBackgroundScript(
107 [{ x: 1, y: 2, w: 3, h: 0.5 }],
108 { widthIn: 10, heightIn: 7.5 }
109 )
110
111 expect(script).toContain('const slideWidth = 10;')
112 expect(script).toContain('const slideHeight = 7.5;')
113 })
114
115 it('marks a mixed text container only when every fragment has an exported PPT text box', () => {
116 document.body.innerHTML = `
117 <div class="ppt-page-root" data-ppt-guard-root="1">
118 <p id="exported">$528<span>亿</span></p>
119 <p id="fallback">Background fallback</p>
120 </div>
121 `
122 assignRect('.ppt-page-root', 0, 0, 1600, 900)
123
124 const originalCreateRange = document.createRange.bind(document)
125 document.createRange = (() => {
126 let selectedNode: Node | null = null
127 return {
128 selectNodeContents: (node: Node) => {
129 selectedNode = node
130 },
131 getClientRects: () =>
132 selectedNode?.textContent === '$528'
133 ? [rect(120, 250, 100, 24)]
134 : selectedNode?.textContent === '亿'
135 ? [rect(220, 260, 30, 18)]
136 : [rect(760, 450, 180, 24)]
137 } as unknown as Range
138 }) as typeof document.createRange
139
140 try {
141 const script = buildMarkPptxExtractedTextForBackgroundScript([
142 { x: 1, y: 2, w: 1.8, h: 0.5 },
143 { x: 1.8, y: 2.1, w: 0.4, h: 0.3 }
144 ])
145 const marked = new Function(`return ${script.trim()}`)() as number
146
147 expect(marked).toBeGreaterThan(0)
148 expect(
149 document.querySelector('#exported')?.getAttribute('data-pptx-background-text-match')
150 ).toBe('1')
151 expect(
152 document.querySelector('#fallback')?.hasAttribute('data-pptx-background-text-match')
153 ).toBe(false)
154 } finally {
155 document.createRange = originalCreateRange
156 }
157 })
158
159 it('clears stale text masks before a background-capture retry', () => {
160 document.body.innerHTML = `
161 <div class="ppt-page-root" data-ppt-guard-root="1">
162 <p id="exported">Exported text</p>
163 </div>
164 `
165 assignRect('.ppt-page-root', 0, 0, 1600, 900)
166
167 const originalCreateRange = document.createRange.bind(document)
168 document.createRange = (() => {
169 return {
170 selectNodeContents: () => {},
171 getClientRects: () => [rect(120, 240, 160, 24)]
172 } as unknown as Range
173 }) as typeof document.createRange
174
175 try {
176 const firstAttempt = buildMarkPptxExtractedTextForBackgroundScript([
177 { x: 1, y: 2, w: 1.5, h: 0.3 }
178 ])
179 new Function(`return ${firstAttempt.trim()}`)()
180 expect(
181 document.querySelector('#exported')?.hasAttribute('data-pptx-background-text-match')
182 ).toBe(true)
183
184 const retryWithoutText = buildMarkPptxExtractedTextForBackgroundScript([])
185 new Function(`return ${retryWithoutText.trim()}`)()
186 expect(
187 document.querySelector('#exported')?.hasAttribute('data-pptx-background-text-match')
188 ).toBe(false)
189 } finally {
190 document.createRange = originalCreateRange
191 }
192 })
193
194 it('keeps partially covered text in the raster background', () => {
195 document.body.innerHTML = `
196 <div class="ppt-page-root" data-ppt-guard-root="1">
197 <p id="partial">Long fallback text</p>
198 </div>
199 `
200 assignRect('.ppt-page-root', 0, 0, 1600, 900)
201
202 const originalCreateRange = document.createRange.bind(document)
203 document.createRange = (() => {
204 return {
205 selectNodeContents: () => {},
206 getClientRects: () => [rect(120, 250, 100, 24)]
207 } as unknown as Range
208 }) as typeof document.createRange
209
210 try {
211 const script = buildMarkPptxExtractedTextForBackgroundScript([
212 { x: 1, y: 2.083333, w: 0.416667, h: 0.2 }
213 ])
214 new Function(`return ${script.trim()}`)()
215
216 expect(
217 document.querySelector('#partial')?.hasAttribute('data-pptx-background-text-match')
218 ).toBe(false)
219 } finally {
220 document.createRange = originalCreateRange
221 }
222 })
223
224 it('collects extended data-anim metadata for native PPTX export', () => {
225 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain("'fly-in'")
226 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain("'slide-down'")
227 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain("'slide-right'")
228 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain("'grow-shrink-soft'")
229 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain("'pulse-strong'")
230 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain("'exit-scale'")
231 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain("'exit-zoom'")
232 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain("'exit-wipe'")
233 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain("'exit-fly'")
234 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain("'path'")
235 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain(
236 "const supportedTriggers = new Set(['load', 'click', 'with', 'after'])"
237 )
238 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain(
239 "const supportedSequences = new Set(['with', 'after'])"
240 )
241 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain(
242 "const sequence = normalizeSequence(el.getAttribute('data-anim-sequence'));"
243 )
244 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain(
245 "const clickGroupRaw = normalizeClickGroup(el.getAttribute('data-anim-click-group'));"
246 )
247 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain(
248 "const staggerRaw = (el.getAttribute('data-anim-stagger') || '').trim();"
249 )
250 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain('from,')
251 expect(COLLECT_PPTX_ANIMATION_TRACES_SCRIPT).toContain(
252 'collectTrace(target, type, effectiveTrigger, from'
253 )
254 })
255
256 it('keeps unsupported path motion in the static fallback', () => {
257 document.body.innerHTML = `
258 <div class="ppt-page-root">
259 <div data-anim="path" data-anim-path="M 0 0 L 120 30" data-pptx-extracted-text="1" id="path-ok">Path</div>
260 <div data-anim="path" data-anim-path="#curve" data-pptx-extracted-text="1" id="path-bad">Bad</div>
261 </div>
262 `
263
264 assignRect('.ppt-page-root', 0, 0, 1600, 900)
265 assignRect('#path-ok', 100, 100)
266 assignRect('#path-bad', 100, 180)
267
268 const traces = collectTraces()
269
270 expect(traces).toHaveLength(0)
271 })
272
273 it('only collects extracted descendants so unmapped visuals stay in the fallback', () => {
274 document.body.innerHTML = `
275 <div class="ppt-page-root">
276 <section data-anim="zoom-in" id="group">
277 <p data-pptx-extracted-text="1" id="title">Extracted title</p>
278 <canvas id="chart"></canvas>
279 </section>
280 <p data-anim="fade" id="unmapped">Unmapped text</p>
281 </div>
282 `
283
284 assignRect('.ppt-page-root', 0, 0, 1600, 900)
285 assignRect('#group', 100, 80, 600, 260)
286 assignRect('#title', 140, 110, 360, 64)
287 assignRect('#chart', 140, 200, 420, 120)
288 assignRect('#unmapped', 120, 400, 360, 64)
289
290 const traces = collectTraces()
291
292 expect(traces).toHaveLength(1)
293 expect(traces[0]).toMatchObject({ type: 'zoom-in', x: 140, y: 110, w: 360, h: 64 })
294 })
295
296 it('computes load sequencing from data-anim-sequence during trace collection', () => {
297 document.body.innerHTML = `
298 <div class="ppt-page-root">
299 <div data-anim="fade-up" data-anim-duration="400" data-pptx-extracted-text="1" id="lead">Lead</div>
300 <div data-anim="fade-up" data-anim-sequence="with" data-anim-delay="50" data-anim-duration="300" data-pptx-extracted-text="1" id="with">With</div>
301 <div data-anim="fade-up" data-anim-sequence="after" data-anim-delay="20" data-anim-duration="200" data-pptx-extracted-text="1" id="after">After</div>
302 </div>
303 `
304
305 assignRect('.ppt-page-root', 0, 0, 1600, 900)
306 assignRect('#lead', 100, 100)
307 assignRect('#with', 100, 180)
308 assignRect('#after', 100, 260)
309
310 const traces = collectTraces()
311
312 expect(traces).toHaveLength(3)
313 expect(traces[0]).toMatchObject({ trigger: 'load', delay: 0, order: 0 })
314 expect(traces[1]).toMatchObject({ trigger: 'load', delay: 50, order: 1 })
315 expect(traces[2]).toMatchObject({ trigger: 'load', delay: 420, order: 2 })
316 })
317
318 it('keeps click-trigger stagger independent from load sequencing during trace collection', () => {
319 document.body.innerHTML = `
320 <div class="ppt-page-root">
321 <div data-anim="fade-up" data-anim-stagger="80" data-pptx-extracted-text="1" id="load-a">Load A</div>
322 <div data-anim="fade-up" data-anim-stagger="80" data-pptx-extracted-text="1" id="load-b">Load B</div>
323 <div data-anim="fade-up" data-anim-trigger="click" data-anim-stagger="90" data-pptx-extracted-text="1" id="click-a">Click A</div>
324 <div data-anim="fade-up" data-anim-trigger="click" data-anim-stagger="90" data-anim-sequence="after" data-pptx-extracted-text="1" id="click-b">Click B</div>
325 </div>
326 `
327
328 assignRect('.ppt-page-root', 0, 0, 1600, 900)
329 assignRect('#load-a', 100, 100)
330 assignRect('#load-b', 100, 180)
331 assignRect('#click-a', 100, 260)
332 assignRect('#click-b', 100, 340)
333
334 const traces = collectTraces()
335
336 expect(traces).toHaveLength(4)
337 expect(traces[0]).toMatchObject({ trigger: 'load', delay: 0, order: 0 })
338 expect(traces[1]).toMatchObject({ trigger: 'load', delay: 80, order: 1 })
339 expect(traces[2]).toMatchObject({ trigger: 'click', delay: 0, order: 2 })
340 expect(traces[3]).toMatchObject({ trigger: 'click', delay: 90, order: 3 })
341 })
342
343 it('keeps contiguous click-group traces on the same click step and drops non-click grouping metadata', () => {
344 document.body.innerHTML = `
345 <div class="ppt-page-root">
346 <div data-anim="fade-up" data-anim-trigger="click" data-anim-click-group="reveal" data-pptx-extracted-text="1" id="click-a">Click A</div>
347 <div data-anim="pulse-soft" data-anim-trigger="click" data-anim-click-group="reveal" data-pptx-extracted-text="1" id="click-b">Click B</div>
348 <div data-anim="pulse-strong" data-anim-trigger="click" data-pptx-extracted-text="1" id="click-c">Click C</div>
349 <div data-anim="fade" data-anim-click-group="ignored" data-pptx-extracted-text="1" id="load-a">Load A</div>
350 </div>
351 `
352
353 assignRect('.ppt-page-root', 0, 0, 1600, 900)
354 assignRect('#click-a', 100, 100)
355 assignRect('#click-b', 100, 180)
356 assignRect('#click-c', 100, 260)
357 assignRect('#load-a', 100, 340)
358
359 const traces = collectTraces()
360
361 expect(traces).toHaveLength(4)
362 expect(traces[0]).toMatchObject({ trigger: 'click', clickGroup: 'reveal', type: 'fade-up' })
363 expect(traces[1]).toMatchObject({ trigger: 'click', clickGroup: 'reveal', type: 'pulse-soft' })
364 expect(traces[2]).toMatchObject({ trigger: 'click', type: 'pulse-strong' })
365 expect(traces[2]).not.toHaveProperty('clickGroup')
366 expect(traces[3]).toMatchObject({ trigger: 'load', type: 'fade' })
367 expect(traces[3]).not.toHaveProperty('clickGroup')
368 })
369
370 it('derives default directional origins for symmetry and exit-wipe candidates', () => {
371 document.body.innerHTML = `
372 <div class="ppt-page-root">
373 <div data-anim="slide-down" data-pptx-extracted-text="1" id="down">Down</div>
374 <div data-anim="slide-right" data-pptx-extracted-text="1" id="right">Right</div>
375 <div data-anim="exit-wipe" data-pptx-extracted-text="1" id="exit">Exit</div>
376 </div>
377 `
378
379 assignRect('.ppt-page-root', 0, 0, 1600, 900)
380 assignRect('#down', 100, 100)
381 assignRect('#right', 100, 180)
382 assignRect('#exit', 100, 260)
383
384 const traces = collectTraces()
385
386 expect(traces[0]).toMatchObject({ type: 'slide-down', from: 'top' })
387 expect(traces[1]).toMatchObject({ type: 'slide-right', from: 'left' })
388 expect(traces[2]).toMatchObject({ type: 'exit-wipe', from: 'left' })
389 })
390 })
391
391 lines TYPESCRIPT