@flighthq/timeline 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.
@@ -0,0 +1,505 @@
1
+ import type { DisplayObject, Timeline } from '@flighthq/types';
2
+
3
+ import {
4
+ addTimelineFrameScript,
5
+ createTimeline,
6
+ createTimelineSource,
7
+ disposeTimelineSignals,
8
+ enableTimelineSignals,
9
+ findTimelineLabel,
10
+ getTimelineCurrentLabel,
11
+ getTimelineFrameScript,
12
+ gotoAndPlayTimeline,
13
+ gotoAndStopTimeline,
14
+ nextFrameTimeline,
15
+ playTimeline,
16
+ prevFrameTimeline,
17
+ removeTimelineFrameScript,
18
+ stopTimeline,
19
+ updateTimeline,
20
+ } from './timeline';
21
+
22
+ interface MakeOptions {
23
+ totalFrames?: number;
24
+ frameRate?: number | null;
25
+ labels?: { name: string; frame: number }[];
26
+ constructFrame?: (frame: number) => void;
27
+ currentFrame?: number;
28
+ isPlaying?: boolean;
29
+ }
30
+
31
+ // Builds a timeline backed by a source (totalFrames/frameRate/labels/constructFrame) with a mock target
32
+ // so constructFrame fires. Test callbacks take just the frame; the source adapts to (target, frame).
33
+ function make(o: MakeOptions = {}): Timeline {
34
+ return createTimeline({
35
+ source: createTimelineSource({
36
+ totalFrames: o.totalFrames ?? 4,
37
+ frameRate: o.frameRate === undefined ? 10 : o.frameRate,
38
+ labels: o.labels,
39
+ constructFrame: o.constructFrame ? (_target, frame) => o.constructFrame!(frame) : undefined,
40
+ }),
41
+ target: {} as DisplayObject,
42
+ currentFrame: o.currentFrame,
43
+ isPlaying: o.isPlaying,
44
+ });
45
+ }
46
+
47
+ describe('addTimelineFrameScript', () => {
48
+ it('attaches a script that fires once on frame entry', () => {
49
+ const fired: number[] = [];
50
+ const t = make({ frameRate: null });
51
+ addTimelineFrameScript(t, 2, (_target, f) => fired.push(f));
52
+ playTimeline(t);
53
+ updateTimeline(t, 0); // frame 1 — no script
54
+ updateTimeline(t, 0); // frame 2 — script fires
55
+ expect(fired).toEqual([2]);
56
+ });
57
+
58
+ it('does not re-fire the script on repeated updates to the same stopped frame', () => {
59
+ const fired: number[] = [];
60
+ const t = make({ frameRate: null });
61
+ addTimelineFrameScript(t, 2, (_target, f) => fired.push(f));
62
+ playTimeline(t);
63
+ updateTimeline(t, 0); // frame 1
64
+ updateTimeline(t, 0); // frame 2 — fires once
65
+ stopTimeline(t);
66
+ updateTimeline(t, 0); // still frame 2, stopped — no re-fire
67
+ updateTimeline(t, 0); // still frame 2, stopped — no re-fire
68
+ expect(fired).toEqual([2]);
69
+ });
70
+
71
+ it('accepts a label string as the frame argument', () => {
72
+ const fired: number[] = [];
73
+ const t = make({ frameRate: null, labels: [{ name: 'run', frame: 3 }] });
74
+ addTimelineFrameScript(t, 'run', (_target, f) => fired.push(f));
75
+ gotoAndStopTimeline(t, 3);
76
+ expect(fired).toEqual([3]);
77
+ });
78
+ });
79
+
80
+ describe('createTimeline', () => {
81
+ it('starts at frame 1, stopped, lastFrameUpdate -1', () => {
82
+ const t = make();
83
+ expect(t.currentFrame).toBe(1);
84
+ expect(t.isPlaying).toBe(false);
85
+ expect(t.lastFrameUpdate).toBe(-1);
86
+ });
87
+
88
+ it('defaults playMode to loop', () => {
89
+ const t = make();
90
+ expect(t.playMode).toBe('loop');
91
+ });
92
+
93
+ it('defaults frameScripts to null', () => {
94
+ const t = make();
95
+ expect(t.frameScripts).toBeNull();
96
+ });
97
+
98
+ it('defaults signals to null', () => {
99
+ const t = make();
100
+ expect(t.signals).toBeNull();
101
+ });
102
+
103
+ it('applies overrides', () => {
104
+ const t = make({ currentFrame: 3, frameRate: 24 });
105
+ expect(t.currentFrame).toBe(3);
106
+ expect(t.source?.frameRate).toBe(24);
107
+ });
108
+ });
109
+
110
+ describe('createTimelineSource', () => {
111
+ it('builds a source with defaults', () => {
112
+ const s = createTimelineSource({});
113
+ expect(s.totalFrames).toBe(1);
114
+ expect(s.frameRate).toBeNull();
115
+ expect(s.labels).toEqual([]);
116
+ });
117
+
118
+ it('carries provided fields and invokes constructFrame with (target, frame)', () => {
119
+ const seen: number[] = [];
120
+ const s = createTimelineSource({
121
+ totalFrames: 3,
122
+ frameRate: 12,
123
+ labels: [{ name: 'a', frame: 2 }],
124
+ constructFrame: (_target, frame) => seen.push(frame),
125
+ });
126
+ expect(s.totalFrames).toBe(3);
127
+ expect(s.frameRate).toBe(12);
128
+ expect(s.labels).toEqual([{ name: 'a', frame: 2 }]);
129
+ s.constructFrame({} as unknown as DisplayObject, 2);
130
+ expect(seen).toEqual([2]);
131
+ });
132
+ });
133
+
134
+ describe('disposeTimelineSignals', () => {
135
+ it('clears timeline.signals to null', () => {
136
+ const t = make();
137
+ enableTimelineSignals(t);
138
+ expect(t.signals).not.toBeNull();
139
+ disposeTimelineSignals(t);
140
+ expect(t.signals).toBeNull();
141
+ });
142
+
143
+ it('is idempotent — no-op when signals are already null', () => {
144
+ const t = make();
145
+ expect(() => disposeTimelineSignals(t)).not.toThrow();
146
+ expect(t.signals).toBeNull();
147
+ });
148
+
149
+ it('allows enableTimelineSignals to re-arm after dispose', () => {
150
+ const t = make();
151
+ const first = enableTimelineSignals(t);
152
+ disposeTimelineSignals(t);
153
+ const second = enableTimelineSignals(t);
154
+ expect(second).not.toBe(first);
155
+ expect(t.signals).toBe(second);
156
+ });
157
+ });
158
+
159
+ describe('enableTimelineSignals', () => {
160
+ it('returns a TimelineSignals group with all lifecycle signals defined', () => {
161
+ const t = make();
162
+ const signals = enableTimelineSignals(t);
163
+ expect(signals.onEnterFrame).toBeDefined();
164
+ expect(signals.onExitFrame).toBeDefined();
165
+ expect(signals.onFrameConstructed).toBeDefined();
166
+ expect(signals.onComplete).toBeDefined();
167
+ expect(signals.onLoop).toBeDefined();
168
+ });
169
+
170
+ it('is idempotent — returns the same group on subsequent calls', () => {
171
+ const t = make();
172
+ expect(enableTimelineSignals(t)).toBe(enableTimelineSignals(t));
173
+ });
174
+
175
+ it('stores the signals on timeline.signals', () => {
176
+ const t = make();
177
+ const signals = enableTimelineSignals(t);
178
+ expect(t.signals).toBe(signals);
179
+ });
180
+
181
+ it('emits onEnterFrame with correct frame and previousFrame when frame changes', () => {
182
+ const events: { frame: number; previousFrame: number }[] = [];
183
+ const t = make({ frameRate: null });
184
+ const signals = enableTimelineSignals(t);
185
+ signals.onEnterFrame.emit = (event) => events.push({ frame: event.frame, previousFrame: event.previousFrame });
186
+ playTimeline(t);
187
+ updateTimeline(t, 0); // frame 1 (was -1 sentinel)
188
+ updateTimeline(t, 0); // frame 2
189
+ expect(events[0].frame).toBe(1);
190
+ expect(events[1].frame).toBe(2);
191
+ expect(events[1].previousFrame).toBe(1);
192
+ });
193
+
194
+ it('emits onExitFrame before frame changes', () => {
195
+ const order: string[] = [];
196
+ const t = make({ frameRate: null });
197
+ const signals = enableTimelineSignals(t);
198
+ signals.onExitFrame.emit = () => order.push('exit');
199
+ signals.onEnterFrame.emit = () => order.push('enter');
200
+ signals.onFrameConstructed.emit = () => order.push('constructed');
201
+ playTimeline(t);
202
+ updateTimeline(t, 0); // first frame entry
203
+ expect(order).toEqual(['exit', 'enter', 'constructed']);
204
+ });
205
+
206
+ it('emits onLoop when the timeline wraps in loop mode', () => {
207
+ let looped = false;
208
+ const t = make({ totalFrames: 2, frameRate: null });
209
+ const signals = enableTimelineSignals(t);
210
+ signals.onLoop.emit = () => {
211
+ looped = true;
212
+ };
213
+ playTimeline(t);
214
+ updateTimeline(t, 0); // frame 1
215
+ updateTimeline(t, 0); // frame 2
216
+ updateTimeline(t, 0); // wraps to frame 1 → onLoop
217
+ expect(looped).toBe(true);
218
+ });
219
+
220
+ it('emits onComplete and stops when playMode is once and last frame is reached', () => {
221
+ let completed = false;
222
+ const t = make({ totalFrames: 2, frameRate: null });
223
+ t.playMode = 'once';
224
+ const signals = enableTimelineSignals(t);
225
+ signals.onComplete.emit = () => {
226
+ completed = true;
227
+ };
228
+ playTimeline(t);
229
+ updateTimeline(t, 0); // frame 1
230
+ updateTimeline(t, 0); // frame 2
231
+ updateTimeline(t, 0); // would loop — stops, fires onComplete
232
+ expect(completed).toBe(true);
233
+ expect(t.isPlaying).toBe(false);
234
+ });
235
+ });
236
+
237
+ describe('findTimelineLabel', () => {
238
+ it('returns the matching label', () => {
239
+ const t = make({
240
+ labels: [
241
+ { name: 'idle', frame: 1 },
242
+ { name: 'run', frame: 3 },
243
+ ],
244
+ });
245
+ expect(findTimelineLabel(t, 'run')?.frame).toBe(3);
246
+ });
247
+
248
+ it('returns null for an unknown name', () => {
249
+ const t = make();
250
+ expect(findTimelineLabel(t, 'missing')).toBeNull();
251
+ });
252
+ });
253
+
254
+ describe('getTimelineCurrentLabel', () => {
255
+ it('returns null when there are no labels', () => {
256
+ const t = make();
257
+ expect(getTimelineCurrentLabel(t)).toBeNull();
258
+ });
259
+
260
+ it('returns null when no label precedes the current frame', () => {
261
+ const t = make({ labels: [{ name: 'run', frame: 3 }], currentFrame: 1 });
262
+ expect(getTimelineCurrentLabel(t)).toBeNull();
263
+ });
264
+
265
+ it('returns the label exactly at the current frame', () => {
266
+ const t = make({ labels: [{ name: 'run', frame: 3 }], currentFrame: 3 });
267
+ expect(getTimelineCurrentLabel(t)?.name).toBe('run');
268
+ });
269
+
270
+ it('returns the last label at or before the current frame', () => {
271
+ const t = make({
272
+ totalFrames: 6,
273
+ labels: [
274
+ { name: 'idle', frame: 1 },
275
+ { name: 'run', frame: 3 },
276
+ ],
277
+ currentFrame: 4,
278
+ });
279
+ expect(getTimelineCurrentLabel(t)?.name).toBe('run');
280
+ });
281
+ });
282
+
283
+ describe('getTimelineFrameScript', () => {
284
+ it('returns null when no scripts are attached', () => {
285
+ const t = make();
286
+ expect(getTimelineFrameScript(t, 1)).toBeNull();
287
+ });
288
+
289
+ it('returns null for a frame with no script when others exist', () => {
290
+ const t = make();
291
+ addTimelineFrameScript(t, 2, () => {});
292
+ expect(getTimelineFrameScript(t, 1)).toBeNull();
293
+ });
294
+
295
+ it('returns the script attached to a frame', () => {
296
+ const t = make();
297
+ const fn = () => {};
298
+ addTimelineFrameScript(t, 3, fn);
299
+ expect(getTimelineFrameScript(t, 3)).toBe(fn);
300
+ });
301
+ });
302
+
303
+ describe('gotoAndPlayTimeline', () => {
304
+ it('seeks to frame and starts playing', () => {
305
+ const t = make();
306
+ gotoAndPlayTimeline(t, 3);
307
+ expect(t.currentFrame).toBe(3);
308
+ expect(t.isPlaying).toBe(true);
309
+ });
310
+
311
+ it('fires constructFrame immediately for the target frame', () => {
312
+ const frames: number[] = [];
313
+ const t = make({ constructFrame: (f) => frames.push(f) });
314
+ gotoAndPlayTimeline(t, 3);
315
+ expect(frames).toEqual([3]);
316
+ });
317
+
318
+ it('resolves a label name to a frame number', () => {
319
+ const t = make({ labels: [{ name: 'run', frame: 2 }] });
320
+ gotoAndPlayTimeline(t, 'run');
321
+ expect(t.currentFrame).toBe(2);
322
+ expect(t.isPlaying).toBe(true);
323
+ });
324
+
325
+ it('throws for an unknown label', () => {
326
+ const t = make();
327
+ expect(() => gotoAndPlayTimeline(t, 'missing')).toThrow();
328
+ });
329
+ });
330
+
331
+ describe('gotoAndStopTimeline', () => {
332
+ it('seeks to frame and stops', () => {
333
+ const t = make();
334
+ playTimeline(t);
335
+ gotoAndStopTimeline(t, 2);
336
+ expect(t.currentFrame).toBe(2);
337
+ expect(t.isPlaying).toBe(false);
338
+ });
339
+
340
+ it('clamps frame to valid range', () => {
341
+ const t = make({ totalFrames: 4 });
342
+ gotoAndStopTimeline(t, 99);
343
+ expect(t.currentFrame).toBe(4);
344
+ gotoAndStopTimeline(t, 0);
345
+ expect(t.currentFrame).toBe(1);
346
+ });
347
+ });
348
+
349
+ describe('nextFrameTimeline', () => {
350
+ it('advances one frame and stops', () => {
351
+ const t = make();
352
+ playTimeline(t);
353
+ nextFrameTimeline(t);
354
+ expect(t.currentFrame).toBe(2);
355
+ expect(t.isPlaying).toBe(false);
356
+ });
357
+
358
+ it('clamps at the last frame', () => {
359
+ const t = make({ totalFrames: 4 });
360
+ gotoAndStopTimeline(t, 4);
361
+ nextFrameTimeline(t);
362
+ expect(t.currentFrame).toBe(4);
363
+ });
364
+ });
365
+
366
+ describe('playTimeline', () => {
367
+ it('sets isPlaying to true', () => {
368
+ const t = make();
369
+ playTimeline(t);
370
+ expect(t.isPlaying).toBe(true);
371
+ });
372
+
373
+ it('does nothing when totalFrames < 2', () => {
374
+ const t = createTimeline({ source: createTimelineSource({ totalFrames: 1 }) });
375
+ playTimeline(t);
376
+ expect(t.isPlaying).toBe(false);
377
+ });
378
+
379
+ it('resets timeElapsed on play', () => {
380
+ const t = make();
381
+ t.timeElapsed = 999;
382
+ playTimeline(t);
383
+ expect(t.timeElapsed).toBe(0);
384
+ });
385
+ });
386
+
387
+ describe('prevFrameTimeline', () => {
388
+ it('retreats one frame and stops', () => {
389
+ const t = make();
390
+ gotoAndStopTimeline(t, 3);
391
+ prevFrameTimeline(t);
392
+ expect(t.currentFrame).toBe(2);
393
+ expect(t.isPlaying).toBe(false);
394
+ });
395
+
396
+ it('clamps at frame 1', () => {
397
+ const t = make();
398
+ prevFrameTimeline(t);
399
+ expect(t.currentFrame).toBe(1);
400
+ });
401
+ });
402
+
403
+ describe('removeTimelineFrameScript', () => {
404
+ it('is a no-op when no scripts are attached', () => {
405
+ const t = make();
406
+ expect(() => removeTimelineFrameScript(t, 2)).not.toThrow();
407
+ });
408
+
409
+ it('removes the script so it no longer fires', () => {
410
+ const fired: number[] = [];
411
+ const t = make({ frameRate: null });
412
+ addTimelineFrameScript(t, 2, (_target, f) => fired.push(f));
413
+ removeTimelineFrameScript(t, 2);
414
+ playTimeline(t);
415
+ updateTimeline(t, 0); // frame 1
416
+ updateTimeline(t, 0); // frame 2 — script should not fire
417
+ expect(fired).toEqual([]);
418
+ });
419
+
420
+ it('clears frameScripts to null when the last script is removed', () => {
421
+ const t = make();
422
+ addTimelineFrameScript(t, 2, () => {});
423
+ removeTimelineFrameScript(t, 2);
424
+ expect(t.frameScripts).toBeNull();
425
+ });
426
+ });
427
+
428
+ describe('stopTimeline', () => {
429
+ it('sets isPlaying to false', () => {
430
+ const t = make();
431
+ playTimeline(t);
432
+ stopTimeline(t);
433
+ expect(t.isPlaying).toBe(false);
434
+ });
435
+ });
436
+
437
+ describe('updateTimeline', () => {
438
+ it('fires constructFrame for frame 1 on first update even when stopped', () => {
439
+ const frames: number[] = [];
440
+ const t = make({ constructFrame: (f) => frames.push(f) });
441
+ updateTimeline(t, 0);
442
+ expect(frames).toEqual([1]);
443
+ });
444
+
445
+ it('does not double-fire constructFrame on repeated stopped updates', () => {
446
+ const frames: number[] = [];
447
+ const t = make({ constructFrame: (f) => frames.push(f) });
448
+ updateTimeline(t, 0);
449
+ updateTimeline(t, 0);
450
+ expect(frames).toEqual([1]);
451
+ });
452
+
453
+ it('advances one frame per update when frameRate is null', () => {
454
+ const frames: number[] = [];
455
+ const t = make({ frameRate: null, constructFrame: (f) => frames.push(f) });
456
+ playTimeline(t);
457
+ updateTimeline(t, 0);
458
+ updateTimeline(t, 0);
459
+ updateTimeline(t, 0);
460
+ expect(frames).toEqual([1, 2, 3]);
461
+ });
462
+
463
+ it('advances frame after enough time has elapsed for frameRate', () => {
464
+ const frames: number[] = [];
465
+ const t = make({ frameRate: 10, constructFrame: (f) => frames.push(f) }); // 100ms/frame
466
+ playTimeline(t);
467
+ updateTimeline(t, 50);
468
+ expect(frames).toEqual([1]);
469
+ updateTimeline(t, 50);
470
+ expect(frames).toEqual([1, 2]);
471
+ });
472
+
473
+ it('wraps around to frame 1 after the last frame in loop mode', () => {
474
+ const frames: number[] = [];
475
+ const t = make({ totalFrames: 3, frameRate: null, constructFrame: (f) => frames.push(f) });
476
+ playTimeline(t);
477
+ updateTimeline(t, 0); // frame 1
478
+ updateTimeline(t, 0); // frame 2
479
+ updateTimeline(t, 0); // frame 3
480
+ updateTimeline(t, 0); // wraps to frame 1
481
+ expect(frames).toEqual([1, 2, 3, 1]);
482
+ });
483
+
484
+ it('stops at the last frame in once mode and does not wrap', () => {
485
+ const frames: number[] = [];
486
+ const t = make({ totalFrames: 3, frameRate: null, constructFrame: (f) => frames.push(f) });
487
+ t.playMode = 'once';
488
+ playTimeline(t);
489
+ updateTimeline(t, 0); // frame 1
490
+ updateTimeline(t, 0); // frame 2
491
+ updateTimeline(t, 0); // frame 3
492
+ updateTimeline(t, 0); // stopped — no new frame
493
+ expect(frames).toEqual([1, 2, 3]);
494
+ expect(t.isPlaying).toBe(false);
495
+ expect(t.currentFrame).toBe(3);
496
+ });
497
+
498
+ it('can skip multiple frames in one large deltaTime', () => {
499
+ const frames: number[] = [];
500
+ const t = make({ totalFrames: 4, frameRate: 10, constructFrame: (f) => frames.push(f) }); // 100ms/frame
501
+ playTimeline(t);
502
+ updateTimeline(t, 250); // should advance 2 frames beyond current
503
+ expect(t.currentFrame).toBe(3);
504
+ });
505
+ });