返回 oh-my-ppt
index-runtime-logic.test.ts
根目录 / tests / unit / runtime / index-runtime-logic.test.ts
1 /**
2 * Unit tests for index-runtime.js logic:
3 * - playback-mode click routing
4 * - Transition type/direction resolution
5 * - Duration clamping
6 * - Reduced motion guard
7 *
8 * These test the actual logic extracted from the runtime, not abstract mocks.
9 */
10 import { describe, it, expect } from 'vitest'
11 import fs from 'node:fs'
12 import path from 'node:path'
13
14 function advanceClickState(
15 clicks: { total: number; advance: () => boolean } | null | undefined
16 ): boolean {
17 if (!clicks) return false
18 // Only forward when the page actually has click-triggered animation steps
19 if (clicks.total > 0 && typeof clicks.advance === 'function') {
20 return clicks.advance()
21 }
22 return false
23 }
24
25 const indexTransitionTypes = new Set([
26 'none',
27 'fade',
28 'slide-left',
29 'slide-up',
30 'push',
31 'wipe',
32 'zoom',
33 'flip',
34 'stack',
35 'rotate',
36 'cube',
37 'cover-flow',
38 'blur',
39 'iris',
40 'swing',
41 'center-reveal'
42 ])
43
44 function normalizeIndexTransitionType(type: string): string {
45 return indexTransitionTypes.has(type) ? type : 'fade'
46 }
47
48 function clampTransitionDuration(type: string, value: number | undefined): number {
49 if (type === 'none') return 0
50 if (!Number.isFinite(value)) return 600
51 return Math.max(120, Math.min(1200, Math.round(value as number)))
52 }
53
54 function transitionDirection(previousIndex: number, nextIndex: number): 1 | -1 {
55 return previousIndex >= 0 && nextIndex < previousIndex ? -1 : 1
56 }
57
58 function slideEntryValue(type: string, direction: 1 | -1): string {
59 if (type === 'slide-left' || type === 'push') return `${100 * direction}%`
60 if (type === 'slide-up') return `${100 * direction}%`
61 if (type === 'flip') return `${72 * direction}deg`
62 if (type === 'wipe') return direction > 0 ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)'
63 return '0'
64 }
65
66 function shouldBindFrameDocument(previousDocument: object | undefined, nextDocument: object | null): boolean {
67 return Boolean(nextDocument && previousDocument !== nextDocument)
68 }
69
70 function simulateEnsureFrameLoadedOrder(): string[] {
71 const calls: string[] = []
72 const frame = {
73 addEventListener: (eventName: string) => {
74 calls.push(`listen:${eventName}`)
75 },
76 set src(_value: string) {
77 calls.push('set-src')
78 }
79 }
80
81 frame.addEventListener('load')
82 frame.src = 'page.html'
83 return calls
84 }
85
86 function simulateWaitForFrameLoadBeforeActivation(): string[] {
87 const calls: string[] = []
88 const frame = {
89 listeners: {} as Record<string, () => void>,
90 addEventListener: (eventName: string, callback: () => void) => {
91 calls.push(`listen:${eventName}`)
92 frame.listeners[eventName] = callback
93 },
94 set src(_value: string) {
95 calls.push('set-src')
96 }
97 }
98
99 frame.addEventListener('load', () => {
100 calls.push('load')
101 calls.push('activate')
102 })
103 frame.src = 'page.html'
104 calls.push('before-load')
105 frame.listeners.load()
106 return calls
107 }
108
109 function adjacentPageKeys(keys: string[], activeKey: string): string[] {
110 const index = keys.indexOf(activeKey)
111 if (index < 0) return []
112 return [keys[index - 1], keys[index + 1]].filter(Boolean)
113 }
114
115 function shouldEnableDeckPlayback(args: {
116 embedMode: boolean
117 presentMode: boolean
118 }): boolean {
119 return args.presentMode && !args.embedMode
120 }
121
122 function shouldAnimateDeckTransition(args: {
123 presentMode: boolean
124 transitionType: string
125 hasPreviousPage: boolean
126 samePage: boolean
127 }): boolean {
128 return (
129 args.presentMode &&
130 args.transitionType !== 'none' &&
131 args.hasPreviousPage &&
132 !args.samePage
133 )
134 }
135
136 function resolveFrameClickAction(args: {
137 playbackMode: boolean
138 forwarded: boolean
139 }): 'advance-animation' | 'goto-next' | 'none' {
140 if (!args.playbackMode) return 'none'
141 if (args.forwarded) return 'advance-animation'
142 return 'goto-next'
143 }
144
145 function shouldAcceptFramePlaybackMessage(args: {
146 hasFrame: boolean
147 source: object | null
148 frameWindow: object
149 }): boolean {
150 if (!args.hasFrame) return false
151 return !args.source || args.source === args.frameWindow
152 }
153
154 function clearPendingPlaybackRequestsForTest(
155 pending: Record<string, number>,
156 clearTimeoutFn: (id: number) => void
157 ): void {
158 Object.keys(pending).forEach((requestId) => {
159 clearTimeoutFn(pending[requestId])
160 delete pending[requestId]
161 })
162 }
163
164 function normalizeWheelDeltaForTest(event: {
165 deltaX: number
166 deltaY: number
167 deltaMode: number
168 }): number {
169 let delta = Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX
170 if (event.deltaMode === 1) delta *= 16
171 else if (event.deltaMode === 2) delta *= 900
172 return delta
173 }
174
175 function createWheelNavigatorForTest(args?: {
176 threshold?: number
177 cooldown?: number
178 now?: () => number
179 navigate?: (offset: number) => boolean
180 }) {
181 const threshold = args?.threshold ?? 80
182 const cooldown = args?.cooldown ?? 520
183 const now = args?.now ?? (() => Date.now())
184 const navigate = args?.navigate ?? (() => true)
185 let wheelDeltaBuffer = 0
186 let wheelGestureLocked = false
187 let wheelGestureLockDirection = 0
188 let lastWheelNavigateAt = 0
189 const offsets: number[] = []
190
191 return {
192 offsets,
193 unlockGesture() {
194 wheelDeltaBuffer = 0
195 wheelGestureLocked = false
196 wheelGestureLockDirection = 0
197 },
198 handle(event: {
199 deltaX: number
200 deltaY: number
201 deltaMode: number
202 ctrlKey?: boolean
203 metaKey?: boolean
204 editableTarget?: boolean
205 deckSwitcherTarget?: boolean
206 preventDefault: () => void
207 }) {
208 if (event.ctrlKey || event.metaKey) return
209 if (event.editableTarget || event.deckSwitcherTarget) return
210
211 const delta = normalizeWheelDeltaForTest(event)
212 if (!Number.isFinite(delta) || Math.abs(delta) < 1) return
213 const direction = delta > 0 ? 1 : -1
214
215 if (Math.sign(delta) !== Math.sign(wheelDeltaBuffer)) wheelDeltaBuffer = 0
216 wheelDeltaBuffer += delta
217
218 if (Math.abs(wheelDeltaBuffer) < threshold) return
219
220 if (wheelGestureLocked && wheelGestureLockDirection && direction !== wheelGestureLockDirection) {
221 wheelGestureLocked = false
222 wheelGestureLockDirection = 0
223 lastWheelNavigateAt = 0
224 }
225
226 if (wheelGestureLocked) {
227 wheelDeltaBuffer = 0
228 event.preventDefault()
229 return
230 }
231
232 const currentTime = now()
233 if (currentTime - lastWheelNavigateAt < cooldown) {
234 wheelDeltaBuffer = 0
235 wheelGestureLocked = true
236 wheelGestureLockDirection = direction
237 event.preventDefault()
238 return
239 }
240
241 const offset = wheelDeltaBuffer > 0 ? 1 : -1
242 wheelDeltaBuffer = 0
243 event.preventDefault()
244 if (navigate(offset)) {
245 wheelGestureLocked = true
246 wheelGestureLockDirection = offset
247 lastWheelNavigateAt = currentTime
248 offsets.push(offset)
249 } else {
250 wheelGestureLocked = false
251 wheelGestureLockDirection = 0
252 lastWheelNavigateAt = 0
253 }
254 }
255 }
256 }
257
258 describe('click state advance helper (total > 0 guard)', () => {
259 function makeClicks(total: number) {
260 let current = 0
261 return {
262 total,
263 advance: () => {
264 if (total > 0 && current >= total) return false
265 current++
266 return true
267 }
268 }
269 }
270
271 it('returns false when clicks is null/undefined', () => {
272 expect(advanceClickState(null)).toBe(false)
273 expect(advanceClickState(undefined)).toBe(false)
274 })
275
276 it('returns false when total is 0 (no click-triggered elements)', () => {
277 const clicks = makeClicks(0)
278 expect(advanceClickState(clicks)).toBe(false)
279 })
280
281 it('returns true when step consumed', () => {
282 const clicks = makeClicks(3)
283 expect(advanceClickState(clicks)).toBe(true)
284 })
285
286 it('returns false when all steps exhausted', () => {
287 const clicks = makeClicks(2)
288 clicks.advance() // → 1
289 clicks.advance() // → 2
290 expect(advanceClickState(clicks)).toBe(false) // exhausted, nav should proceed
291 })
292
293 it('allows nav after last click step exhausted', () => {
294 const clicks = makeClicks(1)
295 expect(advanceClickState(clicks)).toBe(true) // consumed step 1
296 expect(advanceClickState(clicks)).toBe(false) // exhausted → navigate
297 })
298 })
299
300 describe('iframe load binding order', () => {
301 it('registers load listener before setting iframe src', () => {
302 expect(simulateEnsureFrameLoadedOrder()).toEqual(['listen:load', 'set-src'])
303 })
304
305 it('waits for iframe load before activating the target page', () => {
306 expect(simulateWaitForFrameLoadBeforeActivation()).toEqual([
307 'listen:load',
308 'set-src',
309 'before-load',
310 'load',
311 'activate'
312 ])
313 })
314
315 it('rebinds when iframe document changes after reload', () => {
316 const firstDocument = {}
317 const secondDocument = {}
318
319 expect(shouldBindFrameDocument(undefined, firstDocument)).toBe(true)
320 expect(shouldBindFrameDocument(firstDocument, firstDocument)).toBe(false)
321 expect(shouldBindFrameDocument(firstDocument, secondDocument)).toBe(true)
322 })
323 })
324
325 describe('adjacent page prefetch selection', () => {
326 it('prefetches only immediate neighbors', () => {
327 expect(adjacentPageKeys(['p1', 'p2', 'p3', 'p4'], 'p2')).toEqual(['p1', 'p3'])
328 expect(adjacentPageKeys(['p1', 'p2', 'p3', 'p4'], 'p1')).toEqual(['p2'])
329 expect(adjacentPageKeys(['p1', 'p2', 'p3', 'p4'], 'p4')).toEqual(['p3'])
330 })
331 })
332
333 describe('iframe click behavior', () => {
334 it('ignores iframe clicks outside deck playback mode', () => {
335 expect(resolveFrameClickAction({ playbackMode: false, forwarded: true })).toBe('none')
336 expect(resolveFrameClickAction({ playbackMode: false, forwarded: false })).toBe('none')
337 })
338
339 it('keeps controls visible while supporting clicks in full-deck playback', () => {
340 expect(resolveFrameClickAction({
341 playbackMode: true,
342 forwarded: true
343 })).toBe('advance-animation')
344 expect(resolveFrameClickAction({
345 playbackMode: true,
346 forwarded: false
347 })).toBe('goto-next')
348 })
349 })
350
351 describe('index.html deck playback mode', () => {
352 it('enables click playback for full-deck index without forcing present CSS', () => {
353 expect(shouldEnableDeckPlayback({ embedMode: false, presentMode: true })).toBe(true)
354 })
355
356 it('does not enable deck playback in embed mode', () => {
357 expect(shouldEnableDeckPlayback({ embedMode: true, presentMode: true })).toBe(false)
358 })
359
360 it('does not enable click playback in ordinary preview mode', () => {
361 expect(shouldEnableDeckPlayback({ embedMode: false, presentMode: false })).toBe(false)
362 })
363
364 it('does not animate page transitions in ordinary preview mode', () => {
365 expect(
366 shouldAnimateDeckTransition({
367 presentMode: false,
368 transitionType: 'fade',
369 hasPreviousPage: true,
370 samePage: false
371 })
372 ).toBe(false)
373 expect(
374 shouldAnimateDeckTransition({
375 presentMode: true,
376 transitionType: 'fade',
377 hasPreviousPage: true,
378 samePage: false
379 })
380 ).toBe(true)
381 })
382
383 it('derives playback mode from present mode in the index runtime', () => {
384 const source = fs.readFileSync(
385 path.resolve(process.cwd(), 'resources/index-runtime.js'),
386 'utf8'
387 )
388 expect(source).toContain('var playbackMode = presentMode && !embedMode;')
389 expect(source).toContain("url.searchParams.set('pptPlayback', playbackMode ? '1' : '0');")
390 expect(source).toContain('presentMode &&\n indexTransitionType !== \'none\'')
391 })
392 })
393
394 describe('frame playback postMessage source guard', () => {
395 it('accepts messages from the active frame window', () => {
396 const frameWindow = {}
397 expect(shouldAcceptFramePlaybackMessage({
398 hasFrame: true,
399 source: frameWindow,
400 frameWindow
401 })).toBe(true)
402 })
403
404 it('accepts null source because some presentation webviews omit event.source', () => {
405 expect(shouldAcceptFramePlaybackMessage({
406 hasFrame: true,
407 source: null,
408 frameWindow: {}
409 })).toBe(true)
410 })
411
412 it('rejects messages from another frame window', () => {
413 expect(shouldAcceptFramePlaybackMessage({
414 hasFrame: true,
415 source: {},
416 frameWindow: {}
417 })).toBe(false)
418 })
419 })
420
421 describe('pending playback request cleanup', () => {
422 it('clears and removes all pending fallback timers', () => {
423 const pending = { a: 1, b: 2 }
424 const cleared: number[] = []
425
426 clearPendingPlaybackRequestsForTest(pending, (id) => cleared.push(id))
427
428 expect(cleared).toEqual([1, 2])
429 expect(pending).toEqual({})
430 })
431 })
432
433 describe('wheel page navigation', () => {
434 const wheel = (deltaY: number, overrides: Partial<{
435 deltaX: number
436 deltaMode: number
437 ctrlKey: boolean
438 metaKey: boolean
439 editableTarget: boolean
440 deckSwitcherTarget: boolean
441 preventDefault: () => void
442 }> = {}) => ({
443 deltaX: overrides.deltaX ?? 0,
444 deltaY,
445 deltaMode: overrides.deltaMode ?? 0,
446 ctrlKey: overrides.ctrlKey,
447 metaKey: overrides.metaKey,
448 editableTarget: overrides.editableTarget,
449 deckSwitcherTarget: overrides.deckSwitcherTarget,
450 preventDefault: overrides.preventDefault ?? (() => {})
451 })
452
453 it('accumulates small trackpad deltas before navigating', () => {
454 let time = 1000
455 let prevented = 0
456 const navigator = createWheelNavigatorForTest({ now: () => time })
457
458 navigator.handle(wheel(30, { preventDefault: () => prevented++ }))
459 navigator.handle(wheel(30, { preventDefault: () => prevented++ }))
460 expect(navigator.offsets).toEqual([])
461
462 navigator.handle(wheel(25, { preventDefault: () => prevented++ }))
463 expect(navigator.offsets).toEqual([1])
464 expect(prevented).toBe(1)
465 })
466
467 it('uses upward wheel motion for previous page', () => {
468 const navigator = createWheelNavigatorForTest({ now: () => 1000 })
469
470 navigator.handle(wheel(-90))
471
472 expect(navigator.offsets).toEqual([-1])
473 })
474
475 it('locks a continuous wheel gesture so one trackpad swipe does not flip many pages', () => {
476 let time = 1000
477 let prevented = 0
478 const navigator = createWheelNavigatorForTest({ now: () => time })
479
480 navigator.handle(wheel(90, { preventDefault: () => prevented++ }))
481 time = 1100
482 navigator.handle(wheel(90, { preventDefault: () => prevented++ }))
483 time = 1700
484 navigator.handle(wheel(90, { preventDefault: () => prevented++ }))
485
486 expect(navigator.offsets).toEqual([1])
487 expect(prevented).toBe(3)
488 })
489
490 it('allows another page turn after the wheel gesture goes idle', () => {
491 let time = 1000
492 const navigator = createWheelNavigatorForTest({ now: () => time })
493
494 navigator.handle(wheel(90))
495 time = 1700
496 navigator.unlockGesture()
497 navigator.handle(wheel(90))
498
499 expect(navigator.offsets).toEqual([1, 1])
500 })
501
502 it('allows immediate reverse navigation even while the previous wheel direction is locked', () => {
503 let time = 1000
504 const navigator = createWheelNavigatorForTest({ now: () => time })
505
506 navigator.handle(wheel(90))
507 time = 1100
508 navigator.handle(wheel(-90))
509
510 expect(navigator.offsets).toEqual([1, -1])
511 })
512
513 it('does not keep the wheel locked when scrolling outward at a page boundary', () => {
514 let time = 1000
515 const navigator = createWheelNavigatorForTest({
516 now: () => time,
517 navigate: (offset) => offset < 0
518 })
519
520 navigator.handle(wheel(90))
521 time = 1100
522 navigator.handle(wheel(-90))
523
524 expect(navigator.offsets).toEqual([-1])
525 })
526
527 it('keeps zoom gestures, editable targets, and deck switcher wheel events untouched', () => {
528 const navigator = createWheelNavigatorForTest({ now: () => 1000 })
529
530 navigator.handle(wheel(100, { ctrlKey: true }))
531 navigator.handle(wheel(100, { metaKey: true }))
532 navigator.handle(wheel(100, { editableTarget: true }))
533 navigator.handle(wheel(100, { deckSwitcherTarget: true }))
534
535 expect(navigator.offsets).toEqual([])
536 })
537
538 it('normalizes line and page wheel deltas', () => {
539 expect(normalizeWheelDeltaForTest(wheel(6, { deltaMode: 1 }))).toBe(96)
540 expect(normalizeWheelDeltaForTest(wheel(-1, { deltaMode: 2 }))).toBe(-900)
541 expect(normalizeWheelDeltaForTest(wheel(10, { deltaX: -120 }))).toBe(-120)
542 })
543 })
544
545 describe('Transition type and direction resolution', () => {
546 it('all 16 types normalize correctly', () => {
547 for (const type of indexTransitionTypes) {
548 expect(normalizeIndexTransitionType(type)).toBe(type)
549 }
550 expect(normalizeIndexTransitionType('sparkle')).toBe('fade')
551 })
552
553 it('resolves reverse direction for previous-page navigation', () => {
554 expect(transitionDirection(1, 2)).toBe(1)
555 expect(transitionDirection(2, 1)).toBe(-1)
556 expect(slideEntryValue('slide-left', -1)).toBe('-100%')
557 expect(slideEntryValue('slide-up', -1)).toBe('-100%')
558 expect(slideEntryValue('push', -1)).toBe('-100%')
559 expect(slideEntryValue('flip', -1)).toBe('-72deg')
560 expect(slideEntryValue('wipe', -1)).toBe('inset(0 100% 0 0)')
561 })
562 })
563
564 describe('Transition duration clamping', () => {
565 it('clamps min 120ms', () => {
566 expect(clampTransitionDuration('fade', 50)).toBe(120)
567 expect(clampTransitionDuration('fade', 0)).toBe(120)
568 expect(clampTransitionDuration('fade', -100)).toBe(120)
569 })
570 it('clamps max 1200ms', () => {
571 expect(clampTransitionDuration('fade', 2000)).toBe(1200)
572 })
573 it('preserves valid values', () => {
574 expect(clampTransitionDuration('fade', 480)).toBe(480)
575 })
576 it('keeps none at 0ms', () => {
577 expect(clampTransitionDuration('none', 480)).toBe(0)
578 })
579 it('defaults to 600ms for undefined/NaN/Infinity', () => {
580 expect(clampTransitionDuration('fade', undefined)).toBe(600)
581 expect(clampTransitionDuration('fade', NaN)).toBe(600)
582 expect(clampTransitionDuration('fade', Infinity)).toBe(600)
583 })
584 it('rounds to integer', () => {
585 expect(clampTransitionDuration('fade', 333.7)).toBe(334)
586 })
587 })
588
589 describe('Reduced motion guard', () => {
590 it('uses no-op transition when reduced motion is preferred', () => {
591 const shouldAnimate = (reducedMotion: boolean, transitionType: string): boolean =>
592 transitionType !== 'none' && !reducedMotion
593
594 expect(shouldAnimate(true, 'fade')).toBe(false)
595 expect(shouldAnimate(false, 'none')).toBe(false)
596 expect(shouldAnimate(false, 'fade')).toBe(true)
597 })
598 })
599
600 describe('hasDataAnim / hasCustomPageAnimation coexistence logic', () => {
601 function hasDataAnim(html: string): boolean {
602 return /\bdata-anim\b/i.test(html)
603 }
604 function hasCustomPageAnimation(html: string): boolean {
605 return (
606 /(?:anime\s*\(|anime\.(?:createTimeline|timeline|animate|stagger)\s*\()/m.test(html) ||
607 /PPT\.(?:animate|stagger|createTimeline)\s*\(/m.test(html) ||
608 /data-(?:anime|animate)\b/i.test(html)
609 )
610 }
611 function shouldIncludeDefaultMotion(html: string): boolean {
612 return hasDataAnim(html) || !hasCustomPageAnimation(html)
613 }
614
615 it('includes default motion when only data-anim present', () => {
616 expect(shouldIncludeDefaultMotion('<div data-anim="fade-up">Hello</div>')).toBe(true)
617 })
618 it('includes default motion when neither data-anim nor PPT.animate present', () => {
619 expect(shouldIncludeDefaultMotion('<div class="card">Plain</div>')).toBe(true)
620 })
621 it('excludes default motion when only PPT.animate present (no data-anim)', () => {
622 expect(shouldIncludeDefaultMotion('<script>PPT.animate(".card", { opacity: [0,1] })</script>')).toBe(false)
623 })
624 it('includes default motion when BOTH data-anim and PPT.animate coexist', () => {
625 const html = '<div data-anim="fade-up">A</div><script>PPT.animate(".b", {})</script>'
626 expect(shouldIncludeDefaultMotion(html)).toBe(true)
627 })
628 it('data-anim is detected as separate from data-anime/data-animate', () => {
629 expect(hasDataAnim('<div data-anim="fade-up">A</div>')).toBe(true)
630 expect(hasDataAnim('<div data-anime="true">B</div>')).toBe(false)
631 expect(hasDataAnim('<div data-animate="true">C</div>')).toBe(false)
632 })
633 })
634
635 describe('Transition config JSON round-trip', () => {
636 it('all 16 types survive JSON round-trip', () => {
637 for (const type of indexTransitionTypes) {
638 const json = JSON.stringify({ type, durationMs: type === 'none' ? 0 : 480 })
639 const parsed = JSON.parse(json)
640 expect(parsed.type).toBe(type)
641 }
642 })
643 })
644
644 lines TYPESCRIPT