Skip to main content

azul_css/props/basic/
time.rs

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