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

            
3
use alloc::string::{String, ToString};
4

            
5
use crate::{corety::AzString, props::formatter::PrintAsCssValue};
6

            
7
/// Nominal engine tick (frame) rate, in ticks per second.
8
///
9
/// The CSS `t` unit — and `azul_core::task::Duration::Tick` behind it — counts
10
/// FRAMES, not wall time. Nothing needs a real clock to advance a tick; that is
11
/// the entire point of the unit. But a tick span still has to be COMPARABLE
12
/// against a wall-clock one, because the engine's interval constants are
13
/// milliseconds (`Duration::System`) and a comparison between the two variants
14
/// has to answer something truthful rather than "not yet, forever".
15
///
16
/// This constant is the single exchange rate between the two scales, shared by
17
/// `azul-css` (parsing/printing) and `azul-core` (`Duration` arithmetic). It is
18
/// NOT a clock: nothing reads it to decide *when* a frame happens, only how many
19
/// nanoseconds a frame is worth when the two units must be put side by side.
20
///
21
/// 60 Hz because that is the frame budget the renderer already targets (see the
22
/// `16_666_667`ns scroll-animation step in `azul-layout`), so `1t` is one frame
23
/// at the target rate and `60t` is exactly one second.
24
pub const TICKS_PER_SECOND: u64 = 60;
25

            
26
/// The unit a [`CssDuration`]'s magnitude is expressed in.
27
///
28
/// `Milliseconds` is the CSS `ms` / `s` family (wall time). `Ticks` is the CSS
29
/// `t` unit: engine frames, which advance because the engine rendered, not
30
/// because a clock ticked. `t` was chosen over `fr` because `fr` is already
31
/// taken by CSS grid (`grid-template-columns: 1fr`) and would collide in
32
/// dimension parsing.
33
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
34
#[repr(C)]
35
pub enum CssDurationUnit {
36
    /// Wall-clock milliseconds (parsed from `ms` and `s`).
37
    #[default]
38
    Milliseconds,
39
    /// Engine ticks / frames (parsed from `t`).
40
    Ticks,
41
}
42

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

            
52
impl crate::codegen::format::FormatAsRustCode for CssDurationUnit {
53
6
    fn format_as_rust_code(&self, _tabs: usize) -> String {
54
6
        match self {
55
4
            Self::Milliseconds => "CssDurationUnit::Milliseconds".to_string(),
56
2
            Self::Ticks => "CssDurationUnit::Ticks".to_string(),
57
        }
58
6
    }
59
}
60

            
61
/// A CSS time duration: a magnitude plus the unit it is counted in.
62
///
63
/// `inner` is NOT unconditionally milliseconds — read it together with `unit`,
64
/// or go through [`CssDuration::millis`] / [`CssDuration::ticks`], which convert.
65
///
66
/// The derived `Ord` compares `inner` first and only then `unit`, so it is a
67
/// total order for storage/dedup purposes but is NOT a chronological comparison
68
/// across units (`5ms` sorts below `5t` purely by field order). Compare
69
/// durations chronologically by converting them first, or by handing them to
70
/// `azul_core::task::Duration`, which compares on a canonical scale.
71
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
72
#[repr(C)]
73
#[derive(Default)]
74
pub struct CssDuration {
75
    /// Magnitude, counted in `unit`.
76
    pub inner: u32,
77
    /// The unit `inner` is counted in.
78
    pub unit: CssDurationUnit,
79
}
80

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

            
91
    /// A duration of `ticks` engine frames (the CSS `t` unit).
92
    #[must_use]
93
105
    pub const fn from_ticks(ticks: u32) -> Self {
94
105
        Self {
95
105
            inner: ticks,
96
105
            unit: CssDurationUnit::Ticks,
97
105
        }
98
105
    }
99

            
100
    /// This duration in whole milliseconds, converting ticks at
101
    /// [`TICKS_PER_SECOND`] and truncating toward zero.
102
    ///
103
    /// Saturates at `u32::MAX` rather than wrapping: `u32::MAX` ticks is ~828
104
    /// days, which does not fit `u32` milliseconds.
105
    // `as` rather than `From`/`TryFrom`: this is a `const fn`. The widening is
106
    // lossless and the narrowing is range-checked immediately above it.
107
    #[allow(clippy::cast_lossless, clippy::cast_possible_truncation)]
108
    #[must_use]
109
737
    pub const fn millis(&self) -> u32 {
110
737
        match self.unit {
111
726
            CssDurationUnit::Milliseconds => self.inner,
112
            CssDurationUnit::Ticks => {
113
                // `* 1000` first, then divide: 60t is exactly 1000ms, not 996ms.
114
11
                let ms = (self.inner as u64) * 1000 / TICKS_PER_SECOND;
115
11
                if ms > u32::MAX as u64 {
116
1
                    u32::MAX
117
                } else {
118
10
                    ms as u32
119
                }
120
            }
121
        }
122
737
    }
123

            
124
    /// This duration in whole ticks, converting milliseconds at
125
    /// [`TICKS_PER_SECOND`] and truncating toward zero.
126
    ///
127
    /// Truncation means a sub-frame duration (`10ms` at 60Hz) is **zero** ticks,
128
    /// not one — "how many whole frames fit in this span".
129
    // `as` rather than `From`/`TryFrom`: this is a `const fn`. `u32::MAX * 60 /
130
    // 1000` is ~2.6e8, comfortably inside u32, so the narrowing cannot truncate.
131
    #[allow(clippy::cast_lossless, clippy::cast_possible_truncation)]
