@flighthq/tween 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/colorTween.d.ts +14 -0
  2. package/dist/colorTween.d.ts.map +1 -0
  3. package/dist/colorTween.js +28 -0
  4. package/dist/colorTween.js.map +1 -0
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +8 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/internal.d.ts +3 -0
  10. package/dist/internal.d.ts.map +1 -0
  11. package/dist/internal.js +18 -0
  12. package/dist/internal.js.map +1 -0
  13. package/dist/timer.d.ts +3 -0
  14. package/dist/timer.d.ts.map +1 -0
  15. package/dist/timer.js +5 -0
  16. package/dist/timer.js.map +1 -0
  17. package/dist/tween.d.ts +20 -0
  18. package/dist/tween.d.ts.map +1 -0
  19. package/dist/tween.js +194 -0
  20. package/dist/tween.js.map +1 -0
  21. package/dist/tweenManager.d.ts +5 -0
  22. package/dist/tweenManager.d.ts.map +1 -0
  23. package/dist/tweenManager.js +10 -0
  24. package/dist/tweenManager.js.map +1 -0
  25. package/dist/tweenProgress.d.ts +33 -0
  26. package/dist/tweenProgress.d.ts.map +1 -0
  27. package/dist/tweenProgress.js +87 -0
  28. package/dist/tweenProgress.js.map +1 -0
  29. package/dist/tweenStagger.d.ts +27 -0
  30. package/dist/tweenStagger.d.ts.map +1 -0
  31. package/dist/tweenStagger.js +51 -0
  32. package/dist/tweenStagger.js.map +1 -0
  33. package/dist/updateTweens.d.ts +4 -0
  34. package/dist/updateTweens.d.ts.map +1 -0
  35. package/dist/updateTweens.js +78 -0
  36. package/dist/updateTweens.js.map +1 -0
  37. package/package.json +39 -0
  38. package/src/colorTween.test.ts +48 -0
  39. package/src/timer.test.ts +56 -0
  40. package/src/tween.test.ts +385 -0
  41. package/src/tweenManager.test.ts +52 -0
  42. package/src/tweenProgress.test.ts +197 -0
  43. package/src/tweenStagger.test.ts +94 -0
  44. package/src/updateTweens.test.ts +292 -0
