返回 oh-my-ppt
element-animation.test.ts
根目录 / tests / unit / animation / element-animation.test.ts
1 import { describe, expect, it } from 'vitest'
2 import {
3 parseElementAnimationConfig,
4 patchElementAnimationConfig
5 } from '../../../src/main/animation/element-animation'
6 import {
7 validateDataAnimContract,
8 validateDataAnimPatch
9 } from '../../../src/main/animation/data-anim-validator'
10
11 const selector = '[data-block-id="target"]'
12
13 function page(targetAttributes = '', sibling = ''): string {
14 return `<!doctype html><html><body><main data-page-id="page-1"><div data-block-id="target" ${targetAttributes}>Target</div>${sibling}</main></body></html>`
15 }
16
17 describe('element animation patcher', () => {
18 it('reads no animation as null', () => {
19 expect(parseElementAnimationConfig(page(), selector)).toBeNull()
20 })
21
22 it('sets a declarative animation and preserves valid advanced timing', () => {
23 const source = page(
24 'data-anim="fade" data-anim-trigger="after" data-anim-sequence="with" data-anim-delay="80" data-anim-stagger="90" data-anim-duration="450"'
25 )
26 const result = patchElementAnimationConfig(source, selector, { type: 'fade-up' })
27
28 expect(result.changed).toBe(true)
29 expect(result.config).toMatchObject({
30 type: 'fade-up',
31 trigger: 'after',
32 sequence: 'with',
33 delay: '80',
34 staggerMs: 90,
35 durationMs: 450
36 })
37 })
38
39 it('removes the full element animation contract when disabled', () => {
40 const source = page(
41 'data-anim="path" data-anim-trigger="click" data-anim-click-group="step-1" data-anim-duration="600" data-anim-path="M 0 0 L 120 30" data-anim-easing="linear"'
42 )
43 const result = patchElementAnimationConfig(source, selector, { type: null })
44
45 expect(result.config).toBeNull()
46 expect(result.html).not.toContain('data-anim')
47 })
48
49 it('removes path and direction attributes when switching to a non-directional type', () => {
50 const source = page(
51 'data-anim="path" data-anim-path="M 0 0 L 120 30" data-anim-from="left"'
52 )
53 const result = patchElementAnimationConfig(source, selector, { type: 'pulse' })
54
55 expect(result.html).toContain('data-anim="pulse"')
56 expect(result.html).not.toContain('data-anim-path')
57 expect(result.html).not.toContain('data-anim-from')
58 })
59
60 it('normalizes automatic and click trigger transitions', () => {
61 const automatic = page(
62 'data-anim="fade-up" data-anim-trigger="after" data-anim-sequence="with"'
63 )
64 const click = patchElementAnimationConfig(automatic, selector, { trigger: 'click' })
65 expect(click.config).toMatchObject({ trigger: 'click' })
66 expect(click.html).not.toContain('data-anim-sequence')
67
68 const groupedClick = page(
69 'data-anim="fade-up" data-anim-trigger="click" data-anim-click-group="step-1"'
70 )
71 const load = patchElementAnimationConfig(groupedClick, selector, { trigger: 'load' })
72 expect(load.config).toMatchObject({ trigger: 'load' })
73 expect(load.html).not.toContain('data-anim-trigger')
74 expect(load.html).not.toContain('data-anim-click-group')
75 })
76
77 it('keeps a no-op patch unchanged', () => {
78 const source = page('data-anim="fade-up" data-anim-duration="600"')
79 const result = patchElementAnimationConfig(source, selector, { durationMs: 600 })
80 expect(result.changed).toBe(false)
81 expect(result.html).toBe(source)
82 })
83
84 it('rejects invalid values and ambiguous selectors', () => {
85 expect(() =>
86 patchElementAnimationConfig(page('data-anim="fade"'), selector, {
87 durationMs: 80
88 })
89 ).toThrow('100-5000ms')
90 expect(() =>
91 patchElementAnimationConfig(
92 page('', '<div data-block-id="target">Duplicate</div>'),
93 selector,
94 { type: 'fade' }
95 )
96 ).toThrow('命中多个目标')
97 })
98
99 it('reads legacy PowerPoint trigger aliases without failing', () => {
100 const onClick = page('data-anim="fade" data-anim-trigger="on-click"')
101 expect(parseElementAnimationConfig(onClick, selector)?.trigger).toBe('click')
102 const afterPrevious = page('data-anim="fade" data-anim-trigger="after-previous"')
103 expect(parseElementAnimationConfig(afterPrevious, selector)?.trigger).toBe('after')
104 const withPrevious = page('data-anim="fade" data-anim-trigger="with-previous"')
105 expect(parseElementAnimationConfig(withPrevious, selector)?.trigger).toBe('with')
106 })
107 })
108
109 describe('data animation contract validation', () => {
110 it('allows automatic and click animations on the same page', () => {
111 const result = validateDataAnimContract(`
112 <div data-anim="fade-up">Automatic</div>
113 <div data-anim="exit-fade" data-anim-trigger="click">Click</div>
114 `)
115 expect(result).toEqual({ valid: true, errors: [] })
116 })
117
118 it('rejects sequence on click and non-contiguous click groups', () => {
119 const sequence = validateDataAnimContract(
120 '<div data-anim="fade" data-anim-trigger="click" data-anim-sequence="after">A</div>'
121 )
122 expect(sequence.errors.join('\n')).toContain('data-anim-sequence 仅用于自动动画顺序')
123
124 const groups = validateDataAnimContract(`
125 <div data-anim="fade" data-anim-trigger="click" data-anim-click-group="step">A</div>
126 <div data-anim="fade" data-anim-trigger="click">B</div>
127 <div data-anim="fade" data-anim-trigger="click" data-anim-click-group="step">C</div>
128 `)
129 expect(groups.errors.join('\n')).toContain('data-anim-click-group 必须在 click 动画的 DOM 顺序上连续出现')
130 })
131 })
132
133 describe('element animation edit validation scoping', () => {
134 it('does not block a targeted edit when a sibling already violates the contract', () => {
135 // Sibling carries a pre-existing runtime-only easing, unrelated to this edit.
136 const before = page(
137 '',
138 '<div data-anim="fade" data-anim-easing="easeOutCubic">Sibling</div>'
139 )
140 const result = patchElementAnimationConfig(before, selector, { type: 'pulse' })
141
142 expect(result.changed).toBe(true)
143 expect(result.html).toContain('data-anim="pulse"')
144 // The sibling's pre-existing easing appears in both before and after, so it cancels.
145 expect(validateDataAnimPatch(before, result.html).newErrors).toEqual([])
146 })
147
148 it('still flags contract violations newly introduced on the target', () => {
149 const before = page('data-anim="fade"')
150 // Simulate the patch introducing an illegal type on the target.
151 const after = page('data-anim="not-a-real-type"')
152 const { newErrors } = validateDataAnimPatch(before, after)
153 expect(newErrors.some((error) => error.includes('not-a-real-type'))).toBe(true)
154 })
155
156 it('flags page-level click-group continuity violations introduced by a target edit', () => {
157 const before = `
158 <div data-block-id="target" data-anim="fade" data-anim-trigger="click" data-anim-click-group="step">A</div>
159 <div data-block-id="middle" data-anim="fade" data-anim-trigger="click" data-anim-click-group="step">B</div>
160 <div data-block-id="tail" data-anim="fade" data-anim-trigger="click" data-anim-click-group="step">C</div>
161 `
162 const after = `
163 <div data-block-id="target" data-anim="fade" data-anim-trigger="click" data-anim-click-group="step">A</div>
164 <div data-block-id="middle" data-anim="fade" data-anim-trigger="click">B</div>
165 <div data-block-id="tail" data-anim="fade" data-anim-trigger="click" data-anim-click-group="step">C</div>
166 `
167 const { newErrors } = validateDataAnimPatch(before, after)
168 expect(newErrors.join('\n')).toContain('data-anim-click-group 必须在 click 动画的 DOM 顺序上连续出现')
169 })
170 })
171
171 lines TYPESCRIPT