132
    #[must_use]
133
7
    pub const fn ticks(&self) -> u32 {
134
7
        match self.unit {
135
1
            CssDurationUnit::Ticks => self.inner,
136
            CssDurationUnit::Milliseconds => {
137
                // Cannot overflow: u32::MAX ms * 60 / 1000 < u32::MAX.
138
6
                ((self.inner as u64) * TICKS_PER_SECOND / 1000) as u32
139
            }
140
        }
141
7
    }
142
}
143

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

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

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

            
169
#[cfg(feature = "parser")]
170
impl_debug_as_display!(DurationParseError<'a>);
171
#[cfg(feature = "parser")]
172
impl_display! { DurationParseError<'a>, {
173
    InvalidValue(v) => format!("Invalid time value: \"{}\"", v),
174
    ParseFloat(e) => format!("Invalid number for time value: {}", e),
175
}}
176

            
177
/// Owned version of [`DurationParseError`] for FFI and storage.
178
#[cfg(feature = "parser")]
179
#[derive(Debug, Clone, PartialEq, Eq)]
180
#[repr(C, u8)]
181
pub enum DurationParseErrorOwned {
182
    InvalidValue(AzString),
183
    ParseFloat(AzString),
184
}
185

            
186
#[cfg(feature = "parser")]
187
impl DurationParseError<'_> {
188
    #[must_use]
189
6
    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
    #[must_use]
200
6
    pub fn to_shared(&self) -> DurationParseError<'_> {
201
6
        match self {
202
4
            Self::InvalidValue(s) => DurationParseError::InvalidValue(s),
203
2
            Self::ParseFloat(s) => DurationParseError::InvalidValue(s.as_str()),
204
        }
205
6
    }
206
}
207

            
208
/// Parses a CSS duration string (e.g. `"200ms"`, `"1.5s"`, `"5t"`) into a
209
/// [`CssDuration`].
210
///
211
/// Three units are accepted:
212
///
213
/// * `ms` — milliseconds
214
/// * `s`  — seconds (stored as milliseconds)
215
/// * `t`  — engine ticks / frames, kept as ticks (see [`CssDurationUnit::Ticks`])
216
///
217
/// `t` is deliberately NOT normalised to milliseconds here: the whole reason the
218
/// unit exists is that a tick count survives to the timer as an exact frame
219
/// count, so a test can advance N ticks and assert the Nth frame — and only the
220
/// Nth — flipped. Converting at parse time would throw that away and reintroduce
221
/// the wall-clock rounding the unit is meant to escape.
222
#[cfg(feature = "parser")]
223
/// # Errors
224
///
225
/// Returns an error if `input` is not a valid CSS `duration` value.
226
1441
pub fn parse_duration(input: &str) -> Result<CssDuration, DurationParseError<'_>> {
227
1441
    let trimmed = input.trim().to_lowercase();
228
1441
    if trimmed == "0" {
229
7
        return Ok(CssDuration::from_millis(0));
230
1434
    }
231
    // Suffix order matters: `ms` must be stripped before the bare `s`, otherwise
232
    // "5ms" reads as 5 *seconds*. `t` shares no suffix with either, so it can sit
233
    // anywhere in the chain.
234
1434
    if let Some(num_str) = trimmed.strip_suffix("ms") {
235
157
        let ms = num_str
236
157
            .parse::<f32>()
237
157
            .map_err(DurationParseError::ParseFloat)?;
238
136
        if ms < 0.0 {
239
6
            return Err(DurationParseError::InvalidValue(input));
240
130
        }
241
130
        Ok(CssDuration::from_millis(crate::cast::f32_to_u32(ms)))
242
1277
    } else if let Some(num_str) = trimmed.strip_suffix('s') {
243
521
        let s = num_str
244
521
            .parse::<f32>()
245
521
            .map_err(DurationParseError::ParseFloat)?;
246
507
        if s < 0.0 {
247
5
            return Err(DurationParseError::InvalidValue(input));
248
502
        }
249
502
        Ok(CssDuration::from_millis(crate::cast::f32_to_u32(
250
502
            s * 1000.0,
251
502
        )))
252
756
    } else if let Some(num_str) = trimmed.strip_suffix('t') {
253
399
        let t = num_str
254
399
            .parse::<f32>()
255
399
            .map_err(DurationParseError::ParseFloat)?;
256
73
        if t < 0.0 {
257
1
            return Err(DurationParseError::InvalidValue(input));
258
72
        }
259
72
        Ok(CssDuration::from_ticks(crate::cast::f32_to_u32(t)))
260
    } else {
261
357
        Err(DurationParseError::InvalidValue(input))
262
    }
