返回 oh-my-ppt
cookbook-animation-verify.test.ts
根目录 / tests / unit / runtime / cookbook-animation-verify.test.ts
1 /**
2 * @vitest-environment happy-dom
3 *
4 * Verify every animation type from the cookbook produces correct anime.js params.
5 */
6 import { describe, it, expect, beforeEach, vi } from 'vitest'
7 import fs from 'fs'
8 import path from 'path'
9
10 const runtimeSrc = fs.readFileSync(
11 path.resolve(__dirname, '../../../resources/ppt-runtime.js'),
12 'utf-8'
13 )
14
15 function createMockAnime() {
16 const calls: Array<{ targets: unknown; params: Record<string, unknown> }> = []
17 const anime = {
18 animate: vi.fn((targets: unknown, params: Record<string, unknown>) => {
19 calls.push({ targets, params: { ...params } })
20 let resolveFinished!: () => void
21 const finished = new Promise<void>((r) => { resolveFinished = r })
22 return {
23 pause: vi.fn(),
24 play: vi.fn(),
25 complete: vi.fn(() => resolveFinished()),
26 finished
27 }
28 }),
29 stagger: vi.fn((gap: number) => (_el: unknown, i: number) => i * gap),
30 createTimeline: vi.fn(() => ({ add: vi.fn() })),
31 timeline: vi.fn(() => ({ add: vi.fn() }))
32 }
33 return { anime, calls }
34 }
35
36 function setupAndScan(html: string) {
37 const { anime, calls } = createMockAnime()
38 ;(globalThis as Record<string, unknown>).__ohmypptPlaybackBridgeInstalled = false
39 const existingPPT = (globalThis as Record<string, unknown>).PPT as Record<string, unknown> | undefined
40 if (existingPPT) existingPPT.__runtimeVersion = null
41 ;(globalThis as Record<string, unknown>).anime = anime
42 document.body.innerHTML = html
43 window.history.replaceState(null, '', '/page.html?pptPlayback=1')
44 new Function(runtimeSrc)()
45 const PPT = (globalThis as Record<string, unknown>).PPT as Record<string, unknown>
46 const root = document.querySelector('.ppt-page-root')
47 const config = (PPT.scanDataAnim as Function)(root)
48 if (config && config.load && config.load.length > 0) {
49 (PPT.executeDataAnim as Function)(config.load)
50 }
51 return { calls, PPT, config }
52 }
53
54 describe('cookbook animation verification', () => {
55 beforeEach(() => {
56 document.body.innerHTML = ''
57 })
58
59 // Entrance animations
60 const entranceTests: Array<{ type: string; extra?: string; expectParams: Record<string, unknown> }> = [
61 { type: 'fade', expectParams: { opacity: [0, 1] } },
62 { type: 'fade-up', expectParams: { opacity: [0, 1], translateY: [20, 0] } },
63 { type: 'fade-down', expectParams: { opacity: [0, 1], translateY: [-20, 0] } },
64 { type: 'fade-left', expectParams: { opacity: [0, 1], translateX: [20, 0] } },
65 { type: 'fade-right', expectParams: { opacity: [0, 1], translateX: [-20, 0] } },
66 { type: 'scale-in', expectParams: { opacity: [0, 1], scale: [0.85, 1] } },
67 { type: 'slide-up', expectParams: { opacity: [0, 1], translateY: [64, 0] } },
68 { type: 'slide-down', expectParams: { opacity: [0, 1], translateY: [-64, 0] } },
69 { type: 'slide-left', expectParams: { opacity: [0, 1], translateX: [72, 0] } },
70 { type: 'slide-right', expectParams: { opacity: [0, 1], translateX: [-72, 0] } },
71 { type: 'zoom-in', expectParams: { opacity: [0, 1], scale: [0.75, 1] } },
72 { type: 'spin-in', expectParams: { opacity: [0, 1], rotate: [-12, 0], scale: [0.92, 1] } },
73 { type: 'fly-in', extra: 'data-anim-from="left"', expectParams: { opacity: [0, 1], translateX: [-72, 0] } },
74 { type: 'fly-in', extra: 'data-anim-from="right"', expectParams: { opacity: [0, 1], translateX: [72, 0] } },
75 { type: 'fly-in', extra: 'data-anim-from="top"', expectParams: { opacity: [0, 1], translateY: [-72, 0] } },
76 { type: 'fly-in', extra: 'data-anim-from="bottom"', expectParams: { opacity: [0, 1], translateY: [72, 0] } },
77 { type: 'fly-in', extra: 'data-anim-from="center"', expectParams: { opacity: [0, 1], scale: [0.9, 1] } },
78 { type: 'wipe', extra: 'data-anim-from="left"', expectParams: { opacity: [0, 1] } }, // clipPath checked separately
79 { type: 'wipe', extra: 'data-anim-from="right"', expectParams: { opacity: [0, 1] } },
80 ]
81
82 for (const t of entranceTests) {
83 const label = t.extra ? `${t.type} ${t.extra}` : t.type
84 it(`entrance: ${label}`, () => {
85 const html = `<div class="ppt-page-root"><div data-anim="${t.type}" ${t.extra || ''}>test</div></div>`
86 const { calls } = setupAndScan(html)
87 expect(calls.length).toBe(1)
88 for (const [key, val] of Object.entries(t.expectParams)) {
89 expect(calls[0].params[key]).toEqual(val)
90 }
91 })
92 }
93
94 // Wipe clip-path specifics
95 it('wipe from=left has correct clipPath', () => {
96 const html = `<div class="ppt-page-root"><div data-anim="wipe" data-anim-from="left">test</div></div>`
97 const { calls } = setupAndScan(html)
98 const cp = calls[0].params.clipPath as unknown[]
99 expect(cp[0]).toContain('inset(0% 100% 0% 0%)')
100 expect(cp[1]).toContain('inset(0% 0% 0% 0%)')
101 })
102
103 it('wipe from=right has correct clipPath', () => {
104 const html = `<div class="ppt-page-root"><div data-anim="wipe" data-anim-from="right">test</div></div>`
105 const { calls } = setupAndScan(html)
106 const cp = calls[0].params.clipPath as unknown[]
107 expect(cp[0]).toContain('inset(0% 0% 0% 100%)')
108 expect(cp[1]).toContain('inset(0% 0% 0% 0%)')
109 })
110
111 // Path animation
112 it('path: M 0 0 L 120 30 produces correct translateX/translateY', () => {
113 const html = `<div class="ppt-page-root"><div data-anim="path" data-anim-path="M 0 0 L 120 30">test</div></div>`
114 const { calls } = setupAndScan(html)
115 expect(calls.length).toBe(1)
116 expect(calls[0].params.translateX).toEqual([0, 120])
117 expect(calls[0].params.translateY).toEqual([0, 30])
118 })
119
120 it('path: M 0 0 L 0 -150 produces vertical motion', () => {
121 const html = `<div class="ppt-page-root"><div data-anim="path" data-anim-path="M 0 0 L 0 -150">test</div></div>`
122 const { calls } = setupAndScan(html)
123 expect(calls[0].params.translateX).toEqual([0, 0])
124 expect(calls[0].params.translateY).toEqual([0, -150])
125 })
126
127 // Emphasis animations
128 const emphasisTests: Array<{ type: string; expectParams: Record<string, unknown> }> = [
129 { type: 'pulse-soft', expectParams: { scale: [1, 1.03, 1] } },
130 { type: 'pulse', expectParams: { scale: [1, 1.06, 1] } },
131 { type: 'pulse-strong', expectParams: { scale: [1, 1.1, 1] } },
132 { type: 'grow-shrink-soft', expectParams: { scale: [0.95, 1.04, 1] } },
133 { type: 'grow-shrink', expectParams: { scale: [0.9, 1.08, 1] } },
134 { type: 'grow-shrink-strong', expectParams: { scale: [0.85, 1.12, 1] } },
135 ]
136
137 for (const t of emphasisTests) {
138 it(`emphasis: ${t.type}`, () => {
139 const html = `<div class="ppt-page-root"><div data-anim="${t.type}">test</div></div>`
140 const { calls } = setupAndScan(html)
141 expect(calls.length).toBe(1)
142 for (const [key, val] of Object.entries(t.expectParams)) {
143 expect(calls[0].params[key]).toEqual(val)
144 }
145 })
146 }
147
148 // Exit animations
149 const exitTests: Array<{ type: string; extra?: string; expectParams: Record<string, unknown> }> = [
150 { type: 'exit-fade', expectParams: { opacity: [1, 0] } },
151 { type: 'exit-scale', expectParams: { opacity: [1, 0], scale: [1, 0.85] } },
152 { type: 'exit-zoom', expectParams: { opacity: [1, 0], scale: [1, 0.75] } },
153 { type: 'exit-fly', extra: 'data-anim-from="left"', expectParams: { opacity: [1, 0], translateX: [0, -40] } },
154 { type: 'exit-fly', extra: 'data-anim-from="right"', expectParams: { opacity: [1, 0], translateX: [0, 40] } },
155 { type: 'exit-fly', extra: 'data-anim-from="top"', expectParams: { opacity: [1, 0], translateY: [0, -40] } },
156 { type: 'exit-fly', extra: 'data-anim-from="bottom"', expectParams: { opacity: [1, 0], translateY: [0, 40] } },
157 ]
158
159 for (const t of exitTests) {
160 const label = t.extra ? `${t.type} ${t.extra}` : t.type
161 it(`exit: ${label}`, () => {
162 const html = `<div class="ppt-page-root"><div data-anim="${t.type}" ${t.extra || ''}>test</div></div>`
163 const { calls } = setupAndScan(html)
164 expect(calls.length).toBe(1)
165 for (const [key, val] of Object.entries(t.expectParams)) {
166 expect(calls[0].params[key]).toEqual(val)
167 }
168 })
169 }
170
171 // Exit-wipe clipPath
172 it('exit-wipe from=left has reverse clipPath', () => {
173 const html = `<div class="ppt-page-root"><div data-anim="exit-wipe" data-anim-from="left">test</div></div>`
174 const { calls } = setupAndScan(html)
175 const cp = calls[0].params.clipPath as unknown[]
176 expect(cp[0]).toContain('inset(0% 0% 0% 0%)')
177 expect(cp[1]).toContain('inset(0% 100% 0% 0%)')
178 })
179
180 // Stagger
181 it('stagger: 3 fade-up cards with stagger=100 produce sequential delays', () => {
182 const html = `<div class="ppt-page-root">
183 <div data-anim="fade-up" data-anim-stagger="100">A</div>
184 <div data-anim="fade-up" data-anim-stagger="100">B</div>
185 <div data-anim="fade-up" data-anim-stagger="100">C</div>
186 </div>`
187 const { calls } = setupAndScan(html)
188 expect(calls.length).toBe(3)
189 expect(calls[0].params.delay).toBe(0)
190 expect(calls[1].params.delay).toBe(100)
191 expect(calls[2].params.delay).toBe(200)
192 })
193
194 // Sequence="after"
195 it('sequence="after": second element delays after first duration', () => {
196 const html = `<div class="ppt-page-root">
197 <div data-anim="fade-up" data-anim-duration="600" id="first">First</div>
198 <div data-anim="fade-up" data-anim-sequence="after" data-anim-duration="500" id="second">Second</div>
199 </div>`
200 const { calls } = setupAndScan(html)
201 expect(calls.length).toBe(2)
202 expect(calls[0].params.duration).toBe(600)
203 // "after" means delay = previous duration
204 expect(calls[1].params.delay).toBe(600)
205 })
206
207 // Click trigger
208 it('click-triggered animations are not auto-played', () => {
209 const html = `<div class="ppt-page-root">
210 <div data-anim="fade-up">Auto</div>
211 <div data-anim="fade-up" data-anim-trigger="click">Manual</div>
212 </div>`
213 const { calls, config } = setupAndScan(html)
214 // Only 1 auto-played (the non-click one)
215 expect(calls.length).toBe(1)
216 expect(config.click.length).toBe(1)
217 })
218
219 // Duration override
220 it('data-anim-duration overrides default', () => {
221 const html = `<div class="ppt-page-root"><div data-anim="zoom-in" data-anim-duration="800">test</div></div>`
222 const { calls } = setupAndScan(html)
223 expect(calls[0].params.duration).toBe(800)
224 })
225 })
226
226 lines TYPESCRIPT