返回 oh-my-ppt
ppt-runtime.test.ts
根目录 / tests / unit / runtime / ppt-runtime.test.ts
1 /**
2 * @vitest-environment happy-dom
3 *
4 * Unit tests for ppt-runtime.js v2.0.21:
5 * - PPT.stopAnimations() / PPT.resumeAnimations()
6 * - PPT.clicks state machine (advance returns boolean, _dispatch exact match)
7 * - PPT.scanDataAnim() / PPT.executeDataAnim() (routed through PPT.animate)
8 * - Click-triggered initial hidden state
9 * - Lottie hook (PPT.playLottie placeholder)
10 */
11 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
12 import fs from 'fs'
13 import path from 'path'
14
15 const runtimeSrc = fs.readFileSync(
16 path.resolve(__dirname, '../../../resources/ppt-runtime.js'),
17 'utf-8'
18 )
19
20 function createMockAnime() {
21 const animations: Array<{
22 pause: ReturnType<typeof vi.fn>
23 play: ReturnType<typeof vi.fn>
24 complete: ReturnType<typeof vi.fn>
25 finished: Promise<void>
26 }> = []
27
28 const anime = {
29 animate: vi.fn((_targets: unknown, _params: unknown) => {
30 let resolveFinished!: () => void
31 const finished = new Promise<void>((r) => { resolveFinished = r })
32 const anim = {
33 pause: vi.fn(),
34 play: vi.fn(),
35 complete: vi.fn(() => resolveFinished()),
36 finished,
37 _resolve: resolveFinished
38 }
39 animations.push(anim)
40 return anim
41 }),
42 stagger: vi.fn((gap: number) => {
43 return (_el: unknown, i: number) => i * gap
44 }),
45 createTimeline: vi.fn(() => ({ add: vi.fn() })),
46 timeline: vi.fn(() => ({ add: vi.fn() }))
47 }
48
49 return { anime, animations }
50 }
51
52 function setupRuntime(options?: {
53 search?: string
54 parent?: { postMessage: ReturnType<typeof vi.fn> }
55 guardedRoot?: boolean
56 slideHeight?: number
57 }) {
58 const { anime, animations } = createMockAnime()
59
60 document.body.innerHTML = `
61 <div class="ppt-page-root"${options?.guardedRoot ? ' data-ppt-guard-root="1"' : ''}${options?.slideHeight ? ` data-ppt-height="${options.slideHeight}"` : ''}>
62 <div data-anim="fade-up" data-anim-duration="500" id="el1">Card 1</div>
63 <div data-anim="fade-up" data-anim-delay="stagger(100)" id="el2">Card 2</div>
64 <div data-anim="fade-up" data-anim-delay="stagger(100)" id="el3">Card 3</div>
65 <div data-anim="scale-in" data-anim-trigger="click" id="el4">Reveal click</div>
66 <div data-anim="fade-left" data-anim-trigger="click" id="el5">Reveal click 2</div>
67 <div data-anim="none" id="el6">Skipped</div>
68 <div class="card" id="el7">Legacy target</div>
69 </div>
70 `
71
72 const existingPPT = (globalThis as Record<string, unknown>).PPT as Record<string, unknown> | undefined
73 if (existingPPT) existingPPT.__runtimeVersion = null
74 ;(globalThis as Record<string, unknown>).__ohmypptPlaybackBridgeInstalled = false
75 ;(globalThis as Record<string, unknown>).anime = anime
76 window.history.replaceState(null, '', `/page.html${options?.search || '?pptPlayback=1'}`)
77 try {
78 Object.defineProperty(window, 'parent', {
79 value: options?.parent || window,
80 configurable: true
81 })
82 } catch {
83 // happy-dom allows this; real browsers keep window.parent read-only.
84 }
85
86 new Function(runtimeSrc)()
87
88 const PPT = (globalThis as Record<string, unknown>).PPT as Record<string, unknown>
89 return { PPT, anime, animations }
90 }
91
92 // ── Helper: typed clicks access ──
93 type ClicksAPI = {
94 current: number; total: number
95 _listeners: unknown[]
96 _advanceListeners: unknown[]
97 setTotal: (n: number) => void
98 advance: () => boolean
99 reset: () => void
100 on: (clickNum: number, fn: () => void) => void
101 onAdvance: (fn: (click: number, current: number, total: number) => void) => void
102 }
103 function getClicks(PPT: Record<string, unknown>): ClicksAPI {
104 return PPT.clicks as ClicksAPI
105 }
106
107 function dispatchWheel(
108 target: EventTarget,
109 options: Partial<{
110 deltaX: number
111 deltaY: number
112 deltaMode: number
113 ctrlKey: boolean
114 metaKey: boolean
115 }> = {}
116 ): Event {
117 const event = new Event('wheel', { bubbles: true, cancelable: true })
118 Object.defineProperties(event, {
119 deltaX: { value: options.deltaX ?? 0 },
120 deltaY: { value: options.deltaY ?? 0 },
121 deltaMode: { value: options.deltaMode ?? 0 },
122 ctrlKey: { value: options.ctrlKey ?? false },
123 metaKey: { value: options.metaKey ?? false }
124 })
125 target.dispatchEvent(event)
126 return event
127 }
128
129 afterEach(() => {
130 try {
131 vi.runOnlyPendingTimers()
132 } catch {
133 // Some tests use real timers.
134 }
135 vi.useRealTimers()
136 vi.unstubAllGlobals()
137 })
138
139 describe('master elements runtime', () => {
140 it('injects a fixed layer even when a legacy fragment still contains enabled false', async () => {
141 vi.stubGlobal(
142 'fetch',
143 vi.fn().mockResolvedValue({
144 ok: true,
145 text: vi.fn().mockResolvedValue(`
146 <script type="application/json" data-ppt-master-elements-config="1">{"enabled":false}</script>
147 <template data-ppt-master-elements="1">
148 <div data-ppt-master-elements-layer="1">
149 <div data-ppt-master-page-number="1"></div>
150 </div>
151 </template>
152 `)
153 })
154 )
155
156 const { PPT } = setupRuntime({
157 search: '?print=1&_pptMasterElementsExpected=1',
158 guardedRoot: true
159 })
160
161 await expect((PPT.assertMasterElementsReady as Function)(50)).resolves.toBeUndefined()
162 expect(document.querySelector('[data-ppt-master-elements-layer="1"]')).not.toBeNull()
163 })
164
165 it('scales watermark text from its persisted bounding-box height', async () => {
166 vi.stubGlobal(
167 'fetch',
168 vi.fn().mockResolvedValue({
169 ok: true,
170 text: vi.fn().mockResolvedValue(`
171 <script type="application/json" data-ppt-master-elements-config="1">{}</script>
172 <template data-ppt-master-elements="1">
173 <div data-ppt-master-elements-layer="1">
174 <div data-ppt-master-watermark="1" data-ppt-master-watermark-height="32">INTERNAL</div>
175 </div>
176 </template>
177 `)
178 })
179 )
180
181 const { PPT } = setupRuntime({
182 search: '?print=1&_pptMasterElementsExpected=1',
183 guardedRoot: true,
184 slideHeight: 900
185 })
186
187 await expect((PPT.assertMasterElementsReady as Function)(50)).resolves.toBeUndefined()
188 expect(
189 (document.querySelector('[data-ppt-master-watermark="1"]') as HTMLElement).style.fontSize
190 ).toBe('130px')
191 })
192
193 it('fails print readiness when an expected master fragment cannot load', async () => {
194 vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('fragment unavailable')))
195
196 const { PPT } = setupRuntime({
197 search: '?print=1&_pptMasterElementsExpected=1',
198 guardedRoot: true
199 })
200
201 await expect((PPT.assertMasterElementsReady as Function)(50)).rejects.toThrow(
202 'fragment unavailable'
203 )
204 })
205
206 it('keeps historical pages without an expected fragment exportable', async () => {
207 vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('fragment unavailable')))
208
209 const { PPT } = setupRuntime({ search: '?print=1', guardedRoot: true })
210
211 await expect((PPT.whenReadyForPrint as Function)(50)).resolves.toBeUndefined()
212 })
213 })
214
215 describe('PPT.stopAnimations / PPT.resumeAnimations', () => {
216 let PPT: Record<string, unknown>
217 let animations: ReturnType<typeof createMockAnime>['animations']
218
219 beforeEach(() => {
220 const s = setupRuntime(); PPT = s.PPT; animations = s.animations
221 })
222
223 it('pauses all active animations', () => {
224 const animate = PPT.animate as Function
225 animate('.card', { opacity: [0, 1] })
226 animate('.card', { opacity: [0, 1] })
227 expect(animations.length).toBeGreaterThanOrEqual(2)
228 ;(PPT.stopAnimations as Function)()
229 animations.forEach(a => { expect(a.pause).toHaveBeenCalled() })
230 })
231
232 it('resumes all active animations', () => {
233 const animate = PPT.animate as Function
234 animate('.card', { opacity: [0, 1] })
235 ;(PPT.resumeAnimations as Function)()
236 animations.forEach(a => { expect(a.play).toHaveBeenCalled() })
237 })
238
239 it('finishes active animations before pausing them', () => {
240 const animate = PPT.animate as Function
241 animate('.card', { opacity: [0, 1] })
242 ;(PPT.finishAnimations as Function)()
243 animations.forEach(a => {
244 expect(a.complete).toHaveBeenCalled()
245 })
246 })
247
248 it('handles empty active set gracefully', () => {
249 expect(() => (PPT.stopAnimations as Function)()).not.toThrow()
250 expect(() => (PPT.resumeAnimations as Function)()).not.toThrow()
251 })
252 })
253
254 describe('PPT.clicks state machine', () => {
255 let PPT: Record<string, unknown>
256
257 beforeEach(() => { PPT = setupRuntime().PPT })
258
259 it('init: current=0, total=0', () => {
260 const c = getClicks(PPT)
261 expect(c.current).toBe(0)
262 expect(c.total).toBe(0)
263 })
264
265 it('setTotal', () => {
266 const c = getClicks(PPT)
267 c.setTotal(5)
268 expect(c.total).toBe(5)
269 })
270
271 it('setTotal clamps current when the click count shrinks', () => {
272 const c = getClicks(PPT)
273 c.setTotal(2)
274 c.advance()
275 c.advance()
276 c.setTotal(0)
277 expect(c.current).toBe(0)
278 expect(c.total).toBe(0)
279 })
280
281 it('advance increments current and returns true when step consumed', () => {
282 const c = getClicks(PPT)
283 expect(c.advance()).toBe(true)
284 expect(c.current).toBe(1)
285 expect(c.advance()).toBe(true)
286 expect(c.current).toBe(2)
287 })
288
289 it('advance stops at total and returns false when exhausted', () => {
290 const c = getClicks(PPT)
291 c.setTotal(2)
292 expect(c.advance()).toBe(true) // → 1
293 expect(c.advance()).toBe(true) // → 2
294 expect(c.advance()).toBe(false) // exhausted
295 expect(c.current).toBe(2) // never goes past total
296 })
297
298 it('advance returns true in unbounded auto mode when total=0', () => {
299 const c = getClicks(PPT)
300 expect(c.total).toBe(0)
301 expect(c.advance()).toBe(true)
302 expect(c.current).toBe(1)
303 })
304
305 it('reset sets current back to 0', () => {
306 const c = getClicks(PPT)
307 c.advance()
308 c.advance()
309 c.reset()
310 expect(c.current).toBe(0)
311 })
312
313 it('reset preserves click listeners for manual replay', () => {
314 const c = getClicks(PPT)
315 c.on(1, vi.fn())
316 c.onAdvance(vi.fn())
317 c.reset()
318
319 expect(c._listeners).toHaveLength(1)
320 expect(c._advanceListeners).toHaveLength(1)
321 })
322
323 it('on() fires callback at matching click, does NOT replay on later clicks', () => {
324 const c = getClicks(PPT)
325 const fn1 = vi.fn(), fn2 = vi.fn(), fn3 = vi.fn()
326 c.on(1, fn1)
327 c.on(2, fn2)
328 c.on(3, fn3)
329
330 c.advance() // click 1
331 expect(fn1).toHaveBeenCalledTimes(1)
332 expect(fn2).not.toHaveBeenCalled()
333 expect(fn3).not.toHaveBeenCalled()
334
335 c.advance() // click 2
336 expect(fn1).toHaveBeenCalledTimes(1) // ⬅ NOT replayed
337 expect(fn2).toHaveBeenCalledTimes(1)
338 expect(fn3).not.toHaveBeenCalled()
339
340 c.advance() // click 3
341 expect(fn1).toHaveBeenCalledTimes(1) // ⬅ NOT replayed
342 expect(fn2).toHaveBeenCalledTimes(1)
343 expect(fn3).toHaveBeenCalledTimes(1)
344 })
345
346 it('on() fires immediately if current >= clickNum (late registration)', () => {
347 const c = getClicks(PPT)
348 c.advance()
349 c.advance() // current=2
350 const fn = vi.fn()
351 c.on(1, fn) // click 1 already past
352 expect(fn).toHaveBeenCalledTimes(1)
353 })
354
355 it('onAdvance fires on every advance with (click, current, total)', () => {
356 const c = getClicks(PPT)
357 const fn = vi.fn()
358 c.onAdvance(fn)
359 c.advance()
360 expect(fn).toHaveBeenCalledWith(1, 1, 0)
361 c.advance()
362 expect(fn).toHaveBeenCalledWith(2, 2, 0)
363 })
364 })
365
366 describe('PPT playback bridge', () => {
367 it('does not install for normal page preview URLs', () => {
368 const parent = { postMessage: vi.fn() }
369 const { PPT } = setupRuntime({ search: '?pptPlayback=0', parent })
370 const c = getClicks(PPT)
371 c.setTotal(1)
372
373 document.body.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
374 dispatchWheel(document.body, { deltaY: 90 })
375
376 expect(c.current).toBe(0)
377 expect(parent.postMessage).not.toHaveBeenCalled()
378 })
379
380 it('consumes click-triggered animation before asking the parent deck to navigate', () => {
381 const parent = { postMessage: vi.fn() }
382 const { PPT } = setupRuntime({ search: '?pptPlayback=1', parent })
383 const c = getClicks(PPT)
384 c.setTotal(1)
385
386 document.body.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
387 document.body.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
388
389 expect(c.current).toBe(1)
390 expect(parent.postMessage).toHaveBeenCalledWith({
391 type: 'ohmyppt:playback:goto',
392 offset: 1,
393 requestId: null
394 }, '*')
395 })
396
397 it('accepts parent advance messages for focused top-level deck shortcuts', () => {
398 const parent = { postMessage: vi.fn() }
399 const { PPT } = setupRuntime({ search: '?pptPlayback=1', parent })
400 const c = getClicks(PPT)
401 c.setTotal(1)
402
403 window.dispatchEvent(new MessageEvent('message', {
404 data: { type: 'ohmyppt:playback:advance', offset: 1 }
405 }))
406 window.dispatchEvent(new MessageEvent('message', {
407 data: { type: 'ohmyppt:playback:advance', offset: 1 }
408 }))
409
410 expect(c.current).toBe(1)
411 expect(parent.postMessage).toHaveBeenCalledWith({
412 type: 'ohmyppt:playback:goto',
413 offset: 1,
414 requestId: null
415 }, '*')
416 })
417
418 it('acknowledges parent advance messages when an animation step is consumed', () => {
419 const parent = { postMessage: vi.fn() }
420 const { PPT } = setupRuntime({ search: '?pptPlayback=1', parent })
421 const c = getClicks(PPT)
422 c.setTotal(1)
423
424 window.dispatchEvent(new MessageEvent('message', {
425 data: { type: 'ohmyppt:playback:advance', offset: 1, requestId: 'req-1' }
426 }))
427
428 expect(c.current).toBe(1)
429 expect(parent.postMessage).toHaveBeenCalledWith({
430 type: 'ohmyppt:playback:handled',
431 requestId: 'req-1'
432 }, '*')
433 })
434
435 it('ignores playback advance messages from non-parent windows', () => {
436 const parent = { postMessage: vi.fn() }
437 const otherWindow = {} as Window
438 const { PPT } = setupRuntime({ search: '?pptPlayback=1', parent })
439 const c = getClicks(PPT)
440 c.setTotal(1)
441
442 window.dispatchEvent(new MessageEvent('message', {
443 data: { type: 'ohmyppt:playback:advance', offset: 1, requestId: 'req-2' },
444 source: otherWindow
445 }))
446
447 expect(c.current).toBe(0)
448 expect(parent.postMessage).not.toHaveBeenCalled()
449 })
450
451 it('navigates parent deck from wheel events inside the slide iframe', () => {
452 vi.useFakeTimers()
453 vi.setSystemTime(1000)
454 const parent = { postMessage: vi.fn() }
455 setupRuntime({ search: '?pptPlayback=1', parent })
456
457 const event = dispatchWheel(document.body, { deltaY: 90 })
458
459 expect(event.defaultPrevented).toBe(true)
460 expect(parent.postMessage).toHaveBeenCalledWith({
461 type: 'ohmyppt:playback:goto',
462 offset: 1,
463 requestId: expect.any(String)
464 }, '*')
465 })
466
467 it('uses upward wheel motion for previous page inside playback mode', () => {
468 vi.useFakeTimers()
469 vi.setSystemTime(1000)
470 const parent = { postMessage: vi.fn() }
471 setupRuntime({ search: '?pptPlayback=1', parent })
472
473 dispatchWheel(document.body, { deltaY: -90 })
474
475 expect(parent.postMessage).toHaveBeenCalledWith({
476 type: 'ohmyppt:playback:goto',
477 offset: -1,
478 requestId: expect.any(String)
479 }, '*')
480 })
481
482 it('wheel consumes click-triggered animation before asking parent deck to navigate', () => {
483 vi.useFakeTimers()
484 vi.setSystemTime(1000)
485 const parent = { postMessage: vi.fn() }
486 const { PPT } = setupRuntime({ search: '?pptPlayback=1', parent })
487 const c = getClicks(PPT)
488 c.setTotal(1)
489
490 dispatchWheel(document.body, { deltaY: 90 })
491 expect(c.current).toBe(1)
492 expect(parent.postMessage).not.toHaveBeenCalled()
493
494 vi.advanceTimersByTime(600)
495 dispatchWheel(document.body, { deltaY: 90 })
496 expect(parent.postMessage).toHaveBeenCalledWith({
497 type: 'ohmyppt:playback:goto',
498 offset: 1,
499 requestId: expect.any(String)
500 }, '*')
501 })
502
503 it('locks one continuous trackpad wheel gesture to a single page turn', () => {
504 vi.useFakeTimers()
505 vi.setSystemTime(1000)
506 const parent = { postMessage: vi.fn() }
507 setupRuntime({ search: '?pptPlayback=1', parent })
508
509 dispatchWheel(document.body, { deltaY: 90 })
510 for (let i = 0; i < 7; i++) {
511 vi.advanceTimersByTime(100)
512 dispatchWheel(document.body, { deltaY: 90 })
513 }
514
515 expect(parent.postMessage).toHaveBeenCalledTimes(1)
516
517 vi.advanceTimersByTime(300)
518 dispatchWheel(document.body, { deltaY: 90 })
519
520 expect(parent.postMessage).toHaveBeenCalledTimes(2)
521 })
522
523 it('allows immediate reverse wheel navigation while the previous direction is locked', () => {
524 vi.useFakeTimers()
525 vi.setSystemTime(1000)
526 const parent = { postMessage: vi.fn() }
527 setupRuntime({ search: '?pptPlayback=1', parent })
528
529 dispatchWheel(document.body, { deltaY: 90 })
530 vi.advanceTimersByTime(100)
531 dispatchWheel(document.body, { deltaY: -90 })
532
533 expect(parent.postMessage).toHaveBeenNthCalledWith(1, {
534 type: 'ohmyppt:playback:goto',
535 offset: 1,
536 requestId: expect.any(String)
537 }, '*')
538 expect(parent.postMessage).toHaveBeenNthCalledWith(2, {
539 type: 'ohmyppt:playback:goto',
540 offset: -1,
541 requestId: expect.any(String)
542 }, '*')
543 })
544
545 it('unlocks wheel gesture when parent reports boundary navigation did not move', () => {
546 vi.useFakeTimers()
547 vi.setSystemTime(1000)
548 const parent = { postMessage: vi.fn() }
549 setupRuntime({ search: '?pptPlayback=1', parent })
550
551 dispatchWheel(document.body, { deltaY: 90 })
552 const firstMessage = parent.postMessage.mock.calls[0]?.[0] as { requestId?: string }
553 expect(firstMessage.requestId).toEqual(expect.any(String))
554
555 window.dispatchEvent(new MessageEvent('message', {
556 data: {
557 type: 'ohmyppt:playback:navigation-result',
558 requestId: firstMessage.requestId,
559 navigated: false
560 }
561 }))
562
563 vi.advanceTimersByTime(100)
564 dispatchWheel(document.body, { deltaY: 90 })
565
566 expect(parent.postMessage).toHaveBeenCalledTimes(2)
567 expect(parent.postMessage.mock.calls[1]?.[0]).toEqual({
568 type: 'ohmyppt:playback:goto',
569 offset: 1,
570 requestId: expect.any(String)
571 })
572 })
573
574 it('keeps wheel zoom gestures and editable targets untouched in playback mode', () => {
575 vi.useFakeTimers()
576 vi.setSystemTime(1000)
577 const parent = { postMessage: vi.fn() }
578 setupRuntime({ search: '?pptPlayback=1', parent })
579
580 const input = document.createElement('input')
581 document.body.appendChild(input)
582 const zoomEvent = dispatchWheel(document.body, { deltaY: 100, ctrlKey: true })
583 const editEvent = dispatchWheel(input, { deltaY: 100 })
584
585 expect(zoomEvent.defaultPrevented).toBe(false)
586 expect(editEvent.defaultPrevented).toBe(false)
587 expect(parent.postMessage).not.toHaveBeenCalled()
588 })
589 })
590
591 describe('PPT.scanDataAnim', () => {
592 let PPT: Record<string, unknown>
593
594 beforeEach(() => { PPT = setupRuntime().PPT })
595
596 it('returns null when no data-anim elements found', () => {
597 document.body.innerHTML = '<div class="ppt-page-root"></div>'
598 const result = (PPT.scanDataAnim as Function)(document.body)
599 expect(result).toBeNull()
600 })
601
602 it('splits load vs click animations', () => {
603 const root = document.querySelector('.ppt-page-root')!
604 const result = (PPT.scanDataAnim as Function)(root) as { load: unknown[]; click: unknown[]; all: unknown[] }
605 expect(result.load).toHaveLength(3)
606 expect(result.click).toHaveLength(2)
607 expect(result.all).toHaveLength(5)
608 })
609
610 it('sets PPT.clicks.total to click-triggered count', () => {
611 const root = document.querySelector('.ppt-page-root')!
612 ;(PPT.scanDataAnim as Function)(root)
613 const c = getClicks(PPT)
614 expect(c.total).toBe(2)
615 })
616
617 it('resets PPT.clicks.total when a later scan has no click-triggered elements', () => {
618 const root = document.querySelector('.ppt-page-root')!
619 ;(PPT.scanDataAnim as Function)(root)
620 const c = getClicks(PPT)
621 c.advance()
622 c.advance()
623
624 document.body.innerHTML = `
625 <div class="ppt-page-root">
626 <div data-anim="fade-up">Only load animation</div>
627 </div>
628 `
629 ;(PPT.scanDataAnim as Function)(document.querySelector('.ppt-page-root'))
630
631 expect(c.total).toBe(0)
632 expect(c.current).toBe(0)
633 })
634
635 it('clears previous click listeners before rescanning data-anim elements', () => {
636 const root = document.querySelector('.ppt-page-root')!
637 ;(PPT.scanDataAnim as Function)(root)
638 const c = getClicks(PPT)
639 c.on(1, vi.fn())
640 c.onAdvance(vi.fn())
641
642 ;(PPT.scanDataAnim as Function)(root)
643
644 expect(c._listeners).toHaveLength(0)
645 expect(c._advanceListeners).toHaveLength(0)
646 })
647
648 it('applies initial hidden state to click-triggered elements', () => {
649 const el4 = document.getElementById('el4')!
650 const el5 = document.getElementById('el5')!
651 const root = document.querySelector('.ppt-page-root')!
652 ;(PPT.scanDataAnim as Function)(root)
653
654 expect(el4.style.opacity).toBe('0')
655 expect(el5.style.opacity).toBe('0')
656 })
657
658 it('marks click-triggered elements with data-ppt-anim-initialized', () => {
659 const el4 = document.getElementById('el4')!
660 const root = document.querySelector('.ppt-page-root')!
661 ;(PPT.scanDataAnim as Function)(root)
662 expect(el4.getAttribute('data-ppt-anim-initialized')).toBe('1')
663 })
664
665 it('does NOT mark load-triggered elements with initialization marker', () => {
666 const el1 = document.getElementById('el1')!
667 const root = document.querySelector('.ppt-page-root')!
668 ;(PPT.scanDataAnim as Function)(root)
669 expect(el1.getAttribute('data-ppt-anim-initialized')).toBeNull()
670 expect(el1.style.opacity).toBe('')
671 })
672
673 it('skips data-anim="none" elements', () => {
674 const root = document.querySelector('.ppt-page-root')!
675 const result = (PPT.scanDataAnim as Function)(root) as { all: Array<{ type: string }> }
676 const types = result.all.map(a => a.type)
677 expect(types).not.toContain('none')
678 })
679
680 it('falls back to document when root is null', () => {
681 const result = (PPT.scanDataAnim as Function)(null)
682 expect(result).not.toBeNull()
683 expect((result as { all: unknown[] }).all.length).toBeGreaterThan(0)
684 })
685
686 it('parses extended animation attributes without adding non-click steps', () => {
687 document.body.innerHTML = `
688 <div class="ppt-page-root">
689 <div data-anim="fly-in" data-anim-from="left" data-anim-trigger="with" id="fly">Fly</div>
690 <div data-anim="wipe" data-anim-from="right" data-anim-trigger="after" id="wipe">Wipe</div>
691 <div data-anim="pulse" data-anim-repeat="3" data-anim-direction="alternate" id="pulse">Pulse</div>
692 </div>
693 `
694
695 const result = (PPT.scanDataAnim as Function)(document.querySelector('.ppt-page-root')) as {
696 load: Array<Record<string, unknown>>
697 click: unknown[]
698 all: Array<Record<string, unknown>>
699 }
700
701 expect(result.load).toHaveLength(3)
702 expect(result.click).toHaveLength(0)
703 expect(getClicks(PPT).total).toBe(0)
704 expect(result.all[0]).toMatchObject({ type: 'fly-in', trigger: 'with', effectiveTrigger: 'load', from: 'left' })
705 expect(result.all[1]).toMatchObject({ type: 'wipe', trigger: 'after', effectiveTrigger: 'load', from: 'right' })
706 expect(result.all[2]).toMatchObject({ type: 'pulse', repeat: 3, direction: 'alternate' })
707 expect(Number(result.all[1].delay)).toBeGreaterThan(0)
708 })
709
710 it('supports declarative data-anim-stagger as the preferred stagger syntax', () => {
711 document.body.innerHTML = `
712 <div class="ppt-page-root">
713 <div data-anim="fade-up" data-anim-stagger="80" id="a">A</div>
714 <div data-anim="fade-up" data-anim-stagger="80" id="b">B</div>
715 <div data-anim="fade-up" data-anim-stagger="80" id="c">C</div>
716 </div>
717 `
718
719 const result = (PPT.scanDataAnim as Function)(document.querySelector('.ppt-page-root')) as {
720 load: Array<Record<string, unknown>>
721 }
722
723 expect(result.load).toHaveLength(3)
724 expect(result.load[0]).toMatchObject({ delay: 0, stagger: 80 })
725 expect(result.load[1]).toMatchObject({ delay: 80, stagger: 80 })
726 expect(result.load[2]).toMatchObject({ delay: 160, stagger: 80 })
727 })
728
729 it('supports data-anim-sequence without overloading trigger semantics for new content', () => {
730 document.body.innerHTML = `
731 <div class="ppt-page-root">
732 <div data-anim="fade-up" data-anim-duration="400" id="lead">Lead</div>
733 <div data-anim="fade" data-anim-sequence="with" data-anim-delay="80" data-anim-duration="300" id="with">With</div>
734 <div data-anim="fade-up" data-anim-sequence="after" data-anim-duration="200" id="after">After</div>
735 </div>
736 `
737
738 const result = (PPT.scanDataAnim as Function)(document.querySelector('.ppt-page-root')) as {
739 load: Array<Record<string, unknown>>
740 }
741
742 expect(result.load).toHaveLength(3)
743 expect(result.load[0]).toMatchObject({ trigger: 'load', effectiveTrigger: 'load', delay: 0 })
744 expect(result.load[1]).toMatchObject({ trigger: 'load', effectiveTrigger: 'load', sequence: 'with', delay: 80 })
745 expect(result.load[2]).toMatchObject({ trigger: 'load', effectiveTrigger: 'load', sequence: 'after', delay: 400 })
746 })
747
748 it('groups contiguous click animations with the same click-group into one click step', () => {
749 document.body.innerHTML = `
750 <div class="ppt-page-root">
751 <div data-anim="fade-up" data-anim-trigger="click" data-anim-click-group="reveal" id="a">A</div>
752 <div data-anim="pulse-soft" data-anim-trigger="click" data-anim-click-group="reveal" id="b">B</div>
753 <div data-anim="pulse-strong" data-anim-trigger="click" id="c">C</div>
754 </div>
755 `
756
757 const result = (PPT.scanDataAnim as Function)(document.querySelector('.ppt-page-root')) as {
758 click: Array<Record<string, unknown>>
759 clickSteps: Array<Array<Record<string, unknown>>>
760 }
761
762 expect(result.click).toHaveLength(3)
763 expect(result.clickSteps).toHaveLength(2)
764 expect(result.clickSteps[0]).toHaveLength(2)
765 expect(result.clickSteps[0][0]).toMatchObject({ clickGroup: 'reveal', type: 'fade-up' })
766 expect(result.clickSteps[0][1]).toMatchObject({ clickGroup: 'reveal', type: 'pulse-soft' })
767 expect(result.clickSteps[1]).toHaveLength(1)
768 expect(result.clickSteps[1][0]).toMatchObject({ type: 'pulse-strong' })
769 expect(getClicks(PPT).total).toBe(2)
770 })
771
772 it('does not hide click-triggered emphasis or exit animations before playback', () => {
773 document.body.innerHTML = `
774 <div class="ppt-page-root">
775 <div data-anim="pulse" data-anim-trigger="click" id="pulse">Pulse</div>
776 <div data-anim="exit-scale" data-anim-trigger="click" id="scale-exit">Scale Exit</div>
777 <div data-anim="exit-fly" data-anim-trigger="click" data-anim-from="bottom" id="exit">Exit</div>
778 </div>
779 `
780
781 ;(PPT.scanDataAnim as Function)(document.querySelector('.ppt-page-root'))
782
783 expect(document.getElementById('pulse')!.style.opacity).toBe('')
784 expect(document.getElementById('scale-exit')!.style.opacity).toBe('')
785 expect(document.getElementById('exit')!.style.opacity).toBe('')
786 expect(document.getElementById('pulse')!.getAttribute('data-ppt-anim-initialized')).toBeNull()
787 expect(document.getElementById('scale-exit')!.getAttribute('data-ppt-anim-initialized')).toBeNull()
788 expect(getClicks(PPT).total).toBe(3)
789 })
790
791 it('disables click-triggered animations when playback is not enabled', () => {
792 const { PPT } = setupRuntime({ search: '?pptPlayback=0' })
793 const root = document.querySelector('.ppt-page-root')
794 const result = (PPT.scanDataAnim as Function)(root) as { click: unknown[] }
795
796 expect(result.click).toHaveLength(0)
797 expect(getClicks(PPT).total).toBe(0)
798 expect(document.getElementById('el4')!.style.opacity).toBe('')
799 expect(document.getElementById('el5')!.style.opacity).toBe('')
800 })
801 })
802
803 describe('PPT.executeDataAnim (routed through PPT.animate)', () => {
804 let PPT: Record<string, unknown>
805 let anime: Record<string, unknown>
806
807 beforeEach(() => {
808 const s = setupRuntime(); PPT = s.PPT; anime = s.anime
809 })
810
811 it('handles empty config', () => {
812 expect(() => (PPT.executeDataAnim as Function)([])).not.toThrow()
813 })
814
815 it('calls PPT.animate (not timeline) for each config entry', () => {
816 const animateSpy = vi.spyOn(PPT, 'animate' as never)
817 const config = [
818 { targets: document.getElementById('el1'), type: 'fade-up', duration: 500, easing: 'easeOutCubic', delay: 0 }
819 ]
820 ;(PPT.executeDataAnim as Function)(config)
821 expect(animateSpy).toHaveBeenCalled()
822 animateSpy.mockRestore()
823 })
824
825 it('slide-up params include opacity for click reveal visibility', () => {
826 const animateSpy = vi.spyOn(PPT, 'animate' as never)
827 const el = document.getElementById('el1')!
828 const config = [{ targets: el, type: 'slide-up', duration: 500, easing: 'easeOutCubic', delay: 0 }]
829 ;(PPT.executeDataAnim as Function)(config)
830 expect(animateSpy).toHaveBeenCalled()
831 const callArgs = animateSpy.mock.calls[0]
832 const params = callArgs[1] as Record<string, unknown>
833 expect(params.opacity).toEqual([0, 1])
834 expect(params.translateY).toEqual([64, 0])
835 animateSpy.mockRestore()
836 })
837
838 it('slide-left params include opacity for click reveal visibility', () => {
839 const animateSpy = vi.spyOn(PPT, 'animate' as never)
840 const el = document.getElementById('el1')!
841 const config = [{ targets: el, type: 'slide-left', duration: 500, easing: 'easeOutCubic', delay: 0 }]
842 ;(PPT.executeDataAnim as Function)(config)
843 const params = animateSpy.mock.calls[0][1] as Record<string, unknown>
844 expect(params.opacity).toEqual([0, 1])
845 expect(params.translateX).toEqual([72, 0])
846 animateSpy.mockRestore()
847 })
848
849 it('slide-down params include opacity for downward directional entry', () => {
850 const animateSpy = vi.spyOn(PPT, 'animate' as never)
851 const el = document.getElementById('el1')!
852 const config = [{ targets: el, type: 'slide-down', duration: 500, easing: 'easeOutCubic', delay: 0 }]
853 ;(PPT.executeDataAnim as Function)(config)
854 const params = animateSpy.mock.calls[0][1] as Record<string, unknown>
855 expect(params.opacity).toEqual([0, 1])
856 expect(params.translateY).toEqual([-64, 0])
857 animateSpy.mockRestore()
858 })
859
860 it('slide-right params include opacity for rightward directional entry', () => {
861 const animateSpy = vi.spyOn(PPT, 'animate' as never)
862 const el = document.getElementById('el1')!
863 const config = [{ targets: el, type: 'slide-right', duration: 500, easing: 'easeOutCubic', delay: 0 }]
864 ;(PPT.executeDataAnim as Function)(config)
865 const params = animateSpy.mock.calls[0][1] as Record<string, unknown>
866 expect(params.opacity).toEqual([0, 1])
867 expect(params.translateX).toEqual([-72, 0])
868 animateSpy.mockRestore()
869 })
870
871 it('maps fly-in direction to real translate params', () => {
872 const animateSpy = vi.spyOn(PPT, 'animate' as never)
873 const el = document.getElementById('el1')!
874 const config = [{ targets: el, type: 'fly-in', from: 'left', duration: 500, easing: 'easeOutCubic', delay: 0 }]
875 ;(PPT.executeDataAnim as Function)(config)
876 const params = animateSpy.mock.calls[0][1] as Record<string, unknown>
877 expect(params.opacity).toEqual([0, 1])
878 expect(params.translateX).toEqual([-72, 0])
879 animateSpy.mockRestore()
880 })
881
882 it('maps wipe direction to clipPath params', () => {
883 const animateSpy = vi.spyOn(PPT, 'animate' as never)
884 const el = document.getElementById('el1')!
885 const config = [{ targets: el, type: 'wipe', from: 'right', duration: 500, easing: 'easeOutCubic', delay: 0 }]
886 ;(PPT.executeDataAnim as Function)(config)
887 const params = animateSpy.mock.calls[0][1] as Record<string, unknown>
888 expect(params.opacity).toEqual([0, 1])
889 expect(params.clipPath).toEqual(['inset(0% 0% 0% 100%)', 'inset(0% 0% 0% 0%)'])
890 animateSpy.mockRestore()
891 })
892
893 it('maps emphasis repeat and direction to anime loop params', () => {
894 const animateSpy = vi.spyOn(PPT, 'animate' as never)
895 const el = document.getElementById('el1')!
896 const config = [{ targets: el, type: 'pulse', repeat: 3, direction: 'alternate', duration: 500, easing: 'easeOutCubic', delay: 0 }]
897 ;(PPT.executeDataAnim as Function)(config)
898 const params = animateSpy.mock.calls[0][1] as Record<string, unknown>
899 expect(params.scale).toEqual([1, 1.06, 1])
900 expect(params.loop).toBe(2)
901 expect(params.alternate).toBe(true)
902 animateSpy.mockRestore()
903 })
904
905 it('maps bounded emphasis variants to distinct scale arrays', () => {
906 const animateSpy = vi.spyOn(PPT, 'animate' as never)
907 const el = document.getElementById('el1')!
908 ;(PPT.executeDataAnim as Function)([
909 { targets: el, type: 'pulse-soft', duration: 500, easing: 'easeOutCubic', delay: 0 },
910 { targets: el, type: 'pulse-strong', duration: 500, easing: 'easeOutCubic', delay: 0 },
911 { targets: el, type: 'grow-shrink-soft', duration: 500, easing: 'easeOutCubic', delay: 0 },
912 { targets: el, type: 'grow-shrink-strong', duration: 500, easing: 'easeOutCubic', delay: 0 }
913 ])
914
915 expect((animateSpy.mock.calls[0][1] as Record<string, unknown>).scale).toEqual([1, 1.03, 1])
916 expect((animateSpy.mock.calls[1][1] as Record<string, unknown>).scale).toEqual([1, 1.1, 1])
917 expect((animateSpy.mock.calls[2][1] as Record<string, unknown>).scale).toEqual([0.95, 1.04, 1])
918 expect((animateSpy.mock.calls[3][1] as Record<string, unknown>).scale).toEqual([0.85, 1.12, 1])
919 animateSpy.mockRestore()
920 })
921
922 it('maps exit-fly to visible-to-hidden movement', () => {
923 const animateSpy = vi.spyOn(PPT, 'animate' as never)
924 const el = document.getElementById('el1')!
925 const config = [{ targets: el, type: 'exit-fly', from: 'bottom', duration: 500, easing: 'easeOutCubic', delay: 0 }]
926 ;(PPT.executeDataAnim as Function)(config)
927 const params = animateSpy.mock.calls[0][1] as Record<string, unknown>
928 expect(params.opacity).toEqual([1, 0])
929 expect(params.translateY).toEqual([0, 40])
930 animateSpy.mockRestore()
931 })
932
933 it('maps exit-wipe to visible-to-hidden clipPath conceal semantics', () => {
934 const animateSpy = vi.spyOn(PPT, 'animate' as never)
935 const el = document.getElementById('el1')!
936 const config = [{ targets: el, type: 'exit-wipe', from: 'right', duration: 500, easing: 'easeOutCubic', delay: 0 }]
937 ;(PPT.executeDataAnim as Function)(config)
938 const params = animateSpy.mock.calls[0][1] as Record<string, unknown>
939 expect(params.opacity).toEqual([1, 0])
940 expect(params.clipPath).toEqual(['inset(0% 0% 0% 0%)', 'inset(0% 0% 0% 100%)'])
941 animateSpy.mockRestore()
942 })
943
944 it('maps exit-scale and exit-zoom to visible-to-hidden scale semantics', () => {
945 const animateSpy = vi.spyOn(PPT, 'animate' as never)
946 const el = document.getElementById('el1')!
947 ;(PPT.executeDataAnim as Function)([
948 { targets: el, type: 'exit-scale', duration: 500, easing: 'easeOutCubic', delay: 0 },
949 { targets: el, type: 'exit-zoom', duration: 500, easing: 'easeOutCubic', delay: 0 }
950 ])
951 expect((animateSpy.mock.calls[0][1] as Record<string, unknown>).opacity).toEqual([1, 0])
952 expect((animateSpy.mock.calls[0][1] as Record<string, unknown>).scale).toEqual([1, 0.85])
953 expect((animateSpy.mock.calls[1][1] as Record<string, unknown>).opacity).toEqual([1, 0])
954 expect((animateSpy.mock.calls[1][1] as Record<string, unknown>).scale).toEqual([1, 0.75])
955 animateSpy.mockRestore()
956 })
957
958 it('supports simple data-anim-path deltas', () => {
959 const animateSpy = vi.spyOn(PPT, 'animate' as never)
960 const el = document.getElementById('el1')!
961 const config = [{ targets: el, type: 'path', path: 'M 0 0 L 120 30', duration: 500, easing: 'linear', delay: 0 }]
962 ;(PPT.executeDataAnim as Function)(config)
963 const params = animateSpy.mock.calls[0][1] as Record<string, unknown>
964 expect(params.translateX).toEqual([0, 120])
965 expect(params.translateY).toEqual([0, 30])
966 animateSpy.mockRestore()
967 })
968
969 it('passes through print mode via PPT.animate', () => {
970 const el = document.getElementById('el1')!
971 const config = [{ targets: el, type: 'fade', duration: 500, easing: 'linear', delay: 0 }]
972 expect(() => (PPT.executeDataAnim as Function)(config)).not.toThrow()
973 })
974
975 it('calls PPT.playLottie for lottie type', () => {
976 const playLottieSpy = vi.fn()
977 const origPlayLottie = PPT.playLottie
978 PPT.playLottie = playLottieSpy
979
980 const el = document.getElementById('el1')!
981 const config = [{ targets: el, type: 'lottie', lottieSrc: './test.json', lottieLoop: true, lottieAutoplay: true, duration: 500, easing: 'linear', delay: 0 }]
982 ;(PPT.executeDataAnim as Function)(config)
983 expect(playLottieSpy).toHaveBeenCalledWith(el, config[0])
984
985 PPT.playLottie = origPlayLottie
986 })
987 })
988
989 describe('PPT.playLottie placeholder', () => {
990 it('exists as a no-op function', () => {
991 const PPT = setupRuntime().PPT
992 expect(typeof PPT.playLottie).toBe('function')
993 expect(() => (PPT.playLottie as Function)(document.body, {})).not.toThrow()
994 })
995 })
996
997 describe('PPT.animate tracks animations for stop/resume', () => {
998 it('adds animation to active set', () => {
999 const { PPT, animations } = setupRuntime()
1000 ;(PPT.animate as Function)('.card', { opacity: [0, 1] })
1001 ;(PPT.stopAnimations as Function)()
1002 const pauseCalls = animations.filter(a => a.pause.mock.calls.length > 0)
1003 expect(pauseCalls.length).toBeGreaterThan(0)
1004 })
1005 })
1006
1007 describe('PPT.createChart tick formatters', () => {
1008 function installMockChart() {
1009 const previousChart = (globalThis as Record<string, unknown>).Chart
1010 const instances = new Map<HTMLCanvasElement, Record<string, any>>()
1011 const ChartMock = vi.fn(function (this: Record<string, any>, target: HTMLCanvasElement, config: Record<string, any>) {
1012 this.canvas = target
1013 this.data = config.data
1014 this.options = config.options
1015 this.resize = vi.fn()
1016 this.update = vi.fn()
1017 this.destroy = vi.fn()
1018 instances.set(target, this)
1019 }) as unknown as ReturnType<typeof vi.fn> & {
1020 getChart: ReturnType<typeof vi.fn>
1021 }
1022 ChartMock.getChart = vi.fn((target: HTMLCanvasElement) => instances.get(target) || null)
1023 ;(globalThis as Record<string, unknown>).Chart = ChartMock
1024 return {
1025 ChartMock,
1026 restore: () => {
1027 if (previousChart === undefined) {
1028 delete (globalThis as Record<string, unknown>).Chart
1029 } else {
1030 ;(globalThis as Record<string, unknown>).Chart = previousChart
1031 }
1032 }
1033 }
1034 }
1035
1036 it('keeps category axis labels instead of displaying numeric indexes', () => {
1037 const { PPT } = setupRuntime()
1038 document.body.innerHTML = '<canvas id="chart"></canvas>'
1039
1040 const { restore } = installMockChart()
1041
1042 try {
1043 const chart = (PPT.createChart as Function)(document.getElementById('chart'), {
1044 type: 'line',
1045 data: {
1046 labels: ['2000', '2005', '2010'],
1047 datasets: [{ data: [21.5, 20.3, 19.2] }]
1048 },
1049 options: {
1050 scales: {
1051 x: { type: 'category', ticks: {} },
1052 y: { ticks: {} }
1053 }
1054 }
1055 })
1056
1057 const xCallback = chart.options.scales.x.ticks.callback
1058 const yCallback = chart.options.scales.y.ticks.callback
1059 const categoryScale = {
1060 getLabelForValue: (value: number) => chart.data.labels[value]
1061 }
1062
1063 expect(xCallback.call(categoryScale, 1)).toBe('2005')
1064 expect(yCallback(20.300000000000004)).toBe('20.3')
1065 } finally {
1066 restore()
1067 }
1068 })
1069
1070 it('keeps category labels when updateChart replaces options', () => {
1071 const { PPT } = setupRuntime()
1072 document.body.innerHTML = '<canvas id="chart"></canvas>'
1073
1074 const { restore } = installMockChart()
1075
1076 try {
1077 const canvas = document.getElementById('chart')
1078 const chart = (PPT.createChart as Function)(canvas, {
1079 type: 'bar',
1080 data: {
1081 labels: ['North', 'South'],
1082 datasets: [{ data: [10, 20] }]
1083 },
1084 options: {}
1085 })
1086
1087 ;(PPT.updateChart as Function)(canvas, {
1088 options: {
1089 scales: {
1090 x: { ticks: {} }
1091 }
1092 }
1093 })
1094
1095 const xCallback = chart.options.scales.x.ticks.callback
1096 const categoryScale = {
1097 getLabelForValue: (value: number) => chart.data.labels[value]
1098 }
1099
1100 expect(xCallback.call(categoryScale, 1)).toBe('South')
1101 } finally {
1102 restore()
1103 }
1104 })
1105
1106 it('uses the value axis in horizontal bar tooltips', () => {
1107 const { PPT } = setupRuntime()
1108 document.body.innerHTML = '<canvas id="chart"></canvas>'
1109
1110 const { restore } = installMockChart()
1111
1112 try {
1113 const chart = (PPT.createChart as Function)(document.getElementById('chart'), {
1114 type: 'bar',
1115 data: {
1116 labels: ['North', 'South'],
1117 datasets: [{ label: 'Revenue', data: [10, 20] }]
1118 },
1119 options: {
1120 indexAxis: 'y'
1121 }
1122 })
1123
1124 const labelCallback = chart.options.plugins.tooltip.callbacks.label
1125 expect(labelCallback({
1126 chart,
1127 dataset: { label: 'Revenue' },
1128 parsed: { x: 20.300000000000004, y: 1 },
1129 raw: 20.300000000000004
1130 })).toBe('Revenue: 20.3')
1131 } finally {
1132 restore()
1133 }
1134 })
1135 })
1136
1137 describe('Version guard', () => {
1138 it('runtime version is 2.0.21', () => {
1139 const PPT = setupRuntime().PPT
1140 expect(PPT.__runtimeVersion).toBe('2.0.21')
1141 })
1142 })
1143
1143 lines TYPESCRIPT