263
1441
}
264

            
265
#[cfg(test)]
266
#[allow(clippy::unreadable_literal)]
267
mod autotest_generated {
268
    use super::*;
269
    use crate::{codegen::format::FormatAsRustCode, props::formatter::PrintAsCssValue};
270

            
271
    /// Largest integer an `f32` represents exactly (`2^24`). Above this, the
272
    /// spacing between neighbouring `f32`s exceeds 1ms, so `parse_duration`
273
    /// (which round-trips through `f32`) can no longer be lossless.
274
    #[cfg(feature = "parser")]
275
    const TWO_POW_24: u32 = 16_777_216;
276

            
277
    /// Convenience: parse, assert the result is in milliseconds, and unwrap to
278
    /// the raw millisecond count.
279
    #[cfg(feature = "parser")]
280
    fn ms(input: &str) -> u32 {
281
        let d = parse_duration(input)
282
            .unwrap_or_else(|e| panic!("expected {input:?} to parse, got {e}"));
283
        assert_eq!(
284
            d.unit,
285
            CssDurationUnit::Milliseconds,
286
            "{input:?} parsed as {:?}, not milliseconds",
287
            d.unit
288
        );
289
        d.inner
290
    }
291

            
292
    /// Convenience: parse, assert the result is in ticks, and unwrap to the raw
293
    /// tick count.
294
    #[cfg(feature = "parser")]
295
    fn ticks(input: &str) -> u32 {
296
        let d = parse_duration(input)
297
            .unwrap_or_else(|e| panic!("expected {input:?} to parse, got {e}"));
298
        assert_eq!(
299
            d.unit,
300
            CssDurationUnit::Ticks,
301
            "{input:?} parsed as {:?}, not ticks",
302
            d.unit
303
        );
304
        d.inner
305
    }
306

            
307
    // ------------------------------------------------------ positive control ---
308

            
309
    #[cfg(feature = "parser")]
310
    #[test]
311
    fn valid_minimal_inputs_parse_to_expected_values() {
312
        assert_eq!(ms("0"), 0);
313
        assert_eq!(ms("0ms"), 0);
314
        assert_eq!(ms("0s"), 0);
315
        assert_eq!(ms("200ms"), 200);
316
        assert_eq!(ms("1s"), 1000);
317
        assert_eq!(ms("1.5s"), 1500);
318
        assert_eq!(ms("0.5s"), 500);
319
        assert_eq!(ms(".25s"), 250);
320
        assert_eq!(ms("5e2ms"), 500);
321
        assert_eq!(ms("+5ms"), 5);
322
    }
323

            
324
    /// The `ms` suffix must be stripped before the bare `s` suffix, otherwise
325
    /// `"5ms"` would be read as 5 *seconds* (a 1000x error).
326
    #[cfg(feature = "parser")]
327
    #[test]
328
    fn ms_suffix_wins_over_s_suffix() {
329
        assert_eq!(ms("5ms"), 5);
330
        assert_ne!(ms("5ms"), ms("5s"));
331
        assert_eq!(ms("5s"), 5000);
332
    }
333

            
334
    #[cfg(feature = "parser")]
335
    #[test]
336
    fn units_are_case_insensitive() {
337
        assert_eq!(ms("200MS"), 200);
338
        assert_eq!(ms("200Ms"), 200);
339
        assert_eq!(ms("1S"), 1000);
340
        assert_eq!(ms("1.5E1S"), 15000);
341
        assert_eq!(ticks("5T"), 5);
342
    }
343

            
344
    // ---------------------------------------------------------- tick unit ---
345

            
346
    /// `t` counts FRAMES and must survive parsing as a frame count. If this ever
347
    /// starts returning milliseconds, every "advance exactly N ticks" test
348
    /// silently becomes a wall-clock test again.
349
    #[cfg(feature = "parser")]
350
    #[test]
351
    fn the_t_unit_parses_to_a_tick_count_and_is_not_normalised_to_millis() {
352
        assert_eq!(parse_duration("5t"), Ok(CssDuration::from_ticks(5)));
353
        assert_eq!(ticks("0t"), 0);
354
        assert_eq!(ticks("1t"), 1);
355
        assert_eq!(ticks("60t"), 60);
356
        assert_eq!(ticks("4294967295t"), u32::MAX);
357
        // Not milliseconds, and not silently multiplied by anything.
358
        assert_ne!(parse_duration("5t"), parse_duration("5ms"));
359
        assert_ne!(parse_duration("60t"), parse_duration("1s"));
360
    }
361

            
362
    /// `t` is only ever the *last* suffix tried, so it must not steal values that
363
    /// belong to `ms` / `s`, and it must not accept unit-ish garbage.
364
    #[cfg(feature = "parser")]
365
    #[test]
366
    fn the_t_unit_does_not_collide_with_the_other_units_or_swallow_garbage() {
367
        assert_eq!(ms("5ms"), 5);
368
        assert_eq!(ms("5s"), 5000);
369
        // Suffixes that merely END in `t` are not durations.
370
        for garbage in ["5pt", "5t5", "t", "5tt", "5mst", "5st", "5 t", "-5t"] {
371
            assert!(
372
                parse_duration(garbage).is_err(),
373
                "expected {garbage:?} to be rejected"
374
            );
375
        }
376
    }
377

            
378
    /// Truncation across units is exact at the boundaries that matter: 60 ticks
379
    /// is one whole second, and a sub-frame millisecond span is zero frames (not
380
    /// one) — "how many whole frames fit", never "round up so something happens".
381
    #[test]
382
    fn millis_and_ticks_convert_at_the_nominal_frame_rate() {
383
        assert_eq!(TICKS_PER_SECOND, 60);
384

            
385
        assert_eq!(CssDuration::from_ticks(60).millis(), 1000);
386
        assert_eq!(CssDuration::from_ticks(30).millis(), 500);
387
        assert_eq!(CssDuration::from_ticks(1).millis(), 16);
388
        assert_eq!(CssDuration::from_ticks(0).millis(), 0);
389

            
390
        assert_eq!(CssDuration::from_millis(1000).ticks(), 60);
391
        assert_eq!(CssDuration::from_millis(500).ticks(), 30);
392
        assert_eq!(
393
            CssDuration::from_millis(16).ticks(),
394
            0,
395
            "sub-frame is 0 frames"
396
        );
397
        assert_eq!(CssDuration::from_millis(17).ticks(), 1);
398
        assert_eq!(CssDuration::from_millis(0).ticks(), 0);
399

            
400
        // Same-unit conversions are the identity, not a round-trip through the
401
        // other scale (which would lose precision).
402
        assert_eq!(CssDuration::from_millis(7).millis(), 7);
403
        assert_eq!(CssDuration::from_ticks(7).ticks(), 7);
404
    }
405

            
406
    /// `u32::MAX` ticks is ~828 days, which does not fit in `u32` milliseconds.
407
    /// It must clamp, not wrap.
408
    #[test]
409
    fn tick_to_milli_conversion_saturates_instead_of_wrapping() {
410
        assert_eq!(CssDuration::from_ticks(u32::MAX).millis(), u32::MAX);
411
        // The largest tick count that still fits: floor(u32::MAX * 60 / 1000).
412
        let last_exact = (u64::from(u32::MAX) * TICKS_PER_SECOND / 1000) as u32;
413
        assert!(CssDuration::from_ticks(last_exact).millis() < u32::MAX);
414
        // ...and the reverse direction cannot overflow at all.
415
        assert_eq!(
416
            CssDuration::from_millis(u32::MAX).ticks(),
417
            (u64::from(u32::MAX) * TICKS_PER_SECOND / 1000) as u32
418
        );
419
    }
420

            
421
    // ----------------------------------------------------------- truncation ---
422

            
423
    /// Fractional milliseconds are truncated toward zero, never rounded.
424
    #[cfg(feature = "parser")]
425
    #[test]
426
    fn sub_millisecond_values_truncate_toward_zero() {
427
        assert_eq!(ms("5.9ms"), 5);
428
        assert_eq!(ms("0.9ms"), 0);
429
        assert_eq!(ms("0.0009s"), 0); // 0.9ms
430
        assert_eq!(ms("0.0015s"), 1); // 1.5ms
431
    }
432

            
433
    // ------------------------------------------------------- empty / blank ---
434

            
435
    #[cfg(feature = "parser")]
436
    #[test]
437
    fn empty_input_is_rejected_without_panicking() {
438
        assert_eq!(
439
            parse_duration(""),
440
            Err(DurationParseError::InvalidValue(""))
441
        );
442
    }
443

            
444
    #[cfg(feature = "parser")]
445
    #[test]
446
    fn whitespace_only_input_is_rejected_and_error_keeps_the_raw_input() {
447
        // The input is trimmed for parsing but the *error* carries the original
448
        // (untrimmed) slice, so callers can point at the offending source text.
449
        assert_eq!(
450
            parse_duration("   "),
451
            Err(DurationParseError::InvalidValue("   "))
452
        );
453
        assert_eq!(
454
            parse_duration("\t\n"),
455
            Err(DurationParseError::InvalidValue("\t\n"))
456
        );
457
    }
458

            
459
    // ---------------------------------------------------------- malformed ---
460

            
461
    #[cfg(feature = "parser")]
462
    #[test]
463
    fn a_bare_unit_with_no_number_is_a_parse_float_error_not_a_panic() {
464
        assert!(matches!(
465
            parse_duration("ms"),
466
            Err(DurationParseError::ParseFloat(_))
467
        ));
468
        assert!(matches!(
469
            parse_duration("s"),
470
            Err(DurationParseError::ParseFloat(_))
471
        ));
472
    }
473

            
474
    #[cfg(feature = "parser")]
475
    #[test]
476
    fn unitless_numbers_other_than_literal_zero_are_rejected() {
477
        // Only the exact string "0" is accepted without a unit.
478
        assert_eq!(ms("0"), 0);
479
        assert_eq!(
480
            parse_duration("200"),
481
            Err(DurationParseError::InvalidValue("200"))
482
        );
483
        assert_eq!(
484
            parse_duration("1.5"),
485
            Err(DurationParseError::InvalidValue("1.5"))
486
        );
487
        assert_eq!(
488
            parse_duration("0.0"),
489
            Err(DurationParseError::InvalidValue("0.0"))
490
        );
491
        assert_eq!(
492
            parse_duration("00"),
493
            Err(DurationParseError::InvalidValue("00"))
494
        );
495
        assert_eq!(
496
            parse_duration("-0"),
497
            Err(DurationParseError::InvalidValue("-0"))
498
        );
499
    }
500

            
501
    #[cfg(feature = "parser")]
502
    #[test]
503
    fn garbage_and_junk_never_panic() {
504
        for garbage in [
505
            "abc",
506
            "!!!",
507
            "\0\0\0",
508
            "ms ms",
509
            "1,5s",
510
            "1 ms",
511
            "--5ms",
512
            "5mss",
513
            "5sms",
514
            "0x10ms",
515
            "1e",
516
            "1e+",
517
            ".s",
518
            "-.ms",
519
            "s1",
520
            "ms200",
521
            "200ms;garbage",
522
            "200ms !important",
523
        ] {
524
            // The only contract is: never panic, and never silently succeed with
525
            // a value we did not ask for. Every one of these is an error.
526
            assert!(
527
                parse_duration(garbage).is_err(),
528
                "expected {garbage:?} to be rejected"
529
            );
530
        }
531
    }
532

            
533
    #[cfg(feature = "parser")]
534
    #[test]
535
    fn leading_and_trailing_whitespace_is_trimmed_but_interior_space_is_not() {
536
        assert_eq!(ms("   200ms   "), 200);
537
        assert_eq!(ms("\t\n1.5s\r\n"), 1500);
538
        // Interior whitespace stays inside the number and kills the float parse.
539
        assert!(matches!(
540
            parse_duration("200 ms"),
541
            Err(DurationParseError::ParseFloat(_))
542
        ));
543
        assert!(matches!(
544
            parse_duration("2 0 0ms"),
545
            Err(DurationParseError::ParseFloat(_))
546
        ));
547
    }
548

            
549
    #[cfg(feature = "parser")]
550
    #[test]
551
    fn trailing_junk_after_a_valid_value_is_rejected_not_silently_accepted() {
552
        assert!(parse_duration("200ms;").is_err());
553
        assert!(parse_duration("200msx").is_err());
554
        // ...but note "200msms" strips one "ms" and then fails the float parse.
555
        assert!(matches!(
556
            parse_duration("200msms"),
557
            Err(DurationParseError::ParseFloat(_))
558
        ));
559
    }
560

            
561
    // ------------------------------------------------------------ negative ---
562

            
563
    #[cfg(feature = "parser")]
564
    #[test]
565
    fn negative_durations_are_rejected_in_both_units() {
566
        assert_eq!(
567
            parse_duration("-1ms"),
568
            Err(DurationParseError::InvalidValue("-1ms"))
569
        );
570
        assert_eq!(
571
            parse_duration("-0.5s"),
572
            Err(DurationParseError::InvalidValue("-0.5s"))
573
        );
574
        assert_eq!(
575
            parse_duration("-1e-30s"),
576
            Err(DurationParseError::InvalidValue("-1e-30s"))
577
        );
578
    }
579

            
580
    #[cfg(feature = "parser")]
581
    #[test]
582
    fn the_invalid_value_error_reports_the_original_untrimmed_uncased_input() {
583
        // Not the lowercased/trimmed copy used internally.
584
        assert_eq!(
585
            parse_duration("  -1MS  "),
586
            Err(DurationParseError::InvalidValue("  -1MS  "))
587
        );
588
    }
589

            
590
    /// `-0.0 < 0.0` is false, so signed zero slips past the negativity check —
591
    /// but the cast lands on `0`, so the result is still sane.
592
    #[cfg(feature = "parser")]
593
    #[test]
594
    fn negative_zero_is_accepted_and_clamps_to_zero() {
595
        assert_eq!(ms("-0ms"), 0);
596
        assert_eq!(ms("-0.0s"), 0);
597
        assert_eq!(ms("-0e10ms"), 0);
598
    }
599

            
600
    // ---------------------------------------------- overflow / saturation ---
601

            
602
    #[cfg(feature = "parser")]
603
    #[test]
604
    fn values_beyond_u32_max_saturate_instead_of_wrapping_or_panicking() {
605
        assert_eq!(ms("4294967296ms"), u32::MAX); // 2^32 exactly
606
        assert_eq!(ms("99999999999ms"), u32::MAX);
607
        assert_eq!(ms("1e30s"), u32::MAX);
608
        assert_eq!(ms("5000000s"), u32::MAX); // 5e6 * 1000 = 5e9 > u32::MAX
609
    }
610

            
611
    /// A float literal too large for `f32` parses to `+inf` (not an error), and
612
    /// `inf as u32` saturates. Assert the whole chain lands on `u32::MAX`.
613
    #[cfg(feature = "parser")]
614
    #[test]
615
    fn float_overflow_to_infinity_saturates_to_u32_max() {
616
        assert_eq!(ms("1e39ms"), u32::MAX); // > f32::MAX
617
        assert_eq!(ms("1e999999ms"), u32::MAX);
618
        assert_eq!(ms("infms"), u32::MAX);
619
        assert_eq!(ms("infinityms"), u32::MAX);
620
        assert_eq!(ms("INFms"), u32::MAX);
621
        assert_eq!(ms("infs"), u32::MAX);
622
    }
623

            
624
    #[cfg(feature = "parser")]
625
    #[test]
626
    fn negative_infinity_is_rejected_as_a_negative_duration() {
627
        assert_eq!(
628
            parse_duration("-infms"),
629
            Err(DurationParseError::InvalidValue("-infms"))
630
        );
631
        assert_eq!(
632
            parse_duration("-infinitys"),
633
            Err(DurationParseError::InvalidValue("-infinitys"))
634
        );
635
    }
636

            
637
    /// `NaN < 0.0` is false, so `"nan"` is *accepted* rather than rejected; the
638
    /// saturating cast then turns it into `0ms`. Documented here so that any
639
    /// future change to reject NaN outright is a visible, intentional change.
640
    #[cfg(feature = "parser")]
641
    #[test]
642
    fn nan_is_accepted_and_degrades_to_zero_rather_than_panicking() {
643
        assert_eq!(ms("nanms"), 0);
644
        assert_eq!(ms("NaNms"), 0);
645
        assert_eq!(ms("-nanms"), 0);
646
        assert_eq!(ms("nans"), 0); // NaN * 1000.0 is still NaN
647
    }
648

            
649
    #[cfg(feature = "parser")]
650
    #[test]
651
    fn underflow_to_zero_is_not_an_error() {
652
        assert_eq!(ms("1e-30ms"), 0);
653
        assert_eq!(ms("1e-999999s"), 0);
654
    }
655

            
656
    #[cfg(feature = "parser")]
657
    #[test]
658
    fn u32_max_and_f32_max_boundary_strings_are_handled() {
659
        assert_eq!(ms("4294967295ms"), u32::MAX); // u32::MAX, rounds up in f32 then saturates back
660
        assert_eq!(ms("4294967040ms"), 4294967040); // 2^32 - 256: exactly representable in f32
661

            
662
        let f32_max = format!("{}ms", f32::MAX);
663
        assert_eq!(ms(&f32_max), u32::MAX);
664

            
665
        let i64_max = format!("{}ms", i64::MAX);
666
        assert_eq!(ms(&i64_max), u32::MAX);
667
    }
668

            
669
    // ------------------------------------------------------------ huge input ---
670

            
671
    #[cfg(feature = "parser")]
672
    #[test]
673
    fn extremely_long_digit_string_saturates_without_hanging() {
674
        let mut input = "9".repeat(100_000);
675
        input.push_str("ms");
676
        assert_eq!(ms(&input), u32::MAX);
677
    }
678

            
679
    #[cfg(feature = "parser")]
680
    #[test]
681
    fn extremely_long_run_of_leading_zeros_still_parses_exactly() {
682
        let mut input = "0".repeat(100_000);
683
        input.push_str("1ms");
684
        assert_eq!(ms(&input), 1);
685
    }
686

            
687
    #[cfg(feature = "parser")]
688
    #[test]
689
    fn extremely_long_garbage_is_rejected_without_hanging() {
690
        let input = "x".repeat(100_000);
691
        assert!(parse_duration(&input).is_err());
692

            
693
        // Long, *trimmable* padding around a valid value.
694
        let padded = format!("{}200ms{}", " ".repeat(50_000), " ".repeat(50_000));
695
        assert_eq!(ms(&padded), 200);
696
    }
697

            
698
    #[cfg(feature = "parser")]
699
    #[test]
700
    fn deeply_nested_brackets_do_not_stack_overflow() {
701
        // The parser is not recursive; prove it by feeding it 10k nesting levels.
702
        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
703
        assert!(parse_duration(&nested).is_err());
704

            
705
        let nested_with_unit = format!("{nested}s");
706
        assert!(parse_duration(&nested_with_unit).is_err());
707
    }
708

            
709
    // -------------------------------------------------------------- unicode ---
710

            
711
    #[cfg(feature = "parser")]
712
    #[test]
713
    fn non_ascii_input_is_rejected_without_panicking() {
714
        for input in [
715
            "\u{1F600}",     // emoji
716
            "\u{1F600}ms",   // emoji + valid unit
717
            "1\u{FF53}",     // FULLWIDTH LATIN SMALL LETTER S is not "s"
718
            "1s\u{0301}",    // combining acute after the unit
719
            "200ms",      // fullwidth digits
720
            "\u{202E}200ms", // RTL override prefix
721
            "1\u{00A0}s",    // NBSP *inside* the value
722
        ] {
723
            assert!(
724
                parse_duration(input).is_err(),
725
                "expected {input:?} to be rejected"
726
            );
727
        }
728
    }
729

            
730
    /// `str::trim` strips Unicode whitespace, not just ASCII.
731
    #[cfg(feature = "parser")]
732
    #[test]
733
    fn unicode_whitespace_around_a_valid_value_is_trimmed() {
734
        assert_eq!(ms("\u{00A0}200ms\u{00A0}"), 200); // NBSP
735
        assert_eq!(ms("\u{3000}1.5s\u{3000}"), 1500); // ideographic space
736
    }
737

            
738
    /// `to_lowercase` can *grow* the string (`İ` -> `i` + combining dot), which
739
    /// would corrupt any byte-index-based suffix logic. Suffix stripping here is
740
    /// char-safe, so this must merely fail to parse.
741
    #[cfg(feature = "parser")]
742
    #[test]
743
    fn lowercasing_that_changes_the_byte_length_does_not_panic() {
744
        assert!(parse_duration("\u{0130}ms").is_err()); // LATIN CAPITAL I WITH DOT ABOVE
745
        assert!(parse_duration("1\u{0130}s").is_err());
746
    }
747

            
748
    // ----------------------------------------------------------- round-trip ---
749

            
750
    #[cfg(feature = "parser")]
751
    #[test]
752
    fn print_as_css_value_round_trips_through_parse_duration() {
753
        for inner in [
754
            0,
755
            1,
756
            2,
757
            17,
758
            999,
759
            1000,
760
            65_535,
761
            1_000_000,
762
            TWO_POW_24,    // last exactly-representable integer in f32
763
            4_294_967_040, // 2^32 - 256: still exact (a multiple of the f32 ulp there)
764
            u32::MAX,      // rounds up to 2^32 in f32, then the cast saturates back down
765
        ] {
766
            let duration = CssDuration::from_millis(inner);
767
            let printed = duration.print_as_css_value();
768
            assert_eq!(
769
                parse_duration(&printed),
770
                Ok(duration),
771
                "round-trip failed for {inner}ms (printed as {printed:?})"
772
            );
773
        }
774
    }
775

            
776
    /// A tick duration must print back as `t` and reparse as the SAME tick count.
777
    /// A printer that emitted `ms` here would silently convert every stylesheet
778
    /// round-trip from frames to wall time.
779
    #[cfg(feature = "parser")]
780
    #[test]
781
    fn print_as_css_value_round_trips_tick_durations_as_ticks() {
782
        for inner in [0, 1, 5, 60, 999, TWO_POW_24, u32::MAX] {
783
            let duration = CssDuration::from_ticks(inner);
784
            let printed = duration.print_as_css_value();
785
            assert_eq!(printed, format!("{inner}t"));
786
            assert_eq!(
787
                parse_duration(&printed),
788
                Ok(duration),
789
                "round-trip failed for {inner}t (printed as {printed:?})"
790
            );
791
        }
792
    }
793

            
794
    #[test]
795
    fn print_as_css_value_always_emits_the_unit_it_was_built_with() {
796
        for inner in [0, 1, u32::MAX] {
797
            let printed = CssDuration::from_millis(inner).print_as_css_value();
798
            assert!(printed.ends_with("ms"), "{printed:?} lacks a unit");
799
            assert_eq!(printed, format!("{inner}ms"));
800

            
801
            let printed = CssDuration::from_ticks(inner).print_as_css_value();
802
            assert!(printed.ends_with('t'), "{printed:?} lacks a unit");
803
            assert!(!printed.ends_with("ms"), "{printed:?} lost the tick unit");
804
            assert_eq!(printed, format!("{inner}t"));
805
        }
806
    }
807

            
808
    /// Above `2^24` the millisecond count no longer survives an `f32`, so the
809
    /// round-trip is lossy. This is a real precision limit of the parser, pinned
810
    /// here so it cannot regress further (the error must stay within one ulp).
811
    #[cfg(feature = "parser")]
812
    #[test]
813
    fn round_trip_above_two_pow_24_is_lossy_but_bounded() {
814
        let duration = CssDuration::from_millis(TWO_POW_24 + 1);
815
        let reparsed = parse_duration(&duration.print_as_css_value()).unwrap();
816
        assert_ne!(reparsed.inner, duration.inner);
817
        assert_eq!(reparsed.inner, TWO_POW_24);
818
        assert!(reparsed.inner.abs_diff(duration.inner) <= 1);
819
    }
820

            
821
    #[cfg(feature = "parser")]
822
    #[test]
823
    fn seconds_and_milliseconds_agree_for_the_same_duration() {
824
        assert_eq!(ms("2s"), ms("2000ms"));
825
        assert_eq!(ms("0.001s"), ms("1ms"));
826
        assert_eq!(ms("0s"), ms("0ms"));
827
    }
828

            
829
    // ------------------------------------------------------- CssDuration ---
830

            
831
    /// The default unit is milliseconds, not ticks: every pre-existing
832
    /// `CssDuration::default()` in the tree means "0ms", and a default that
833
    /// silently meant frames would reinterpret all of them.
834
    #[test]
835
    fn default_duration_is_zero_milliseconds() {
836
        assert_eq!(CssDuration::default(), CssDuration::from_millis(0));
837
        assert_eq!(CssDuration::default().inner, 0);
838
        assert_eq!(CssDuration::default().unit, CssDurationUnit::Milliseconds);
839
        assert_eq!(CssDurationUnit::default(), CssDurationUnit::Milliseconds);
840
    }
841

            
842
    #[test]
843
    fn ordering_and_equality_follow_the_inner_count_within_one_unit() {
844
        let a = CssDuration::from_millis(1);
845
        let b = CssDuration::from_millis(2);
846
        let max = CssDuration::from_millis(u32::MAX);
847
        assert!(a < b);
848
        assert!(b < max);
849
        assert_eq!(a, CssDuration::from_millis(1));
850
        assert_eq!(a.max(b), b);
851
        assert_eq!(CssDuration::default(), CssDuration::from_millis(0));
852

            
853
        // Same magnitude, different unit: NOT equal. `5ms` and `5t` are
854
        // different durations and must never compare equal, or a stylesheet
855
        // dedup/cache would collapse them into one.
856
        assert_ne!(CssDuration::from_millis(5), CssDuration::from_ticks(5));
857
    }
858

            
859
    #[test]
860
    fn format_as_rust_code_emits_a_constructor_and_ignores_indentation() {
861
        let d = CssDuration::from_millis(42);
862
        assert_eq!(
863
            d.format_as_rust_code(0),
864
            "CssDuration { inner: 42, unit: CssDurationUnit::Milliseconds }"
865
        );
866
        assert_eq!(d.format_as_rust_code(7), d.format_as_rust_code(0));
867
        assert_eq!(
868
            CssDuration::from_millis(u32::MAX).format_as_rust_code(0),
869
            "CssDuration { inner: 4294967295, unit: CssDurationUnit::Milliseconds }"
870
        );
871
        assert_eq!(
872
            CssDuration::from_ticks(5).format_as_rust_code(0),
873
            "CssDuration { inner: 5, unit: CssDurationUnit::Ticks }"
874
        );
875
        assert_eq!(
876
            CssDurationUnit::Ticks.format_as_rust_code(0),
877
            "CssDurationUnit::Ticks"
878
        );
879
    }
880

            
881
    // --------------------------------------------------- error conversions ---
882

            
883
    #[cfg(feature = "parser")]
884
    fn parse_float_error() -> core::num::ParseFloatError {
885
        "not-a-float".parse::<f32>().unwrap_err()
886
    }
887

            
888
    #[cfg(feature = "parser")]
889
    #[test]
890
    fn to_contained_preserves_an_invalid_value_payload() {
891
        let owned = DurationParseError::InvalidValue("10px").to_contained();
892
        match owned {
893
            DurationParseErrorOwned::InvalidValue(s) => assert_eq!(s.as_str(), "10px"),
894
            DurationParseErrorOwned::ParseFloat(_) => panic!("variant changed"),
895
        }
896
    }
897

            
898
    #[cfg(feature = "parser")]
899
    #[test]
900
    fn to_contained_stringifies_the_float_error() {
901
        let owned = DurationParseError::ParseFloat(parse_float_error()).to_contained();
902
        match owned {
903
            DurationParseErrorOwned::ParseFloat(s) => {
904
                assert!(!s.as_str().is_empty(), "float error message was empty");
905
                assert_eq!(s.as_str(), parse_float_error().to_string());
906
            }
907
            DurationParseErrorOwned::InvalidValue(_) => panic!("variant changed"),
908
        }
909
    }
910

            
911
    #[cfg(feature = "parser")]
912
    #[test]
913
    fn to_contained_handles_empty_and_extreme_payloads() {
914
        assert_eq!(
915
            DurationParseError::InvalidValue("").to_contained(),
916
            DurationParseErrorOwned::InvalidValue(String::new().into())
917
        );
918

            
919
        let huge = "x".repeat(100_000);
920
        let owned = DurationParseError::InvalidValue(&huge).to_contained();
921
        match owned {
922
            DurationParseErrorOwned::InvalidValue(s) => assert_eq!(s.as_str().len(), 100_000),
923
            DurationParseErrorOwned::ParseFloat(_) => panic!("variant changed"),
924
        }
925

            
926
        // Non-UTF8-boundary-unsafe payloads must survive the copy intact.
927
        let unicode = "\u{1F600}\u{0301}";
928
        assert_eq!(
929
            DurationParseError::InvalidValue(unicode).to_contained(),
930
            DurationParseErrorOwned::InvalidValue(unicode.to_string().into())
931
        );
932
    }
933

            
934
    #[cfg(feature = "parser")]
935
    #[test]
936
    fn to_shared_preserves_an_invalid_value_payload() {
937
        let owned = DurationParseErrorOwned::InvalidValue("garbage".to_string().into());
938
        assert_eq!(
939
            owned.to_shared(),
940
            DurationParseError::InvalidValue("garbage")
941
        );
942
    }
943

            
944
    /// `DurationParseErrorOwned::to_shared` maps `ParseFloat(msg)` onto
945
    /// `DurationParseError::InvalidValue(msg)` — the variant is *not* preserved,
946
    /// so the error message ("invalid float literal") ends up in the slot that
947
    /// normally holds the offending source text. Pinned as the current behaviour;
948
    /// see the report accompanying this test module.
949
    #[cfg(feature = "parser")]
950
    #[test]
951
    fn to_shared_downgrades_parse_float_to_invalid_value() {
952
        let msg = parse_float_error().to_string();
953
        let owned = DurationParseErrorOwned::ParseFloat(msg.clone().into());
954
        let shared = owned.to_shared();
955

            
956
        assert!(!matches!(shared, DurationParseError::ParseFloat(_)));
957
        assert_eq!(shared, DurationParseError::InvalidValue(msg.as_str()));
958
    }
959

            
960
    #[cfg(feature = "parser")]
961
    #[test]
962
    fn to_shared_does_not_panic_on_empty_or_extreme_payloads() {
963
        assert_eq!(
964
            DurationParseErrorOwned::InvalidValue(String::new().into()).to_shared(),
965
            DurationParseError::InvalidValue("")
966
        );
967

            
968
        let huge = "y".repeat(100_000);
969
        let owned = DurationParseErrorOwned::InvalidValue(huge.clone().into());
970
        assert_eq!(owned.to_shared(), DurationParseError::InvalidValue(&huge));
971

            
972
        let empty_float = DurationParseErrorOwned::ParseFloat(String::new().into());
973
        assert_eq!(
974
            empty_float.to_shared(),
975
            DurationParseError::InvalidValue("")
976
        );
977
    }
978

            
979
    /// A real error straight out of the parser must survive the owned round-trip
980
    /// (this is the FFI path: borrow -> own -> borrow).
981
    #[cfg(feature = "parser")]
982
    #[test]
983
    fn invalid_value_survives_a_full_shared_owned_shared_round_trip() {
984
        let input = "10px";
985
        let err = parse_duration(input).unwrap_err();
986
        assert_eq!(err, DurationParseError::InvalidValue(input));
987

            
988
        let owned = err.to_contained();
989
        assert_eq!(owned.to_shared(), DurationParseError::InvalidValue(input));
990
    }
991

            
992
    /// `"200 nanoseconds"` ends in `s`, so it goes down the *seconds* branch and
993
    /// fails in the float parse — not the "unknown unit" branch. Pinning this
994
    /// keeps the two error variants from being swapped by accident.
995
    #[cfg(feature = "parser")]
996
    #[test]
997
    fn a_word_ending_in_s_is_treated_as_a_seconds_value() {
998
        assert!(matches!(
999
            parse_duration("200 nanoseconds"),
            Err(DurationParseError::ParseFloat(_))
        ));
        assert!(matches!(
            parse_duration("always"),
            Err(DurationParseError::ParseFloat(_))
        ));
        // ...whereas a word *not* ending in s/ms is an unknown-unit error.
        assert_eq!(
            parse_duration("200 nanosecond"),
            Err(DurationParseError::InvalidValue("200 nanosecond"))
        );
    }
    #[cfg(feature = "parser")]
    #[test]
    fn error_display_never_panics_and_mentions_the_offender() {
        let invalid = DurationParseError::InvalidValue("\u{1F600}");
        let printed = format!("{invalid}");
        assert!(printed.contains('\u{1F600}'), "{printed:?}");
        let float = DurationParseError::ParseFloat(parse_float_error());
        assert!(!format!("{float}").is_empty());
        // Debug is wired to Display; both must work on both variants.
        assert!(!format!("{invalid:?}").is_empty());
        assert!(!format!("{float:?}").is_empty());
    }
}