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