返回 oh-my-ppt
animation-writer.test.ts
根目录 / tests / unit / html-pptx / animation-writer.test.ts
1 import * as cheerio from 'cheerio'
2 import { describe, expect, it } from 'vitest'
3 import {
4 buildSlideTimingXml,
5 type PptxTargetAnimation
6 } from '@arcsin1/html2pptx/animation'
7
8 const makeAnim = (overrides: Partial<PptxTargetAnimation> = {}): PptxTargetAnimation => ({
9 spid: 2,
10 type: 'fade-up',
11 trigger: 'load',
12 duration: 500,
13 delay: 0,
14 order: 0,
15 ...overrides
16 })
17
18 const parseTimingXml = (xml: string) => {
19 if (!xml) return null
20 return cheerio.load(xml, { xmlMode: true }, false)
21 }
22
23 describe('OOXML timing tree structure', () => {
24 it('has exactly one tmRoot containing one mainSeq', () => {
25 const xml = buildSlideTimingXml([makeAnim({ spid: 7, type: 'fade' })])
26 const $ = parseTimingXml(xml)
27 expect($).not.toBeNull()
28
29 const root = $('p\\:par p\\:cTn[nodeType="tmRoot"]')
30 expect(root.length).toBe(1)
31
32 const mainSeq = root.find('p\\:cTn[nodeType="mainSeq"]')
33 expect(mainSeq.length).toBe(1)
34 expect(mainSeq.parents('p\\:cTn[nodeType="tmRoot"]').length).toBeGreaterThanOrEqual(1)
35 })
36
37 it('every effect node has exactly one stCondLst child', () => {
38 const xml = buildSlideTimingXml([
39 makeAnim({ spid: 3, type: 'fade' }),
40 makeAnim({ spid: 4, type: 'pulse', trigger: 'click', order: 1 })
41 ])
42 const $ = parseTimingXml(xml)
43 expect($).not.toBeNull()
44
45 // Find all par-level cTn that contain presetID (effect containers)
46 $('p\\:cTn[presetID]').each((_, el) => {
47 const ctn = $(el)
48 const stCond = ctn.children('p\\:stCondLst')
49 expect(stCond.length, `effect node id=${ctn.attr('id')} has stCondLst`).toBe(1)
50 })
51 })
52
53 it('every effect node has exactly one childTnLst parent wrapping its children', () => {
54 const xml = buildSlideTimingXml([
55 makeAnim({ spid: 3, type: 'fade' }),
56 makeAnim({ spid: 4, type: 'scale-in', order: 1 })
57 ])
58 const $ = parseTimingXml(xml)
59 expect($).not.toBeNull()
60
61 $('p\\:cTn[presetID]').each((_, el) => {
62 const ctn = $(el)
63 const childList = ctn.children('p\\:childTnLst')
64 expect(childList.length, `effect node id=${ctn.attr('id')} has childTnLst`).toBe(1)
65 })
66 })
67
68 it('starts the main animation sequence immediately so load animations do not wait for a click', () => {
69 const xml = buildSlideTimingXml([
70 makeAnim({ spid: 3, type: 'fade-up', trigger: 'load' }),
71 makeAnim({ spid: 4, type: 'exit-fade', trigger: 'click', order: 1 })
72 ])
73 const $ = parseTimingXml(xml)
74 expect($).not.toBeNull()
75
76 expect(xml).not.toContain('<p:cond delay="indefinite"/>')
77 expect($('p\\:cTn[nodeType="withEffect"]').length).toBeGreaterThan(0)
78 expect($('p\\:cTn[nodeType="clickEffect"]').length).toBeGreaterThan(0)
79 })
80
81 it('emphasis effects contain a p:seq with two child animScale elements', () => {
82 const xml = buildSlideTimingXml([makeAnim({ spid: 3, type: 'pulse' })])
83 const $ = parseTimingXml(xml)
84 expect($).not.toBeNull()
85
86 // The timing tree has multiple <p:seq> elements:
87 // - One for the main sequence wrapper (mainSeq)
88 // - One nested inside the effect for emphasis rebound
89 // Find the seq that contains animScale (the emphasis rebound seq)
90 const reboundSeq = $('p\\:seq').filter((_, el) => $(el).find('p\\:animScale').length > 0)
91 expect(reboundSeq.length).toBeGreaterThanOrEqual(1)
92
93 // The rebound seq should contain exactly 2 animScale children
94 const scales = reboundSeq.find('p\\:animScale')
95 expect(scales.length).toBe(2)
96 })
97
98 it('emphasis rebound: first animScale uses fill="hold", second uses fill="remove"', () => {
99 const xml = buildSlideTimingXml([makeAnim({ spid: 5, type: 'pulse', duration: 800 })])
100 const $ = parseTimingXml(xml)
101 expect($).not.toBeNull()
102
103 const scales = $('p\\:animScale')
104 expect(scales.length).toBe(2)
105
106 const first = $(scales[0])
107 const firstTn = first.find('p\\:cTn[dur][fill]').first()
108 expect(firstTn.attr('fill')).toBe('hold')
109 expect(Number(firstTn.attr('dur'))).toBeGreaterThan(0)
110
111 const second = $(scales[1])
112 const secondTn = second.find('p\\:cTn[dur][fill]').first()
113 expect(secondTn.attr('fill')).toBe('remove')
114 expect(Number(secondTn.attr('dur'))).toBeGreaterThan(0)
115 })
116
117 it('emphasis rebound: second animScale transitions peak back to identity (x=100000, y=100000)', () => {
118 const xml = buildSlideTimingXml([makeAnim({ spid: 5, type: 'pulse', duration: 600 })])
119 const $ = parseTimingXml(xml)
120 expect($).not.toBeNull()
121
122 const scales = $('p\\:animScale')
123 const second = $(scales[1])
124 const toEl = second.find('p\\:to')
125 expect(toEl.length).toBe(1)
126 expect(toEl.attr('x')).toBe('100000')
127 expect(toEl.attr('y')).toBe('100000')
128 })
129
130 it('emphasis rebound: second phase duration equals first phase (half total each)', () => {
131 const totalDur = 1000
132 const xml = buildSlideTimingXml([makeAnim({ spid: 5, type: 'grow-shrink', duration: totalDur })])
133 const $ = parseTimingXml(xml)
134 expect($).not.toBeNull()
135
136 const scales = $('p\\:animScale')
137 const dur1 = Number($(scales[0]).find('p\\:cTn[dur][fill]').first().attr('dur'))
138 const dur2 = Number($(scales[1]).find('p\\:cTn[dur][fill]').first().attr('dur'))
139 expect(dur1 + dur2).toBeLessThanOrEqual(totalDur)
140 // Should be roughly half each (floor division may lose 1ms)
141 expect(Math.abs(dur1 - dur2)).toBeLessThanOrEqual(1)
142 })
143
144 it('emphasis seq is a child of the effect childTnLst (not top-level)', () => {
145 const xml = buildSlideTimingXml([makeAnim({ spid: 5, type: 'pulse-strong' })])
146 const $ = parseTimingXml(xml)
147 expect($).not.toBeNull()
148
149 // Find the emphasis rebound seq: a p:seq whose direct children include
150 // a p:cTn that directly contains p:childTnLst > p:animScale
151 const reboundSeq = $('p\\:seq').filter((_, el) => {
152 const $seq = $(el)
153 return $seq.children('p\\:cTn').children('p\\:childTnLst').children('p\\:animScale').length > 0
154 })
155 expect(reboundSeq.length).toBe(1)
156
157 // The seq must be inside a childTnLst that belongs to the effect par
158 const parentChildTn = reboundSeq.closest('p\\:childTnLst')
159 expect(parentChildTn.length).toBe(1)
160 // That childTnLst must be inside a cTn that has presetID
161 const parentCtn = parentChildTn.closest('p\\:cTn[presetID]')
162 expect(parentCtn.length).toBe(1)
163 })
164
165 it('non-emphasis scale animations do not use rebound p:seq', () => {
166 const xml = buildSlideTimingXml([makeAnim({ spid: 3, type: 'scale-in' })])
167 const $ = parseTimingXml(xml)
168 expect($).not.toBeNull()
169
170 // A rebound seq is a p:seq whose direct children include p:cTn > p:childTnLst > p:animScale
171 // Non-emphasis scale animations should NOT have this structure
172 const reboundSeq = $('p\\:seq').filter((_, el) => {
173 return $(el).children('p\\:cTn').children('p\\:childTnLst').children('p\\:animScale').length > 0
174 })
175 expect(reboundSeq.length).toBe(0)
176
177 // But there should still be exactly one animScale total
178 const allScales = $('p\\:animScale')
179 expect(allScales.length).toBe(1)
180 })
181
182 it('path animation generates two anim channels (x, y) and no animEffect', () => {
183 const xml = buildSlideTimingXml([
184 makeAnim({ spid: 8, type: 'path', path: 'M 0 0 L 120 30' })
185 ])
186 const $ = parseTimingXml(xml)
187 expect($).not.toBeNull()
188
189 const animNodes = $('p\\:anim[calcmode="lin"]')
190 expect(animNodes.length).toBe(2)
191
192 const xAnim = animNodes.filter((_, el) => $(el).find('p\\:attrName').text() === 'ppt_x')
193 const yAnim = animNodes.filter((_, el) => $(el).find('p\\:attrName').text() === 'ppt_y')
194 expect(xAnim.length).toBe(1)
195 expect(yAnim.length).toBe(1)
196
197 // No animEffect for path (P1-4)
198 expect($('p\\:animEffect').length).toBe(0)
199 })
200
201 it('fade animation generates exactly one animEffect with filter="fade"', () => {
202 const xml = buildSlideTimingXml([makeAnim({ spid: 3, type: 'fade' })])
203 const $ = parseTimingXml(xml)
204 expect($).not.toBeNull()
205
206 const effects = $('p\\:animEffect')
207 expect(effects.length).toBe(1)
208 expect(effects.attr('filter')).toBe('fade')
209 })
210
211 it('entrance animation channels use smooth ease-out timing', () => {
212 const xml = buildSlideTimingXml([makeAnim({ spid: 3, type: 'fade-up', duration: 600 })])
213 const $ = parseTimingXml(xml)
214 expect($).not.toBeNull()
215
216 const timedNodes = $('p\\:cTn[dur="600"][accel][decel]')
217 expect(timedNodes.length).toBeGreaterThanOrEqual(2)
218 timedNodes.each((_, el) => {
219 expect($(el).attr('accel')).toBe('0')
220 expect($(el).attr('decel')).toBe('70000')
221 })
222 })
223
224 it('exit animation channels use smooth ease-in timing', () => {
225 const xml = buildSlideTimingXml([
226 makeAnim({ spid: 3, type: 'exit-fly', trigger: 'click', duration: 600 })
227 ])
228 const $ = parseTimingXml(xml)
229 expect($).not.toBeNull()
230
231 const timedNodes = $('p\\:cTn[dur="600"][accel][decel]')
232 expect(timedNodes.length).toBeGreaterThanOrEqual(2)
233 timedNodes.each((_, el) => {
234 expect($(el).attr('accel')).toBe('70000')
235 expect($(el).attr('decel')).toBe('0')
236 })
237 })
238
239 it('emphasis rebound uses smooth ease-in-out timing on both phases', () => {
240 const xml = buildSlideTimingXml([makeAnim({ spid: 5, type: 'pulse', duration: 600 })])
241 const $ = parseTimingXml(xml)
242 expect($).not.toBeNull()
243
244 const scaleTimingNodes = $('p\\:animScale p\\:cTn[dur][accel][decel]')
245 expect(scaleTimingNodes.length).toBe(2)
246 scaleTimingNodes.each((_, el) => {
247 expect($(el).attr('accel')).toBe('20000')
248 expect($(el).attr('decel')).toBe('60000')
249 })
250 })
251
252 it('build list entries match unique target spids exactly', () => {
253 const xml = buildSlideTimingXml([
254 makeAnim({ spid: 7, type: 'fade-up', delay: 100 }),
255 makeAnim({ spid: 7, type: 'pulse', trigger: 'click', order: 1 }),
256 makeAnim({ spid: 8, type: 'fade', order: 2 })
257 ])
258 const $ = parseTimingXml(xml)
259 expect($).not.toBeNull()
260
261 const buildEntries = $('p\\:bldP')
262 expect(buildEntries.length).toBe(2) // spid 7 and spid 8
263 const spids = buildEntries.map((_, el) => $(el).attr('spid')).get()
264 expect(spids).toContain('7')
265 expect(spids).toContain('8')
266 })
267
268 it('tmRoot contains exactly one bldLst', () => {
269 const xml = buildSlideTimingXml([makeAnim({ spid: 7, type: 'fade' })])
270 const $ = parseTimingXml(xml)
271 expect($).not.toBeNull()
272
273 const bldLst = $('p\\:bldLst')
274 expect(bldLst.length).toBe(1)
275
276 // bldLst must be a direct child of timing
277 const timingParent = bldLst.closest('p\\:timing')
278 expect(timingParent.length).toBe(1)
279 })
280 })
281
282 describe('Decimal path precision in timing XML', () => {
283 it('emits fractional deltas for decimal path coordinates', () => {
284 // delta = end - start: 120.5 - 0.5 = 120 (x), 30.25 - 0 = 30.25 (y)
285 const xml = buildSlideTimingXml([
286 makeAnim({ spid: 8, type: 'path', path: 'M 0.5 0 L 120.5 30.25' })
287 ])
288 const $ = parseTimingXml(xml)
289 expect($).not.toBeNull()
290
291 const toValues = $('p\\:tav[tm="100000"] p\\:strVal')
292 const vals = toValues.map((_, el) => $(el).attr('val') || '').get()
293 // x delta = 120.5 - 0.5 = 120 (integer, no fraction)
294 expect(vals).toContain('#ppt_x+120')
295 // y delta = 30.25 - 0 = 30.25 (fractional)
296 expect(vals).toContain('#ppt_y+30.25')
297 })
298 })
299
299 lines TYPESCRIPT