@@ -0,0 +1,197 @@
1
+ import { connectSignal } from '@flighthq/signals';
2
+
3
+ import { createTween } from './tween';
4
+ import { createTweenManager } from './tweenManager';
5
+ import { getTweenProgress, invalidateTween, restartTween, seekTween, setTweenProgress } from './tweenProgress';
6
+ import { updateTweens } from './updateTweens';
7
+
8
+ describe('getTweenProgress', () => {
9
+ it('returns 0 before any update', () => {
10
+ const manager = createTweenManager();
11
+ const tween = createTween(manager, { x: 0 }, 1000, { x: 100 });
12
+ expect(getTweenProgress(tween)).toBe(0);
13
+ });
14
+ it('returns 0 while still in delay phase', () => {
15
+ const manager = createTweenManager();
16
+ const tween = createTween(manager, { x: 0 }, 1000, { x: 100 }, { delay: 500 });
17
+ updateTweens(manager, 400);
18
+ expect(getTweenProgress(tween)).toBe(0);
19
+ });
20
+ it('returns 0.5 at the halfway point', () => {
21
+ const manager = createTweenManager();
22
+ const tween = createTween(manager, { x: 0 }, 1000, { x: 100 }, { ease: (t) => t });
23
+ updateTweens(manager, 500);
24
+ expect(getTweenProgress(tween)).toBeCloseTo(0.5);
25
+ });
26
+ it('returns 1 when complete', () => {
27
+ const manager = createTweenManager();
28
+ const tween = createTween(manager, { x: 0 }, 1000, { x: 100 });
29
+ updateTweens(manager, 1000);
30
+ expect(getTweenProgress(tween)).toBe(1);
31
+ });
32
+ });
33
+
34
+ describe('invalidateTween', () => {
35
+ it('resets initialized, complete, and elapsed', () => {
36
+ const manager = createTweenManager();
37
+ const tween = createTween(manager, { x: 0 }, 1000, { x: 100 }, { ease: (t) => t });
38
+ updateTweens(manager, 1000);
39
+ expect(tween.complete).toBe(true);
40
+ invalidateTween(tween);
41
+ expect(tween.initialized).toBe(false);
42
+ expect(tween.complete).toBe(false);
43
+ expect(tween.elapsed).toBe(0);
44
+ });
45
+ it('re-captures start values from current target state on next update', () => {
46
+ const manager = createTweenManager();
47
+ const target = { x: 0 };
48
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
49
+ updateTweens(manager, 1000); // x is now 100
50
+ invalidateTween(tween);
51
+ // Re-register: the new start is 100
52
+ manager.tweens.get(target)?.push(tween);
53
+ updateTweens(manager, 1000); // tween from 100 to 100 (end is absolute 100, so change is 0)
54
+ expect(target.x).toBe(100);
55
+ });
56
+ });
57
+
58
+ describe('restartTween', () => {
59
+ it('resets complete and elapsed to zero', () => {
60
+ const manager = createTweenManager();
61
+ const tween = createTween(manager, { x: 0 }, 1000, { x: 100 }, { ease: (t) => t });
62
+ updateTweens(manager, 1000);
63
+ expect(tween.complete).toBe(true);
64
+ restartTween(tween);
65
+ expect(tween.complete).toBe(false);
66
+ expect(tween.elapsed).toBe(0);
67
+ });
68
+ it('includeDelay=false skips the initial delay', () => {
69
+ const manager = createTweenManager();
70
+ const tween = createTween(manager, { x: 0 }, 1000, { x: 100 }, { delay: 500 });
71
+ restartTween(tween, false);
72
+ expect(tween.elapsed).toBe(500); // elapsed = delay so active immediately
73
+ });
74
+ it('keeps initialized=false so start values are re-captured', () => {
75
+ const manager = createTweenManager();
76
+ const tween = createTween(manager, { x: 0 }, 1000, { x: 100 });
77
+ updateTweens(manager, 500); // gets initialized
78
+ restartTween(tween);
79
+ expect(tween.initialized).toBe(false);
80
+ });
81
+ });
82
+
83
+ describe('seekTween', () => {
84
+ it('immediately applies property values at the given time', () => {
85
+ const manager = createTweenManager();
86
+ const target = { x: 0 };
87
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
88
+ seekTween(tween, 500);
89
+ expect(target.x).toBeCloseTo(50);
90
+ });
91
+ it('clamps to 0 when time is negative', () => {
92
+ const manager = createTweenManager();
93
+ const target = { x: 0 };
94
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
95
+ seekTween(tween, -100);
96
+ expect(target.x).toBe(0);
97
+ });
98
+ it('clamps to the end value when time exceeds duration', () => {
99
+ const manager = createTweenManager();
100
+ const target = { x: 0 };
101
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
102
+ seekTween(tween, 9999);
103
+ expect(target.x).toBe(100);
104
+ expect(tween.complete).toBe(true);
105
+ });
106
+ it('accounts for delay in the elapsed time', () => {
107
+ const manager = createTweenManager();
108
+ const target = { x: 0 };
109
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t, delay: 500 });
110
+ seekTween(tween, 1000); // delay=500 + 500ms active = 50%
111
+ expect(target.x).toBeCloseTo(50);
112
+ });
113
+ it('emits onUpdate when applying values', () => {
114
+ const manager = createTweenManager();
115
+ const tween = createTween(manager, { x: 0 }, 1000, { x: 100 });
116
+ let updates = 0;
117
+ connectSignal(tween.onUpdate, () => updates++);
118
+ seekTween(tween, 500);
119
+ expect(updates).toBe(1);
120
+ });
121
+ it('fires onComplete when seeking to exact end (delay + duration)', () => {
122
+ const manager = createTweenManager();
123
+ const target = { x: 0 };
124
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t, delay: 200 });
125
+ let fired = 0;
126
+ connectSignal(tween.onComplete, () => fired++);
127
+ seekTween(tween, 1200);
128
+ expect(fired).toBe(1);
129
+ expect(tween.complete).toBe(true);
130
+ });
131
+ it('does not fire onComplete when seeking just before the end', () => {
132
+ const manager = createTweenManager();
133
+ const target = { x: 0 };
134
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t, delay: 200 });
135
+ let fired = 0;
136
+ connectSignal(tween.onComplete, () => fired++);
137
+ seekTween(tween, 1199.999);
138
+ expect(fired).toBe(0);
139
+ expect(tween.complete).toBe(false);
140
+ });
141
+ it('is alias-safe: out === input does not produce wrong values', () => {
142
+ // The tween's target is both the input (start values are read from it) and the output.
143
+ // initializeTween reads `target.x` then we write to `target.x`.
144
+ const manager = createTweenManager();
145
+ const target = { x: 0 };
146
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
147
+ seekTween(tween, 500);
148
+ expect(target.x).toBeCloseTo(50);
149
+ // Calling again should be consistent
150
+ seekTween(tween, 750);
151
+ expect(target.x).toBeCloseTo(75);
152
+ });
153
+ });
154
+
155
+ describe('setTweenProgress', () => {
156
+ it('applies the progress fraction to target values', () => {
157
+ const manager = createTweenManager();
158
+ const target = { x: 0 };
159
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
160
+ setTweenProgress(tween, 0.5);
161
+ expect(target.x).toBeCloseTo(50);
162
+ });
163
+ it('clamps progress to 0', () => {
164
+ const manager = createTweenManager();
165
+ const target = { x: 0 };
166
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
167
+ setTweenProgress(tween, -1);
168
+ expect(target.x).toBe(0);
169
+ });
170
+ it('clamps progress to 1 and marks tween complete', () => {
171
+ const manager = createTweenManager();
172
+ const target = { x: 0 };
173
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
174
+ setTweenProgress(tween, 2);
175
+ expect(target.x).toBe(100);
176
+ expect(tween.complete).toBe(true);
177
+ });
178
+ it('fires onComplete when setting progress to 1', () => {
179
+ const manager = createTweenManager();
180
+ const target = { x: 0 };
181
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
182
+ let fired = 0;
183
+ connectSignal(tween.onComplete, () => fired++);
184
+ setTweenProgress(tween, 1);
185
+ expect(fired).toBe(1);
186
+ expect(tween.complete).toBe(true);
187
+ });
188
+ it('is alias-safe: multiple sequential calls produce consistent results', () => {
189
+ const manager = createTweenManager();
190
+ const target = { x: 0 };
191
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
192
+ setTweenProgress(tween, 0.25);
193
+ expect(target.x).toBeCloseTo(25);
194
+ setTweenProgress(tween, 0.75);
195
+ expect(target.x).toBeCloseTo(75);
196
+ });
197
+ });
@@ -0,0 +1,94 @@
1
+ import { createTweenManager } from './tweenManager';
2
+ import { createTweenStagger } from './tweenStagger';
3
+ import { updateTweens } from './updateTweens';
4
+
5
+ describe('createTweenStagger', () => {
6
+ it('returns an empty array for empty targets', () => {
7
+ const manager = createTweenManager();
8
+ const targets: { x: number }[] = [];
9
+ expect(createTweenStagger(manager, targets, 1000, {})).toEqual([]);
10
+ });
11
+
12
+ it('returns one tween per target', () => {
13
+ const manager = createTweenManager();
14
+ const targets = [{ x: 0 }, { x: 0 }, { x: 0 }];
15
+ const tweens = createTweenStagger(manager, targets, 1000, { x: 100 });
16
+ expect(tweens).toHaveLength(3);
17
+ });
18
+
19
+ it('each tween targets the corresponding element', () => {
20
+ const manager = createTweenManager();
21
+ const targets = [{ x: 0 }, { x: 0 }];
22
+ const tweens = createTweenStagger(manager, targets, 1000, { x: 100 });
23
+ expect(tweens[0].target).toBe(targets[0]);
24
+ expect(tweens[1].target).toBe(targets[1]);
25
+ });
26
+
27
+ it('staggers start delays by each interval (from: start default)', () => {
28
+ const manager = createTweenManager();
29
+ const targets = [{ x: 0 }, { x: 0 }, { x: 0 }];
30
+ // Using ms units throughout. each=200ms.
31
+ // Formula: position[i] = i/(count-1), delay = position * each * (count-1) = i * each
32
+ const tweens = createTweenStagger(manager, targets, 1000, { x: 100 }, { each: 200 });
33
+ expect(tweens[0].delay).toBeCloseTo(0);
34
+ expect(tweens[1].delay).toBeCloseTo(200);
35
+ expect(tweens[2].delay).toBeCloseTo(400);
36
+ });
37
+
38
+ it('from: end reverses the stagger order', () => {
39
+ const manager = createTweenManager();
40
+ const targets = [{ x: 0 }, { x: 0 }, { x: 0 }];
41
+ // each=100ms. from end: last target delay=0, first target delay=200ms
42
+ const tweens = createTweenStagger(manager, targets, 1000, { x: 100 }, { each: 100, from: 'end' });
43
+ expect(tweens[2].delay).toBeCloseTo(0);
44
+ expect(tweens[0].delay).toBeCloseTo(200);
45
+ });
46
+
47
+ it('from: center staggers outward from the middle', () => {
48
+ const manager = createTweenManager();
49
+ const targets = [{ x: 0 }, { x: 0 }, { x: 0 }, { x: 0 }, { x: 0 }];
50
+ // center index = 2. Distances: [2,1,0,1,2]. Max=2.
51
+ // positions: [1, 0.5, 0, 0.5, 1] * each * (count-1) = pos * 100 * 4
52
+ const tweens = createTweenStagger(manager, targets, 1000, { x: 100 }, { each: 100, from: 'center' });
53
+ expect(tweens[2].delay).toBeCloseTo(0);
54
+ expect(tweens[0].delay).toBeCloseTo(400);
55
+ expect(tweens[4].delay).toBeCloseTo(400);
56
+ });
57
+
58
+ it('from: numeric index staggers outward from that index', () => {
59
+ const manager = createTweenManager();
60
+ const targets = [{ x: 0 }, { x: 0 }, { x: 0 }];
61
+ // Origin index 0, max distance=2. positions: [0, 0.5, 1] * each * (count-1) = pos * 100 * 2
62
+ const tweens = createTweenStagger(manager, targets, 1000, { x: 100 }, { each: 100, from: 0 });
63
+ expect(tweens[0].delay).toBeCloseTo(0);
64
+ expect(tweens[1].delay).toBeCloseTo(100);
65
+ expect(tweens[2].delay).toBeCloseTo(200);
66
+ });
67
+
68
+ it('adds baseDelay from options to all stagger delays', () => {
69
+ const manager = createTweenManager();
70
+ const targets = [{ x: 0 }, { x: 0 }];
71
+ // each=100ms, base delay=500ms
72
+ const tweens = createTweenStagger(manager, targets, 1000, { x: 100 }, { each: 100 }, { delay: 500 });
73
+ expect(tweens[0].delay).toBeCloseTo(500);
74
+ expect(tweens[1].delay).toBeCloseTo(600);
75
+ });
76
+
77
+ it('actually animates targets with staggered timing', () => {
78
+ const manager = createTweenManager();
79
+ const targets = [{ x: 0 }, { x: 0 }];
80
+ // each=200ms: target[0] starts at 0ms, target[1] starts at 200ms
81
+ createTweenStagger(manager, targets, 1000, { x: 100 }, { each: 200 }, { ease: (t) => t });
82
+ updateTweens(manager, 100); // target[0] at 100/1000=10%; target[1] delay=200 not reached
83
+ expect(targets[0].x).toBeCloseTo(10);
84
+ expect(targets[1].x).toBe(0); // still in delay
85
+ });
86
+
87
+ it('passes duration correctly to each tween', () => {
88
+ const manager = createTweenManager();
89
+ const targets = [{ x: 0 }, { x: 0 }];
90
+ const tweens = createTweenStagger(manager, targets, 2000, { x: 100 });
91
+ expect(tweens[0].duration).toBe(2000);
92
+ expect(tweens[1].duration).toBe(2000);
93
+ });
94
+ });
@@ -0,0 +1,292 @@
1
+ import { connectSignal } from '@flighthq/signals';
2
+
3
+ import { createTween, pauseTween, resumeTween } from './tween';
4
+ import { createTweenManager } from './tweenManager';
5
+ import { completeTween, updateTweens } from './updateTweens';
6
+
7
+ describe('completeTween', () => {
8
+ it('jumps target to end value and marks tween complete', () => {
9
+ const manager = createTweenManager();
10
+ const target = { x: 0 };
11
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
12
+ completeTween(tween);
13
+ expect(target.x).toBe(100);
14
+ expect(tween.complete).toBe(true);
15
+ });
16
+
17
+ it('emits onComplete', () => {
18
+ const manager = createTweenManager();
19
+ const target = { x: 0 };
20
+ const tween = createTween(manager, target, 1000, { x: 100 });
21
+ let fired = 0;
22
+ connectSignal(tween.onComplete, () => fired++);
23
+ completeTween(tween);
24
+ expect(fired).toBe(1);
25
+ });
26
+
27
+ it('works on an uninitialized tween (before first update)', () => {
28
+ const manager = createTweenManager();
29
+ const target = { x: 25 };
30
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
31
+ expect(tween.initialized).toBe(false);
32
+ completeTween(tween);
33
+ expect(target.x).toBe(100);
34
+ });
35
+
36
+ it('lands on start value when reverse=true', () => {
37
+ const manager = createTweenManager();
38
+ const target = { x: 0 };
39
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t, reverse: true });
40
+ completeTween(tween);
41
+ expect(target.x).toBe(0); // reverse completes at start (0), not end (100)
42
+ expect(tween.complete).toBe(true);
43
+ });
44
+
45
+ it('is a no-op on an already complete tween', () => {
46
+ const manager = createTweenManager();
47
+ const target = { x: 0 };
48
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
49
+ completeTween(tween);
50
+ target.x = 0;
51
+ completeTween(tween);
52
+ expect(target.x).toBe(0);
53
+ });
54
+ });
55
+
56
+ describe('smartRotation', () => {
57
+ it('takes the short path for a large positive delta', () => {
58
+ const manager = createTweenManager();
59
+ const target = { rotation: 0 };
60
+ // 350° forward is the same as -10° backward — smart path is -10
61
+ createTween(manager, target, 1000, { rotation: 350 }, { ease: (t) => t, smartRotation: true });
62
+ updateTweens(manager, 500);
63
+ expect(target.rotation).toBeCloseTo(-5);
64
+ });
65
+
66
+ it('takes the short path for a large negative delta', () => {
67
+ const manager = createTweenManager();
68
+ const target = { rotation: 0 };
69
+ // -350° backward is the same as +10° forward — smart path is +10
70
+ createTween(manager, target, 1000, { rotation: -350 }, { ease: (t) => t, smartRotation: true });
71
+ updateTweens(manager, 500);
72
+ expect(target.rotation).toBeCloseTo(5);
73
+ });
74
+
75
+ it('finds the short path when starting from a non-zero angle', () => {
76
+ const manager = createTweenManager();
77
+ const target = { rotation: 350 };
78
+ // Naive delta: 10 - 350 = -340°. Smart path: +20° (go forward, not backward).
79
+ // start=350, change=+20, midpoint: 360, end: 370
80
+ createTween(manager, target, 1000, { rotation: 10 }, { ease: (t) => t, smartRotation: true });
81
+ updateTweens(manager, 500);
82
+ expect(target.rotation).toBeCloseTo(360);
83
+ updateTweens(manager, 500);
84
+ expect(target.rotation).toBeCloseTo(370);
85
+ });
86
+
87
+ it('leaves small deltas unchanged', () => {
88
+ const manager = createTweenManager();
89
+ const target = { rotation: 0 };
90
+ createTween(manager, target, 1000, { rotation: 90 }, { ease: (t) => t, smartRotation: true });
91
+ updateTweens(manager, 500);
92
+ expect(target.rotation).toBeCloseTo(45);
93
+ });
94
+ });
95
+
96
+ describe('updateTweens', () => {
97
+ it('interpolates property toward target value', () => {
98
+ const manager = createTweenManager();
99
+ const target = { x: 0 };
100
+ createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
101
+ updateTweens(manager, 500);
102
+ expect(target.x).toBeCloseTo(50);
103
+ });
104
+
105
+ it('reaches target value at end of duration', () => {
106
+ const manager = createTweenManager();
107
+ const target = { x: 0 };
108
+ createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
109
+ updateTweens(manager, 1000);
110
+ expect(target.x).toBe(100);
111
+ });
112
+
113
+ it('animates multiple properties independently to their respective targets', () => {
114
+ const manager = createTweenManager();
115
+ const target = { x: 0, y: 0 };
116
+ createTween(manager, target, 1000, { x: 100, y: 200 }, { ease: (t) => t });
117
+ updateTweens(manager, 1000);
118
+ expect(target.x).toBe(100);
119
+ expect(target.y).toBe(200);
120
+ });
121
+
122
+ it('marks tween complete after duration', () => {
123
+ const manager = createTweenManager();
124
+ const target = { x: 0 };
125
+ const tween = createTween(manager, target, 1000, { x: 100 });
126
+ updateTweens(manager, 1000);
127
+ expect(tween.complete).toBe(true);
128
+ });
129
+
130
+ it('removes completed tweens from manager', () => {
131
+ const manager = createTweenManager();
132
+ const target = { x: 0 };
133
+ createTween(manager, target, 1000, { x: 100 });
134
+ updateTweens(manager, 1000);
135
+ updateTweens(manager, 0);
136
+ expect(manager.tweens.has(target)).toBe(false);
137
+ });
138
+
139
+ it('emits onComplete when finished', () => {
140
+ const manager = createTweenManager();
141
+ const target = { x: 0 };
142
+ const tween = createTween(manager, target, 1000, { x: 100 });
143
+ let fired = 0;
144
+ connectSignal(tween.onComplete, () => fired++);
145
+ updateTweens(manager, 1000);
146
+ expect(fired).toBe(1);
147
+ });
148
+
149
+ it('emits onUpdate each tick', () => {
150
+ const manager = createTweenManager();
151
+ const target = { x: 0 };
152
+ const tween = createTween(manager, target, 1000, { x: 100 });
153
+ let updates = 0;
154
+ connectSignal(tween.onUpdate, () => updates++);
155
+ updateTweens(manager, 250);
156
+ updateTweens(manager, 250);
157
+ expect(updates).toBe(2);
158
+ });
159
+
160
+ it('respects delay before starting', () => {
161
+ const manager = createTweenManager();
162
+ const target = { x: 0 };
163
+ createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t, delay: 500 });
164
+ updateTweens(manager, 500);
165
+ expect(target.x).toBe(0);
166
+ updateTweens(manager, 500);
167
+ expect(target.x).toBeCloseTo(50);
168
+ });
169
+
170
+ it('repeats the specified number of times', () => {
171
+ const manager = createTweenManager();
172
+ const target = { x: 0 };
173
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t, repeat: 2 });
174
+ let repeats = 0;
175
+ connectSignal(tween.onRepeat, () => repeats++);
176
+ updateTweens(manager, 1000);
177
+ updateTweens(manager, 1000);
178
+ updateTweens(manager, 1000);
179
+ expect(repeats).toBe(2);
180
+ expect(tween.complete).toBe(true);
181
+ });
182
+
183
+ it('reverse: true animates from end value back to start', () => {
184
+ const manager = createTweenManager();
185
+ const target = { x: 0 };
186
+ // At 10% duration, a reversed linear tween is at 90% of the range
187
+ createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t, reverse: true });
188
+ updateTweens(manager, 100);
189
+ expect(target.x).toBeCloseTo(90);
190
+ updateTweens(manager, 900);
191
+ expect(target.x).toBe(0);
192
+ });
193
+
194
+ it('reflect reverses direction on each repeat, arriving back at start', () => {
195
+ const manager = createTweenManager();
196
+ const target = { x: 0 };
197
+ createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t, repeat: 1, reflect: true });
198
+ updateTweens(manager, 1000); // play 1 forward: x → 100
199
+ expect(target.x).toBe(100);
200
+ updateTweens(manager, 250); // 25% into reflected play 2: going backward, so x = 75 not 25
201
+ expect(target.x).toBeCloseTo(75);
202
+ updateTweens(manager, 750); // play 2 completes: x → 0
203
+ expect(target.x).toBe(0);
204
+ });
205
+
206
+ it('emits onYoyo on direction flip with reflect', () => {
207
+ const manager = createTweenManager();
208
+ const target = { x: 0 };
209
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t, repeat: 1, reflect: true });
210
+ let yoyos = 0;
211
+ connectSignal(tween.onYoyo, () => yoyos++);
212
+ updateTweens(manager, 1000);
213
+ expect(yoyos).toBe(1);
214
+ });
215
+
216
+ it('does not emit onYoyo without reflect', () => {
217
+ const manager = createTweenManager();
218
+ const target = { x: 0 };
219
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t, repeat: 1 });
220
+ let yoyos = 0;
221
+ connectSignal(tween.onYoyo, () => yoyos++);
222
+ updateTweens(manager, 1000);
223
+ updateTweens(manager, 1000);
224
+ expect(yoyos).toBe(0);
225
+ });
226
+
227
+ it('emits onYoyo each reflect cycle', () => {
228
+ const manager = createTweenManager();
229
+ const target = { x: 0 };
230
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t, repeat: 3, reflect: true });
231
+ let yoyos = 0;
232
+ connectSignal(tween.onYoyo, () => yoyos++);
233
+ updateTweens(manager, 1000);
234
+ updateTweens(manager, 1000);
235
+ updateTweens(manager, 1000);
236
+ expect(yoyos).toBe(3);
237
+ });
238
+
239
+ it('repeat: -1 loops indefinitely without ever completing', () => {
240
+ const manager = createTweenManager();
241
+ const target = { x: 0 };
242
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t, repeat: -1 });
243
+ let repeats = 0;
244
+ connectSignal(tween.onRepeat, () => repeats++);
245
+ updateTweens(manager, 1000);
246
+ updateTweens(manager, 1000);
247
+ updateTweens(manager, 1000);
248
+ expect(repeats).toBe(3);
249
+ expect(tween.complete).toBe(false);
250
+ expect(tween.repeat).toBe(-1);
251
+ });
252
+
253
+ it('rounds values when snapping=true', () => {
254
+ const manager = createTweenManager();
255
+ const target = { x: 0 };
256
+ createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t, snapping: true });
257
+ updateTweens(manager, 333);
258
+ expect(Number.isInteger(target.x)).toBe(true);
259
+ });
260
+
261
+ it('does not update paused tween', () => {
262
+ const manager = createTweenManager();
263
+ const target = { x: 0 };
264
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
265
+ pauseTween(tween);
266
+ updateTweens(manager, 500);
267
+ expect(target.x).toBe(0);
268
+ });
269
+
270
+ it('resumes after pause', () => {
271
+ const manager = createTweenManager();
272
+ const target = { x: 0 };
273
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
274
+ pauseTween(tween);
275
+ updateTweens(manager, 500);
276
+ resumeTween(tween);
277
+ updateTweens(manager, 500);
278
+ expect(target.x).toBeCloseTo(50);
279
+ });
280
+
281
+ it('elapsed does not accumulate while paused', () => {
282
+ const manager = createTweenManager();
283
+ const target = { x: 0 };
284
+ const tween = createTween(manager, target, 1000, { x: 100 }, { ease: (t) => t });
285
+ pauseTween(tween);
286
+ updateTweens(manager, 500);
287
+ expect(tween.elapsed).toBe(0);
288
+ resumeTween(tween);
289
+ updateTweens(manager, 500);
290
+ expect(target.x).toBeCloseTo(50); // 500ms of actual animation, not 1000ms
291
+ });
292
+ });