Skip to main content

ferroui_base/animation/
time_span.rs

1//! The time type of the animation system.
2//!
3//! Animation timing is expressed in signed 100-nanosecond ticks: clocks hand
4//! out the time elapsed since they started, tests step clocks backwards, and
5//! a negative `Delay` or `Duration` has to be representable so that it can be
6//! rejected when an animation runs rather than when it is declared. A
7//! [`std::time::Duration`] cannot hold any of that, so the animation API uses
8//! [`TimeSpan`] and converts from and to `Duration` at its edges.
9
10use crate::utilities::FormatError;
11use std::fmt;
12use std::ops::{Add, AddAssign, Mul, Neg, Sub, SubAssign};
13use std::str::FromStr;
14use std::time::Duration;
15
16/// A signed time interval with a resolution of 100 nanoseconds.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
18pub struct TimeSpan {
19    ticks: i64,
20}
21
22impl TimeSpan {
23    /// The number of ticks in one millisecond.
24    pub const TICKS_PER_MILLISECOND: i64 = 10_000;
25    /// The number of ticks in one second.
26    pub const TICKS_PER_SECOND: i64 = 10_000_000;
27    /// The number of ticks in one minute.
28    pub const TICKS_PER_MINUTE: i64 = 600_000_000;
29    /// The number of ticks in one hour.
30    pub const TICKS_PER_HOUR: i64 = 36_000_000_000;
31    /// The number of ticks in one day.
32    pub const TICKS_PER_DAY: i64 = 864_000_000_000;
33
34    /// The zero interval.
35    pub const ZERO: TimeSpan = TimeSpan { ticks: 0 };
36    /// The largest interval.
37    pub const MAX_VALUE: TimeSpan = TimeSpan { ticks: i64::MAX };
38    /// The smallest (most negative) interval.
39    pub const MIN_VALUE: TimeSpan = TimeSpan { ticks: i64::MIN };
40
41    /// Creates an interval from a number of 100-nanosecond ticks.
42    #[inline]
43    pub const fn from_ticks(ticks: i64) -> Self {
44        Self { ticks }
45    }
46
47    /// Creates an interval from a number of seconds, rounded to the nearest
48    /// tick. Panics if the value is not a number or out of range.
49    pub fn from_seconds(value: f64) -> Self {
50        Self::interval(value, Self::TICKS_PER_SECOND as f64)
51    }
52
53    /// Creates an interval from a number of milliseconds, rounded to the
54    /// nearest tick. Panics if the value is not a number or out of range.
55    pub fn from_milliseconds(value: f64) -> Self {
56        Self::interval(value, Self::TICKS_PER_MILLISECOND as f64)
57    }
58
59    /// Creates an interval from a number of minutes.
60    pub fn from_minutes(value: f64) -> Self {
61        Self::interval(value, Self::TICKS_PER_MINUTE as f64)
62    }
63
64    fn interval(value: f64, scale: f64) -> Self {
65        assert!(!value.is_nan(), "TimeSpan does not accept floating point Not-a-Number values.");
66        let ticks = value * scale;
67        assert!(
68            ticks <= i64::MAX as f64 && ticks >= i64::MIN as f64,
69            "TimeSpan overflowed because the duration is too long."
70        );
71        Self { ticks: ticks.round() as i64 }
72    }
73
74    /// The number of ticks.
75    #[inline]
76    pub const fn ticks(self) -> i64 {
77        self.ticks
78    }
79
80    /// The interval in whole and fractional seconds.
81    #[inline]
82    pub fn total_seconds(self) -> f64 {
83        self.ticks as f64 / Self::TICKS_PER_SECOND as f64
84    }
85
86    /// The interval in whole and fractional milliseconds.
87    #[inline]
88    pub fn total_milliseconds(self) -> f64 {
89        self.ticks as f64 / Self::TICKS_PER_MILLISECOND as f64
90    }
91
92    /// The interval as a [`Duration`]; `None` when it is negative.
93    pub fn to_duration(self) -> Option<Duration> {
94        if self.ticks < 0 {
95            return None;
96        }
97        let ticks = self.ticks as u64;
98        Some(Duration::new(ticks / Self::TICKS_PER_SECOND as u64, ((ticks % Self::TICKS_PER_SECOND as u64) * 100) as u32))
99    }
100
101    /// Parses an interval in the invariant format
102    /// `[-]{ d | [d.]hh:mm[:ss[.fffffff]] | d:hh:mm:ss[.fffffff] }`.
103    pub fn parse(s: &str) -> Result<TimeSpan, FormatError> {
104        let invalid = || FormatError::from_string(format!("Invalid TimeSpan string: \"{s}\"."));
105        let text = s.trim();
106        let (negative, body) = match text.strip_prefix('-') {
107            Some(rest) => (true, rest),
108            None => (false, text),
109        };
110        if body.is_empty() {
111            return Err(invalid());
112        }
113        let number = |part: &str| -> Result<i64, FormatError> {
114            if part.is_empty() || part.len() > 9 || !part.bytes().all(|b| b.is_ascii_digit()) {
115                return Err(invalid());
116            }
117            part.parse::<i64>().map_err(|_| invalid())
118        };
119
120        let parts: Vec<&str> = body.split(':').collect();
121        let (mut days, hours, minutes, seconds_part): (i64, i64, i64, Option<&str>) = match parts.as_slice() {
122            [days] => (number(days)?, 0, 0, None),
123            [hours, minutes] => {
124                let (days, hours) = Self::split_days(hours, &number)?;
125                (days, hours, number(minutes)?, None)
126            }
127            [hours, minutes, seconds] => {
128                let (days, hours) = Self::split_days(hours, &number)?;
129                (days, hours, number(minutes)?, Some(*seconds))
130            }
131            [days, hours, minutes, seconds] => (number(days)?, number(hours)?, number(minutes)?, Some(*seconds)),
132            _ => return Err(invalid()),
133        };
134        let (seconds, fraction_ticks) = match seconds_part {
135            None => (0, 0),
136            Some(part) => match part.split_once('.') {
137                None => (number(part)?, 0),
138                Some((whole, fraction)) => {
139                    if fraction.is_empty() || fraction.len() > 7 || !fraction.bytes().all(|b| b.is_ascii_digit()) {
140                        return Err(invalid());
141                    }
142                    let mut ticks = fraction.parse::<i64>().map_err(|_| invalid())?;
143                    for _ in fraction.len()..7 {
144                        ticks *= 10;
145                    }
146                    // The seconds may be left out before the fraction ("0:0:.075").
147                    (if whole.is_empty() { 0 } else { number(whole)? }, ticks)
148                }
149            },
150        };
151        if hours > 23 || minutes > 59 || seconds > 59 {
152            return Err(FormatError::from_string(format!(
153                "The TimeSpan string \"{s}\" could not be parsed because at least one of the numeric components is out of range."
154            )));
155        }
156        if days > 10_675_199 {
157            days = i64::MAX / Self::TICKS_PER_DAY + 1;
158        }
159        let ticks = days
160            .checked_mul(Self::TICKS_PER_DAY)
161            .and_then(|t| t.checked_add(hours * Self::TICKS_PER_HOUR))
162            .and_then(|t| t.checked_add(minutes * Self::TICKS_PER_MINUTE))
163            .and_then(|t| t.checked_add(seconds * Self::TICKS_PER_SECOND))
164            .and_then(|t| t.checked_add(fraction_ticks))
165            .ok_or_else(|| {
166                FormatError::from_string(format!(
167                    "The TimeSpan string \"{s}\" could not be parsed because at least one of the numeric components is out of range."
168                ))
169            })?;
170        Ok(TimeSpan { ticks: if negative { -ticks } else { ticks } })
171    }
172
173    fn split_days(part: &str, number: &dyn Fn(&str) -> Result<i64, FormatError>) -> Result<(i64, i64), FormatError> {
174        match part.split_once('.') {
175            Some((days, hours)) => Ok((number(days)?, number(hours)?)),
176            None => Ok((0, number(part)?)),
177        }
178    }
179}
180
181/// Members used by the date and time types and the pickers.
182impl TimeSpan {
183    /// The interval of the given hours, minutes and seconds (the C#
184    /// constructor `TimeSpan(int, int, int)`). Panics on overflow.
185    pub fn from_hms(hours: i32, minutes: i32, seconds: i32) -> Self {
186        Self::from_dhms_milliseconds(0, hours, minutes, seconds, 0)
187    }
188
189    /// The interval of the given days, hours, minutes and seconds (the C#
190    /// constructor `TimeSpan(int, int, int, int)`). Panics on overflow.
191    pub fn from_dhms(days: i32, hours: i32, minutes: i32, seconds: i32) -> Self {
192        Self::from_dhms_milliseconds(days, hours, minutes, seconds, 0)
193    }
194
195    /// The interval of the given days, hours, minutes, seconds and
196    /// milliseconds (the C# constructor `TimeSpan(int, int, int, int, int)`).
197    /// Panics on overflow.
198    pub fn from_dhms_milliseconds(days: i32, hours: i32, minutes: i32, seconds: i32, milliseconds: i32) -> Self {
199        let total_milliseconds = (days as i64 * 86_400 + hours as i64 * 3_600 + minutes as i64 * 60 + seconds as i64) * 1_000
200            + milliseconds as i64;
201        let ticks = total_milliseconds
202            .checked_mul(Self::TICKS_PER_MILLISECOND)
203            .expect("TimeSpan overflowed because the duration is too long.");
204        Self { ticks }
205    }
206
207    /// Creates an interval from a number of hours.
208    pub fn from_hours(value: f64) -> Self {
209        Self::interval(value, Self::TICKS_PER_HOUR as f64)
210    }
211
212    /// Creates an interval from a number of days.
213    pub fn from_days(value: f64) -> Self {
214        Self::interval(value, Self::TICKS_PER_DAY as f64)
215    }
216
217    /// The whole days of the interval (negative for a negative interval).
218    #[inline]
219    pub const fn days(self) -> i32 {
220        (self.ticks / Self::TICKS_PER_DAY) as i32
221    }
222
223    /// The hours component, -23..=23.
224    #[inline]
225    pub const fn hours(self) -> i32 {
226        (self.ticks / Self::TICKS_PER_HOUR % 24) as i32
227    }
228
229    /// The minutes component, -59..=59.
230    #[inline]
231    pub const fn minutes(self) -> i32 {
232        (self.ticks / Self::TICKS_PER_MINUTE % 60) as i32
233    }
234
235    /// The seconds component, -59..=59.
236    #[inline]
237    pub const fn seconds(self) -> i32 {
238        (self.ticks / Self::TICKS_PER_SECOND % 60) as i32
239    }
240
241    /// The milliseconds component, -999..=999.
242    #[inline]
243    pub const fn milliseconds(self) -> i32 {
244        (self.ticks / Self::TICKS_PER_MILLISECOND % 1000) as i32
245    }
246
247    /// The interval in whole and fractional days.
248    #[inline]
249    pub fn total_days(self) -> f64 {
250        self.ticks as f64 / Self::TICKS_PER_DAY as f64
251    }
252
253    /// The interval in whole and fractional hours.
254    #[inline]
255    pub fn total_hours(self) -> f64 {
256        self.ticks as f64 / Self::TICKS_PER_HOUR as f64
257    }
258
259    /// The interval in whole and fractional minutes.
260    #[inline]
261    pub fn total_minutes(self) -> f64 {
262        self.ticks as f64 / Self::TICKS_PER_MINUTE as f64
263    }
264
265    /// The absolute value of the interval. Panics for [`TimeSpan::MIN_VALUE`].
266    pub fn duration(self) -> TimeSpan {
267        TimeSpan { ticks: self.ticks.checked_abs().expect("TimeSpan overflowed because the duration is too long.") }
268    }
269
270    /// Formats with a standard (`c`, `t`, `T`, `g`, `G`) or custom .NET
271    /// `TimeSpan` format string; an empty format is `c`. Panics when the
272    /// format string is invalid (.NET throws `FormatException`).
273    ///
274    /// The `g` and `G` formats always use `.` as the decimal separator; .NET
275    /// uses the one of the culture.
276    #[track_caller]
277    pub fn to_string_format(self, format: &str) -> String {
278        match self.try_to_string_format(format) {
279            Ok(text) => text,
280            Err(error) => panic!("{error}"),
281        }
282    }
283
284    /// Like [`to_string_format`](Self::to_string_format), but gives the error
285    /// of an invalid format string.
286    pub fn try_to_string_format(self, format: &str) -> Result<String, FormatError> {
287        use std::fmt::Write;
288
289        let invalid = || FormatError::new("Input string was not in a correct format.");
290        let negative = self.ticks < 0;
291        let ticks = self.ticks.unsigned_abs();
292        let days = ticks / Self::TICKS_PER_DAY as u64;
293        let hours = ticks / Self::TICKS_PER_HOUR as u64 % 24;
294        let minutes = ticks / Self::TICKS_PER_MINUTE as u64 % 60;
295        let seconds = ticks / Self::TICKS_PER_SECOND as u64 % 60;
296        let fraction = ticks % Self::TICKS_PER_SECOND as u64;
297
298        let mut out = String::new();
299        let mut chars = format.chars();
300        match (chars.next(), chars.next()) {
301            (None, _) | (Some('c' | 't' | 'T'), None) => return Ok(self.to_string()),
302            (Some('g'), None) => {
303                if negative {
304                    out.push('-');
305                }
306                if days != 0 {
307                    let _ = write!(out, "{days}:");
308                }
309                let _ = write!(out, "{hours}:{minutes:02}:{seconds:02}");
310                if fraction != 0 {
311                    let digits = format!("{fraction:07}");
312                    let _ = write!(out, ".{}", digits.trim_end_matches('0'));
313                }
314                return Ok(out);
315            }
316            (Some('G'), None) => {
317                if negative {
318                    out.push('-');
319                }
320                let _ = write!(out, "{days}:{hours:02}:{minutes:02}:{seconds:02}.{fraction:07}");
321                return Ok(out);
322            }
323            (Some(_), None) => return Err(invalid()),
324            _ => {}
325        }
326
327        // A custom format: every character other than a specifier has to be
328        // quoted or escaped; the sign is never written.
329        let bytes = format.as_bytes();
330        let mut index = 0;
331        let mut single = false;
332        while index < bytes.len() {
333            let byte = bytes[index];
334            let count = if single { 1 } else { bytes[index..].iter().take_while(|other| **other == byte).count() };
335            single = false;
336            match byte {
337                b'd' if count <= 8 => {
338                    let _ = write!(out, "{days:0count$}");
339                }
340                b'h' if count <= 2 => {
341                    let _ = write!(out, "{hours:0count$}");
342                }
343                b'm' if count <= 2 => {
344                    let _ = write!(out, "{minutes:0count$}");
345                }
346                b's' if count <= 2 => {
347                    let _ = write!(out, "{seconds:0count$}");
348                }
349                b'f' if count <= 7 => {
350                    let value = fraction / 10_u64.pow((7 - count) as u32);
351                    let _ = write!(out, "{value:0count$}");
352                }
353                b'F' if count <= 7 => {
354                    let mut value = fraction / 10_u64.pow((7 - count) as u32);
355                    let mut digits = count;
356                    while digits > 0 && value % 10 == 0 {
357                        value /= 10;
358                        digits -= 1;
359                    }
360                    if digits > 0 {
361                        let _ = write!(out, "{value:0digits$}");
362                    }
363                }
364                b'\'' | b'"' => {
365                    // A quoted literal; a backslash in it escapes the next
366                    // character.
367                    let mut rest = format[index + 1..].char_indices();
368                    let end = loop {
369                        match rest.next() {
370                            Some((offset, c)) if c == byte as char => break index + 1 + offset,
371                            Some((_, '\\')) => out.push(rest.next().ok_or_else(invalid)?.1),
372                            Some((_, c)) => out.push(c),
373                            None => return Err(invalid()),
374                        }
375                    };
376                    index = end + 1;
377                    continue;
378                }
379                b'%' => {
380                    // The next character is a custom specifier on its own: a
381                    // format of that one character, which a quote or a
382                    // backslash is not.
383                    match bytes.get(index + 1) {
384                        Some(next) if !matches!(*next, b'%' | b'\'' | b'"' | b'\\') => single = true,
385                        _ => return Err(invalid()),
386                    }
387                    index += 1;
388                    continue;
389                }
390                b'\\' => {
391                    let escaped = format[index + 1..].chars().next().ok_or_else(invalid)?;
392                    out.push(escaped);
393                    index += 1 + escaped.len_utf8();
394                    continue;
395                }
396                _ => return Err(invalid()),
397            }
398            index += count;
399        }
400        Ok(out)
401    }
402}
403
404impl From<Duration> for TimeSpan {
405    /// Converts a duration, truncating to whole ticks and saturating at
406    /// [`TimeSpan::MAX_VALUE`].
407    fn from(value: Duration) -> Self {
408        let ticks = value.as_nanos() / 100;
409        TimeSpan { ticks: i64::try_from(ticks).unwrap_or(i64::MAX) }
410    }
411}
412
413impl FromStr for TimeSpan {
414    type Err = FormatError;
415    fn from_str(s: &str) -> Result<Self, Self::Err> {
416        TimeSpan::parse(s)
417    }
418}
419
420impl fmt::Display for TimeSpan {
421    /// Formats as `[-][d.]hh:mm:ss[.fffffff]`.
422    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
423        let negative = self.ticks < 0;
424        let ticks = self.ticks.unsigned_abs();
425        let days = ticks / Self::TICKS_PER_DAY as u64;
426        let hours = ticks / Self::TICKS_PER_HOUR as u64 % 24;
427        let minutes = ticks / Self::TICKS_PER_MINUTE as u64 % 60;
428        let seconds = ticks / Self::TICKS_PER_SECOND as u64 % 60;
429        let fraction = ticks % Self::TICKS_PER_SECOND as u64;
430        if negative {
431            f.write_str("-")?;
432        }
433        if days != 0 {
434            write!(f, "{days}.")?;
435        }
436        write!(f, "{hours:02}:{minutes:02}:{seconds:02}")?;
437        if fraction != 0 {
438            write!(f, ".{fraction:07}")?;
439        }
440        Ok(())
441    }
442}
443
444impl Add for TimeSpan {
445    type Output = TimeSpan;
446    #[inline]
447    fn add(self, rhs: TimeSpan) -> TimeSpan {
448        TimeSpan { ticks: self.ticks.checked_add(rhs.ticks).expect("TimeSpan overflowed because the duration is too long.") }
449    }
450}
451
452impl Sub for TimeSpan {
453    type Output = TimeSpan;
454    #[inline]
455    fn sub(self, rhs: TimeSpan) -> TimeSpan {
456        TimeSpan { ticks: self.ticks.checked_sub(rhs.ticks).expect("TimeSpan overflowed because the duration is too long.") }
457    }
458}
459
460impl AddAssign for TimeSpan {
461    #[inline]
462    fn add_assign(&mut self, rhs: TimeSpan) {
463        *self = *self + rhs;
464    }
465}
466
467impl SubAssign for TimeSpan {
468    #[inline]
469    fn sub_assign(&mut self, rhs: TimeSpan) {
470        *self = *self - rhs;
471    }
472}
473
474impl Neg for TimeSpan {
475    type Output = TimeSpan;
476    #[inline]
477    fn neg(self) -> TimeSpan {
478        TimeSpan { ticks: self.ticks.checked_neg().expect("TimeSpan overflowed because the duration is too long.") }
479    }
480}
481
482impl Mul<f64> for TimeSpan {
483    type Output = TimeSpan;
484    /// Scales the interval, rounding to the nearest tick.
485    fn mul(self, rhs: f64) -> TimeSpan {
486        TimeSpan::interval(self.ticks as f64, rhs)
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn from_seconds_rounds_to_ticks() {
496        assert_eq!(TimeSpan::from_seconds(1.0).ticks(), 10_000_000);
497        assert_eq!(TimeSpan::from_seconds(-0.5).ticks(), -5_000_000);
498        assert_eq!(TimeSpan::from_milliseconds(10.0).ticks(), 100_000);
499        assert_eq!(TimeSpan::from_seconds(0.5).total_seconds(), 0.5);
500    }
501
502    #[test]
503    fn parse_accepts_the_invariant_formats() {
504        assert_eq!(TimeSpan::parse("0:0:1").unwrap(), TimeSpan::from_seconds(1.0));
505        assert_eq!(TimeSpan::parse("00:00:00.3").unwrap(), TimeSpan::from_milliseconds(300.0));
506        assert_eq!(TimeSpan::parse("00:00:10.0153932").unwrap().ticks(), 100_153_932);
507        assert_eq!(TimeSpan::parse("1").unwrap().ticks(), TimeSpan::TICKS_PER_DAY);
508        assert_eq!(TimeSpan::parse("1:30").unwrap().ticks(), TimeSpan::TICKS_PER_HOUR + 30 * TimeSpan::TICKS_PER_MINUTE);
509        assert_eq!(TimeSpan::parse("1.02:03:04").unwrap().ticks(), TimeSpan::parse("1:2:3:4").unwrap().ticks());
510        assert_eq!(TimeSpan::parse(" -0:0:2 ").unwrap(), TimeSpan::from_seconds(-2.0));
511        // The seconds may be left out before their fraction.
512        assert_eq!(TimeSpan::parse("0:0:.075").unwrap().ticks(), 750_000);
513    }
514
515    #[test]
516    fn parse_rejects_malformed_input() {
517        for input in ["", "abc", "1:2:3:4:5", "0:60:0", "24:00", "0:0:1.", "0:0:1.12345678", "1,5"] {
518            assert!(TimeSpan::parse(input).is_err(), "{input}");
519        }
520    }
521
522    #[test]
523    fn display_round_trips() {
524        for input in ["00:00:01", "1.02:03:04", "-00:00:02.5000000", "00:00:10.0153932"] {
525            assert_eq!(TimeSpan::parse(input).unwrap().to_string(), input);
526        }
527    }
528
529    /// The expected texts are the ones .NET gives (`TimeSpan.ToString(format,
530    /// CultureInfo.InvariantCulture)`); `None` is a `FormatException`.
531    #[test]
532    fn custom_formats_match_net() {
533        let spans = [
534            TimeSpan::from_hms(0, 0, 0),
535            TimeSpan::from_hms(9, 4, 5),
536            TimeSpan::from_hms(15, 4, 5),
537            TimeSpan::from_dhms(1, 2, 3, 4),
538            -TimeSpan::from_hms(3, 4, 5),
539            TimeSpan::from_dhms_milliseconds(0, 23, 59, 59, 999),
540        ];
541        let rows: [(&str, Option<[&str; 6]>); 20] = [
542            ("%h", Some(["0", "9", "15", "2", "3", "23"])),
543            ("hh", Some(["00", "09", "15", "02", "03", "23"])),
544            ("h", None),
545            ("%m", Some(["0", "4", "4", "3", "4", "59"])),
546            ("mm", Some(["00", "04", "04", "03", "04", "59"])),
547            ("ss", Some(["00", "05", "05", "04", "05", "59"])),
548            ("%s", Some(["0", "5", "5", "4", "5", "59"])),
549            ("%'a'", None),
550            ("%%", None),
551            ("h\\:mm", Some(["0:00", "9:04", "15:04", "2:03", "3:04", "23:59"])),
552            ("hh':'mm", Some(["00:00", "09:04", "15:04", "02:03", "03:04", "23:59"])),
553            ("%d", Some(["0", "0", "0", "1", "0", "0"])),
554            ("hhh", None),
555            ("%hh", Some(["00", "99", "1515", "22", "33", "2323"])),
556            ("h mm", None),
557            ("\\h", Some(["h"; 6])),
558            ("'ab", None),
559            ("%\\h", None),
560            ("ff", Some(["00", "00", "00", "00", "00", "99"])),
561            ("%F", Some(["", "", "", "", "", "9"])),
562        ];
563        for (format, expected) in rows {
564            for (index, span) in spans.iter().enumerate() {
565                let actual = span.try_to_string_format(format).ok();
566                assert_eq!(expected.map(|texts| texts[index].to_string()), actual, "{format:?} of {span}");
567            }
568        }
569
570        // A backslash escapes inside a quoted literal too.
571        assert_eq!("h'm 09", TimeSpan::from_hms(9, 4, 5).to_string_format("'h\\'m 'hh"));
572        assert!(TimeSpan::from_hms(9, 4, 5).try_to_string_format("'h\\'").is_err());
573    }
574
575    #[test]
576    fn converts_from_and_to_duration() {
577        assert_eq!(TimeSpan::from(Duration::from_millis(1500)), TimeSpan::from_seconds(1.5));
578        assert_eq!(TimeSpan::from_seconds(1.5).to_duration(), Some(Duration::from_millis(1500)));
579        assert_eq!(TimeSpan::from_seconds(-1.0).to_duration(), None);
580    }
581}