Lines
100 %
Functions
68.04 %
Branches
//! CSS property types for time durations (`s`, `ms`, `t`).
use alloc::string::{String, ToString};
use crate::corety::AzString;
use crate::props::formatter::PrintAsCssValue;
/// Nominal engine tick (frame) rate, in ticks per second.
///
/// The CSS `t` unit — and `azul_core::task::Duration::Tick` behind it — counts
/// FRAMES, not wall time. Nothing needs a real clock to advance a tick; that is
/// the entire point of the unit. But a tick span still has to be COMPARABLE
/// against a wall-clock one, because the engine's interval constants are
/// milliseconds (`Duration::System`) and a comparison between the two variants
/// has to answer something truthful rather than "not yet, forever".
/// This constant is the single exchange rate between the two scales, shared by
/// `azul-css` (parsing/printing) and `azul-core` (`Duration` arithmetic). It is
/// NOT a clock: nothing reads it to decide *when* a frame happens, only how many
/// nanoseconds a frame is worth when the two units must be put side by side.
/// 60 Hz because that is the frame budget the renderer already targets (see the
/// `16_666_667`ns scroll-animation step in `azul-layout`), so `1t` is one frame
/// at the target rate and `60t` is exactly one second.
pub const TICKS_PER_SECOND: u64 = 60;
/// The unit a [`CssDuration`]'s magnitude is expressed in.
/// `Milliseconds` is the CSS `ms` / `s` family (wall time). `Ticks` is the CSS
/// `t` unit: engine frames, which advance because the engine rendered, not
/// because a clock ticked. `t` was chosen over `fr` because `fr` is already
/// taken by CSS grid (`grid-template-columns: 1fr`) and would collide in
/// dimension parsing.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
#[repr(C)]
pub enum CssDurationUnit {
/// Wall-clock milliseconds (parsed from `ms` and `s`).
#[default]
Milliseconds,
/// Engine ticks / frames (parsed from `t`).
Ticks,
}
impl PrintAsCssValue for CssDurationUnit {
fn print_as_css_value(&self) -> String {
match self {
Self::Milliseconds => "ms".to_string(),
Self::Ticks => "t".to_string(),
impl crate::codegen::format::FormatAsRustCode for CssDurationUnit {
fn format_as_rust_code(&self, _tabs: usize) -> String {
Self::Milliseconds => "CssDurationUnit::Milliseconds".to_string(),
Self::Ticks => "CssDurationUnit::Ticks".to_string(),
/// A CSS time duration: a magnitude plus the unit it is counted in.
/// `inner` is NOT unconditionally milliseconds — read it together with `unit`,
/// or go through [`CssDuration::millis`] / [`CssDuration::ticks`], which convert.
/// The derived `Ord` compares `inner` first and only then `unit`, so it is a
/// total order for storage/dedup purposes but is NOT a chronological comparison
/// across units (`5ms` sorts below `5t` purely by field order). Compare
/// durations chronologically by converting them first, or by handing them to
/// `azul_core::task::Duration`, which compares on a canonical scale.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(Default)]
pub struct CssDuration {
/// Magnitude, counted in `unit`.
pub inner: u32,
/// The unit `inner` is counted in.
pub unit: CssDurationUnit,
impl CssDuration {
/// A duration of `ms` wall-clock milliseconds.
#[must_use]
pub const fn from_millis(ms: u32) -> Self {
Self {
inner: ms,
unit: CssDurationUnit::Milliseconds,
/// A duration of `ticks` engine frames (the CSS `t` unit).
pub const fn from_ticks(ticks: u32) -> Self {
inner: ticks,
unit: CssDurationUnit::Ticks,
/// This duration in whole milliseconds, converting ticks at
/// [`TICKS_PER_SECOND`] and truncating toward zero.
/// Saturates at `u32::MAX` rather than wrapping: `u32::MAX` ticks is ~828
/// days, which does not fit `u32` milliseconds.
// `as` rather than `From`/`TryFrom`: this is a `const fn`. The widening is
// lossless and the narrowing is range-checked immediately above it.
#[allow(clippy::cast_lossless, clippy::cast_possible_truncation)]
pub const fn millis(&self) -> u32 {
match self.unit {
CssDurationUnit::Milliseconds => self.inner,
CssDurationUnit::Ticks => {
// `* 1000` first, then divide: 60t is exactly 1000ms, not 996ms.
let ms = (self.inner as u64) * 1000 / TICKS_PER_SECOND;
if ms > u32::MAX as u64 {
u32::MAX
} else {
ms as u32
/// This duration in whole ticks, converting milliseconds at
/// Truncation means a sub-frame duration (`10ms` at 60Hz) is **zero** ticks,
/// not one — "how many whole frames fit in this span".
// `as` rather than `From`/`TryFrom`: this is a `const fn`. `u32::MAX * 60 /
// 1000` is ~2.6e8, comfortably inside u32, so the narrowing cannot truncate.
pub const fn ticks(&self) -> u32 {
CssDurationUnit::Ticks => self.inner,
CssDurationUnit::Milliseconds => {
// Cannot overflow: u32::MAX ms * 60 / 1000 < u32::MAX.
((self.inner as u64) * TICKS_PER_SECOND / 1000) as u32
impl PrintAsCssValue for CssDuration {
format!("{}{}", self.inner, self.unit.print_as_css_value())
impl crate::codegen::format::FormatAsRustCode for CssDuration {
use crate::codegen::format::FormatAsRustCode;
format!(
"CssDuration {{ inner: {}, unit: {} }}",
self.inner,
self.unit.format_as_rust_code(0)
)
/// Error returned when parsing a CSS duration string fails.
#[cfg(feature = "parser")]
#[derive(Clone, PartialEq, Eq)]
pub enum DurationParseError<'a> {
InvalidValue(&'a str),
ParseFloat(core::num::ParseFloatError),
impl_debug_as_display!(DurationParseError<'a>);
impl_display! { DurationParseError<'a>, {
InvalidValue(v) => format!("Invalid time value: \"{}\"", v),
ParseFloat(e) => format!("Invalid number for time value: {}", e),
}}
/// Owned version of [`DurationParseError`] for FFI and storage.
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(C, u8)]
pub enum DurationParseErrorOwned {
InvalidValue(AzString),
ParseFloat(AzString),
impl DurationParseError<'_> {
#[must_use] pub fn to_contained(&self) -> DurationParseErrorOwned {
Self::InvalidValue(s) => DurationParseErrorOwned::InvalidValue((*s).to_string().into()),
Self::ParseFloat(e) => DurationParseErrorOwned::ParseFloat(e.to_string().into()),
impl DurationParseErrorOwned {
#[must_use] pub fn to_shared(&self) -> DurationParseError<'_> {
Self::InvalidValue(s) => DurationParseError::InvalidValue(s),
Self::ParseFloat(s) => DurationParseError::InvalidValue(s.as_str()),
/// Parses a CSS duration string (e.g. `"200ms"`, `"1.5s"`, `"5t"`) into a
/// [`CssDuration`].
/// Three units are accepted:
/// * `ms` — milliseconds
/// * `s` — seconds (stored as milliseconds)
/// * `t` — engine ticks / frames, kept as ticks (see [`CssDurationUnit::Ticks`])
/// `t` is deliberately NOT normalised to milliseconds here: the whole reason the
/// unit exists is that a tick count survives to the timer as an exact frame
/// count, so a test can advance N ticks and assert the Nth frame — and only the
/// Nth — flipped. Converting at parse time would throw that away and reintroduce
/// the wall-clock rounding the unit is meant to escape.
/// # Errors
/// Returns an error if `input` is not a valid CSS `duration` value.
pub fn parse_duration(input: &str) -> Result<CssDuration, DurationParseError<'_>> {
let trimmed = input.trim().to_lowercase();
if trimmed == "0" {
return Ok(CssDuration::from_millis(0));
// Suffix order matters: `ms` must be stripped before the bare `s`, otherwise
// "5ms" reads as 5 *seconds*. `t` shares no suffix with either, so it can sit
// anywhere in the chain.
if let Some(num_str) = trimmed.strip_suffix("ms") {
let ms = num_str
.parse::<f32>()
.map_err(DurationParseError::ParseFloat)?;
if ms < 0.0 {
return Err(DurationParseError::InvalidValue(input));
Ok(CssDuration::from_millis(crate::cast::f32_to_u32(ms)))
} else if let Some(num_str) = trimmed.strip_suffix('s') {
let s = num_str
if s < 0.0 {
Ok(CssDuration::from_millis(crate::cast::f32_to_u32(s * 1000.0)))
} else if let Some(num_str) = trimmed.strip_suffix('t') {
let t = num_str
if t < 0.0 {
Ok(CssDuration::from_ticks(crate::cast::f32_to_u32(t)))
Err(DurationParseError::InvalidValue(input))
#[cfg(test)]
#[allow(clippy::unreadable_literal)]
mod autotest_generated {
use super::*;
/// Largest integer an `f32` represents exactly (`2^24`). Above this, the
/// spacing between neighbouring `f32`s exceeds 1ms, so `parse_duration`
/// (which round-trips through `f32`) can no longer be lossless.
const TWO_POW_24: u32 = 16_777_216;
/// Convenience: parse, assert the result is in milliseconds, and unwrap to
/// the raw millisecond count.
fn ms(input: &str) -> u32 {
let d = parse_duration(input)
.unwrap_or_else(|e| panic!("expected {input:?} to parse, got {e}"));
assert_eq!(
d.unit,
CssDurationUnit::Milliseconds,
"{input:?} parsed as {:?}, not milliseconds",
d.unit
);
d.inner
/// Convenience: parse, assert the result is in ticks, and unwrap to the raw
/// tick count.
fn ticks(input: &str) -> u32 {
CssDurationUnit::Ticks,
"{input:?} parsed as {:?}, not ticks",
// ------------------------------------------------------ positive control ---
#[test]
fn valid_minimal_inputs_parse_to_expected_values() {
assert_eq!(ms("0"), 0);
assert_eq!(ms("0ms"), 0);
assert_eq!(ms("0s"), 0);
assert_eq!(ms("200ms"), 200);
assert_eq!(ms("1s"), 1000);
assert_eq!(ms("1.5s"), 1500);
assert_eq!(ms("0.5s"), 500);
assert_eq!(ms(".25s"), 250);
assert_eq!(ms("5e2ms"), 500);
assert_eq!(ms("+5ms"), 5);
/// The `ms` suffix must be stripped before the bare `s` suffix, otherwise
/// `"5ms"` would be read as 5 *seconds* (a 1000x error).
fn ms_suffix_wins_over_s_suffix() {
assert_eq!(ms("5ms"), 5);
assert_ne!(ms("5ms"), ms("5s"));
assert_eq!(ms("5s"), 5000);
fn units_are_case_insensitive() {
assert_eq!(ms("200MS"), 200);
assert_eq!(ms("200Ms"), 200);
assert_eq!(ms("1S"), 1000);
assert_eq!(ms("1.5E1S"), 15000);
assert_eq!(ticks("5T"), 5);
// ---------------------------------------------------------- tick unit ---
/// `t` counts FRAMES and must survive parsing as a frame count. If this ever
/// starts returning milliseconds, every "advance exactly N ticks" test
/// silently becomes a wall-clock test again.
fn the_t_unit_parses_to_a_tick_count_and_is_not_normalised_to_millis() {
assert_eq!(parse_duration("5t"), Ok(CssDuration::from_ticks(5)));
assert_eq!(ticks("0t"), 0);
assert_eq!(ticks("1t"), 1);
assert_eq!(ticks("60t"), 60);
assert_eq!(ticks("4294967295t"), u32::MAX);
// Not milliseconds, and not silently multiplied by anything.
assert_ne!(parse_duration("5t"), parse_duration("5ms"));
assert_ne!(parse_duration("60t"), parse_duration("1s"));
/// `t` is only ever the *last* suffix tried, so it must not steal values that
/// belong to `ms` / `s`, and it must not accept unit-ish garbage.
fn the_t_unit_does_not_collide_with_the_other_units_or_swallow_garbage() {
// Suffixes that merely END in `t` are not durations.
for garbage in ["5pt", "5t5", "t", "5tt", "5mst", "5st", "5 t", "-5t"] {
assert!(
parse_duration(garbage).is_err(),
"expected {garbage:?} to be rejected"
/// Truncation across units is exact at the boundaries that matter: 60 ticks
/// is one whole second, and a sub-frame millisecond span is zero frames (not
/// one) — "how many whole frames fit", never "round up so something happens".
fn millis_and_ticks_convert_at_the_nominal_frame_rate() {
assert_eq!(TICKS_PER_SECOND, 60);
assert_eq!(CssDuration::from_ticks(60).millis(), 1000);
assert_eq!(CssDuration::from_ticks(30).millis(), 500);
assert_eq!(CssDuration::from_ticks(1).millis(), 16);
assert_eq!(CssDuration::from_ticks(0).millis(), 0);
assert_eq!(CssDuration::from_millis(1000).ticks(), 60);
assert_eq!(CssDuration::from_millis(500).ticks(), 30);
assert_eq!(CssDuration::from_millis(16).ticks(), 0, "sub-frame is 0 frames");
assert_eq!(CssDuration::from_millis(17).ticks(), 1);
assert_eq!(CssDuration::from_millis(0).ticks(), 0);
// Same-unit conversions are the identity, not a round-trip through the
// other scale (which would lose precision).
assert_eq!(CssDuration::from_millis(7).millis(), 7);
assert_eq!(CssDuration::from_ticks(7).ticks(), 7);
/// `u32::MAX` ticks is ~828 days, which does not fit in `u32` milliseconds.
/// It must clamp, not wrap.
fn tick_to_milli_conversion_saturates_instead_of_wrapping() {
assert_eq!(CssDuration::from_ticks(u32::MAX).millis(), u32::MAX);
// The largest tick count that still fits: floor(u32::MAX * 60 / 1000).
let last_exact = (u64::from(u32::MAX) * TICKS_PER_SECOND / 1000) as u32;
assert!(CssDuration::from_ticks(last_exact).millis() < u32::MAX);
// ...and the reverse direction cannot overflow at all.
CssDuration::from_millis(u32::MAX).ticks(),
(u64::from(u32::MAX) * TICKS_PER_SECOND / 1000) as u32
// ----------------------------------------------------------- truncation ---
/// Fractional milliseconds are truncated toward zero, never rounded.
fn sub_millisecond_values_truncate_toward_zero() {
assert_eq!(ms("5.9ms"), 5);
assert_eq!(ms("0.9ms"), 0);
assert_eq!(ms("0.0009s"), 0); // 0.9ms
assert_eq!(ms("0.0015s"), 1); // 1.5ms
// ------------------------------------------------------- empty / blank ---
fn empty_input_is_rejected_without_panicking() {
assert_eq!(parse_duration(""), Err(DurationParseError::InvalidValue("")));
fn whitespace_only_input_is_rejected_and_error_keeps_the_raw_input() {
// The input is trimmed for parsing but the *error* carries the original
// (untrimmed) slice, so callers can point at the offending source text.
parse_duration(" "),
Err(DurationParseError::InvalidValue(" "))
parse_duration("\t\n"),
Err(DurationParseError::InvalidValue("\t\n"))
// ---------------------------------------------------------- malformed ---
fn a_bare_unit_with_no_number_is_a_parse_float_error_not_a_panic() {
assert!(matches!(
parse_duration("ms"),
Err(DurationParseError::ParseFloat(_))
));
parse_duration("s"),
fn unitless_numbers_other_than_literal_zero_are_rejected() {
// Only the exact string "0" is accepted without a unit.
parse_duration("200"),
Err(DurationParseError::InvalidValue("200"))
parse_duration("1.5"),
Err(DurationParseError::InvalidValue("1.5"))
parse_duration("0.0"),
Err(DurationParseError::InvalidValue("0.0"))
parse_duration("00"),
Err(DurationParseError::InvalidValue("00"))
parse_duration("-0"),
Err(DurationParseError::InvalidValue("-0"))
fn garbage_and_junk_never_panic() {
for garbage in [
"abc", "!!!", "\0\0\0", "ms ms", "1,5s", "1 ms", "--5ms", "5mss", "5sms", "0x10ms",
"1e", "1e+", ".s", "-.ms", "s1", "ms200", "200ms;garbage", "200ms !important",
] {
// The only contract is: never panic, and never silently succeed with
// a value we did not ask for. Every one of these is an error.
fn leading_and_trailing_whitespace_is_trimmed_but_interior_space_is_not() {
assert_eq!(ms(" 200ms "), 200);
assert_eq!(ms("\t\n1.5s\r\n"), 1500);
// Interior whitespace stays inside the number and kills the float parse.
parse_duration("200 ms"),
parse_duration("2 0 0ms"),
fn trailing_junk_after_a_valid_value_is_rejected_not_silently_accepted() {
assert!(parse_duration("200ms;").is_err());
assert!(parse_duration("200msx").is_err());
// ...but note "200msms" strips one "ms" and then fails the float parse.
parse_duration("200msms"),
// ------------------------------------------------------------ negative ---
fn negative_durations_are_rejected_in_both_units() {
parse_duration("-1ms"),
Err(DurationParseError::InvalidValue("-1ms"))
parse_duration("-0.5s"),
Err(DurationParseError::InvalidValue("-0.5s"))
parse_duration("-1e-30s"),
Err(DurationParseError::InvalidValue("-1e-30s"))
fn the_invalid_value_error_reports_the_original_untrimmed_uncased_input() {
// Not the lowercased/trimmed copy used internally.
parse_duration(" -1MS "),
Err(DurationParseError::InvalidValue(" -1MS "))
/// `-0.0 < 0.0` is false, so signed zero slips past the negativity check —
/// but the cast lands on `0`, so the result is still sane.
fn negative_zero_is_accepted_and_clamps_to_zero() {
assert_eq!(ms("-0ms"), 0);
assert_eq!(ms("-0.0s"), 0);
assert_eq!(ms("-0e10ms"), 0);
// ---------------------------------------------- overflow / saturation ---
fn values_beyond_u32_max_saturate_instead_of_wrapping_or_panicking() {
assert_eq!(ms("4294967296ms"), u32::MAX); // 2^32 exactly
assert_eq!(ms("99999999999ms"), u32::MAX);
assert_eq!(ms("1e30s"), u32::MAX);
assert_eq!(ms("5000000s"), u32::MAX); // 5e6 * 1000 = 5e9 > u32::MAX
/// A float literal too large for `f32` parses to `+inf` (not an error), and
/// `inf as u32` saturates. Assert the whole chain lands on `u32::MAX`.
fn float_overflow_to_infinity_saturates_to_u32_max() {
assert_eq!(ms("1e39ms"), u32::MAX); // > f32::MAX
assert_eq!(ms("1e999999ms"), u32::MAX);
assert_eq!(ms("infms"), u32::MAX);
assert_eq!(ms("infinityms"), u32::MAX);
assert_eq!(ms("INFms"), u32::MAX);
assert_eq!(ms("infs"), u32::MAX);
fn negative_infinity_is_rejected_as_a_negative_duration() {
parse_duration("-infms"),
Err(DurationParseError::InvalidValue("-infms"))
parse_duration("-infinitys"),
Err(DurationParseError::InvalidValue("-infinitys"))
/// `NaN < 0.0` is false, so `"nan"` is *accepted* rather than rejected; the
/// saturating cast then turns it into `0ms`. Documented here so that any
/// future change to reject NaN outright is a visible, intentional change.
fn nan_is_accepted_and_degrades_to_zero_rather_than_panicking() {
assert_eq!(ms("nanms"), 0);
assert_eq!(ms("NaNms"), 0);
assert_eq!(ms("-nanms"), 0);
assert_eq!(ms("nans"), 0); // NaN * 1000.0 is still NaN
fn underflow_to_zero_is_not_an_error() {
assert_eq!(ms("1e-30ms"), 0);
assert_eq!(ms("1e-999999s"), 0);
fn u32_max_and_f32_max_boundary_strings_are_handled() {
assert_eq!(ms("4294967295ms"), u32::MAX); // u32::MAX, rounds up in f32 then saturates back
assert_eq!(ms("4294967040ms"), 4294967040); // 2^32 - 256: exactly representable in f32
let f32_max = format!("{}ms", f32::MAX);
assert_eq!(ms(&f32_max), u32::MAX);
let i64_max = format!("{}ms", i64::MAX);
assert_eq!(ms(&i64_max), u32::MAX);
// ------------------------------------------------------------ huge input ---
fn extremely_long_digit_string_saturates_without_hanging() {
let mut input = "9".repeat(100_000);
input.push_str("ms");
assert_eq!(ms(&input), u32::MAX);
fn extremely_long_run_of_leading_zeros_still_parses_exactly() {
let mut input = "0".repeat(100_000);
input.push_str("1ms");
assert_eq!(ms(&input), 1);
fn extremely_long_garbage_is_rejected_without_hanging() {
let input = "x".repeat(100_000);
assert!(parse_duration(&input).is_err());
// Long, *trimmable* padding around a valid value.
let padded = format!("{}200ms{}", " ".repeat(50_000), " ".repeat(50_000));
assert_eq!(ms(&padded), 200);
fn deeply_nested_brackets_do_not_stack_overflow() {
// The parser is not recursive; prove it by feeding it 10k nesting levels.
let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
assert!(parse_duration(&nested).is_err());
let nested_with_unit = format!("{nested}s");
assert!(parse_duration(&nested_with_unit).is_err());
// -------------------------------------------------------------- unicode ---
fn non_ascii_input_is_rejected_without_panicking() {
for input in [
"\u{1F600}", // emoji
"\u{1F600}ms", // emoji + valid unit
"1\u{FF53}", // FULLWIDTH LATIN SMALL LETTER S is not "s"
"1s\u{0301}", // combining acute after the unit
"200ms", // fullwidth digits
"\u{202E}200ms", // RTL override prefix
"1\u{00A0}s", // NBSP *inside* the value
parse_duration(input).is_err(),
"expected {input:?} to be rejected"
/// `str::trim` strips Unicode whitespace, not just ASCII.
fn unicode_whitespace_around_a_valid_value_is_trimmed() {
assert_eq!(ms("\u{00A0}200ms\u{00A0}"), 200); // NBSP
assert_eq!(ms("\u{3000}1.5s\u{3000}"), 1500); // ideographic space
/// `to_lowercase` can *grow* the string (`İ` -> `i` + combining dot), which
/// would corrupt any byte-index-based suffix logic. Suffix stripping here is
/// char-safe, so this must merely fail to parse.
fn lowercasing_that_changes_the_byte_length_does_not_panic() {
assert!(parse_duration("\u{0130}ms").is_err()); // LATIN CAPITAL I WITH DOT ABOVE
assert!(parse_duration("1\u{0130}s").is_err());
// ----------------------------------------------------------- round-trip ---
fn print_as_css_value_round_trips_through_parse_duration() {
for inner in [
0,
1,
2,
17,
999,
1000,
65_535,
1_000_000,
TWO_POW_24, // last exactly-representable integer in f32
4_294_967_040, // 2^32 - 256: still exact (a multiple of the f32 ulp there)
u32::MAX, // rounds up to 2^32 in f32, then the cast saturates back down
let duration = CssDuration::from_millis(inner);
let printed = duration.print_as_css_value();
parse_duration(&printed),
Ok(duration),
"round-trip failed for {inner}ms (printed as {printed:?})"
/// A tick duration must print back as `t` and reparse as the SAME tick count.
/// A printer that emitted `ms` here would silently convert every stylesheet
/// round-trip from frames to wall time.
fn print_as_css_value_round_trips_tick_durations_as_ticks() {
for inner in [0, 1, 5, 60, 999, TWO_POW_24, u32::MAX] {
let duration = CssDuration::from_ticks(inner);
assert_eq!(printed, format!("{inner}t"));
"round-trip failed for {inner}t (printed as {printed:?})"
fn print_as_css_value_always_emits_the_unit_it_was_built_with() {
for inner in [0, 1, u32::MAX] {
let printed = CssDuration::from_millis(inner).print_as_css_value();
assert!(printed.ends_with("ms"), "{printed:?} lacks a unit");
assert_eq!(printed, format!("{inner}ms"));
let printed = CssDuration::from_ticks(inner).print_as_css_value();
assert!(printed.ends_with('t'), "{printed:?} lacks a unit");
assert!(!printed.ends_with("ms"), "{printed:?} lost the tick unit");
/// Above `2^24` the millisecond count no longer survives an `f32`, so the
/// round-trip is lossy. This is a real precision limit of the parser, pinned
/// here so it cannot regress further (the error must stay within one ulp).
fn round_trip_above_two_pow_24_is_lossy_but_bounded() {
let duration = CssDuration::from_millis(TWO_POW_24 + 1);
let reparsed = parse_duration(&duration.print_as_css_value()).unwrap();
assert_ne!(reparsed.inner, duration.inner);
assert_eq!(reparsed.inner, TWO_POW_24);
assert!(reparsed.inner.abs_diff(duration.inner) <= 1);
fn seconds_and_milliseconds_agree_for_the_same_duration() {
assert_eq!(ms("2s"), ms("2000ms"));
assert_eq!(ms("0.001s"), ms("1ms"));
assert_eq!(ms("0s"), ms("0ms"));
// ------------------------------------------------------- CssDuration ---
/// The default unit is milliseconds, not ticks: every pre-existing
/// `CssDuration::default()` in the tree means "0ms", and a default that
/// silently meant frames would reinterpret all of them.
fn default_duration_is_zero_milliseconds() {
assert_eq!(CssDuration::default(), CssDuration::from_millis(0));
assert_eq!(CssDuration::default().inner, 0);
assert_eq!(CssDuration::default().unit, CssDurationUnit::Milliseconds);
assert_eq!(CssDurationUnit::default(), CssDurationUnit::Milliseconds);
fn ordering_and_equality_follow_the_inner_count_within_one_unit() {
let a = CssDuration::from_millis(1);
let b = CssDuration::from_millis(2);
let max = CssDuration::from_millis(u32::MAX);
assert!(a < b);
assert!(b < max);
assert_eq!(a, CssDuration::from_millis(1));
assert_eq!(a.max(b), b);
// Same magnitude, different unit: NOT equal. `5ms` and `5t` are
// different durations and must never compare equal, or a stylesheet
// dedup/cache would collapse them into one.
assert_ne!(CssDuration::from_millis(5), CssDuration::from_ticks(5));
fn format_as_rust_code_emits_a_constructor_and_ignores_indentation() {
let d = CssDuration::from_millis(42);
d.format_as_rust_code(0),
"CssDuration { inner: 42, unit: CssDurationUnit::Milliseconds }"
assert_eq!(d.format_as_rust_code(7), d.format_as_rust_code(0));
CssDuration::from_millis(u32::MAX).format_as_rust_code(0),
"CssDuration { inner: 4294967295, unit: CssDurationUnit::Milliseconds }"
CssDuration::from_ticks(5).format_as_rust_code(0),
"CssDuration { inner: 5, unit: CssDurationUnit::Ticks }"
CssDurationUnit::Ticks.format_as_rust_code(0),
"CssDurationUnit::Ticks"
// --------------------------------------------------- error conversions ---
fn parse_float_error() -> core::num::ParseFloatError {
"not-a-float".parse::<f32>().unwrap_err()
fn to_contained_preserves_an_invalid_value_payload() {
let owned = DurationParseError::InvalidValue("10px").to_contained();
match owned {
DurationParseErrorOwned::InvalidValue(s) => assert_eq!(s.as_str(), "10px"),
DurationParseErrorOwned::ParseFloat(_) => panic!("variant changed"),
fn to_contained_stringifies_the_float_error() {
let owned = DurationParseError::ParseFloat(parse_float_error()).to_contained();
DurationParseErrorOwned::ParseFloat(s) => {
assert!(!s.as_str().is_empty(), "float error message was empty");
assert_eq!(s.as_str(), parse_float_error().to_string());
DurationParseErrorOwned::InvalidValue(_) => panic!("variant changed"),
fn to_contained_handles_empty_and_extreme_payloads() {
DurationParseError::InvalidValue("").to_contained(),
DurationParseErrorOwned::InvalidValue(String::new().into())
let huge = "x".repeat(100_000);
let owned = DurationParseError::InvalidValue(&huge).to_contained();
DurationParseErrorOwned::InvalidValue(s) => assert_eq!(s.as_str().len(), 100_000),
// Non-UTF8-boundary-unsafe payloads must survive the copy intact.
let unicode = "\u{1F600}\u{0301}";
DurationParseError::InvalidValue(unicode).to_contained(),
DurationParseErrorOwned::InvalidValue(unicode.to_string().into())
fn to_shared_preserves_an_invalid_value_payload() {
let owned = DurationParseErrorOwned::InvalidValue("garbage".to_string().into());
assert_eq!(owned.to_shared(), DurationParseError::InvalidValue("garbage"));
/// `DurationParseErrorOwned::to_shared` maps `ParseFloat(msg)` onto
/// `DurationParseError::InvalidValue(msg)` — the variant is *not* preserved,
/// so the error message ("invalid float literal") ends up in the slot that
/// normally holds the offending source text. Pinned as the current behaviour;
/// see the report accompanying this test module.
fn to_shared_downgrades_parse_float_to_invalid_value() {
let msg = parse_float_error().to_string();
let owned = DurationParseErrorOwned::ParseFloat(msg.clone().into());
let shared = owned.to_shared();
assert!(!matches!(shared, DurationParseError::ParseFloat(_)));
assert_eq!(shared, DurationParseError::InvalidValue(msg.as_str()));
fn to_shared_does_not_panic_on_empty_or_extreme_payloads() {
DurationParseErrorOwned::InvalidValue(String::new().into()).to_shared(),
DurationParseError::InvalidValue("")
let huge = "y".repeat(100_000);
let owned = DurationParseErrorOwned::InvalidValue(huge.clone().into());
assert_eq!(owned.to_shared(), DurationParseError::InvalidValue(&huge));
let empty_float = DurationParseErrorOwned::ParseFloat(String::new().into());
assert_eq!(empty_float.to_shared(), DurationParseError::InvalidValue(""));
/// A real error straight out of the parser must survive the owned round-trip
/// (this is the FFI path: borrow -> own -> borrow).
fn invalid_value_survives_a_full_shared_owned_shared_round_trip() {
let input = "10px";
let err = parse_duration(input).unwrap_err();
assert_eq!(err, DurationParseError::InvalidValue(input));
let owned = err.to_contained();
assert_eq!(owned.to_shared(), DurationParseError::InvalidValue(input));
/// `"200 nanoseconds"` ends in `s`, so it goes down the *seconds* branch and
/// fails in the float parse — not the "unknown unit" branch. Pinning this
/// keeps the two error variants from being swapped by accident.
fn a_word_ending_in_s_is_treated_as_a_seconds_value() {
parse_duration("200 nanoseconds"),
parse_duration("always"),
// ...whereas a word *not* ending in s/ms is an unknown-unit error.
parse_duration("200 nanosecond"),
Err(DurationParseError::InvalidValue("200 nanosecond"))
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());