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