Skip to main content

sgl_core/anim/
sequence.rs

1//! Multi-step frame animation: frame runs, pauses, random pauses, and action
2//! hooks played as one sequence with four loop modes.
3//!
4//! [`FrameAnimation`](super::FrameAnimation) plays a single frame-order array
5//! at a fixed fps. [`AnimationSequence`] is its sibling for animations that are
6//! a *script*: "play the idle frames, then hold for two to five seconds, then
7//! start over".
8//!
9//! Pure `core`: [`tick`](AnimationSequence::tick) takes the caller's `dt` and
10//! the caller's [`Rng`] — random pauses never reach for global randomness, so a
11//! seeded run is reproducible.
12//!
13//! ## Ping-pong
14//!
15//! The ends are the first and last frame steps; steps outside them (a trailing
16//! action, a leading pause) play once at the turnaround and are not replayed.
17//! Each pass starts one element in, so neither end plays twice. With one frame
18//! step this counts frames — `[0, 1, 2]` under
19//! [`SequenceLoop::PingPongOnce`] plays `0, 1, 2, 1, 0` and completes, and
20//! [`SequenceLoop::PingPongRepeat`] continues `1, 2, 1, 0, …`. With several it
21//! counts steps: the reverse pass resumes at the step before the last frame
22//! step and enters each reversed step at its *last* frame, and a repeat
23//! resumes at the step after the first frame step. A sequence without frame
24//! steps bounces between its first and last steps the same way.
25
26use crate::random::Rng;
27
28/// One step of an [`AnimationSequence`].
29#[derive(Debug, Clone, PartialEq)]
30pub enum SequenceStep {
31    /// Play `frames` in order, holding each for `frame_duration` seconds.
32    PlayFrames {
33        /// Sheet-frame indices in play order. Never empty.
34        frames: Vec<usize>,
35        /// Seconds each frame is displayed.
36        frame_duration: f32,
37    },
38    /// Hold the previous frame for a fixed number of seconds.
39    Pause(f32),
40    /// Hold the previous frame for a duration in `[min, max]` when the step
41    /// is entered (exactly `min` when `min == max`). Floating-point rounding
42    /// can reach `max` even though the random draw is below one.
43    RandomPause {
44        /// Shortest possible hold, in seconds.
45        min: f32,
46        /// Inclusive upper bound of the hold, in seconds.
47        max: f32,
48    },
49    /// Queue a caller-defined action id and continue immediately. The caller
50    /// drains it with [`AnimationSequence::take_action`].
51    Action(u32),
52}
53
54/// Loop behavior for an [`AnimationSequence`].
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SequenceLoop {
57    /// Play the steps once, then report finished.
58    Once,
59    /// Wrap back to the first step forever, rewinding every step.
60    Repeat,
61    /// Play forward, then back, then forward again, forever.
62    PingPongRepeat,
63    /// Play forward, then back, then report finished.
64    PingPongOnce,
65}
66
67/// A playing multi-step animation.
68///
69/// The caller drives it with [`tick`](AnimationSequence::tick) and reads
70/// [`current_frame`](AnimationSequence::current_frame). Pauses, actions, and
71/// completed sequences hold the last frame reached during playback, including
72/// frames crossed within a single tick. The frame is `None` only until playback
73/// first reaches a frame after construction or [`reset`](Self::reset).
74#[derive(Debug, Clone)]
75pub struct AnimationSequence {
76    /// The authored steps. Never empty.
77    steps: Vec<SequenceStep>,
78    /// Per-step frame cursor, parallel to `steps`.
79    cursors: Vec<usize>,
80    /// Most recent frame traversed, held while no frame step is active.
81    held_frame: Option<usize>,
82    loop_mode: SequenceLoop,
83    /// Cursor into `steps`. Equals `steps.len()` once `Once` runs off the end.
84    step: usize,
85    /// Elapsed time within the current step element (seconds).
86    timer: f32,
87    /// Duration of the current step element, resolved lazily so that a random
88    /// pause draws from the caller's [`Rng`] on the tick that enters it.
89    step_duration: Option<f32>,
90    /// Ping-pong direction.
91    forward: bool,
92    /// Action ids queued by this tick, in the order they fired.
93    actions: std::collections::VecDeque<u32>,
94    finished: bool,
95    just_completed: bool,
96    /// Playback positions (step, frame cursor and, for ping-pong, direction)
97    /// in one loop cycle. More consecutive advances than this without
98    /// consuming time revisit a position: a zero-time cycle.
99    cycle_positions: usize,
100}
101
102impl AnimationSequence {
103    /// Start building a sequence.
104    #[must_use]
105    pub fn builder() -> SequenceBuilder {
106        SequenceBuilder::new()
107    }
108
109    /// Advance by `dt` seconds, drawing random-pause durations from `rng`.
110    /// Returns `true` only on the tick where a [`SequenceLoop::Once`] or
111    /// [`SequenceLoop::PingPongOnce`] sequence completes (a completion edge,
112    /// once).
113    ///
114    /// Large `dt` steps through as many frames and steps as it covers and
115    /// carries the remainder, so long-run timing never drifts. A nonpositive
116    /// or non-finite `dt` is ignored without advancing steps or drawing from
117    /// `rng`; as on every tick, the prior completion edge and action queue
118    /// are cleared. In the repeating modes, a cycle that consumes no time
119    /// cannot spin: a tick stops after as many consecutive zero-time advances
120    /// (zero-duration frames and steps, or durations too small to lower the
121    /// accumulated time in `f32`) as the sequence has playback positions —
122    /// frames plus other steps, doubled for ping-pong — and drops the
123    /// remainder. Every position in the cycle has then played at least once.
124    /// The play-once modes need no such stop: they end after one bounded
125    /// pass.
126    pub fn tick(&mut self, dt: f32, rng: &mut Rng) -> bool {
127        self.just_completed = false;
128        self.actions.clear();
129        if self.finished || !dt.is_finite() || dt <= 0.0 {
130            return false;
131        }
132        self.timer += dt;
133
134        let repeating = matches!(
135            self.loop_mode,
136            SequenceLoop::Repeat | SequenceLoop::PingPongRepeat
137        );
138        let mut idle_advances = 0;
139        loop {
140            let duration = self.resolved_duration(rng);
141            if self.timer < duration {
142                return false;
143            }
144            let before = self.timer;
145            self.timer -= duration;
146            if self.timer < before {
147                idle_advances = 0;
148            } else if repeating {
149                idle_advances += 1;
150                if idle_advances > self.cycle_positions {
151                    self.timer = 0.0;
152                    return false;
153                }
154            }
155            if self.advance() {
156                self.finished = true;
157                self.just_completed = true;
158                self.timer = 0.0;
159                return true;
160            }
161        }
162    }
163
164    /// The sheet-frame index to display right now. Pauses, actions, and
165    /// completed sequences hold the last frame reached during playback.
166    /// Returns `None` only before the first frame is reached after construction
167    /// or [`reset`](Self::reset).
168    #[must_use]
169    pub fn current_frame(&self) -> Option<usize> {
170        match self.steps.get(self.step) {
171            Some(SequenceStep::PlayFrames { frames, .. }) => {
172                frames.get(self.cursors[self.step]).copied()
173            }
174            _ => self.held_frame,
175        }
176    }
177
178    /// Whether the sequence completed on the most recent
179    /// [`tick`](AnimationSequence::tick).
180    #[must_use]
181    pub fn just_completed(&self) -> bool {
182        self.just_completed
183    }
184
185    /// Whether a `Once` or `PingPongOnce` sequence has played out.
186    #[must_use]
187    pub fn is_finished(&self) -> bool {
188        self.finished
189    }
190
191    /// Take the next action id queued by the most recent
192    /// [`tick`](AnimationSequence::tick), oldest first. Drain it in a loop: one
193    /// tick can cross several [`SequenceStep::Action`] steps. The queue is
194    /// cleared at the start of every tick.
195    pub fn take_action(&mut self) -> Option<u32> {
196        self.actions.pop_front()
197    }
198
199    /// Rewind to the first frame of the first step (restarts a finished
200    /// sequence).
201    pub fn reset(&mut self) {
202        self.step = 0;
203        self.timer = 0.0;
204        self.step_duration = None;
205        self.forward = true;
206        self.finished = false;
207        self.just_completed = false;
208        self.actions.clear();
209        self.cursors.fill(0);
210        self.held_frame = None;
211    }
212
213    /// The current step element's duration, resolving (and remembering) a
214    /// random pause on first use.
215    fn resolved_duration(&mut self, rng: &mut Rng) -> f32 {
216        if let Some(duration) = self.step_duration {
217            return duration;
218        }
219        let duration = match &self.steps[self.step] {
220            SequenceStep::PlayFrames { frame_duration, .. } => *frame_duration,
221            SequenceStep::Pause(duration) => *duration,
222            SequenceStep::RandomPause { min, max } => min + rng.f32() * (max - min),
223            SequenceStep::Action(_) => 0.0,
224        };
225        self.step_duration = Some(duration);
226        duration
227    }
228
229    /// Advance one frame within the current step, or move on to the next step.
230    /// Returns `true` when the sequence completes.
231    fn advance(&mut self) -> bool {
232        let cursor = self.cursors[self.step];
233        let next_frame = match &self.steps[self.step] {
234            SequenceStep::PlayFrames { frames, .. } => {
235                self.held_frame = Some(frames[cursor]);
236                if self.forward {
237                    (cursor + 1 < frames.len()).then_some(cursor + 1)
238                } else {
239                    cursor.checked_sub(1)
240                }
241            }
242            _ => None,
243        };
244        if let Some(cursor) = next_frame {
245            self.cursors[self.step] = cursor;
246            self.step_duration = None;
247            return false;
248        }
249        if let SequenceStep::Action(id) = &self.steps[self.step] {
250            self.actions.push_back(*id);
251        }
252        self.next_step()
253    }
254
255    /// Move the step cursor per the loop mode. Returns `true` when the sequence
256    /// completes.
257    fn next_step(&mut self) -> bool {
258        self.step_duration = None;
259        match self.loop_mode {
260            SequenceLoop::Once => {
261                self.step += 1;
262                self.step >= self.steps.len()
263            }
264            SequenceLoop::Repeat => {
265                self.step += 1;
266                if self.step >= self.steps.len() {
267                    self.step = 0;
268                    self.cursors.fill(0);
269                }
270                false
271            }
272            SequenceLoop::PingPongRepeat | SequenceLoop::PingPongOnce => {
273                if self.forward {
274                    if self.step + 1 < self.steps.len() {
275                        self.step += 1;
276                        self.cursors[self.step] = 0;
277                        false
278                    } else {
279                        self.turn_back()
280                    }
281                } else if self.step > 0 {
282                    self.step -= 1;
283                    self.cursors[self.step] = self.element_count(self.step) - 1;
284                    false
285                } else {
286                    self.restart_forward()
287                }
288            }
289        }
290    }
291
292    /// Start the reverse pass after the forward pass played its last step.
293    /// The end is the last frame shown: steps after the last frame step
294    /// played once at the turnaround and are not replayed. With one frame
295    /// step the pass resumes one frame inside it; with several it resumes at
296    /// the step before the last frame step, entered at its last element.
297    /// Returns `true` when a `PingPongOnce` completes.
298    fn turn_back(&mut self) -> bool {
299        let (first, last) = self.frame_ends();
300        self.forward = false;
301        let resume = if last > first {
302            last - 1
303        } else if self.element_count(last) > 1 {
304            self.step = last;
305            self.cursors[last] = self.element_count(last) - 2;
306            return false;
307        } else if last > 0 {
308            // A one-element end has nothing left to reverse through.
309            last - 1
310        } else {
311            return self.restart_forward();
312        };
313        self.step = resume;
314        self.cursors[resume] = self.element_count(resume) - 1;
315        false
316    }
317
318    /// Start the next forward pass after the reverse pass played step 0, or
319    /// complete a `PingPongOnce`. The start is the first frame shown: steps
320    /// before the first frame step played once at the turnaround and are not
321    /// replayed. With one frame step the pass resumes one frame inside it;
322    /// with several it resumes at the step after the first frame step.
323    fn restart_forward(&mut self) -> bool {
324        if self.loop_mode == SequenceLoop::PingPongOnce {
325            return true;
326        }
327        let (first, last) = self.frame_ends();
328        self.forward = true;
329        self.cursors.fill(0);
330        if last > first {
331            self.step = first + 1;
332        } else {
333            self.step = first;
334            self.cursors[first] = usize::from(self.element_count(first) > 1);
335        }
336        false
337    }
338
339    /// The first and last steps that play frames, or the first and last
340    /// steps when none do.
341    fn frame_ends(&self) -> (usize, usize) {
342        let plays = |step: &SequenceStep| matches!(step, SequenceStep::PlayFrames { .. });
343        match (
344            self.steps.iter().position(plays),
345            self.steps.iter().rposition(plays),
346        ) {
347            (Some(first), Some(last)) => (first, last),
348            _ => (0, self.steps.len() - 1),
349        }
350    }
351
352    /// Playback elements in `step`: its frames, or one for any other step.
353    fn element_count(&self, step: usize) -> usize {
354        match &self.steps[step] {
355            SequenceStep::PlayFrames { frames, .. } => frames.len(),
356            _ => 1,
357        }
358    }
359}
360
361/// Why a sequence could not be built. Every variant is an authoring error.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub enum SequenceError {
364    /// The builder was given no steps.
365    NoSteps,
366    /// The step at `step` plays an empty frame list.
367    EmptyFrames { step: usize },
368    /// The step at `step` was given a negative or `NaN` duration.
369    BadDuration { step: usize },
370    /// The random pause at `step` has `min > max`, or a negative or `NaN`
371    /// bound.
372    BadRandomRange { step: usize },
373}
374
375impl std::fmt::Display for SequenceError {
376    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377        match self {
378            Self::NoSteps => write!(f, "animation sequence has no steps"),
379            Self::EmptyFrames { step } => {
380                write!(f, "animation sequence step {step} plays no frames")
381            }
382            Self::BadDuration { step } => write!(
383                f,
384                "animation sequence step {step} needs a finite, non-negative duration"
385            ),
386            Self::BadRandomRange { step } => write!(
387                f,
388                "animation sequence step {step} needs a finite random pause range with min <= max"
389            ),
390        }
391    }
392}
393
394impl std::error::Error for SequenceError {}
395
396/// Builder for an [`AnimationSequence`]. Steps play in the order they are
397/// added; [`build`](SequenceBuilder::build) rejects authoring errors.
398#[derive(Debug, Clone)]
399pub struct SequenceBuilder {
400    steps: Vec<SequenceStep>,
401    loop_mode: SequenceLoop,
402}
403
404impl SequenceBuilder {
405    fn new() -> Self {
406        Self {
407            steps: Vec::new(),
408            loop_mode: SequenceLoop::Once,
409        }
410    }
411
412    /// Set the loop mode (default [`SequenceLoop::Once`]).
413    #[must_use]
414    pub fn loop_mode(mut self, loop_mode: SequenceLoop) -> Self {
415        self.loop_mode = loop_mode;
416        self
417    }
418
419    /// Play an explicit frame order, holding each frame for `frame_duration`
420    /// seconds.
421    #[must_use]
422    pub fn play_frames(mut self, frames: impl Into<Vec<usize>>, frame_duration: f32) -> Self {
423        self.steps.push(SequenceStep::PlayFrames {
424            frames: frames.into(),
425            frame_duration,
426        });
427        self
428    }
429
430    /// Play the inclusive frame range `first..=last`.
431    #[must_use]
432    pub fn play_frame_range(self, first: usize, last: usize, frame_duration: f32) -> Self {
433        let frames: Vec<usize> = if first <= last {
434            (first..=last).collect()
435        } else {
436            Vec::new()
437        };
438        self.play_frames(frames, frame_duration)
439    }
440
441    /// Hold for `duration` seconds.
442    #[must_use]
443    pub fn pause(mut self, duration: f32) -> Self {
444        self.steps.push(SequenceStep::Pause(duration));
445        self
446    }
447
448    /// Hold for a duration in `[min, max]` when the step is entered;
449    /// floating-point rounding can reach `max`.
450    #[must_use]
451    pub fn random_pause(mut self, min: f32, max: f32) -> Self {
452        self.steps.push(SequenceStep::RandomPause { min, max });
453        self
454    }
455
456    /// Queue `id` for the caller when playback reaches this point.
457    #[must_use]
458    pub fn action(mut self, id: u32) -> Self {
459        self.steps.push(SequenceStep::Action(id));
460        self
461    }
462
463    /// Validate and build. An empty step list, an empty `PlayFrames` step, and
464    /// a negative or `NaN` duration are authoring errors, not runtime states.
465    pub fn build(self) -> Result<AnimationSequence, SequenceError> {
466        if self.steps.is_empty() {
467            return Err(SequenceError::NoSteps);
468        }
469        for (step, item) in self.steps.iter().enumerate() {
470            match item {
471                SequenceStep::PlayFrames {
472                    frames,
473                    frame_duration,
474                } => {
475                    if frames.is_empty() {
476                        return Err(SequenceError::EmptyFrames { step });
477                    }
478                    if !frame_duration.is_finite() || *frame_duration < 0.0 {
479                        return Err(SequenceError::BadDuration { step });
480                    }
481                }
482                SequenceStep::Pause(duration) => {
483                    if !duration.is_finite() || *duration < 0.0 {
484                        return Err(SequenceError::BadDuration { step });
485                    }
486                }
487                SequenceStep::RandomPause { min, max } => {
488                    if !min.is_finite() || !max.is_finite() || *min < 0.0 || max < min {
489                        return Err(SequenceError::BadRandomRange { step });
490                    }
491                }
492                SequenceStep::Action(_) => {}
493            }
494        }
495        let positions: usize = self
496            .steps
497            .iter()
498            .map(|step| match step {
499                SequenceStep::PlayFrames { frames, .. } => frames.len(),
500                _ => 1,
501            })
502            .sum();
503        let directions = match self.loop_mode {
504            SequenceLoop::PingPongRepeat | SequenceLoop::PingPongOnce => 2,
505            SequenceLoop::Repeat | SequenceLoop::Once => 1,
506        };
507        Ok(AnimationSequence {
508            cycle_positions: positions * directions,
509            cursors: vec![0; self.steps.len()],
510            held_frame: None,
511            steps: self.steps,
512            loop_mode: self.loop_mode,
513            step: 0,
514            timer: 0.0,
515            step_duration: None,
516            forward: true,
517            actions: std::collections::VecDeque::new(),
518            finished: false,
519            just_completed: false,
520        })
521    }
522}
523
524impl Default for SequenceBuilder {
525    fn default() -> Self {
526        Self::new()
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use wasm_bindgen_test::wasm_bindgen_test;
534
535    /// #249: a tick over nothing but zero-duration steps makes exactly one
536    /// full pass — every action fires once per step per tick, and the
537    /// sequence keeps repeating on the next tick.
538    #[wasm_bindgen_test(unsupported = test)]
539    fn a_tick_over_zero_duration_steps_makes_one_full_pass() {
540        let mut sequence = AnimationSequence::builder()
541            .loop_mode(SequenceLoop::Repeat)
542            .action(1)
543            .action(2)
544            .build()
545            .unwrap();
546        let mut rng = rng();
547        for _ in 0..2 {
548            sequence.tick(0.5, &mut rng);
549            let mut fired = Vec::new();
550            while let Some(action) = sequence.take_action() {
551                fired.push(action);
552            }
553            assert_eq!(fired.len(), 2, "one action per step per tick: {fired:?}");
554        }
555    }
556
557    /// #249: a two-step ping-pong that repeats restarts its forward pass at
558    /// the second step, entered at its first frame, so the first step (which
559    /// just played in reverse) does not play twice in a row.
560    #[wasm_bindgen_test(unsupported = test)]
561    fn two_step_pingpong_repeat_restarts_one_step_in() {
562        let mut sequence = AnimationSequence::builder()
563            .loop_mode(SequenceLoop::PingPongRepeat)
564            .play_frames(vec![0, 1], 0.1)
565            .play_frames(vec![2, 3], 0.1)
566            .build()
567            .unwrap();
568        let mut rng = rng();
569        let mut seen = vec![sequence.current_frame()];
570        for _ in 0..9 {
571            sequence.tick(0.1, &mut rng);
572            seen.push(sequence.current_frame());
573        }
574        assert_eq!(seen, [0, 1, 2, 3, 1, 0, 2, 3, 1, 0].map(Some).to_vec());
575    }
576
577    /// #249: a tick past several frame boundaries keeps the remainder, so
578    /// the next small tick crosses the following boundary on time.
579    #[wasm_bindgen_test(unsupported = test)]
580    fn a_remainder_carries_into_the_next_tick() {
581        let mut sequence = AnimationSequence::builder()
582            .loop_mode(SequenceLoop::Repeat)
583            .play_frames(vec![0, 1, 2, 3, 4], 0.1)
584            .build()
585            .unwrap();
586        let mut rng = rng();
587        sequence.tick(0.25, &mut rng);
588        assert_eq!(sequence.current_frame(), Some(2));
589        sequence.tick(0.05, &mut rng);
590        assert_eq!(
591            sequence.current_frame(),
592            Some(3),
593            "0.05 s remainder carried"
594        );
595    }
596
597    /// Frames shown after each `dt` tick until completion (or `ticks`), and
598    /// every action fired along the way.
599    fn play_with_actions(
600        seq: &mut AnimationSequence,
601        dt: f32,
602        ticks: usize,
603    ) -> (Vec<Option<usize>>, Vec<u32>) {
604        let mut rng = rng();
605        let mut seen = vec![seq.current_frame()];
606        let mut fired = Vec::new();
607        for _ in 0..ticks {
608            let completed = seq.tick(dt, &mut rng);
609            while let Some(action) = seq.take_action() {
610                fired.push(action);
611            }
612            if completed {
613                break;
614            }
615            seen.push(seq.current_frame());
616        }
617        (seen, fired)
618    }
619
620    /// #314: a trailing action does not move the turnaround off the frame
621    /// step: each end frame shows once per direction change, the action
622    /// fires once per turnaround, and a repeat restarts inside the frame
623    /// step rather than skipping it.
624    #[wasm_bindgen_test(unsupported = test)]
625    fn a_trailing_action_does_not_repeat_the_end_frame() {
626        let build = |loop_mode| {
627            AnimationSequence::builder()
628                .loop_mode(loop_mode)
629                .play_frames(vec![0, 1, 2], 0.1)
630                .action(5)
631                .build()
632                .unwrap()
633        };
634        let (seen, fired) = play_with_actions(&mut build(SequenceLoop::PingPongOnce), 0.1, 10);
635        assert_eq!(seen, [0, 1, 2, 1, 0].map(Some).to_vec());
636        assert_eq!(fired, [5]);
637
638        let (seen, fired) = play_with_actions(&mut build(SequenceLoop::PingPongRepeat), 0.1, 8);
639        assert_eq!(seen, [0, 1, 2, 1, 0, 1, 2, 1, 0].map(Some).to_vec());
640        assert_eq!(fired, [5, 5]);
641    }
642
643    /// #314: a leading action is the start end: the reverse pass reaches it
644    /// once per bounce, and a repeat restarts one frame inside the frame step
645    /// rather than skipping the reverse pass or replaying frame 0.
646    #[wasm_bindgen_test(unsupported = test)]
647    fn a_leading_action_does_not_move_the_start() {
648        let build = |loop_mode| {
649            AnimationSequence::builder()
650                .loop_mode(loop_mode)
651                .action(9)
652                .play_frames(vec![0, 1, 2], 0.1)
653                .build()
654                .unwrap()
655        };
656        let (seen, fired) = play_with_actions(&mut build(SequenceLoop::PingPongRepeat), 0.1, 8);
657        assert_eq!(
658            seen,
659            [
660                None,
661                Some(1),
662                Some(2),
663                Some(1),
664                Some(0),
665                Some(1),
666                Some(2),
667                Some(1),
668                Some(0)
669            ]
670        );
671        assert_eq!(fired, [9, 9]);
672
673        let mut once = build(SequenceLoop::PingPongOnce);
674        let (seen, fired) = play_with_actions(&mut once, 0.1, 10);
675        assert_eq!(
676            seen,
677            [None, Some(1), Some(2), Some(1), Some(0)],
678            "completes on tick 5"
679        );
680        assert_eq!(fired, [9, 9]);
681        assert!(once.is_finished());
682    }
683
684    /// #314: with several frame steps and a trailing action, the reverse
685    /// pass skips the last frame step as it does without the action.
686    #[wasm_bindgen_test(unsupported = test)]
687    fn a_trailing_action_keeps_multi_step_pingpong_endpoints() {
688        let mut seq = AnimationSequence::builder()
689            .loop_mode(SequenceLoop::PingPongOnce)
690            .play_frames(vec![0, 1], 0.1)
691            .play_frames(vec![2, 3], 0.1)
692            .action(5)
693            .build()
694            .unwrap();
695        let (seen, fired) = play_with_actions(&mut seq, 0.1, 10);
696        assert_eq!(seen, [0, 1, 2, 3, 1, 0].map(Some).to_vec());
697        assert_eq!(fired, [5]);
698    }
699
700    /// #249: two ping-pong steps of two frames play 0 1 2 3, turn around one
701    /// step in entering it at its last frame, and finish: 1 0.
702    #[wasm_bindgen_test(unsupported = test)]
703    fn two_step_pingpong_enters_reversed_steps_at_their_last_frame() {
704        let mut sequence = AnimationSequence::builder()
705            .loop_mode(SequenceLoop::PingPongOnce)
706            .play_frames(vec![0, 1], 0.1)
707            .play_frames(vec![2, 3], 0.1)
708            .build()
709            .unwrap();
710        let mut rng = rng();
711        let mut seen = vec![sequence.current_frame()];
712        for _ in 0..6 {
713            sequence.tick(0.1, &mut rng);
714            seen.push(sequence.current_frame());
715        }
716        // The finished sequence keeps showing its last frame.
717        assert_eq!(
718            seen,
719            vec![
720                Some(0),
721                Some(1),
722                Some(2),
723                Some(3),
724                Some(1),
725                Some(0),
726                Some(0)
727            ]
728        );
729        assert!(sequence.is_finished());
730    }
731
732    /// #249: a single one-frame step ping-pongs without underflowing the
733    /// reversed cursor, and finishes.
734    #[wasm_bindgen_test(unsupported = test)]
735    fn a_single_one_frame_step_pingpongs_without_panicking() {
736        let mut sequence = AnimationSequence::builder()
737            .loop_mode(SequenceLoop::PingPongOnce)
738            .play_frames(vec![7], 0.1)
739            .build()
740            .unwrap();
741        let mut rng = rng();
742        for _ in 0..4 {
743            sequence.tick(0.1, &mut rng);
744            assert!(matches!(sequence.current_frame(), Some(7) | None));
745        }
746        assert!(sequence.is_finished());
747    }
748
749    /// #299: a one-frame `PingPongOnce` shows its frame for one frame
750    /// duration, completes then, and reports completion once.
751    #[wasm_bindgen_test(unsupported = test)]
752    fn a_one_frame_pingpong_once_completes_after_one_frame_duration() {
753        let mut sequence = AnimationSequence::builder()
754            .loop_mode(SequenceLoop::PingPongOnce)
755            .play_frames(vec![0], 0.25)
756            .build()
757            .unwrap();
758        let mut rng = rng();
759        assert!(sequence.tick(0.25, &mut rng), "completes after 0.25 s");
760        assert_eq!(sequence.current_frame(), Some(0));
761        assert!(!sequence.tick(0.25, &mut rng), "completion fires once");
762    }
763
764    /// #299: a lone action under `PingPongOnce` fires once and completes.
765    #[wasm_bindgen_test(unsupported = test)]
766    fn a_lone_action_pingpong_once_fires_once() {
767        let mut sequence = AnimationSequence::builder()
768            .loop_mode(SequenceLoop::PingPongOnce)
769            .action(5)
770            .build()
771            .unwrap();
772        assert!(sequence.tick(0.1, &mut rng()));
773        assert_eq!(sequence.take_action(), Some(5));
774        assert_eq!(sequence.take_action(), None);
775    }
776
777    /// #249: the builder's boundaries — a zero frame duration and a random
778    /// pause with equal bounds are valid; an inverted random range is not.
779    #[wasm_bindgen_test(unsupported = test)]
780    fn the_builder_accepts_zero_durations_and_equal_random_bounds() {
781        assert!(
782            AnimationSequence::builder()
783                .play_frames(vec![0], 0.0)
784                .random_pause(0.5, 0.5)
785                .build()
786                .is_ok()
787        );
788        assert_eq!(
789            AnimationSequence::builder()
790                .random_pause(0.6, 0.5)
791                .build()
792                .err(),
793            Some(SequenceError::BadRandomRange { step: 0 })
794        );
795    }
796
797    fn rng() -> Rng {
798        Rng::from_seed(1)
799    }
800
801    /// The frames displayed by `ticks` ticks of `dt`, starting with the frame
802    /// shown before the first tick. Stops at the completion edge.
803    fn play(seq: &mut AnimationSequence, dt: f32, ticks: usize) -> Vec<Option<usize>> {
804        let mut rng = rng();
805        let mut seen = vec![seq.current_frame()];
806        for _ in 0..ticks {
807            if seq.tick(dt, &mut rng) {
808                break;
809            }
810            seen.push(seq.current_frame());
811        }
812        seen
813    }
814
815    fn single(frames: [usize; 3], loop_mode: SequenceLoop) -> AnimationSequence {
816        AnimationSequence::builder()
817            .loop_mode(loop_mode)
818            .play_frames(frames, 0.1)
819            .build()
820            .expect("valid sequence")
821    }
822
823    /// Single-step ping-pong turns around one frame in, so neither end plays
824    /// twice: `0,1,2,1,0` then complete.
825    #[wasm_bindgen_test(unsupported = test)]
826    fn single_step_pingpong_once_plays_up_and_back() {
827        let mut seq = single([0, 1, 2], SequenceLoop::PingPongOnce);
828        assert_eq!(
829            play(&mut seq, 0.1, 10),
830            vec![Some(0), Some(1), Some(2), Some(1), Some(0)]
831        );
832        assert!(seq.is_finished() && seq.just_completed());
833        let mut rng = rng();
834        assert!(!seq.tick(0.1, &mut rng), "the completion edge fires once");
835        assert!(!seq.just_completed());
836    }
837
838    /// Ping-pong repeat keeps bouncing: `0,1,2,1,0,1,2,1,0,…`.
839    #[wasm_bindgen_test(unsupported = test)]
840    fn single_step_pingpong_repeat_bounces_forever() {
841        let mut seq = single([0, 1, 2], SequenceLoop::PingPongRepeat);
842        assert_eq!(
843            play(&mut seq, 0.1, 8),
844            vec![
845                Some(0),
846                Some(1),
847                Some(2),
848                Some(1),
849                Some(0),
850                Some(1),
851                Some(2),
852                Some(1),
853                Some(0),
854            ]
855        );
856        assert!(!seq.is_finished());
857    }
858
859    /// Multi-step ping-pong reverses the *step* order, turning around one step
860    /// in, and enters each reversed step at its last frame.
861    #[wasm_bindgen_test(unsupported = test)]
862    fn multi_step_pingpong_reverses_step_order() {
863        let mut seq = AnimationSequence::builder()
864            .loop_mode(SequenceLoop::PingPongOnce)
865            .play_frames([0, 1], 0.1)
866            .play_frames([2, 3], 0.1)
867            .play_frames([4, 5], 0.1)
868            .build()
869            .expect("valid sequence");
870        // Forward through all three steps, then back down through steps 1 and
871        // 0 — step 2 is the turnaround and does not replay — each entered at
872        // its last frame.
873        assert_eq!(
874            play(&mut seq, 0.1, 20),
875            [0, 1, 2, 3, 4, 5, 3, 2, 1, 0].map(Some).to_vec()
876        );
877        assert!(seq.just_completed());
878    }
879
880    /// `Repeat` wraps to the first step and rewinds every step's frame cursor,
881    /// so the second pass replays the first pass exactly.
882    #[wasm_bindgen_test(unsupported = test)]
883    fn repeat_rewinds_every_step_on_wrap() {
884        let mut seq = AnimationSequence::builder()
885            .loop_mode(SequenceLoop::Repeat)
886            .play_frames([0, 1], 0.1)
887            .play_frames([7, 8], 0.1)
888            .build()
889            .expect("valid sequence");
890        assert_eq!(
891            play(&mut seq, 0.1, 8),
892            [0, 1, 7, 8, 0, 1, 7, 8, 0].map(Some).to_vec()
893        );
894        assert!(!seq.is_finished());
895    }
896
897    /// A large `dt` steps several frames and carries the remainder instead of
898    /// discarding it, so a slow frame does not slow the animation down.
899    #[wasm_bindgen_test(unsupported = test)]
900    fn large_dt_steps_several_frames_and_carries_the_remainder() {
901        let mut seq = AnimationSequence::builder()
902            .loop_mode(SequenceLoop::Repeat)
903            .play_frames([0, 1, 2, 3], 0.1)
904            .build()
905            .expect("valid sequence");
906        let mut rng = rng();
907        // 0.35 s = three whole frames plus 0.05 s.
908        assert!(!seq.tick(0.35, &mut rng));
909        assert_eq!(seq.current_frame(), Some(3));
910        // The carried 0.05 s completes the fourth frame.
911        assert!(!seq.tick(0.05, &mut rng));
912        assert_eq!(seq.current_frame(), Some(0));
913    }
914
915    /// One tick's leftover time carries across step boundaries too.
916    #[wasm_bindgen_test(unsupported = test)]
917    fn leftover_time_carries_across_steps() {
918        let mut seq = AnimationSequence::builder()
919            .play_frames([0], 0.1)
920            .pause(0.1)
921            .play_frames([5, 6], 0.1)
922            .build()
923            .expect("valid sequence");
924        let mut rng = rng();
925        // 0.25 s = frame step (0.1) + pause (0.1) + 0.05 s into the last step.
926        assert!(!seq.tick(0.25, &mut rng));
927        assert_eq!(seq.current_frame(), Some(5));
928        assert!(!seq.tick(0.05, &mut rng));
929        assert_eq!(seq.current_frame(), Some(6));
930    }
931
932    /// The authored timeline shows frame 1 from 0.125 s through the pause,
933    /// even when no tick stops on that frame before entering the pause.
934    #[wasm_bindgen_test(unsupported = test)]
935    fn large_ticks_hold_the_last_frame_through_pauses() {
936        let frames = AnimationSequence::builder().play_frames([0, 1], 0.125);
937        for builder in [frames.clone().pause(1.0), frames.random_pause(1.0, 2.0)] {
938            for schedule in [&[0.25][..], &[0.125, 0.125][..]] {
939                let mut seq = builder.clone().build().unwrap();
940                let mut rng = rng();
941                for dt in schedule {
942                    assert!(!seq.tick(*dt, &mut rng));
943                }
944                assert_eq!(seq.current_frame(), Some(1), "entered the pause");
945                assert!(!seq.tick(0.5, &mut rng));
946                assert_eq!(seq.current_frame(), Some(1), "still in the pause");
947            }
948        }
949    }
950
951    /// Completion must retain the authored endpoint even if the entire run
952    /// was crossed in one tick; ping-pong ends back on its first frame.
953    #[wasm_bindgen_test(unsupported = test)]
954    fn completing_tick_holds_the_last_traversed_frame() {
955        for (loop_mode, endpoint) in [(SequenceLoop::Once, 5), (SequenceLoop::PingPongOnce, 3)] {
956            let mut seq = AnimationSequence::builder()
957                .loop_mode(loop_mode)
958                .play_frames([3, 4, 5], 0.125)
959                .build()
960                .unwrap();
961            let mut rng = rng();
962            assert!(seq.tick(1.0, &mut rng));
963            assert_eq!(seq.current_frame(), Some(endpoint));
964            assert!(!seq.tick(1.0, &mut rng));
965            assert_eq!(seq.current_frame(), Some(endpoint));
966        }
967    }
968
969    /// On the reverse pass a pause holds the frame just played, not the
970    /// last frame of the step that precedes the pause in authored order.
971    #[wasm_bindgen_test(unsupported = test)]
972    fn reversed_pause_holds_the_frame_at_the_turnaround() {
973        let mut seq = AnimationSequence::builder()
974            .loop_mode(SequenceLoop::PingPongOnce)
975            .play_frames([0, 1], 0.125)
976            .pause(0.5)
977            .play_frames([2, 3], 0.125)
978            .build()
979            .unwrap();
980        let mut rng = rng();
981        assert!(!seq.tick(1.0, &mut rng));
982        assert_eq!(seq.current_frame(), Some(3), "reverse pause at 1.0 s");
983        assert!(seq.tick(0.75, &mut rng));
984        assert_eq!(seq.current_frame(), Some(0), "reverse run ends at 1.75 s");
985    }
986
987    /// A leading pause has no frame to hold; resetting into it must discard
988    /// the completed run's endpoint until playback reaches a frame again.
989    #[wasm_bindgen_test(unsupported = test)]
990    fn reset_clears_the_held_frame_before_a_leading_pause() {
991        let mut seq = AnimationSequence::builder()
992            .pause(0.25)
993            .play_frames([4, 7], 0.125)
994            .build()
995            .unwrap();
996        let mut rng = rng();
997        assert!(seq.tick(0.5, &mut rng));
998        assert_eq!(seq.current_frame(), Some(7));
999        seq.reset();
1000        assert!(!seq.tick(0.125, &mut rng));
1001        assert_eq!(seq.current_frame(), None, "no frame reached after reset");
1002        assert!(!seq.tick(0.125, &mut rng));
1003        assert_eq!(seq.current_frame(), Some(4), "first frame reached again");
1004    }
1005
1006    /// A `Repeat` sequence made only of zero-duration steps must not spin
1007    /// inside one tick: it stops after one pass and drops the remainder.
1008    #[wasm_bindgen_test(unsupported = test)]
1009    fn zero_duration_steps_cannot_spin_within_a_tick() {
1010        let mut seq = AnimationSequence::builder()
1011            .loop_mode(SequenceLoop::Repeat)
1012            .action(3)
1013            .pause(0.0)
1014            .random_pause(0.0, 0.0)
1015            .build()
1016            .expect("valid sequence");
1017        let mut rng = rng();
1018        assert!(!seq.tick(60.0, &mut rng), "Repeat never completes");
1019        assert_eq!(seq.take_action(), Some(3));
1020        assert_eq!(seq.take_action(), None, "one pass fires one action");
1021        assert!(!seq.tick(60.0, &mut rng));
1022    }
1023
1024    /// Actions queue in order and drain once; a tick that crosses two action
1025    /// steps yields both.
1026    #[wasm_bindgen_test(unsupported = test)]
1027    fn actions_queue_in_order_and_drain_once() {
1028        let mut seq = AnimationSequence::builder()
1029            .play_frames([0], 0.1)
1030            .action(7)
1031            .action(9)
1032            .play_frames([1], 0.1)
1033            .build()
1034            .expect("valid sequence");
1035        let mut rng = rng();
1036        assert_eq!(
1037            seq.take_action(),
1038            None,
1039            "nothing fires before the first tick"
1040        );
1041        assert!(!seq.tick(0.1, &mut rng));
1042        assert_eq!(seq.current_frame(), Some(1), "both actions were crossed");
1043        assert_eq!(seq.take_action(), Some(7));
1044        assert_eq!(seq.take_action(), Some(9));
1045        assert_eq!(seq.take_action(), None);
1046        assert!(seq.tick(0.1, &mut rng), "the last frame completes the run");
1047        assert_eq!(seq.take_action(), None, "actions do not re-fire");
1048    }
1049
1050    /// `min == max` pauses for exactly that long: a 0.5 s pause ends on the
1051    /// fourth 0.125 s tick, not before.
1052    #[wasm_bindgen_test(unsupported = test)]
1053    fn an_exact_random_pause_lasts_exactly_min() {
1054        let mut seq = AnimationSequence::builder()
1055            .play_frames([4], 0.125)
1056            .random_pause(0.5, 0.5)
1057            .build()
1058            .expect("valid sequence");
1059        let mut rng = rng();
1060        assert!(!seq.tick(0.125, &mut rng), "off the frame, onto the pause");
1061        assert_eq!(
1062            seq.current_frame(),
1063            Some(4),
1064            "a pause holds its prior frame"
1065        );
1066        for tick in 1..4 {
1067            assert!(
1068                !seq.tick(0.125, &mut rng),
1069                "still pausing after {tick} ticks"
1070            );
1071        }
1072        assert!(seq.tick(0.125, &mut rng), "0.5 s of pause has elapsed");
1073    }
1074
1075    /// A random pause stays inside its authored bounds and is reproducible
1076    /// from the seed: the same seed replays the same run, and a global source
1077    /// of randomness could not.
1078    #[wasm_bindgen_test(unsupported = test)]
1079    fn random_pauses_are_bounded_and_seed_reproducible() {
1080        let build = || {
1081            AnimationSequence::builder()
1082                .random_pause(0.4, 0.8)
1083                .build()
1084                .expect("valid sequence")
1085        };
1086        // Ticks of 0.05 s to cross a hold in [0.4, 0.8] → 8..=16 ticks.
1087        let ticks_to_finish = |seed: u64| {
1088            let mut seq = build();
1089            let mut rng = Rng::from_seed(seed);
1090            (1..=100)
1091                .find(|_| seq.tick(0.05, &mut rng))
1092                .expect("the pause ends")
1093        };
1094        for seed in 0..32 {
1095            let ticks = ticks_to_finish(seed);
1096            assert!(
1097                (8..=16).contains(&ticks),
1098                "seed {seed} paused {ticks} ticks"
1099            );
1100            assert_eq!(ticks, ticks_to_finish(seed), "seed {seed} is reproducible");
1101        }
1102    }
1103
1104    /// Only random pauses consume the caller's RNG, so adding an animation to
1105    /// a frame cannot shift a seeded simulation's random stream.
1106    #[wasm_bindgen_test(unsupported = test)]
1107    fn steps_without_randomness_do_not_draw_from_the_rng() {
1108        let mut seq = AnimationSequence::builder()
1109            .loop_mode(SequenceLoop::Repeat)
1110            .play_frames([0, 1], 0.1)
1111            .pause(0.2)
1112            .action(1)
1113            .build()
1114            .expect("valid sequence");
1115        let mut used = Rng::from_seed(5);
1116        for _ in 0..50 {
1117            seq.tick(0.1, &mut used);
1118        }
1119        let mut untouched = Rng::from_seed(5);
1120        assert_eq!(used.u32(0..u32::MAX), untouched.u32(0..u32::MAX));
1121    }
1122
1123    /// `reset` rewinds a finished sequence to its first frame.
1124    #[wasm_bindgen_test(unsupported = test)]
1125    fn reset_restarts_a_finished_sequence() {
1126        let mut seq = single([3, 4, 5], SequenceLoop::Once);
1127        let mut rng = rng();
1128        while !seq.is_finished() {
1129            seq.tick(0.1, &mut rng);
1130        }
1131        assert_eq!(seq.current_frame(), Some(5), "Once holds its last frame");
1132        seq.reset();
1133        assert!(!seq.is_finished() && !seq.just_completed());
1134        assert_eq!(seq.current_frame(), Some(3));
1135        assert!(!seq.tick(0.1, &mut rng));
1136        assert_eq!(seq.current_frame(), Some(4));
1137    }
1138
1139    /// Authoring errors are rejected at build time rather than panicking or
1140    /// stalling at runtime.
1141    #[wasm_bindgen_test(unsupported = test)]
1142    fn the_builder_rejects_authoring_errors() {
1143        let err = |builder: SequenceBuilder| builder.build().expect_err("authoring error");
1144        assert_eq!(err(AnimationSequence::builder()), SequenceError::NoSteps);
1145        assert_eq!(
1146            err(AnimationSequence::builder().play_frames(Vec::new(), 0.1)),
1147            SequenceError::EmptyFrames { step: 0 }
1148        );
1149        assert_eq!(
1150            err(AnimationSequence::builder().play_frame_range(4, 2, 0.1)),
1151            SequenceError::EmptyFrames { step: 0 },
1152            "a backwards range plays nothing"
1153        );
1154        assert_eq!(
1155            err(AnimationSequence::builder().play_frames([0], -0.1)),
1156            SequenceError::BadDuration { step: 0 }
1157        );
1158        assert_eq!(
1159            err(AnimationSequence::builder()
1160                .play_frames([0], 0.1)
1161                .pause(f32::NAN)),
1162            SequenceError::BadDuration { step: 1 }
1163        );
1164        assert_eq!(
1165            err(AnimationSequence::builder().pause(f32::INFINITY)),
1166            SequenceError::BadDuration { step: 0 }
1167        );
1168        assert_eq!(
1169            err(AnimationSequence::builder().random_pause(2.0, 1.0)),
1170            SequenceError::BadRandomRange { step: 0 }
1171        );
1172        assert_eq!(
1173            err(AnimationSequence::builder().random_pause(f32::NAN, 1.0)),
1174            SequenceError::BadRandomRange { step: 0 }
1175        );
1176        assert!(
1177            AnimationSequence::builder()
1178                .random_pause(0.0, 0.0)
1179                .build()
1180                .is_ok(),
1181            "a zero-length random pause is legitimate"
1182        );
1183    }
1184
1185    /// #313: a non-finite `dt` neither hangs a repeating sequence nor
1186    /// poisons its clock — the next finite tick advances on time.
1187    #[wasm_bindgen_test(unsupported = test)]
1188    fn a_non_finite_dt_is_ignored() {
1189        let mut seq = AnimationSequence::builder()
1190            .loop_mode(SequenceLoop::PingPongRepeat)
1191            .play_frames(vec![0, 1, 2], 0.1)
1192            .build()
1193            .expect("valid sequence");
1194        let mut rng = rng();
1195        for dt in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1196            assert!(!seq.tick(dt, &mut rng));
1197            assert_eq!(seq.current_frame(), Some(0));
1198        }
1199        seq.tick(0.1, &mut rng);
1200        assert_eq!(seq.current_frame(), Some(1));
1201    }
1202
1203    /// Backward clock steps do not defer frames or completion and do not
1204    /// discard the elapsed fraction of the current frame.
1205    #[wasm_bindgen_test(unsupported = test)]
1206    fn nonpositive_dt_does_not_build_time_debt() {
1207        for loop_mode in [SequenceLoop::Once, SequenceLoop::Repeat] {
1208            let mut seq = single([3, 4, 5], loop_mode);
1209            let mut rng = rng();
1210            assert!(!seq.tick(-1.0, &mut rng));
1211            assert!(!seq.tick(0.1, &mut rng));
1212            assert_eq!(seq.current_frame(), Some(4));
1213            assert!(!seq.tick(0.05, &mut rng));
1214            for dt in [-1.0, 0.0, -0.0] {
1215                assert!(!seq.tick(dt, &mut rng));
1216            }
1217            assert!(!seq.tick(0.05, &mut rng));
1218            assert_eq!(seq.current_frame(), Some(5));
1219            assert_eq!(seq.tick(0.1, &mut rng), loop_mode == SequenceLoop::Once);
1220            assert_eq!(seq.is_finished(), loop_mode == SequenceLoop::Once);
1221            assert_eq!(seq.just_completed(), loop_mode == SequenceLoop::Once);
1222            assert!(!seq.tick(-1.0, &mut rng));
1223            assert!(!seq.just_completed(), "the previous edge is cleared");
1224        }
1225    }
1226
1227    /// Ignored time must not enter zero-duration steps or consume random
1228    /// values reserved for the caller's other systems.
1229    #[wasm_bindgen_test(unsupported = test)]
1230    fn ignored_dt_does_not_fire_actions_or_draw_randomness() {
1231        let mut seq = AnimationSequence::builder()
1232            .action(7)
1233            .random_pause(0.0, 0.0)
1234            .play_frames([4, 5], 0.1)
1235            .build()
1236            .expect("valid sequence");
1237        let mut used = Rng::from_seed(5);
1238        for dt in [-1.0, 0.0, -0.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1239            assert!(!seq.tick(dt, &mut used));
1240            assert_eq!(seq.take_action(), None);
1241            assert_eq!(seq.current_frame(), None);
1242        }
1243        let mut untouched = Rng::from_seed(5);
1244        assert_eq!(used.u32(0..u32::MAX), untouched.u32(0..u32::MAX));
1245        assert!(!seq.tick(0.1, &mut used));
1246        assert_eq!(seq.current_frame(), Some(5));
1247        assert_eq!(
1248            seq.take_action(),
1249            Some(7),
1250            "the action waits for elapsed time"
1251        );
1252    }
1253
1254    /// #313: a play-once sequence whose frame durations are too small to
1255    /// lower the accumulated time still plays to completion in one tick that
1256    /// covers it.
1257    #[wasm_bindgen_test(unsupported = test)]
1258    fn a_once_sequence_with_vanishing_durations_completes_in_one_tick() {
1259        let mut seq = AnimationSequence::builder()
1260            .play_frames(vec![0, 1, 2, 3], 1e-30)
1261            .build()
1262            .expect("valid sequence");
1263        assert!(seq.tick(0.016, &mut rng()), "completion edge");
1264        assert_eq!(seq.current_frame(), Some(3));
1265    }
1266
1267    /// #297: zero-duration frames before an action count as playback
1268    /// positions, not steps: the action fires on the first tick, the frame
1269    /// run's last frame holds through the pause, and the sequence finishes
1270    /// once a second has elapsed. A repeating copy fires the action on the
1271    /// first tick too.
1272    #[wasm_bindgen_test(unsupported = test)]
1273    fn zero_duration_frames_do_not_delay_a_following_action() {
1274        for loop_mode in [SequenceLoop::Once, SequenceLoop::Repeat] {
1275            let mut seq = AnimationSequence::builder()
1276                .loop_mode(loop_mode)
1277                .play_frames([0, 1, 2, 3], 0.0)
1278                .action(7)
1279                .pause(1.0)
1280                .build()
1281                .expect("valid sequence");
1282            let mut rng = rng();
1283            assert!(!seq.tick(0.5, &mut rng));
1284            assert_eq!(seq.take_action(), Some(7), "{loop_mode:?}");
1285            assert_eq!(seq.current_frame(), Some(3));
1286            let completed = seq.tick(0.5, &mut rng);
1287            assert_eq!(completed, loop_mode == SequenceLoop::Once, "{loop_mode:?}");
1288        }
1289    }
1290
1291    /// #297: a zero-time ping-pong cycle reaches the action after its
1292    /// zero-duration frames within the tick.
1293    #[wasm_bindgen_test(unsupported = test)]
1294    fn a_zero_time_pingpong_cycle_reaches_its_action() {
1295        let mut seq = AnimationSequence::builder()
1296            .loop_mode(SequenceLoop::PingPongRepeat)
1297            .play_frames([0, 1, 2], 0.0)
1298            .action(4)
1299            .build()
1300            .expect("valid sequence");
1301        assert!(!seq.tick(1.0, &mut rng()));
1302        assert_eq!(seq.take_action(), Some(4));
1303    }
1304
1305    /// #313: a frame duration too small to lower the accumulated time in
1306    /// `f32` returns instead of spinning, in both repeating modes.
1307    #[wasm_bindgen_test(unsupported = test)]
1308    fn a_vanishing_frame_duration_does_not_hang_a_repeating_sequence() {
1309        for loop_mode in [SequenceLoop::Repeat, SequenceLoop::PingPongRepeat] {
1310            let mut seq = AnimationSequence::builder()
1311                .loop_mode(loop_mode)
1312                .play_frames(vec![0], 1e-30)
1313                .build()
1314                .expect("valid sequence");
1315            assert!(!seq.tick(0.016, &mut rng()));
1316            assert_eq!(seq.current_frame(), Some(0));
1317        }
1318    }
1319
1320    /// `play_frame_range` is the inclusive range the Aseprite tags use.
1321    #[wasm_bindgen_test(unsupported = test)]
1322    fn play_frame_range_is_inclusive() {
1323        let mut seq = AnimationSequence::builder()
1324            .loop_mode(SequenceLoop::Once)
1325            .play_frame_range(8, 11, 0.1)
1326            .build()
1327            .expect("valid sequence");
1328        assert_eq!(play(&mut seq, 0.1, 10), [8, 9, 10, 11].map(Some).to_vec());
1329    }
1330}