Skip to main content

azul_css/props/basic/
time.rs

1//! CSS property types for time durations (s, ms).
2
3use alloc::string::{String, ToString};
4use crate::corety::AzString;
5
6use crate::props::formatter::PrintAsCssValue;
7
8/// A CSS time duration, stored internally in milliseconds.
9#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
10#[repr(C)]
11#[derive(Default)]
12pub struct CssDuration {
13    /// Duration in milliseconds.
14    pub inner: u32,
15}
16
17
18impl PrintAsCssValue for CssDuration {
19    fn print_as_css_value(&self) -> String {
20        format!("{}ms", self.inner)
21    }
22}
23
24impl crate::codegen::format::FormatAsRustCode for CssDuration {
25    fn format_as_rust_code(&self, _tabs: usize) -> String {
26        format!("CssDuration {{ inner: {} }}", self.inner)
27    }
28}
29
30/// Error returned when parsing a CSS duration string fails.
31#[cfg(feature = "parser")]
32#[derive(Clone, PartialEq, Eq)]
33pub enum DurationParseError<'a> {
34    InvalidValue(&'a str),
35    ParseFloat(core::num::ParseFloatError),
36}
37
38#[cfg(feature = "parser")]
39impl_debug_as_display!(DurationParseError<'a>);
40#[cfg(feature = "parser")]
41impl_display! { DurationParseError<'a>, {
42    InvalidValue(v) => format!("Invalid time value: \"{}\"", v),
43    ParseFloat(e) => format!("Invalid number for time value: {}", e),
44}}
45
46/// Owned version of [`DurationParseError`] for FFI and storage.
47#[cfg(feature = "parser")]
48#[derive(Debug, Clone, PartialEq, Eq)]
49#[repr(C, u8)]
50pub enum DurationParseErrorOwned {
51    InvalidValue(AzString),
52    ParseFloat(AzString),
53}
54
55#[cfg(feature = "parser")]
56impl DurationParseError<'_> {
57    #[must_use] pub fn to_contained(&self) -> DurationParseErrorOwned {
58        match self {
59            Self::InvalidValue(s) => DurationParseErrorOwned::InvalidValue((*s).to_string().into()),
60            Self::ParseFloat(e) => DurationParseErrorOwned::ParseFloat(e.to_string().into()),
61        }
62    }
63}
64
65#[cfg(feature = "parser")]
66impl DurationParseErrorOwned {
67    #[must_use] pub fn to_shared(&self) -> DurationParseError<'_> {
68        match self {
69            Self::InvalidValue(s) => DurationParseError::InvalidValue(s),
70            Self::ParseFloat(s) => DurationParseError::InvalidValue(s.as_str()),
71        }
72    }
73}
74
75/// Parses a CSS duration string (e.g. `"200ms"`, `"1.5s"`) into a [`CssDuration`].
76#[cfg(feature = "parser")]
77/// # Errors
78///
79/// Returns an error if `input` is not a valid CSS `duration` value.
80pub fn parse_duration(input: &str) -> Result<CssDuration, DurationParseError<'_>> {
81    let trimmed = input.trim().to_lowercase();
82    if trimmed == "0" {
83        return Ok(CssDuration { inner: 0 });
84    }
85    if let Some(num_str) = trimmed.strip_suffix("ms") {
86        let ms = num_str
87            .parse::<f32>()
88            .map_err(DurationParseError::ParseFloat)?;
89        if ms < 0.0 {
90            return Err(DurationParseError::InvalidValue(input));
91        }
92        Ok(CssDuration { inner: crate::cast::f32_to_u32(ms) })
93    } else if let Some(num_str) = trimmed.strip_suffix('s') {
94        let s = num_str
95            .parse::<f32>()
96            .map_err(DurationParseError::ParseFloat)?;
97        if s < 0.0 {
98            return Err(DurationParseError::InvalidValue(input));
99        }
100        Ok(CssDuration {
101            inner: crate::cast::f32_to_u32(s * 1000.0),
102        })
103    } else {
104        Err(DurationParseError::InvalidValue(input))
105    }
106}
107
108#[cfg(test)]
109#[allow(clippy::unreadable_literal)]
110mod autotest_generated {
111    use super::*;
112    use crate::codegen::format::FormatAsRustCode;
113    use crate::props::formatter::PrintAsCssValue;
114
115    /// Largest integer an `f32` represents exactly (`2^24`). Above this, the
116    /// spacing between neighbouring `f32`s exceeds 1ms, so `parse_duration`
117    /// (which round-trips through `f32`) can no longer be lossless.
118    #[cfg(feature = "parser")]
119    const TWO_POW_24: u32 = 16_777_216;
120
121    /// Convenience: parse and unwrap to the raw millisecond count.
122    #[cfg(feature = "parser")]
123    fn ms(input: &str) -> u32 {
124        parse_duration(input)
125            .unwrap_or_else(|e| panic!("expected {input:?} to parse, got {e}"))
126            .inner
127    }
128
129    // ------------------------------------------------------ positive control ---
130
131    #[cfg(feature = "parser")]
132    #[test]
133    fn valid_minimal_inputs_parse_to_expected_values() {
134        assert_eq!(ms("0"), 0);
135        assert_eq!(ms("0ms"), 0);
136        assert_eq!(ms("0s"), 0);
137        assert_eq!(ms("200ms"), 200);
138        assert_eq!(ms("1s"), 1000);
139        assert_eq!(ms("1.5s"), 1500);
140        assert_eq!(ms("0.5s"), 500);
141        assert_eq!(ms(".25s"), 250);
142        assert_eq!(ms("5e2ms"), 500);
143        assert_eq!(ms("+5ms"), 5);
144    }
145
146    /// The `ms` suffix must be stripped before the bare `s` suffix, otherwise
147    /// `"5ms"` would be read as 5 *seconds* (a 1000x error).
148    #[cfg(feature = "parser")]
149    #[test]
150    fn ms_suffix_wins_over_s_suffix() {
151        assert_eq!(ms("5ms"), 5);
152        assert_ne!(ms("5ms"), ms("5s"));
153        assert_eq!(ms("5s"), 5000);
154    }
155
156    #[cfg(feature = "parser")]
157    #[test]
158    fn units_are_case_insensitive() {
159        assert_eq!(ms("200MS"), 200);
160        assert_eq!(ms("200Ms"), 200);
161        assert_eq!(ms("1S"), 1000);
162        assert_eq!(ms("1.5E1S"), 15000);
163    }
164
165    // ----------------------------------------------------------- truncation ---
166
167    /// Fractional milliseconds are truncated toward zero, never rounded.
168    #[cfg(feature = "parser")]
169    #[test]
170    fn sub_millisecond_values_truncate_toward_zero() {
171        assert_eq!(ms("5.9ms"), 5);
172        assert_eq!(ms("0.9ms"), 0);
173        assert_eq!(ms("0.0009s"), 0); // 0.9ms
174        assert_eq!(ms("0.0015s"), 1); // 1.5ms
175    }
176
177    // ------------------------------------------------------- empty / blank ---
178
179    #[cfg(feature = "parser")]
180    #[test]
181    fn empty_input_is_rejected_without_panicking() {
182        assert_eq!(parse_duration(""), Err(DurationParseError::InvalidValue("")));
183    }
184
185    #[cfg(feature = "parser")]
186    #[test]
187    fn whitespace_only_input_is_rejected_and_error_keeps_the_raw_input() {
188        // The input is trimmed for parsing but the *error* carries the original
189        // (untrimmed) slice, so callers can point at the offending source text.
190        assert_eq!(
191            parse_duration("   "),
192            Err(DurationParseError::InvalidValue("   "))
193        );
194        assert_eq!(
195            parse_duration("\t\n"),
196            Err(DurationParseError::InvalidValue("\t\n"))
197        );
198    }
199
200    // ---------------------------------------------------------- malformed ---
201
202    #[cfg(feature = "parser")]
203    #[test]
204    fn a_bare_unit_with_no_number_is_a_parse_float_error_not_a_panic() {
205        assert!(matches!(
206            parse_duration("ms"),
207            Err(DurationParseError::ParseFloat(_))
208        ));
209        assert!(matches!(
210            parse_duration("s"),
211            Err(DurationParseError::ParseFloat(_))
212        ));
213    }
214
215    #[cfg(feature = "parser")]
216    #[test]
217    fn unitless_numbers_other_than_literal_zero_are_rejected() {
218        // Only the exact string "0" is accepted without a unit.
219        assert_eq!(ms("0"), 0);
220        assert_eq!(
221            parse_duration("200"),
222            Err(DurationParseError::InvalidValue("200"))
223        );
224        assert_eq!(
225            parse_duration("1.5"),
226            Err(DurationParseError::InvalidValue("1.5"))
227        );
228        assert_eq!(
229            parse_duration("0.0"),
230            Err(DurationParseError::InvalidValue("0.0"))
231        );
232        assert_eq!(
233            parse_duration("00"),
234            Err(DurationParseError::InvalidValue("00"))
235        );
236        assert_eq!(
237            parse_duration("-0"),
238            Err(DurationParseError::InvalidValue("-0"))
239        );
240    }
241
242    #[cfg(feature = "parser")]
243    #[test]
244    fn garbage_and_junk_never_panic() {
245        for garbage in [
246            "abc", "!!!", "\0\0\0", "ms ms", "1,5s", "1 ms", "--5ms", "5mss", "5sms", "0x10ms",
247            "1e", "1e+", ".s", "-.ms", "s1", "ms200", "200ms;garbage", "200ms !important",
248        ] {
249            // The only contract is: never panic, and never silently succeed with
250            // a value we did not ask for. Every one of these is an error.
251            assert!(
252                parse_duration(garbage).is_err(),
253                "expected {garbage:?} to be rejected"
254            );
255        }
256    }
257
258    #[cfg(feature = "parser")]
259    #[test]
260    fn leading_and_trailing_whitespace_is_trimmed_but_interior_space_is_not() {
261        assert_eq!(ms("   200ms   "), 200);
262        assert_eq!(ms("\t\n1.5s\r\n"), 1500);
263        // Interior whitespace stays inside the number and kills the float parse.
264        assert!(matches!(
265            parse_duration("200 ms"),
266            Err(DurationParseError::ParseFloat(_))
267        ));
268        assert!(matches!(
269            parse_duration("2 0 0ms"),
270            Err(DurationParseError::ParseFloat(_))
271        ));
272    }
273
274    #[cfg(feature = "parser")]
275    #[test]
276    fn trailing_junk_after_a_valid_value_is_rejected_not_silently_accepted() {
277        assert!(parse_duration("200ms;").is_err());
278        assert!(parse_duration("200msx").is_err());
279        // ...but note "200msms" strips one "ms" and then fails the float parse.
280        assert!(matches!(
281            parse_duration("200msms"),
282            Err(DurationParseError::ParseFloat(_))
283        ));
284    }
285
286    // ------------------------------------------------------------ negative ---
287
288    #[cfg(feature = "parser")]
289    #[test]
290    fn negative_durations_are_rejected_in_both_units() {
291        assert_eq!(
292            parse_duration("-1ms"),
293            Err(DurationParseError::InvalidValue("-1ms"))
294        );
295        assert_eq!(
296            parse_duration("-0.5s"),
297            Err(DurationParseError::InvalidValue("-0.5s"))
298        );
299        assert_eq!(
300            parse_duration("-1e-30s"),
301            Err(DurationParseError::InvalidValue("-1e-30s"))
302        );
303    }
304
305    #[cfg(feature = "parser")]
306    #[test]
307    fn the_invalid_value_error_reports_the_original_untrimmed_uncased_input() {
308        // Not the lowercased/trimmed copy used internally.
309        assert_eq!(
310            parse_duration("  -1MS  "),
311            Err(DurationParseError::InvalidValue("  -1MS  "))
312        );
313    }
314
315    /// `-0.0 < 0.0` is false, so signed zero slips past the negativity check —
316    /// but the cast lands on `0`, so the result is still sane.
317    #[cfg(feature = "parser")]
318    #[test]
319    fn negative_zero_is_accepted_and_clamps_to_zero() {
320        assert_eq!(ms("-0ms"), 0);
321        assert_eq!(ms("-0.0s"), 0);
322        assert_eq!(ms("-0e10ms"), 0);
323    }
324
325    // ---------------------------------------------- overflow / saturation ---
326
327    #[cfg(feature = "parser")]
328    #[test]
329    fn values_beyond_u32_max_saturate_instead_of_wrapping_or_panicking() {
330        assert_eq!(ms("4294967296ms"), u32::MAX); // 2^32 exactly
331        assert_eq!(ms("99999999999ms"), u32::MAX);
332        assert_eq!(ms("1e30s"), u32::MAX);
333        assert_eq!(ms("5000000s"), u32::MAX); // 5e6 * 1000 = 5e9 > u32::MAX
334    }
335
336    /// A float literal too large for `f32` parses to `+inf` (not an error), and
337    /// `inf as u32` saturates. Assert the whole chain lands on `u32::MAX`.
338    #[cfg(feature = "parser")]
339    #[test]
340    fn float_overflow_to_infinity_saturates_to_u32_max() {
341        assert_eq!(ms("1e39ms"), u32::MAX); // > f32::MAX
342        assert_eq!(ms("1e999999ms"), u32::MAX);
343        assert_eq!(ms("infms"), u32::MAX);
344        assert_eq!(ms("infinityms"), u32::MAX);
345        assert_eq!(ms("INFms"), u32::MAX);
346        assert_eq!(ms("infs"), u32::MAX);
347    }
348
349    #[cfg(feature = "parser")]
350    #[test]
351    fn negative_infinity_is_rejected_as_a_negative_duration() {
352        assert_eq!(
353            parse_duration("-infms"),
354            Err(DurationParseError::InvalidValue("-infms"))
355        );
356        assert_eq!(
357            parse_duration("-infinitys"),
358            Err(DurationParseError::InvalidValue("-infinitys"))
359        );
360    }
361
362    /// `NaN < 0.0` is false, so `"nan"` is *accepted* rather than rejected; the
363    /// saturating cast then turns it into `0ms`. Documented here so that any
364    /// future change to reject NaN outright is a visible, intentional change.
365    #[cfg(feature = "parser")]
366    #[test]
367    fn nan_is_accepted_and_degrades_to_zero_rather_than_panicking() {
368        assert_eq!(ms("nanms"), 0);
369        assert_eq!(ms("NaNms"), 0);
370        assert_eq!(ms("-nanms"), 0);
371        assert_eq!(ms("nans"), 0); // NaN * 1000.0 is still NaN
372    }
373
374    #[cfg(feature = "parser")]
375    #[test]
376    fn underflow_to_zero_is_not_an_error() {
377        assert_eq!(ms("1e-30ms"), 0);
378        assert_eq!(ms("1e-999999s"), 0);
379    }
380
381    #[cfg(feature = "parser")]
382    #[test]
383    fn u32_max_and_f32_max_boundary_strings_are_handled() {
384        assert_eq!(ms("4294967295ms"), u32::MAX); // u32::MAX, rounds up in f32 then saturates back
385        assert_eq!(ms("4294967040ms"), 4294967040); // 2^32 - 256: exactly representable in f32
386
387        let f32_max = format!("{}ms", f32::MAX);
388        assert_eq!(ms(&f32_max), u32::MAX);
389
390        let i64_max = format!("{}ms", i64::MAX);
391        assert_eq!(ms(&i64_max), u32::MAX);
392    }
393
394    // ------------------------------------------------------------ huge input ---
395
396    #[cfg(feature = "parser")]
397    #[test]
398    fn extremely_long_digit_string_saturates_without_hanging() {
399        let mut input = "9".repeat(100_000);
400        input.push_str("ms");
401        assert_eq!(ms(&input), u32::MAX);
402    }
403
404    #[cfg(feature = "parser")]
405    #[test]
406    fn extremely_long_run_of_leading_zeros_still_parses_exactly() {
407        let mut input = "0".repeat(100_000);
408        input.push_str("1ms");
409        assert_eq!(ms(&input), 1);
410    }
411
412    #[cfg(feature = "parser")]
413    #[test]
414    fn extremely_long_garbage_is_rejected_without_hanging() {
415        let input = "x".repeat(100_000);
416        assert!(parse_duration(&input).is_err());
417
418        // Long, *trimmable* padding around a valid value.
419        let padded = format!("{}200ms{}", " ".repeat(50_000), " ".repeat(50_000));
420        assert_eq!(ms(&padded), 200);
421    }
422
423    #[cfg(feature = "parser")]
424    #[test]
425    fn deeply_nested_brackets_do_not_stack_overflow() {
426        // The parser is not recursive; prove it by feeding it 10k nesting levels.
427        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
428        assert!(parse_duration(&nested).is_err());
429
430        let nested_with_unit = format!("{nested}s");
431        assert!(parse_duration(&nested_with_unit).is_err());
432    }
433
434    // -------------------------------------------------------------- unicode ---
435
436    #[cfg(feature = "parser")]
437    #[test]
438    fn non_ascii_input_is_rejected_without_panicking() {
439        for input in [
440            "\u{1F600}",       // emoji
441            "\u{1F600}ms",     // emoji + valid unit
442            "1\u{FF53}",       // FULLWIDTH LATIN SMALL LETTER S is not "s"
443            "1s\u{0301}",      // combining acute after the unit
444            "200ms",        // fullwidth digits
445            "\u{202E}200ms",   // RTL override prefix
446            "1\u{00A0}s",      // NBSP *inside* the value
447        ] {
448            assert!(
449                parse_duration(input).is_err(),
450                "expected {input:?} to be rejected"
451            );
452        }
453    }
454
455    /// `str::trim` strips Unicode whitespace, not just ASCII.
456    #[cfg(feature = "parser")]
457    #[test]
458    fn unicode_whitespace_around_a_valid_value_is_trimmed() {
459        assert_eq!(ms("\u{00A0}200ms\u{00A0}"), 200); // NBSP
460        assert_eq!(ms("\u{3000}1.5s\u{3000}"), 1500); // ideographic space
461    }
462
463    /// `to_lowercase` can *grow* the string (`İ` -> `i` + combining dot), which
464    /// would corrupt any byte-index-based suffix logic. Suffix stripping here is
465    /// char-safe, so this must merely fail to parse.
466    #[cfg(feature = "parser")]
467    #[test]
468    fn lowercasing_that_changes_the_byte_length_does_not_panic() {
469        assert!(parse_duration("\u{0130}ms").is_err()); // LATIN CAPITAL I WITH DOT ABOVE
470        assert!(parse_duration("1\u{0130}s").is_err());
471    }
472
473    // ----------------------------------------------------------- round-trip ---
474
475    #[cfg(feature = "parser")]
476    #[test]
477    fn print_as_css_value_round_trips_through_parse_duration() {
478        for inner in [
479            0,
480            1,
481            2,
482            17,
483            999,
484            1000,
485            65_535,
486            1_000_000,
487            TWO_POW_24,      // last exactly-representable integer in f32
488            4_294_967_040,   // 2^32 - 256: still exact (a multiple of the f32 ulp there)
489            u32::MAX,        // rounds up to 2^32 in f32, then the cast saturates back down
490        ] {
491            let duration = CssDuration { inner };
492            let printed = duration.print_as_css_value();
493            assert_eq!(
494                parse_duration(&printed),
495                Ok(duration),
496                "round-trip failed for {inner}ms (printed as {printed:?})"
497            );
498        }
499    }
500
501    #[test]
502    fn print_as_css_value_always_emits_the_ms_unit() {
503        for inner in [0, 1, u32::MAX] {
504            let printed = CssDuration { inner }.print_as_css_value();
505            assert!(printed.ends_with("ms"), "{printed:?} lacks a unit");
506            assert_eq!(printed, format!("{inner}ms"));
507        }
508    }
509
510    /// Above `2^24` the millisecond count no longer survives an `f32`, so the
511    /// round-trip is lossy. This is a real precision limit of the parser, pinned
512    /// here so it cannot regress further (the error must stay within one ulp).
513    #[cfg(feature = "parser")]
514    #[test]
515    fn round_trip_above_two_pow_24_is_lossy_but_bounded() {
516        let duration = CssDuration {
517            inner: TWO_POW_24 + 1,
518        };
519        let reparsed = parse_duration(&duration.print_as_css_value()).unwrap();
520        assert_ne!(reparsed.inner, duration.inner);
521        assert_eq!(reparsed.inner, TWO_POW_24);
522        assert!(reparsed.inner.abs_diff(duration.inner) <= 1);
523    }
524
525    #[cfg(feature = "parser")]
526    #[test]
527    fn seconds_and_milliseconds_agree_for_the_same_duration() {
528        assert_eq!(ms("2s"), ms("2000ms"));
529        assert_eq!(ms("0.001s"), ms("1ms"));
530        assert_eq!(ms("0s"), ms("0ms"));
531    }
532
533    // ------------------------------------------------------- CssDuration ---
534
535    #[test]
536    fn default_duration_is_zero() {
537        assert_eq!(CssDuration::default(), CssDuration { inner: 0 });
538        assert_eq!(CssDuration::default().inner, 0);
539    }
540
541    #[test]
542    fn ordering_and_equality_follow_the_inner_millisecond_count() {
543        let a = CssDuration { inner: 1 };
544        let b = CssDuration { inner: 2 };
545        let max = CssDuration { inner: u32::MAX };
546        assert!(a < b);
547        assert!(b < max);
548        assert_eq!(a, CssDuration { inner: 1 });
549        assert_eq!(a.max(b), b);
550        assert_eq!(CssDuration::default(), CssDuration { inner: 0 });
551    }
552
553    #[test]
554    fn format_as_rust_code_emits_a_constructor_and_ignores_indentation() {
555        let d = CssDuration { inner: 42 };
556        assert_eq!(d.format_as_rust_code(0), "CssDuration { inner: 42 }");
557        assert_eq!(d.format_as_rust_code(7), d.format_as_rust_code(0));
558        assert_eq!(
559            CssDuration { inner: u32::MAX }.format_as_rust_code(0),
560            "CssDuration { inner: 4294967295 }"
561        );
562    }
563
564    // --------------------------------------------------- error conversions ---
565
566    #[cfg(feature = "parser")]
567    fn parse_float_error() -> core::num::ParseFloatError {
568        "not-a-float".parse::<f32>().unwrap_err()
569    }
570
571    #[cfg(feature = "parser")]
572    #[test]
573    fn to_contained_preserves_an_invalid_value_payload() {
574        let owned = DurationParseError::InvalidValue("10px").to_contained();
575        match owned {
576            DurationParseErrorOwned::InvalidValue(s) => assert_eq!(s.as_str(), "10px"),
577            DurationParseErrorOwned::ParseFloat(_) => panic!("variant changed"),
578        }
579    }
580
581    #[cfg(feature = "parser")]
582    #[test]
583    fn to_contained_stringifies_the_float_error() {
584        let owned = DurationParseError::ParseFloat(parse_float_error()).to_contained();
585        match owned {
586            DurationParseErrorOwned::ParseFloat(s) => {
587                assert!(!s.as_str().is_empty(), "float error message was empty");
588                assert_eq!(s.as_str(), parse_float_error().to_string());
589            }
590            DurationParseErrorOwned::InvalidValue(_) => panic!("variant changed"),
591        }
592    }
593
594    #[cfg(feature = "parser")]
595    #[test]
596    fn to_contained_handles_empty_and_extreme_payloads() {
597        assert_eq!(
598            DurationParseError::InvalidValue("").to_contained(),
599            DurationParseErrorOwned::InvalidValue(String::new().into())
600        );
601
602        let huge = "x".repeat(100_000);
603        let owned = DurationParseError::InvalidValue(&huge).to_contained();
604        match owned {
605            DurationParseErrorOwned::InvalidValue(s) => assert_eq!(s.as_str().len(), 100_000),
606            DurationParseErrorOwned::ParseFloat(_) => panic!("variant changed"),
607        }
608
609        // Non-UTF8-boundary-unsafe payloads must survive the copy intact.
610        let unicode = "\u{1F600}\u{0301}";
611        assert_eq!(
612            DurationParseError::InvalidValue(unicode).to_contained(),
613            DurationParseErrorOwned::InvalidValue(unicode.to_string().into())
614        );
615    }
616
617    #[cfg(feature = "parser")]
618    #[test]
619    fn to_shared_preserves_an_invalid_value_payload() {
620        let owned = DurationParseErrorOwned::InvalidValue("garbage".to_string().into());
621        assert_eq!(owned.to_shared(), DurationParseError::InvalidValue("garbage"));
622    }
623
624    /// `DurationParseErrorOwned::to_shared` maps `ParseFloat(msg)` onto
625    /// `DurationParseError::InvalidValue(msg)` — the variant is *not* preserved,
626    /// so the error message ("invalid float literal") ends up in the slot that
627    /// normally holds the offending source text. Pinned as the current behaviour;
628    /// see the report accompanying this test module.
629    #[cfg(feature = "parser")]
630    #[test]
631    fn to_shared_downgrades_parse_float_to_invalid_value() {
632        let msg = parse_float_error().to_string();
633        let owned = DurationParseErrorOwned::ParseFloat(msg.clone().into());
634        let shared = owned.to_shared();
635
636        assert!(!matches!(shared, DurationParseError::ParseFloat(_)));
637        assert_eq!(shared, DurationParseError::InvalidValue(msg.as_str()));
638    }
639
640    #[cfg(feature = "parser")]
641    #[test]
642    fn to_shared_does_not_panic_on_empty_or_extreme_payloads() {
643        assert_eq!(
644            DurationParseErrorOwned::InvalidValue(String::new().into()).to_shared(),
645            DurationParseError::InvalidValue("")
646        );
647
648        let huge = "y".repeat(100_000);
649        let owned = DurationParseErrorOwned::InvalidValue(huge.clone().into());
650        assert_eq!(owned.to_shared(), DurationParseError::InvalidValue(&huge));
651
652        let empty_float = DurationParseErrorOwned::ParseFloat(String::new().into());
653        assert_eq!(empty_float.to_shared(), DurationParseError::InvalidValue(""));
654    }
655
656    /// A real error straight out of the parser must survive the owned round-trip
657    /// (this is the FFI path: borrow -> own -> borrow).
658    #[cfg(feature = "parser")]
659    #[test]
660    fn invalid_value_survives_a_full_shared_owned_shared_round_trip() {
661        let input = "10px";
662        let err = parse_duration(input).unwrap_err();
663        assert_eq!(err, DurationParseError::InvalidValue(input));
664
665        let owned = err.to_contained();
666        assert_eq!(owned.to_shared(), DurationParseError::InvalidValue(input));
667    }
668
669    /// `"200 nanoseconds"` ends in `s`, so it goes down the *seconds* branch and
670    /// fails in the float parse — not the "unknown unit" branch. Pinning this
671    /// keeps the two error variants from being swapped by accident.
672    #[cfg(feature = "parser")]
673    #[test]
674    fn a_word_ending_in_s_is_treated_as_a_seconds_value() {
675        assert!(matches!(
676            parse_duration("200 nanoseconds"),
677            Err(DurationParseError::ParseFloat(_))
678        ));
679        assert!(matches!(
680            parse_duration("always"),
681            Err(DurationParseError::ParseFloat(_))
682        ));
683        // ...whereas a word *not* ending in s/ms is an unknown-unit error.
684        assert_eq!(
685            parse_duration("200 nanosecond"),
686            Err(DurationParseError::InvalidValue("200 nanosecond"))
687        );
688    }
689
690    #[cfg(feature = "parser")]
691    #[test]
692    fn error_display_never_panics_and_mentions_the_offender() {
693        let invalid = DurationParseError::InvalidValue("\u{1F600}");
694        let printed = format!("{invalid}");
695        assert!(printed.contains('\u{1F600}'), "{printed:?}");
696
697        let float = DurationParseError::ParseFloat(parse_float_error());
698        assert!(!format!("{float}").is_empty());
699
700        // Debug is wired to Display; both must work on both variants.
701        assert!(!format!("{invalid:?}").is_empty());
702        assert!(!format!("{float:?}").is_empty());
703    }
704}