1
//! CSS property types for time durations (`s`, `ms`, `t`).
2

            
3
use alloc::string::{String, ToString};
4
use crate::corety::AzString;
5

            
6
use 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.
25
pub 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)]
36
pub 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

            
44
impl PrintAsCssValue for CssDurationUnit {
45
25
    fn print_as_css_value(&self) -> String {
46
25
        match self {
47
15
            Self::Milliseconds => "ms".to_string(),
48
10
            Self::Ticks => "t".to_string(),
49
        }
50
25
    }
51
}
52

            
53
impl crate::codegen::format::FormatAsRustCode for CssDurationUnit {
54
6
    fn format_as_rust_code(&self, _tabs: usize) -> String {
55
6
        match self {
56
4
            Self::Milliseconds => "CssDurationUnit::Milliseconds".to_string(),
57
2
            Self::Ticks => "CssDurationUnit::Ticks".to_string(),
58
        }
59
6
    }
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)]
75
pub struct CssDuration {
76
    /// Magnitude, counted in `unit`.
77
    pub inner: u32,
78
    /// The unit `inner` is counted in.
79
    pub unit: CssDurationUnit,
80
}
81

            
82
impl CssDuration {
83
    /// A duration of `ms` wall-clock milliseconds.
84
    #[must_use]
85
5396
    pub const fn from_millis(ms: u32) -> Self {
86
5396
        Self {
87
5396
            inner: ms,
88
5396
            unit: CssDurationUnit::Milliseconds,
89
5396
        }
90
5396
    }
91

            
92
    /// A duration of `ticks` engine frames (the CSS `t` unit).
93
    #[must_use]
94
100
    pub const fn from_ticks(ticks: u32) -> Self {
95
100
        Self {
96
100
            inner: ticks,
97
100
            unit: CssDurationUnit::Ticks,
98
100
        }
99
100
    }
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
446
    pub const fn millis(&self) -> u32 {
111
446
        match self.unit {
112
435
            CssDurationUnit::Milliseconds => self.inner,
113
            CssDurationUnit::Ticks => {
114
                // `* 1000` first, then divide: 60t is exactly 1000ms, not 996ms.
115
11
                let ms = (self.inner as u64) * 1000 / TICKS_PER_SECOND;
116
11
                if ms > u32::MAX as u64 {
117
1
                    u32::MAX
118
                } else {
119
10
                    ms as u32
120
                }
121
            }
122
        }
123
446
    }
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
7
    pub const fn ticks(&self) -> u32 {
135
7
        match self.unit {
136
1
            CssDurationUnit::Ticks => self.inner,
137
            CssDurationUnit::Milliseconds => {
138
                // Cannot overflow: u32::MAX ms * 60 / 1000 < u32::MAX.
139
6
                ((self.inner as u64) * TICKS_PER_SECOND / 1000) as u32
140
            }
141
        }
142
7
    }
143
}
144

            
145
impl PrintAsCssValue for CssDuration {
146
25
    fn print_as_css_value(&self) -> String {
147
25
        format!("{}{}", self.inner, self.unit.print_as_css_value())
148
25
    }
149
}
150

            
151
impl crate::codegen::format::FormatAsRustCode for CssDuration {
152
5
    fn format_as_rust_code(&self, _tabs: usize) -> String {
153
        use crate::codegen::format::FormatAsRustCode;
154
5
        format!(
155
5
            "CssDuration {{ inner: {}, unit: {} }}",
156
            self.inner,
157
5
            self.unit.format_as_rust_code(0)
158
        )
159
5
    }
160
}
161

            
162
/// Error returned when parsing a CSS duration string fails.
163
#[cfg(feature = "parser")]
164
#[derive(Clone, PartialEq, Eq)]
165
pub enum DurationParseError<'a> {
166
    InvalidValue(&'a str),
167
    ParseFloat(core::num::ParseFloatError),
168
}
169

            
170
#[cfg(feature = "parser")]
171
impl_debug_as_display!(DurationParseError<'a>);
172
#[cfg(feature = "parser")]
173
impl_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)]
182
pub enum DurationParseErrorOwned {
183
    InvalidValue(AzString),
184
    ParseFloat(AzString),
185
}
186

            
187
#[cfg(feature = "parser")]
188
impl DurationParseError<'_> {
189
6
    #[must_use] pub fn to_contained(&self) -> DurationParseErrorOwned {
190
6
        match self {
191
5
            Self::InvalidValue(s) => DurationParseErrorOwned::InvalidValue((*s).to_string().into()),
192
1
            Self::ParseFloat(e) => DurationParseErrorOwned::ParseFloat(e.to_string().into()),
193
        }
194
6
    }
195
}
196

            
197
#[cfg(feature = "parser")]
198
impl DurationParseErrorOwned {
199
6
    #[must_use] pub fn to_shared(&self) -> DurationParseError<'_> {
200
6
        match self {
201
4
            Self::InvalidValue(s) => DurationParseError::InvalidValue(s),
202
2
            Self::ParseFloat(s) => DurationParseError::InvalidValue(s.as_str()),
203
        }
204
6
    }
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.
225
1355
pub fn parse_duration(input: &str) -> Result<CssDuration, DurationParseError<'_>> {
226
1355
    let trimmed = input.trim().to_lowercase();
227
1355
    if trimmed == "0" {
228
7
        return Ok(CssDuration::from_millis(0));
229
1348
    }
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
1348
    if let Some(num_str) = trimmed.strip_suffix("ms") {
234
154
        let ms = num_str
235
154
            .parse::<f32>()
236
154
            .map_err(DurationParseError::ParseFloat)?;
237
133
        if ms < 0.0 {
238
6
            return Err(DurationParseError::InvalidValue(input));
239
127
        }
240
127
        Ok(CssDuration::from_millis(crate::cast::f32_to_u32(ms)))
241
1194
    } else if let Some(num_str) = trimmed.strip_suffix('s') {
242
486
        let s = num_str
243
486
            .parse::<f32>()
244
486
            .map_err(DurationParseError::ParseFloat)?;
245
472
        if s < 0.0 {
246
5
            return Err(DurationParseError::InvalidValue(input));
247
467
        }
248
467
        Ok(CssDuration::from_millis(crate::cast::f32_to_u32(s * 1000.0)))
249
708
    } else if let Some(num_str) = trimmed.strip_suffix('t') {
250
371
        let t = num_str
251
371
            .parse::<f32>()
252
371
            .map_err(DurationParseError::ParseFloat)?;
253
69
        if t < 0.0 {
254
1
            return Err(DurationParseError::InvalidValue(input));
255
68
        }
256
68
        Ok(CssDuration::from_ticks(crate::cast::f32_to_u32(t)))
257
    } else {
258
337
        Err(DurationParseError::InvalidValue(input))
259
    }
260
1355
}
261

            
262
#[cfg(test)]
263
#[allow(clippy::unreadable_literal)]
264
mod 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
}