Skip to main content

azul_css/props/basic/
time.rs

1//! CSS property types for time durations (`s`, `ms`, `t`).
2
3use crate::corety::AzString;
4use alloc::string::{String, ToString};
5
6use crate::props::formatter::PrintAsCssValue;
7
8/// Nominal engine tick (frame) rate, in ticks per second.
9///
10/// The CSS `t` unit — and `azul_core::task::Duration::Tick` behind it — counts
11/// FRAMES, not wall time. Nothing needs a real clock to advance a tick; that is
12/// the entire point of the unit. But a tick span still has to be COMPARABLE
13/// against a wall-clock one, because the engine's interval constants are
14/// milliseconds (`Duration::System`) and a comparison between the two variants
15/// has to answer something truthful rather than "not yet, forever".
16///
17/// This constant is the single exchange rate between the two scales, shared by
18/// `azul-css` (parsing/printing) and `azul-core` (`Duration` arithmetic). It is
19/// NOT a clock: nothing reads it to decide *when* a frame happens, only how many
20/// nanoseconds a frame is worth when the two units must be put side by side.
21///
22/// 60 Hz because that is the frame budget the renderer already targets (see the
23/// `16_666_667`ns scroll-animation step in `azul-layout`), so `1t` is one frame
24/// at the target rate and `60t` is exactly one second.
25pub const TICKS_PER_SECOND: u64 = 60;
26
27/// The unit a [`CssDuration`]'s magnitude is expressed in.
28///
29/// `Milliseconds` is the CSS `ms` / `s` family (wall time). `Ticks` is the CSS
30/// `t` unit: engine frames, which advance because the engine rendered, not
31/// because a clock ticked. `t` was chosen over `fr` because `fr` is already
32/// taken by CSS grid (`grid-template-columns: 1fr`) and would collide in
33/// dimension parsing.
34#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
35#[repr(C)]
36pub enum CssDurationUnit {
37    /// Wall-clock milliseconds (parsed from `ms` and `s`).
38    #[default]
39    Milliseconds,
40    /// Engine ticks / frames (parsed from `t`).
41    Ticks,
42}
43
44impl PrintAsCssValue for CssDurationUnit {
45    fn print_as_css_value(&self) -> String {
46        match self {
47            Self::Milliseconds => "ms".to_string(),
48            Self::Ticks => "t".to_string(),
49        }
50    }
51}
52
53impl crate::codegen::format::FormatAsRustCode for CssDurationUnit {
54    fn format_as_rust_code(&self, _tabs: usize) -> String {
55        match self {
56            Self::Milliseconds => "CssDurationUnit::Milliseconds".to_string(),
57            Self::Ticks => "CssDurationUnit::Ticks".to_string(),
58        }
59    }
60}
61
62/// A CSS time duration: a magnitude plus the unit it is counted in.
63///
64/// `inner` is NOT unconditionally milliseconds — read it together with `unit`,
65/// or go through [`CssDuration::millis`] / [`CssDuration::ticks`], which convert.
66///
67/// The derived `Ord` compares `inner` first and only then `unit`, so it is a
68/// total order for storage/dedup purposes but is NOT a chronological comparison
69/// across units (`5ms` sorts below `5t` purely by field order). Compare
70/// durations chronologically by converting them first, or by handing them to
71/// `azul_core::task::Duration`, which compares on a canonical scale.
72#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
73#[repr(C)]
74#[derive(Default)]
75pub struct CssDuration {
76    /// Magnitude, counted in `unit`.
77    pub inner: u32,
78    /// The unit `inner` is counted in.
79    pub unit: CssDurationUnit,
80}
81
82impl CssDuration {
83    /// A duration of `ms` wall-clock milliseconds.
84    #[must_use]
85    pub const fn from_millis(ms: u32) -> Self {
86        Self {
87            inner: ms,
88            unit: CssDurationUnit::Milliseconds,
89        }
90    }
91
92    /// A duration of `ticks` engine frames (the CSS `t` unit).
93    #[must_use]
94    pub const fn from_ticks(ticks: u32) -> Self {
95        Self {
96            inner: ticks,
97            unit: CssDurationUnit::Ticks,
98        }
99    }
100
101    /// This duration in whole milliseconds, converting ticks at
102    /// [`TICKS_PER_SECOND`] and truncating toward zero.
103    ///
104    /// Saturates at `u32::MAX` rather than wrapping: `u32::MAX` ticks is ~828
105    /// days, which does not fit `u32` milliseconds.
106    // `as` rather than `From`/`TryFrom`: this is a `const fn`. The widening is
107    // lossless and the narrowing is range-checked immediately above it.
108    #[allow(clippy::cast_lossless, clippy::cast_possible_truncation)]
109    #[must_use]
110    pub const fn millis(&self) -> u32 {
111        match self.unit {
112            CssDurationUnit::Milliseconds => self.inner,
113            CssDurationUnit::Ticks => {
114                // `* 1000` first, then divide: 60t is exactly 1000ms, not 996ms.
115                let ms = (self.inner as u64) * 1000 / TICKS_PER_SECOND;
116                if ms > u32::MAX as u64 {
117                    u32::MAX
118                } else {
119                    ms as u32
120                }
121            }
122        }
123    }
124
125    /// This duration in whole ticks, converting milliseconds at
126    /// [`TICKS_PER_SECOND`] and truncating toward zero.
127    ///
128    /// Truncation means a sub-frame duration (`10ms` at 60Hz) is **zero** ticks,
129    /// not one — "how many whole frames fit in this span".
130    // `as` rather than `From`/`TryFrom`: this is a `const fn`. `u32::MAX * 60 /
131    // 1000` is ~2.6e8, comfortably inside u32, so the narrowing cannot truncate.
132    #[allow(clippy::cast_lossless, clippy::cast_possible_truncation)]
133    #[must_use]
134    pub const fn ticks(&self) -> u32 {
135        match self.unit {
136            CssDurationUnit::Ticks => self.inner,
137            CssDurationUnit::Milliseconds => {
138                // Cannot overflow: u32::MAX ms * 60 / 1000 < u32::MAX.
139                ((self.inner as u64) * TICKS_PER_SECOND / 1000) as u32
140            }
141        }
142    }
143}
144
145impl PrintAsCssValue for CssDuration {
146    fn print_as_css_value(&self) -> String {
147        format!("{}{}", self.inner, self.unit.print_as_css_value())
148    }
149}
150
151impl crate::codegen::format::FormatAsRustCode for CssDuration {
152    fn format_as_rust_code(&self, _tabs: usize) -> String {
153        use crate::codegen::format::FormatAsRustCode;
154        format!(
155            "CssDuration {{ inner: {}, unit: {} }}",
156            self.inner,
157            self.unit.format_as_rust_code(0)
158        )
159    }
160}
161
162/// Error returned when parsing a CSS duration string fails.
163#[cfg(feature = "parser")]
164#[derive(Clone, PartialEq, Eq)]
165pub enum DurationParseError<'a> {
166    InvalidValue(&'a str),
167    ParseFloat(core::num::ParseFloatError),
168}
169
170#[cfg(feature = "parser")]
171impl_debug_as_display!(DurationParseError<'a>);
172#[cfg(feature = "parser")]
173impl_display! { DurationParseError<'a>, {
174    InvalidValue(v) => format!("Invalid time value: \"{}\"", v),
175    ParseFloat(e) => format!("Invalid number for time value: {}", e),
176}}
177
178/// Owned version of [`DurationParseError`] for FFI and storage.
179#[cfg(feature = "parser")]
180#[derive(Debug, Clone, PartialEq, Eq)]
181#[repr(C, u8)]
182pub enum DurationParseErrorOwned {
183    InvalidValue(AzString),
184    ParseFloat(AzString),
185}
186
187#[cfg(feature = "parser")]
188impl DurationParseError<'_> {
189    #[must_use]
190    pub fn to_contained(&self) -> DurationParseErrorOwned {
191        match self {
192            Self::InvalidValue(s) => DurationParseErrorOwned::InvalidValue((*s).to_string().into()),
193            Self::ParseFloat(e) => DurationParseErrorOwned::ParseFloat(e.to_string().into()),
194        }
195    }
196}
197
198#[cfg(feature = "parser")]
199impl DurationParseErrorOwned {
200    #[must_use]
201    pub fn to_shared(&self) -> DurationParseError<'_> {
202        match self {
203            Self::InvalidValue(s) => DurationParseError::InvalidValue(s),
204            Self::ParseFloat(s) => DurationParseError::InvalidValue(s.as_str()),
205        }
206    }
207}
208
209/// Parses a CSS duration string (e.g. `"200ms"`, `"1.5s"`, `"5t"`) into a
210/// [`CssDuration`].
211///
212/// Three units are accepted:
213///
214/// * `ms` — milliseconds
215/// * `s`  — seconds (stored as milliseconds)
216/// * `t`  — engine ticks / frames, kept as ticks (see [`CssDurationUnit::Ticks`])
217///
218/// `t` is deliberately NOT normalised to milliseconds here: the whole reason the
219/// unit exists is that a tick count survives to the timer as an exact frame
220/// count, so a test can advance N ticks and assert the Nth frame — and only the
221/// Nth — flipped. Converting at parse time would throw that away and reintroduce
222/// the wall-clock rounding the unit is meant to escape.
223#[cfg(feature = "parser")]
224/// # Errors
225///
226/// Returns an error if `input` is not a valid CSS `duration` value.
227pub fn parse_duration(input: &str) -> Result<CssDuration, DurationParseError<'_>> {
228    let trimmed = input.trim().to_lowercase();
229    if trimmed == "0" {
230        return Ok(CssDuration::from_millis(0));
231    }
232    // Suffix order matters: `ms` must be stripped before the bare `s`, otherwise
233    // "5ms" reads as 5 *seconds*. `t` shares no suffix with either, so it can sit
234    // anywhere in the chain.
235    if let Some(num_str) = trimmed.strip_suffix("ms") {
236        let ms = num_str
237            .parse::<f32>()
238            .map_err(DurationParseError::ParseFloat)?;
239        if ms < 0.0 {
240            return Err(DurationParseError::InvalidValue(input));
241        }
242        Ok(CssDuration::from_millis(crate::cast::f32_to_u32(ms)))
243    } else if let Some(num_str) = trimmed.strip_suffix('s') {
244        let s = num_str
245            .parse::<f32>()
246            .map_err(DurationParseError::ParseFloat)?;
247        if s < 0.0 {
248            return Err(DurationParseError::InvalidValue(input));
249        }
250        Ok(CssDuration::from_millis(crate::cast::f32_to_u32(
251            s * 1000.0,
252        )))
253    } else if let Some(num_str) = trimmed.strip_suffix('t') {
254        let t = num_str
255            .parse::<f32>()
256            .map_err(DurationParseError::ParseFloat)?;
257        if t < 0.0 {
258            return Err(DurationParseError::InvalidValue(input));
259        }
260        Ok(CssDuration::from_ticks(crate::cast::f32_to_u32(t)))
261    } else {
262        Err(DurationParseError::InvalidValue(input))
263    }
264}
265
266#[cfg(test)]
267#[allow(clippy::unreadable_literal)]
268mod autotest_generated {
269    use super::*;
270    use crate::codegen::format::FormatAsRustCode;
271    use crate::props::formatter::PrintAsCssValue;
272
273    /// Largest integer an `f32` represents exactly (`2^24`). Above this, the
274    /// spacing between neighbouring `f32`s exceeds 1ms, so `parse_duration`
275    /// (which round-trips through `f32`) can no longer be lossless.
276    #[cfg(feature = "parser")]
277    const TWO_POW_24: u32 = 16_777_216;
278
279    /// Convenience: parse, assert the result is in milliseconds, and unwrap to
280    /// the raw millisecond count.
281    #[cfg(feature = "parser")]
282    fn ms(input: &str) -> u32 {
283        let d = parse_duration(input)
284            .unwrap_or_else(|e| panic!("expected {input:?} to parse, got {e}"));
285        assert_eq!(
286            d.unit,
287            CssDurationUnit::Milliseconds,
288            "{input:?} parsed as {:?}, not milliseconds",
289            d.unit
290        );
291        d.inner
292    }
293
294    /// Convenience: parse, assert the result is in ticks, and unwrap to the raw
295    /// tick count.
296    #[cfg(feature = "parser")]
297    fn ticks(input: &str) -> u32 {
298        let d = parse_duration(input)
299            .unwrap_or_else(|e| panic!("expected {input:?} to parse, got {e}"));
300        assert_eq!(
301            d.unit,
302            CssDurationUnit::Ticks,
303            "{input:?} parsed as {:?}, not ticks",
304            d.unit
305        );
306        d.inner
307    }
308
309    // ------------------------------------------------------ positive control ---
310
311    #[cfg(feature = "parser")]
312    #[test]
313    fn valid_minimal_inputs_parse_to_expected_values() {
314        assert_eq!(ms("0"), 0);
315        assert_eq!(ms("0ms"), 0);
316        assert_eq!(ms("0s"), 0);
317        assert_eq!(ms("200ms"), 200);
318        assert_eq!(ms("1s"), 1000);
319        assert_eq!(ms("1.5s"), 1500);
320        assert_eq!(ms("0.5s"), 500);
321        assert_eq!(ms(".25s"), 250);
322        assert_eq!(ms("5e2ms"), 500);
323        assert_eq!(ms("+5ms"), 5);
324    }
325
326    /// The `ms` suffix must be stripped before the bare `s` suffix, otherwise
327    /// `"5ms"` would be read as 5 *seconds* (a 1000x error).
328    #[cfg(feature = "parser")]
329    #[test]
330    fn ms_suffix_wins_over_s_suffix() {
331        assert_eq!(ms("5ms"), 5);
332        assert_ne!(ms("5ms"), ms("5s"));
333        assert_eq!(ms("5s"), 5000);
334    }
335
336    #[cfg(feature = "parser")]
337    #[test]
338    fn units_are_case_insensitive() {
339        assert_eq!(ms("200MS"), 200);
340        assert_eq!(ms("200Ms"), 200);
341        assert_eq!(ms("1S"), 1000);
342        assert_eq!(ms("1.5E1S"), 15000);
343        assert_eq!(ticks("5T"), 5);
344    }
345
346    // ---------------------------------------------------------- tick unit ---
347
348    /// `t` counts FRAMES and must survive parsing as a frame count. If this ever
349    /// starts returning milliseconds, every "advance exactly N ticks" test
350    /// silently becomes a wall-clock test again.
351    #[cfg(feature = "parser")]
352    #[test]
353    fn the_t_unit_parses_to_a_tick_count_and_is_not_normalised_to_millis() {
354        assert_eq!(parse_duration("5t"), Ok(CssDuration::from_ticks(5)));
355        assert_eq!(ticks("0t"), 0);
356        assert_eq!(ticks("1t"), 1);
357        assert_eq!(ticks("60t"), 60);
358        assert_eq!(ticks("4294967295t"), u32::MAX);
359        // Not milliseconds, and not silently multiplied by anything.
360        assert_ne!(parse_duration("5t"), parse_duration("5ms"));
361        assert_ne!(parse_duration("60t"), parse_duration("1s"));
362    }
363
364    /// `t` is only ever the *last* suffix tried, so it must not steal values that
365    /// belong to `ms` / `s`, and it must not accept unit-ish garbage.
366    #[cfg(feature = "parser")]
367    #[test]
368    fn the_t_unit_does_not_collide_with_the_other_units_or_swallow_garbage() {
369        assert_eq!(ms("5ms"), 5);
370        assert_eq!(ms("5s"), 5000);
371        // Suffixes that merely END in `t` are not durations.
372        for garbage in ["5pt", "5t5", "t", "5tt", "5mst", "5st", "5 t", "-5t"] {
373            assert!(
374                parse_duration(garbage).is_err(),
375                "expected {garbage:?} to be rejected"
376            );
377        }
378    }
379
380    /// Truncation across units is exact at the boundaries that matter: 60 ticks
381    /// is one whole second, and a sub-frame millisecond span is zero frames (not
382    /// one) — "how many whole frames fit", never "round up so something happens".
383    #[test]
384    fn millis_and_ticks_convert_at_the_nominal_frame_rate() {
385        assert_eq!(TICKS_PER_SECOND, 60);
386
387        assert_eq!(CssDuration::from_ticks(60).millis(), 1000);
388        assert_eq!(CssDuration::from_ticks(30).millis(), 500);
389        assert_eq!(CssDuration::from_ticks(1).millis(), 16);
390        assert_eq!(CssDuration::from_ticks(0).millis(), 0);
391
392        assert_eq!(CssDuration::from_millis(1000).ticks(), 60);
393        assert_eq!(CssDuration::from_millis(500).ticks(), 30);
394        assert_eq!(
395            CssDuration::from_millis(16).ticks(),
396            0,
397            "sub-frame is 0 frames"
398        );
399        assert_eq!(CssDuration::from_millis(17).ticks(), 1);
400        assert_eq!(CssDuration::from_millis(0).ticks(), 0);
401
402        // Same-unit conversions are the identity, not a round-trip through the
403        // other scale (which would lose precision).
404        assert_eq!(CssDuration::from_millis(7).millis(), 7);
405        assert_eq!(CssDuration::from_ticks(7).ticks(), 7);
406    }
407
408    /// `u32::MAX` ticks is ~828 days, which does not fit in `u32` milliseconds.
409    /// It must clamp, not wrap.
410    #[test]
411    fn tick_to_milli_conversion_saturates_instead_of_wrapping() {
412        assert_eq!(CssDuration::from_ticks(u32::MAX).millis(), u32::MAX);
413        // The largest tick count that still fits: floor(u32::MAX * 60 / 1000).
414        let last_exact = (u64::from(u32::MAX) * TICKS_PER_SECOND / 1000) as u32;
415        assert!(CssDuration::from_ticks(last_exact).millis() < u32::MAX);
416        // ...and the reverse direction cannot overflow at all.
417        assert_eq!(
418            CssDuration::from_millis(u32::MAX).ticks(),
419            (u64::from(u32::MAX) * TICKS_PER_SECOND / 1000) as u32
420        );
421    }
422
423    // ----------------------------------------------------------- truncation ---
424
425    /// Fractional milliseconds are truncated toward zero, never rounded.
426    #[cfg(feature = "parser")]
427    #[test]
428    fn sub_millisecond_values_truncate_toward_zero() {
429        assert_eq!(ms("5.9ms"), 5);
430        assert_eq!(ms("0.9ms"), 0);
431        assert_eq!(ms("0.0009s"), 0); // 0.9ms
432        assert_eq!(ms("0.0015s"), 1); // 1.5ms
433    }
434
435    // ------------------------------------------------------- empty / blank ---
436
437    #[cfg(feature = "parser")]
438    #[test]
439    fn empty_input_is_rejected_without_panicking() {
440        assert_eq!(
441            parse_duration(""),
442            Err(DurationParseError::InvalidValue(""))
443        );
444    }
445
446    #[cfg(feature = "parser")]
447    #[test]
448    fn whitespace_only_input_is_rejected_and_error_keeps_the_raw_input() {
449        // The input is trimmed for parsing but the *error* carries the original
450        // (untrimmed) slice, so callers can point at the offending source text.
451        assert_eq!(
452            parse_duration("   "),
453            Err(DurationParseError::InvalidValue("   "))
454        );
455        assert_eq!(
456            parse_duration("\t\n"),
457            Err(DurationParseError::InvalidValue("\t\n"))
458        );
459    }
460
461    // ---------------------------------------------------------- malformed ---
462
463    #[cfg(feature = "parser")]
464    #[test]
465    fn a_bare_unit_with_no_number_is_a_parse_float_error_not_a_panic() {
466        assert!(matches!(
467            parse_duration("ms"),
468            Err(DurationParseError::ParseFloat(_))
469        ));
470        assert!(matches!(
471            parse_duration("s"),
472            Err(DurationParseError::ParseFloat(_))
473        ));
474    }
475
476    #[cfg(feature = "parser")]
477    #[test]
478    fn unitless_numbers_other_than_literal_zero_are_rejected() {
479        // Only the exact string "0" is accepted without a unit.
480        assert_eq!(ms("0"), 0);
481        assert_eq!(
482            parse_duration("200"),
483            Err(DurationParseError::InvalidValue("200"))
484        );
485        assert_eq!(
486            parse_duration("1.5"),
487            Err(DurationParseError::InvalidValue("1.5"))
488        );
489        assert_eq!(
490            parse_duration("0.0"),
491            Err(DurationParseError::InvalidValue("0.0"))
492        );
493        assert_eq!(
494            parse_duration("00"),
495            Err(DurationParseError::InvalidValue("00"))
496        );
497        assert_eq!(
498            parse_duration("-0"),
499            Err(DurationParseError::InvalidValue("-0"))
500        );
501    }
502
503    #[cfg(feature = "parser")]
504    #[test]
505    fn garbage_and_junk_never_panic() {
506        for garbage in [
507            "abc",
508            "!!!",
509            "\0\0\0",
510            "ms ms",
511            "1,5s",
512            "1 ms",
513            "--5ms",
514            "5mss",
515            "5sms",
516            "0x10ms",
517            "1e",
518            "1e+",
519            ".s",
520            "-.ms",
521            "s1",
522            "ms200",
523            "200ms;garbage",
524            "200ms !important",
525        ] {
526            // The only contract is: never panic, and never silently succeed with
527            // a value we did not ask for. Every one of these is an error.
528            assert!(
529                parse_duration(garbage).is_err(),
530                "expected {garbage:?} to be rejected"
531            );
532        }
533    }
534
535    #[cfg(feature = "parser")]
536    #[test]
537    fn leading_and_trailing_whitespace_is_trimmed_but_interior_space_is_not() {
538        assert_eq!(ms("   200ms   "), 200);
539        assert_eq!(ms("\t\n1.5s\r\n"), 1500);
540        // Interior whitespace stays inside the number and kills the float parse.
541        assert!(matches!(
542            parse_duration("200 ms"),
543            Err(DurationParseError::ParseFloat(_))
544        ));
545        assert!(matches!(
546            parse_duration("2 0 0ms"),
547            Err(DurationParseError::ParseFloat(_))
548        ));
549    }
550
551    #[cfg(feature = "parser")]
552    #[test]
553    fn trailing_junk_after_a_valid_value_is_rejected_not_silently_accepted() {
554        assert!(parse_duration("200ms;").is_err());
555        assert!(parse_duration("200msx").is_err());
556        // ...but note "200msms" strips one "ms" and then fails the float parse.
557        assert!(matches!(
558            parse_duration("200msms"),
559            Err(DurationParseError::ParseFloat(_))
560        ));
561    }
562
563    // ------------------------------------------------------------ negative ---
564
565    #[cfg(feature = "parser")]
566    #[test]
567    fn negative_durations_are_rejected_in_both_units() {
568        assert_eq!(
569            parse_duration("-1ms"),
570            Err(DurationParseError::InvalidValue("-1ms"))
571        );
572        assert_eq!(
573            parse_duration("-0.5s"),
574            Err(DurationParseError::InvalidValue("-0.5s"))
575        );
576        assert_eq!(
577            parse_duration("-1e-30s"),
578            Err(DurationParseError::InvalidValue("-1e-30s"))
579        );
580    }
581
582    #[cfg(feature = "parser")]
583    #[test]
584    fn the_invalid_value_error_reports_the_original_untrimmed_uncased_input() {
585        // Not the lowercased/trimmed copy used internally.
586        assert_eq!(
587            parse_duration("  -1MS  "),
588            Err(DurationParseError::InvalidValue("  -1MS  "))
589        );
590    }
591
592    /// `-0.0 < 0.0` is false, so signed zero slips past the negativity check —
593    /// but the cast lands on `0`, so the result is still sane.
594    #[cfg(feature = "parser")]
595    #[test]
596    fn negative_zero_is_accepted_and_clamps_to_zero() {
597        assert_eq!(ms("-0ms"), 0);
598        assert_eq!(ms("-0.0s"), 0);
599        assert_eq!(ms("-0e10ms"), 0);
600    }
601
602    // ---------------------------------------------- overflow / saturation ---
603
604    #[cfg(feature = "parser")]
605    #[test]
606    fn values_beyond_u32_max_saturate_instead_of_wrapping_or_panicking() {
607        assert_eq!(ms("4294967296ms"), u32::MAX); // 2^32 exactly
608        assert_eq!(ms("99999999999ms"), u32::MAX);
609        assert_eq!(ms("1e30s"), u32::MAX);
610        assert_eq!(ms("5000000s"), u32::MAX); // 5e6 * 1000 = 5e9 > u32::MAX
611    }
612
613    /// A float literal too large for `f32` parses to `+inf` (not an error), and
614    /// `inf as u32` saturates. Assert the whole chain lands on `u32::MAX`.
615    #[cfg(feature = "parser")]
616    #[test]
617    fn float_overflow_to_infinity_saturates_to_u32_max() {
618        assert_eq!(ms("1e39ms"), u32::MAX); // > f32::MAX
619        assert_eq!(ms("1e999999ms"), u32::MAX);
620        assert_eq!(ms("infms"), u32::MAX);
621        assert_eq!(ms("infinityms"), u32::MAX);
622        assert_eq!(ms("INFms"), u32::MAX);
623        assert_eq!(ms("infs"), u32::MAX);
624    }
625
626    #[cfg(feature = "parser")]
627    #[test]
628    fn negative_infinity_is_rejected_as_a_negative_duration() {
629        assert_eq!(
630            parse_duration("-infms"),
631            Err(DurationParseError::InvalidValue("-infms"))
632        );
633        assert_eq!(
634            parse_duration("-infinitys"),
635            Err(DurationParseError::InvalidValue("-infinitys"))
636        );
637    }
638
639    /// `NaN < 0.0` is false, so `"nan"` is *accepted* rather than rejected; the
640    /// saturating cast then turns it into `0ms`. Documented here so that any
641    /// future change to reject NaN outright is a visible, intentional change.
642    #[cfg(feature = "parser")]
643    #[test]
644    fn nan_is_accepted_and_degrades_to_zero_rather_than_panicking() {
645        assert_eq!(ms("nanms"), 0);
646        assert_eq!(ms("NaNms"), 0);
647        assert_eq!(ms("-nanms"), 0);
648        assert_eq!(ms("nans"), 0); // NaN * 1000.0 is still NaN
649    }
650
651    #[cfg(feature = "parser")]
652    #[test]
653    fn underflow_to_zero_is_not_an_error() {
654        assert_eq!(ms("1e-30ms"), 0);
655        assert_eq!(ms("1e-999999s"), 0);
656    }
657
658    #[cfg(feature = "parser")]
659    #[test]
660    fn u32_max_and_f32_max_boundary_strings_are_handled() {
661        assert_eq!(ms("4294967295ms"), u32::MAX); // u32::MAX, rounds up in f32 then saturates back
662        assert_eq!(ms("4294967040ms"), 4294967040); // 2^32 - 256: exactly representable in f32
663
664        let f32_max = format!("{}ms", f32::MAX);
665        assert_eq!(ms(&f32_max), u32::MAX);
666
667        let i64_max = format!("{}ms", i64::MAX);
668        assert_eq!(ms(&i64_max), u32::MAX);
669    }
670
671    // ------------------------------------------------------------ huge input ---
672
673    #[cfg(feature = "parser")]
674    #[test]
675    fn extremely_long_digit_string_saturates_without_hanging() {
676        let mut input = "9".repeat(100_000);
677        input.push_str("ms");
678        assert_eq!(ms(&input), u32::MAX);
679    }
680
681    #[cfg(feature = "parser")]
682    #[test]
683    fn extremely_long_run_of_leading_zeros_still_parses_exactly() {
684        let mut input = "0".repeat(100_000);
685        input.push_str("1ms");
686        assert_eq!(ms(&input), 1);
687    }
688
689    #[cfg(feature = "parser")]
690    #[test]
691    fn extremely_long_garbage_is_rejected_without_hanging() {
692        let input = "x".repeat(100_000);
693        assert!(parse_duration(&input).is_err());
694
695        // Long, *trimmable* padding around a valid value.
696        let padded = format!("{}200ms{}", " ".repeat(50_000), " ".repeat(50_000));
697        assert_eq!(ms(&padded), 200);
698    }
699
700    #[cfg(feature = "parser")]
701    #[test]
702    fn deeply_nested_brackets_do_not_stack_overflow() {
703        // The parser is not recursive; prove it by feeding it 10k nesting levels.
704        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
705        assert!(parse_duration(&nested).is_err());
706
707        let nested_with_unit = format!("{nested}s");
708        assert!(parse_duration(&nested_with_unit).is_err());
709    }
710
711    // -------------------------------------------------------------- unicode ---
712
713    #[cfg(feature = "parser")]
714    #[test]
715    fn non_ascii_input_is_rejected_without_panicking() {
716        for input in [
717            "\u{1F600}",     // emoji
718            "\u{1F600}ms",   // emoji + valid unit
719            "1\u{FF53}",     // FULLWIDTH LATIN SMALL LETTER S is not "s"
720            "1s\u{0301}",    // combining acute after the unit
721            "200ms",      // fullwidth digits
722            "\u{202E}200ms", // RTL override prefix
723            "1\u{00A0}s",    // NBSP *inside* the value
724        ] {
725            assert!(
726                parse_duration(input).is_err(),
727                "expected {input:?} to be rejected"
728            );
729        }
730    }
731
732    /// `str::trim` strips Unicode whitespace, not just ASCII.
733    #[cfg(feature = "parser")]
734    #[test]
735    fn unicode_whitespace_around_a_valid_value_is_trimmed() {
736        assert_eq!(ms("\u{00A0}200ms\u{00A0}"), 200); // NBSP
737        assert_eq!(ms("\u{3000}1.5s\u{3000}"), 1500); // ideographic space
738    }
739
740    /// `to_lowercase` can *grow* the string (`İ` -> `i` + combining dot), which
741    /// would corrupt any byte-index-based suffix logic. Suffix stripping here is
742    /// char-safe, so this must merely fail to parse.
743    #[cfg(feature = "parser")]
744    #[test]
745    fn lowercasing_that_changes_the_byte_length_does_not_panic() {
746        assert!(parse_duration("\u{0130}ms").is_err()); // LATIN CAPITAL I WITH DOT ABOVE
747        assert!(parse_duration("1\u{0130}s").is_err());
748    }
749
750    // ----------------------------------------------------------- round-trip ---
751
752    #[cfg(feature = "parser")]
753    #[test]
754    fn print_as_css_value_round_trips_through_parse_duration() {
755        for inner in [
756            0,
757            1,
758            2,
759            17,
760            999,
761            1000,
762            65_535,
763            1_000_000,
764            TWO_POW_24,    // last exactly-representable integer in f32
765            4_294_967_040, // 2^32 - 256: still exact (a multiple of the f32 ulp there)
766            u32::MAX,      // rounds up to 2^32 in f32, then the cast saturates back down
767        ] {
768            let duration = CssDuration::from_millis(inner);
769            let printed = duration.print_as_css_value();
770            assert_eq!(
771                parse_duration(&printed),
772                Ok(duration),
773                "round-trip failed for {inner}ms (printed as {printed:?})"
774            );
775        }
776    }
777
778    /// A tick duration must print back as `t` and reparse as the SAME tick count.
779    /// A printer that emitted `ms` here would silently convert every stylesheet
780    /// round-trip from frames to wall time.
781    #[cfg(feature = "parser")]
782    #[test]
783    fn print_as_css_value_round_trips_tick_durations_as_ticks() {
784        for inner in [0, 1, 5, 60, 999, TWO_POW_24, u32::MAX] {
785            let duration = CssDuration::from_ticks(inner);
786            let printed = duration.print_as_css_value();
787            assert_eq!(printed, format!("{inner}t"));
788            assert_eq!(
789                parse_duration(&printed),
790                Ok(duration),
791                "round-trip failed for {inner}t (printed as {printed:?})"
792            );
793        }
794    }
795
796    #[test]
797    fn print_as_css_value_always_emits_the_unit_it_was_built_with() {
798        for inner in [0, 1, u32::MAX] {
799            let printed = CssDuration::from_millis(inner).print_as_css_value();
800            assert!(printed.ends_with("ms"), "{printed:?} lacks a unit");
801            assert_eq!(printed, format!("{inner}ms"));
802
803            let printed = CssDuration::from_ticks(inner).print_as_css_value();
804            assert!(printed.ends_with('t'), "{printed:?} lacks a unit");
805            assert!(!printed.ends_with("ms"), "{printed:?} lost the tick unit");
806            assert_eq!(printed, format!("{inner}t"));
807        }
808    }
809
810    /// Above `2^24` the millisecond count no longer survives an `f32`, so the
811    /// round-trip is lossy. This is a real precision limit of the parser, pinned
812    /// here so it cannot regress further (the error must stay within one ulp).
813    #[cfg(feature = "parser")]
814    #[test]
815    fn round_trip_above_two_pow_24_is_lossy_but_bounded() {
816        let duration = CssDuration::from_millis(TWO_POW_24 + 1);
817        let reparsed = parse_duration(&duration.print_as_css_value()).unwrap();
818        assert_ne!(reparsed.inner, duration.inner);
819        assert_eq!(reparsed.inner, TWO_POW_24);
820        assert!(reparsed.inner.abs_diff(duration.inner) <= 1);
821    }
822
823    #[cfg(feature = "parser")]
824    #[test]
825    fn seconds_and_milliseconds_agree_for_the_same_duration() {
826        assert_eq!(ms("2s"), ms("2000ms"));
827        assert_eq!(ms("0.001s"), ms("1ms"));
828        assert_eq!(ms("0s"), ms("0ms"));
829    }
830
831    // ------------------------------------------------------- CssDuration ---
832
833    /// The default unit is milliseconds, not ticks: every pre-existing
834    /// `CssDuration::default()` in the tree means "0ms", and a default that
835    /// silently meant frames would reinterpret all of them.
836    #[test]
837    fn default_duration_is_zero_milliseconds() {
838        assert_eq!(CssDuration::default(), CssDuration::from_millis(0));
839        assert_eq!(CssDuration::default().inner, 0);
840        assert_eq!(CssDuration::default().unit, CssDurationUnit::Milliseconds);
841        assert_eq!(CssDurationUnit::default(), CssDurationUnit::Milliseconds);
842    }
843
844    #[test]
845    fn ordering_and_equality_follow_the_inner_count_within_one_unit() {
846        let a = CssDuration::from_millis(1);
847        let b = CssDuration::from_millis(2);
848        let max = CssDuration::from_millis(u32::MAX);
849        assert!(a < b);
850        assert!(b < max);
851        assert_eq!(a, CssDuration::from_millis(1));
852        assert_eq!(a.max(b), b);
853        assert_eq!(CssDuration::default(), CssDuration::from_millis(0));
854
855        // Same magnitude, different unit: NOT equal. `5ms` and `5t` are
856        // different durations and must never compare equal, or a stylesheet
857        // dedup/cache would collapse them into one.
858        assert_ne!(CssDuration::from_millis(5), CssDuration::from_ticks(5));
859    }
860
861    #[test]
862    fn format_as_rust_code_emits_a_constructor_and_ignores_indentation() {
863        let d = CssDuration::from_millis(42);
864        assert_eq!(
865            d.format_as_rust_code(0),
866            "CssDuration { inner: 42, unit: CssDurationUnit::Milliseconds }"
867        );
868        assert_eq!(d.format_as_rust_code(7), d.format_as_rust_code(0));
869        assert_eq!(
870            CssDuration::from_millis(u32::MAX).format_as_rust_code(0),
871            "CssDuration { inner: 4294967295, unit: CssDurationUnit::Milliseconds }"
872        );
873        assert_eq!(
874            CssDuration::from_ticks(5).format_as_rust_code(0),
875            "CssDuration { inner: 5, unit: CssDurationUnit::Ticks }"
876        );
877        assert_eq!(
878            CssDurationUnit::Ticks.format_as_rust_code(0),
879            "CssDurationUnit::Ticks"
880        );
881    }
882
883    // --------------------------------------------------- error conversions ---
884
885    #[cfg(feature = "parser")]
886    fn parse_float_error() -> core::num::ParseFloatError {
887        "not-a-float".parse::<f32>().unwrap_err()
888    }
889
890    #[cfg(feature = "parser")]
891    #[test]
892    fn to_contained_preserves_an_invalid_value_payload() {
893        let owned = DurationParseError::InvalidValue("10px").to_contained();
894        match owned {
895            DurationParseErrorOwned::InvalidValue(s) => assert_eq!(s.as_str(), "10px"),
896            DurationParseErrorOwned::ParseFloat(_) => panic!("variant changed"),
897        }
898    }
899
900    #[cfg(feature = "parser")]
901    #[test]
902    fn to_contained_stringifies_the_float_error() {
903        let owned = DurationParseError::ParseFloat(parse_float_error()).to_contained();
904        match owned {
905            DurationParseErrorOwned::ParseFloat(s) => {
906                assert!(!s.as_str().is_empty(), "float error message was empty");
907                assert_eq!(s.as_str(), parse_float_error().to_string());
908            }
909            DurationParseErrorOwned::InvalidValue(_) => panic!("variant changed"),
910        }
911    }
912
913    #[cfg(feature = "parser")]
914    #[test]
915    fn to_contained_handles_empty_and_extreme_payloads() {
916        assert_eq!(
917            DurationParseError::InvalidValue("").to_contained(),
918            DurationParseErrorOwned::InvalidValue(String::new().into())
919        );
920
921        let huge = "x".repeat(100_000);
922        let owned = DurationParseError::InvalidValue(&huge).to_contained();
923        match owned {
924            DurationParseErrorOwned::InvalidValue(s) => assert_eq!(s.as_str().len(), 100_000),
925            DurationParseErrorOwned::ParseFloat(_) => panic!("variant changed"),
926        }
927
928        // Non-UTF8-boundary-unsafe payloads must survive the copy intact.
929        let unicode = "\u{1F600}\u{0301}";
930        assert_eq!(
931            DurationParseError::InvalidValue(unicode).to_contained(),
932            DurationParseErrorOwned::InvalidValue(unicode.to_string().into())
933        );
934    }
935
936    #[cfg(feature = "parser")]
937    #[test]
938    fn to_shared_preserves_an_invalid_value_payload() {
939        let owned = DurationParseErrorOwned::InvalidValue("garbage".to_string().into());
940        assert_eq!(
941            owned.to_shared(),
942            DurationParseError::InvalidValue("garbage")
943        );
944    }
945
946    /// `DurationParseErrorOwned::to_shared` maps `ParseFloat(msg)` onto
947    /// `DurationParseError::InvalidValue(msg)` — the variant is *not* preserved,
948    /// so the error message ("invalid float literal") ends up in the slot that
949    /// normally holds the offending source text. Pinned as the current behaviour;
950    /// see the report accompanying this test module.
951    #[cfg(feature = "parser")]
952    #[test]
953    fn to_shared_downgrades_parse_float_to_invalid_value() {
954        let msg = parse_float_error().to_string();
955        let owned = DurationParseErrorOwned::ParseFloat(msg.clone().into());
956        let shared = owned.to_shared();
957
958        assert!(!matches!(shared, DurationParseError::ParseFloat(_)));
959        assert_eq!(shared, DurationParseError::InvalidValue(msg.as_str()));
960    }
961
962    #[cfg(feature = "parser")]
963    #[test]
964    fn to_shared_does_not_panic_on_empty_or_extreme_payloads() {
965        assert_eq!(
966            DurationParseErrorOwned::InvalidValue(String::new().into()).to_shared(),
967            DurationParseError::InvalidValue("")
968        );
969
970        let huge = "y".repeat(100_000);
971        let owned = DurationParseErrorOwned::InvalidValue(huge.clone().into());
972        assert_eq!(owned.to_shared(), DurationParseError::InvalidValue(&huge));
973
974        let empty_float = DurationParseErrorOwned::ParseFloat(String::new().into());
975        assert_eq!(
976            empty_float.to_shared(),
977            DurationParseError::InvalidValue("")
978        );
979    }
980
981    /// A real error straight out of the parser must survive the owned round-trip
982    /// (this is the FFI path: borrow -> own -> borrow).
983    #[cfg(feature = "parser")]
984    #[test]
985    fn invalid_value_survives_a_full_shared_owned_shared_round_trip() {
986        let input = "10px";
987        let err = parse_duration(input).unwrap_err();
988        assert_eq!(err, DurationParseError::InvalidValue(input));
989
990        let owned = err.to_contained();
991        assert_eq!(owned.to_shared(), DurationParseError::InvalidValue(input));
992    }
993
994    /// `"200 nanoseconds"` ends in `s`, so it goes down the *seconds* branch and
995    /// fails in the float parse — not the "unknown unit" branch. Pinning this
996    /// keeps the two error variants from being swapped by accident.
997    #[cfg(feature = "parser")]
998    #[test]
999    fn a_word_ending_in_s_is_treated_as_a_seconds_value() {
1000        assert!(matches!(
1001            parse_duration("200 nanoseconds"),
1002            Err(DurationParseError::ParseFloat(_))
1003        ));
1004        assert!(matches!(
1005            parse_duration("always"),
1006            Err(DurationParseError::ParseFloat(_))
1007        ));
1008        // ...whereas a word *not* ending in s/ms is an unknown-unit error.
1009        assert_eq!(
1010            parse_duration("200 nanosecond"),
1011            Err(DurationParseError::InvalidValue("200 nanosecond"))
1012        );
1013    }
1014
1015    #[cfg(feature = "parser")]
1016    #[test]
1017    fn error_display_never_panics_and_mentions_the_offender() {
1018        let invalid = DurationParseError::InvalidValue("\u{1F600}");
1019        let printed = format!("{invalid}");
1020        assert!(printed.contains('\u{1F600}'), "{printed:?}");
1021
1022        let float = DurationParseError::ParseFloat(parse_float_error());
1023        assert!(!format!("{float}").is_empty());
1024
1025        // Debug is wired to Display; both must work on both variants.
1026        assert!(!format!("{invalid:?}").is_empty());
1027        assert!(!format!("{float:?}").is_empty());
1028    }
1029}