Skip to main content

azul_css/props/style/
border_radius.rs

1//! CSS properties for border radius (`border-top-left-radius`,
2//! `border-top-right-radius`, `border-bottom-left-radius`,
3//! `border-bottom-right-radius`) and the `border-radius` shorthand parser.
4
5use crate::corety::AzString;
6use alloc::string::{String, ToString};
7
8use crate::props::{
9    basic::pixel::{
10        parse_pixel_value, CssPixelValueParseError, CssPixelValueParseErrorOwned, PixelValue,
11    },
12    macros::PixelValueTaker,
13};
14
15// --- Property Struct Definitions ---
16
17macro_rules! define_border_radius_property {
18    ($struct_name:ident) => {
19        #[derive(Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
20        #[repr(C)]
21        pub struct $struct_name {
22            pub inner: PixelValue,
23        }
24
25        impl ::core::fmt::Debug for $struct_name {
26            fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
27                write!(f, "{}", self.inner)
28            }
29        }
30
31        impl PixelValueTaker for $struct_name {
32            fn from_pixel_value(inner: PixelValue) -> Self {
33                Self { inner }
34            }
35        }
36
37        impl_pixel_value!($struct_name);
38    };
39}
40
41/// CSS `border-top-left-radius` property value.
42define_border_radius_property!(StyleBorderTopLeftRadius);
43/// CSS `border-top-right-radius` property value.
44define_border_radius_property!(StyleBorderTopRightRadius);
45/// CSS `border-bottom-left-radius` property value.
46define_border_radius_property!(StyleBorderBottomLeftRadius);
47/// CSS `border-bottom-right-radius` property value.
48define_border_radius_property!(StyleBorderBottomRightRadius);
49
50// --- Parser-only Struct ---
51
52/// A temporary struct used only during the parsing of the `border-radius` shorthand property.
53#[cfg(feature = "parser")]
54#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
55pub struct StyleBorderRadius {
56    pub top_left: PixelValue,
57    pub top_right: PixelValue,
58    pub bottom_left: PixelValue,
59    pub bottom_right: PixelValue,
60}
61
62// --- Error Types ---
63
64/// Error for the shorthand `border-radius` property.
65#[derive(Clone, PartialEq, Eq)]
66pub enum CssBorderRadiusParseError<'a> {
67    /// Too many values were provided (max is 4).
68    TooManyValues(&'a str),
69    /// An underlying pixel value could not be parsed.
70    PixelValue(CssPixelValueParseError<'a>),
71}
72
73impl_debug_as_display!(CssBorderRadiusParseError<'a>);
74impl_display! { CssBorderRadiusParseError<'a>, {
75    TooManyValues(val) => format!("Too many values for border-radius: \"{}\"", val),
76    PixelValue(e) => format!("{}", e),
77}}
78impl_from!(
79    CssPixelValueParseError<'a>,
80    CssBorderRadiusParseError::PixelValue
81);
82
83/// Owned version of `CssBorderRadiusParseError`.
84#[derive(Debug, Clone, PartialEq, Eq)]
85#[repr(C, u8)]
86pub enum CssBorderRadiusParseErrorOwned {
87    TooManyValues(AzString),
88    PixelValue(CssPixelValueParseErrorOwned),
89}
90
91/// Newtype wrapper around `CssBorderRadiusParseErrorOwned` for the `border-radius` shorthand.
92#[derive(Debug, Clone, PartialEq, Eq)]
93#[repr(C)]
94pub struct CssStyleBorderRadiusParseErrorOwned {
95    pub inner: CssBorderRadiusParseErrorOwned,
96}
97
98impl From<CssBorderRadiusParseErrorOwned> for CssStyleBorderRadiusParseErrorOwned {
99    fn from(v: CssBorderRadiusParseErrorOwned) -> Self {
100        Self { inner: v }
101    }
102}
103
104impl CssBorderRadiusParseError<'_> {
105    #[must_use]
106    pub fn to_contained(&self) -> CssBorderRadiusParseErrorOwned {
107        match self {
108            CssBorderRadiusParseError::TooManyValues(s) => {
109                CssBorderRadiusParseErrorOwned::TooManyValues((*s).to_string().into())
110            }
111            CssBorderRadiusParseError::PixelValue(e) => {
112                CssBorderRadiusParseErrorOwned::PixelValue(e.to_contained())
113            }
114        }
115    }
116}
117
118impl CssBorderRadiusParseErrorOwned {
119    #[must_use]
120    pub fn to_shared(&self) -> CssBorderRadiusParseError<'_> {
121        match self {
122            Self::TooManyValues(s) => CssBorderRadiusParseError::TooManyValues(s),
123            Self::PixelValue(e) => CssBorderRadiusParseError::PixelValue(e.to_shared()),
124        }
125    }
126}
127
128/// Macro to generate error types for individual radius properties.
129macro_rules! define_border_radius_parse_error {
130    ($error_name:ident, $error_name_owned:ident) => {
131        #[derive(Clone, PartialEq, Eq)]
132        pub enum $error_name<'a> {
133            PixelValue(CssPixelValueParseError<'a>),
134        }
135
136        impl_debug_as_display!($error_name<'a>);
137        impl_display! { $error_name<'a>, {
138            PixelValue(e) => format!("{}", e),
139        }}
140
141        impl_from!(CssPixelValueParseError<'a>, $error_name::PixelValue);
142
143        #[derive(Debug, Clone, PartialEq, Eq)]
144        #[repr(C, u8)]
145        pub enum $error_name_owned {
146            PixelValue(CssPixelValueParseErrorOwned),
147        }
148
149        impl $error_name<'_> {
150            #[must_use]
151            pub fn to_contained(&self) -> $error_name_owned {
152                match self {
153                    $error_name::PixelValue(e) => $error_name_owned::PixelValue(e.to_contained()),
154                }
155            }
156        }
157
158        impl $error_name_owned {
159            #[must_use]
160            pub fn to_shared(&self) -> $error_name<'_> {
161                match self {
162                    $error_name_owned::PixelValue(e) => $error_name::PixelValue(e.to_shared()),
163                }
164            }
165        }
166    };
167}
168
169define_border_radius_parse_error!(
170    StyleBorderTopLeftRadiusParseError,
171    StyleBorderTopLeftRadiusParseErrorOwned
172);
173define_border_radius_parse_error!(
174    StyleBorderTopRightRadiusParseError,
175    StyleBorderTopRightRadiusParseErrorOwned
176);
177define_border_radius_parse_error!(
178    StyleBorderBottomLeftRadiusParseError,
179    StyleBorderBottomLeftRadiusParseErrorOwned
180);
181define_border_radius_parse_error!(
182    StyleBorderBottomRightRadiusParseError,
183    StyleBorderBottomRightRadiusParseErrorOwned
184);
185
186// --- Parsing Functions ---
187
188/// Parse the CSS `border-radius` shorthand into individual corner values.
189#[cfg(feature = "parser")]
190/// # Errors
191///
192/// Returns an error if `input` is not a valid CSS `border-radius` value.
193pub fn parse_style_border_radius(
194    input: &str,
195) -> Result<StyleBorderRadius, CssBorderRadiusParseError<'_>> {
196    let components: Vec<_> = input.split_whitespace().collect();
197    let mut values = Vec::with_capacity(components.len());
198    for comp in &components {
199        values.push(parse_pixel_value(comp)?);
200    }
201
202    match values.len() {
203        1 => Ok(StyleBorderRadius {
204            top_left: values[0],
205            top_right: values[0],
206            bottom_right: values[0],
207            bottom_left: values[0],
208        }),
209        2 => Ok(StyleBorderRadius {
210            top_left: values[0],
211            top_right: values[1],
212            bottom_right: values[0],
213            bottom_left: values[1],
214        }),
215        3 => Ok(StyleBorderRadius {
216            top_left: values[0],
217            top_right: values[1],
218            bottom_right: values[2],
219            bottom_left: values[1],
220        }),
221        4 => Ok(StyleBorderRadius {
222            top_left: values[0],
223            top_right: values[1],
224            bottom_right: values[2],
225            bottom_left: values[3],
226        }),
227        _ => Err(CssBorderRadiusParseError::TooManyValues(input)),
228    }
229}
230
231/// Parse the CSS `border-top-left-radius` longhand property.
232#[cfg(feature = "parser")]
233/// # Errors
234///
235/// Returns an error if `input` is not a valid CSS `border-top-left-radius` value.
236pub fn parse_style_border_top_left_radius(
237    input: &str,
238) -> Result<StyleBorderTopLeftRadius, StyleBorderTopLeftRadiusParseError<'_>> {
239    let pixel_value = parse_pixel_value(input)?;
240    Ok(StyleBorderTopLeftRadius { inner: pixel_value })
241}
242
243/// Parse the CSS `border-top-right-radius` longhand property.
244#[cfg(feature = "parser")]
245/// # Errors
246///
247/// Returns an error if `input` is not a valid CSS `border-top-right-radius` value.
248pub fn parse_style_border_top_right_radius(
249    input: &str,
250) -> Result<StyleBorderTopRightRadius, StyleBorderTopRightRadiusParseError<'_>> {
251    let pixel_value = parse_pixel_value(input)?;
252    Ok(StyleBorderTopRightRadius { inner: pixel_value })
253}
254
255/// Parse the CSS `border-bottom-left-radius` longhand property.
256#[cfg(feature = "parser")]
257/// # Errors
258///
259/// Returns an error if `input` is not a valid CSS `border-bottom-left-radius` value.
260pub fn parse_style_border_bottom_left_radius(
261    input: &str,
262) -> Result<StyleBorderBottomLeftRadius, StyleBorderBottomLeftRadiusParseError<'_>> {
263    let pixel_value = parse_pixel_value(input)?;
264    Ok(StyleBorderBottomLeftRadius { inner: pixel_value })
265}
266
267/// Parse the CSS `border-bottom-right-radius` longhand property.
268#[cfg(feature = "parser")]
269/// # Errors
270///
271/// Returns an error if `input` is not a valid CSS `border-bottom-right-radius` value.
272pub fn parse_style_border_bottom_right_radius(
273    input: &str,
274) -> Result<StyleBorderBottomRightRadius, StyleBorderBottomRightRadiusParseError<'_>> {
275    let pixel_value = parse_pixel_value(input)?;
276    Ok(StyleBorderBottomRightRadius { inner: pixel_value })
277}
278
279#[cfg(all(test, feature = "parser"))]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_parse_border_radius_shorthand() {
285        // One value
286        let result = parse_style_border_radius("10px").unwrap();
287        assert_eq!(result.top_left, PixelValue::px(10.0));
288        assert_eq!(result.top_right, PixelValue::px(10.0));
289        assert_eq!(result.bottom_right, PixelValue::px(10.0));
290        assert_eq!(result.bottom_left, PixelValue::px(10.0));
291
292        // Two values
293        let result = parse_style_border_radius("10px 5%").unwrap();
294        assert_eq!(result.top_left, PixelValue::px(10.0));
295        assert_eq!(result.top_right, PixelValue::percent(5.0));
296        assert_eq!(result.bottom_right, PixelValue::px(10.0));
297        assert_eq!(result.bottom_left, PixelValue::percent(5.0));
298
299        // Three values
300        let result = parse_style_border_radius("2px 4px 8px").unwrap();
301        assert_eq!(result.top_left, PixelValue::px(2.0));
302        assert_eq!(result.top_right, PixelValue::px(4.0));
303        assert_eq!(result.bottom_right, PixelValue::px(8.0));
304        assert_eq!(result.bottom_left, PixelValue::px(4.0));
305
306        // Four values
307        let result = parse_style_border_radius("1px 0 3px 4px").unwrap();
308        assert_eq!(result.top_left, PixelValue::px(1.0));
309        assert_eq!(result.top_right, PixelValue::px(0.0));
310        assert_eq!(result.bottom_right, PixelValue::px(3.0));
311        assert_eq!(result.bottom_left, PixelValue::px(4.0));
312
313        // Weird whitespace
314        let result = parse_style_border_radius("  1em   2em  ").unwrap();
315        assert_eq!(result.top_left, PixelValue::em(1.0));
316        assert_eq!(result.top_right, PixelValue::em(2.0));
317    }
318
319    #[test]
320    fn test_parse_border_radius_shorthand_errors() {
321        assert!(parse_style_border_radius("").is_err());
322        assert!(parse_style_border_radius("1px 2px 3px 4px 5px").is_err());
323        assert!(parse_style_border_radius("1px bad 3px").is_err());
324    }
325
326    #[test]
327    fn test_parse_longhand_radius() {
328        let result = parse_style_border_top_left_radius("25%").unwrap();
329        assert_eq!(result.inner, PixelValue::percent(25.0));
330    }
331}
332
333#[cfg(all(test, feature = "parser"))]
334#[allow(
335    clippy::float_cmp,
336    clippy::unreadable_literal,
337    clippy::too_many_lines,
338    clippy::cast_precision_loss
339)]
340mod autotest_generated {
341    use super::*;
342    use crate::{css::PrintAsCssValue, props::basic::length::SizeMetric};
343
344    /// Every metric that `parse_pixel_value` has a suffix for. `Vmin` is
345    /// deliberately absent — see `vmin_radius_should_parse_but_is_shadowed_by_in`.
346    const ROUNDTRIPPABLE_METRICS: [SizeMetric; 11] = [
347        SizeMetric::Px,
348        SizeMetric::Pt,
349        SizeMetric::Em,
350        SizeMetric::Rem,
351        SizeMetric::In,
352        SizeMetric::Cm,
353        SizeMetric::Mm,
354        SizeMetric::Percent,
355        SizeMetric::Vw,
356        SizeMetric::Vh,
357        SizeMetric::Vmax,
358    ];
359
360    /// Values that survive the 1/1000 fixed-point quantization of `FloatValue`
361    /// exactly, so a failed round-trip means a real parser/printer bug and not
362    /// a rounding artifact.
363    const EXACT_VALUES: [f32; 6] = [0.0, 1.0, 12.5, -3.25, 0.125, 1000.5];
364
365    /// Inputs that are not valid CSS lengths in any position.
366    const GARBAGE: [&str; 14] = [
367        "bad",
368        "px",
369        "%",
370        "em",
371        "-",
372        "+",
373        ".",
374        "e",
375        "1..2px",
376        "1,2px",
377        "10px;",
378        "10px!important",
379        "\0",
380        "\u{7f}\u{1}",
381    ];
382
383    fn all_longhands(input: &str) -> [Result<PixelValue, ()>; 4] {
384        [
385            parse_style_border_top_left_radius(input)
386                .map(|v| v.inner)
387                .map_err(|_| ()),
388            parse_style_border_top_right_radius(input)
389                .map(|v| v.inner)
390                .map_err(|_| ()),
391            parse_style_border_bottom_left_radius(input)
392                .map(|v| v.inner)
393                .map_err(|_| ()),
394            parse_style_border_bottom_right_radius(input)
395                .map(|v| v.inner)
396                .map_err(|_| ()),
397        ]
398    }
399
400    // ================================================= shorthand: malformed ===
401
402    #[test]
403    fn shorthand_rejects_empty_and_whitespace_only() {
404        // `split_whitespace` yields zero components, which falls into the `_`
405        // arm — so a *missing* value is reported as TooManyValues. That variant
406        // is a misnomer for this input (see report), but it is still an Err.
407        for input in ["", " ", "   ", "\t\n", "\r\n\t ", "\u{a0}"] {
408            let err = parse_style_border_radius(input)
409                .expect_err("whitespace-only border-radius must not parse");
410            assert!(
411                matches!(err, CssBorderRadiusParseError::TooManyValues(_)),
412                "unexpected error for {input:?}: {err}"
413            );
414        }
415    }
416
417    #[test]
418    fn shorthand_rejects_garbage_without_panicking() {
419        for input in GARBAGE {
420            assert!(
421                parse_style_border_radius(input).is_err(),
422                "garbage {input:?} was accepted"
423            );
424        }
425    }
426
427    #[test]
428    fn shorthand_rejects_more_than_four_values() {
429        let input = "1px 2px 3px 4px 5px";
430        let err = parse_style_border_radius(input).unwrap_err();
431        assert_eq!(err, CssBorderRadiusParseError::TooManyValues(input));
432        // The error carries the *whole* input back, not just the extra value.
433        assert!(format!("{err}").contains(input));
434    }
435
436    #[test]
437    fn shorthand_reports_the_bad_component_before_counting_values() {
438        // Values are parsed eagerly, so a malformed component wins over the
439        // arity check even when the arity is also wrong.
440        let err = parse_style_border_radius("1px 2px 3px 4px 5px bad").unwrap_err();
441        assert_eq!(
442            err,
443            CssBorderRadiusParseError::PixelValue(CssPixelValueParseError::InvalidPixelValue(
444                "bad"
445            ))
446        );
447    }
448
449    #[test]
450    fn shorthand_rejects_elliptical_slash_syntax() {
451        // `border-radius: 10px / 20px` is valid CSS but unsupported here; the
452        // important part is that it is *rejected* rather than silently
453        // mis-parsed into the wrong corners.
454        assert!(parse_style_border_radius("10px / 20px").is_err());
455        assert!(parse_style_border_radius("10px/20px").is_err());
456        assert!(parse_style_border_radius("1px 2px / 3px 4px").is_err());
457    }
458
459    #[test]
460    fn shorthand_rejects_split_number_and_unit() {
461        // "10 px" is two components: "10" (a bare number => px) and "px"
462        // (a unit with no value) — the latter fails.
463        let err = parse_style_border_radius("10 px").unwrap_err();
464        assert_eq!(
465            err,
466            CssBorderRadiusParseError::PixelValue(CssPixelValueParseError::NoValueGiven(
467                "px",
468                SizeMetric::Px
469            ))
470        );
471    }
472
473    // ================================================== shorthand: numerics ===
474
475    #[test]
476    fn shorthand_boundary_numbers_never_produce_nan_or_infinity() {
477        // Whatever these do, the stored FloatValue must stay finite: it is an
478        // isize under the hood and a NaN/inf leak would poison layout.
479        let inputs = [
480            "0",
481            "-0",
482            "+0",
483            "0px",
484            "-0px",
485            "0.0001px",
486            "1e-30px",
487            "1e30px",
488            "-1e30px",
489            "3.4028235e38px",
490            "-3.4028235e38px",
491            "9223372036854775807px",
492            "-9223372036854775808px",
493            "340282350000000000000000000000000000000%",
494        ];
495        for input in inputs {
496            let Ok(radius) = parse_style_border_radius(input) else {
497                continue; // rejection is an equally safe outcome
498            };
499            for corner in [
500                radius.top_left,
501                radius.top_right,
502                radius.bottom_left,
503                radius.bottom_right,
504            ] {
505                let n = corner.number.get();
506                assert!(n.is_finite(), "{input:?} produced non-finite {n}");
507            }
508        }
509    }
510
511    #[test]
512    fn shorthand_nan_input_is_flattened_to_zero() {
513        // f32::from_str accepts "NaN"; FloatValue::new then casts NaN*1000 to
514        // isize, and `as` maps NaN to 0. So `border-radius: NaNpx` is silently
515        // accepted as 0px rather than rejected — assert it at least cannot
516        // smuggle a NaN into the layout engine.
517        for input in ["NaN", "nan", "NaNpx", "-nan%"] {
518            let radius = parse_style_border_radius(input)
519                .unwrap_or_else(|e| panic!("{input:?} unexpectedly rejected: {e}"));
520            let n = radius.top_left.number.get();
521            assert!(!n.is_nan(), "{input:?} leaked a NaN");
522            assert_eq!(n, 0.0, "{input:?} should quantize to 0");
523        }
524    }
525
526    #[test]
527    fn shorthand_infinite_input_saturates_instead_of_overflowing() {
528        let pos = parse_style_border_radius("infpx").unwrap().top_left;
529        let neg = parse_style_border_radius("-infpx").unwrap().top_left;
530        assert!(pos.number.get().is_finite());
531        assert!(neg.number.get().is_finite());
532        assert!(pos.number.get() > 0.0);
533        assert!(neg.number.get() < 0.0);
534        // Saturation, not wraparound: +inf must not come back out negative.
535        assert_eq!(pos.number.number(), isize::MAX);
536        assert_eq!(neg.number.number(), isize::MIN);
537    }
538
539    #[test]
540    fn shorthand_sub_quantum_values_truncate_to_zero() {
541        // FloatValue keeps 1/1000 of a unit; anything smaller becomes 0.
542        // A 0.0001px radius is therefore indistinguishable from no radius.
543        let radius = parse_style_border_radius("0.0001px").unwrap();
544        assert_eq!(radius.top_left, PixelValue::px(0.0));
545        assert_eq!(radius.top_left.number.number(), 0);
546    }
547
548    #[test]
549    fn shorthand_negative_zero_equals_positive_zero() {
550        let neg = parse_style_border_radius("-0px").unwrap();
551        let pos = parse_style_border_radius("0px").unwrap();
552        assert_eq!(neg, pos);
553        assert_eq!(neg.top_left.number.number(), 0);
554    }
555
556    // ==================================== shorthand: long / unicode / nested ===
557
558    #[test]
559    fn shorthand_extremely_long_input_terminates() {
560        // 100k components: must reject on arity, not hang or blow the stack.
561        let many = "1px ".repeat(100_000);
562        assert!(matches!(
563            parse_style_border_radius(&many),
564            Err(CssBorderRadiusParseError::TooManyValues(_))
565        ));
566
567        // A single 100k-digit number: f32 parsing overflows to inf, which the
568        // fixed-point cast saturates.
569        let huge = format!("{}px", "1".repeat(100_000));
570        let radius = parse_style_border_radius(&huge).unwrap();
571        assert!(radius.top_left.number.get().is_finite());
572
573        // 100k chars of pure junk in one token.
574        let junk = "z".repeat(100_000);
575        assert!(parse_style_border_radius(&junk).is_err());
576    }
577
578    #[test]
579    fn shorthand_deeply_nested_brackets_do_not_stack_overflow() {
580        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
581        assert!(parse_style_border_radius(&nested).is_err());
582
583        let calls = format!("{}1px{}", "calc(".repeat(10_000), ")".repeat(10_000));
584        assert!(parse_style_border_radius(&calls).is_err());
585    }
586
587    #[test]
588    fn shorthand_unicode_input_is_rejected_without_panicking() {
589        // Multibyte input must not be byte-sliced anywhere in the parser.
590        for input in [
591            "\u{1F600}",
592            "10px\u{1F600}",
593            "\u{1F600}px",
594            "\u{661}\u{660}px", // arabic-indic digits
595            "10\u{301}px",      // combining acute
596            "10px\u{200b}",     // zero-width space (not a separator)
597            "10px",           // fullwidth digits
598            "10\u{202e}px",     // right-to-left override
599        ] {
600            assert!(
601                parse_style_border_radius(input).is_err(),
602                "unicode {input:?} was accepted"
603            );
604        }
605    }
606
607    #[test]
608    fn shorthand_treats_non_breaking_space_as_a_value_separator() {
609        // `str::split_whitespace` uses the Unicode White_Space property, so
610        // U+00A0 separates values here even though CSS tokenization does not
611        // treat it as whitespace (browsers reject this). Pinning current
612        // behaviour — see report.
613        let radius = parse_style_border_radius("1px\u{a0}2px").unwrap();
614        assert_eq!(radius.top_left, PixelValue::px(1.0));
615        assert_eq!(radius.top_right, PixelValue::px(2.0));
616    }
617
618    // ==================================== shorthand: corner-expansion invariants
619
620    #[test]
621    fn shorthand_one_value_fills_every_corner() {
622        let radius = parse_style_border_radius("7px").unwrap();
623        assert_eq!(radius.top_left, PixelValue::px(7.0));
624        assert_eq!(radius.top_right, PixelValue::px(7.0));
625        assert_eq!(radius.bottom_left, PixelValue::px(7.0));
626        assert_eq!(radius.bottom_right, PixelValue::px(7.0));
627    }
628
629    #[test]
630    fn shorthand_two_and_three_values_expand_along_the_diagonals() {
631        // CSS Backgrounds 3 §5.1: 2 values => TL/BR = 1st, TR/BL = 2nd.
632        let two = parse_style_border_radius("1px 2px").unwrap();
633        assert_eq!(two.top_left, two.bottom_right);
634        assert_eq!(two.top_right, two.bottom_left);
635        assert_eq!(two.top_left, PixelValue::px(1.0));
636        assert_eq!(two.top_right, PixelValue::px(2.0));
637
638        // 3 values => TL = 1st, TR/BL = 2nd, BR = 3rd.
639        let three = parse_style_border_radius("1px 2px 3px").unwrap();
640        assert_eq!(three.top_left, PixelValue::px(1.0));
641        assert_eq!(three.top_right, PixelValue::px(2.0));
642        assert_eq!(three.bottom_left, PixelValue::px(2.0));
643        assert_eq!(three.bottom_right, PixelValue::px(3.0));
644    }
645
646    #[test]
647    fn shorthand_four_values_map_clockwise_from_top_left() {
648        let four = parse_style_border_radius("1px 2px 3px 4px").unwrap();
649        assert_eq!(four.top_left, PixelValue::px(1.0));
650        assert_eq!(four.top_right, PixelValue::px(2.0));
651        assert_eq!(four.bottom_right, PixelValue::px(3.0));
652        assert_eq!(four.bottom_left, PixelValue::px(4.0));
653        // No two corners aliased: a copy-paste slip in the match arm would
654        // duplicate one of these.
655        assert_ne!(four.top_left, four.top_right);
656        assert_ne!(four.bottom_right, four.bottom_left);
657    }
658
659    #[test]
660    fn shorthand_preserves_per_corner_units() {
661        let radius = parse_style_border_radius("1px 2em 3% 4rem").unwrap();
662        assert_eq!(radius.top_left, PixelValue::px(1.0));
663        assert_eq!(radius.top_right, PixelValue::em(2.0));
664        assert_eq!(radius.bottom_right, PixelValue::percent(3.0));
665        assert_eq!(radius.bottom_left, PixelValue::rem(4.0));
666    }
667
668    #[test]
669    fn shorthand_valid_minimal_input() {
670        let radius = parse_style_border_radius("0").unwrap();
671        assert_eq!(radius.top_left, PixelValue::px(0.0));
672    }
673
674    #[test]
675    fn shorthand_tolerates_arbitrary_ascii_whitespace_runs() {
676        let radius = parse_style_border_radius("\n\t 1px \t 2px\r\n").unwrap();
677        assert_eq!(radius.top_left, PixelValue::px(1.0));
678        assert_eq!(radius.top_right, PixelValue::px(2.0));
679    }
680
681    // ====================================================== longhand parsers ===
682
683    #[test]
684    fn longhands_reject_empty_whitespace_and_garbage() {
685        for input in ["", " ", "\t\n"] {
686            for result in all_longhands(input) {
687                assert_eq!(result, Err(()), "{input:?} was accepted by a longhand");
688            }
689        }
690        for input in GARBAGE {
691            for result in all_longhands(input) {
692                assert_eq!(result, Err(()), "{input:?} was accepted by a longhand");
693            }
694        }
695    }
696
697    #[test]
698    fn longhands_reject_multiple_values() {
699        // A longhand takes exactly one length; the shorthand list must not leak
700        // through (e.g. by silently using the first value).
701        for input in ["1px 2px", "1px 2px 3px 4px", "1px,2px"] {
702            for result in all_longhands(input) {
703                assert_eq!(result, Err(()), "{input:?} was accepted by a longhand");
704            }
705        }
706    }
707
708    #[test]
709    fn all_four_longhands_agree_on_every_input() {
710        // The four parsers are macro-free copies of each other; a copy-paste
711        // bug (wrong metric, wrong field) would show up as a disagreement.
712        let inputs = [
713            "0",
714            "10px",
715            "-3.25em",
716            "50%",
717            "1.5rem",
718            "2pt",
719            "1in",
720            "2.54cm",
721            "10mm",
722            "5vw",
723            "5vh",
724            "5vmax",
725            "bad",
726            "",
727            "   ",
728            "\u{1F600}",
729            "1e30px",
730            "NaNpx",
731        ];
732        for input in inputs {
733            let [tl, tr, bl, br] = all_longhands(input);
734            assert_eq!(tl, tr, "top-left vs top-right disagree on {input:?}");
735            assert_eq!(tl, bl, "top-left vs bottom-left disagree on {input:?}");
736            assert_eq!(tl, br, "top-left vs bottom-right disagree on {input:?}");
737        }
738    }
739
740    #[test]
741    fn longhands_agree_with_the_shorthand_on_single_values() {
742        for input in ["0", "10px", "-3.25em", "50%", "1.5rem", "2pt"] {
743            let shorthand = parse_style_border_radius(input).unwrap();
744            let [tl, tr, bl, br] = all_longhands(input);
745            assert_eq!(tl, Ok(shorthand.top_left), "{input:?}");
746            assert_eq!(tr, Ok(shorthand.top_right), "{input:?}");
747            assert_eq!(bl, Ok(shorthand.bottom_left), "{input:?}");
748            assert_eq!(br, Ok(shorthand.bottom_right), "{input:?}");
749        }
750    }
751
752    #[test]
753    fn longhands_accept_a_gap_between_number_and_unit_but_the_shorthand_does_not() {
754        // `parse_pixel_value` trims the value before parsing, so "10 px" is a
755        // valid longhand — while the shorthand splits it into two components
756        // and fails. Divergent, but deterministic; pinning both sides.
757        for result in all_longhands("10 px") {
758            assert_eq!(result, Ok(PixelValue::px(10.0)));
759        }
760        assert!(parse_style_border_radius("10 px").is_err());
761    }
762
763    #[test]
764    fn longhands_survive_extremely_long_and_nested_input() {
765        let long = format!("{}px", "9".repeat(100_000));
766        for result in all_longhands(&long) {
767            assert!(result.unwrap().number.get().is_finite());
768        }
769        let nested = format!("{}1px{}", "(".repeat(10_000), ")".repeat(10_000));
770        for result in all_longhands(&nested) {
771            assert_eq!(result, Err(()));
772        }
773    }
774
775    #[test]
776    fn longhands_boundary_numbers_stay_finite() {
777        for input in [
778            "0",
779            "-0",
780            "1e-30px",
781            "1e30px",
782            "-1e30px",
783            "9223372036854775807px",
784            "NaNpx",
785            "infpx",
786            "-infpx",
787        ] {
788            for result in all_longhands(input) {
789                let value = result.unwrap_or_else(|()| panic!("{input:?} rejected"));
790                let n = value.number.get();
791                assert!(n.is_finite() && !n.is_nan(), "{input:?} produced {n}");
792            }
793        }
794    }
795
796    // =========================================================== round-trips ===
797
798    #[test]
799    fn print_as_css_value_round_trips_through_every_longhand_parser() {
800        for metric in ROUNDTRIPPABLE_METRICS {
801            for value in EXACT_VALUES {
802                let pixel = PixelValue::from_metric(metric, value);
803
804                let tl = StyleBorderTopLeftRadius { inner: pixel };
805                let reparsed = parse_style_border_top_left_radius(&tl.print_as_css_value())
806                    .unwrap_or_else(|e| {
807                        panic!("{:?} did not re-parse: {e}", tl.print_as_css_value())
808                    });
809                assert_eq!(reparsed, tl);
810
811                let tr = StyleBorderTopRightRadius { inner: pixel };
812                assert_eq!(
813                    parse_style_border_top_right_radius(&tr.print_as_css_value()).unwrap(),
814                    tr
815                );
816
817                let bl = StyleBorderBottomLeftRadius { inner: pixel };
818                assert_eq!(
819                    parse_style_border_bottom_left_radius(&bl.print_as_css_value()).unwrap(),
820                    bl
821                );
822
823                let br = StyleBorderBottomRightRadius { inner: pixel };
824                assert_eq!(
825                    parse_style_border_bottom_right_radius(&br.print_as_css_value()).unwrap(),
826                    br
827                );
828            }
829        }
830    }
831
832    #[test]
833    fn shorthand_round_trips_through_the_printed_corner_values() {
834        let original = parse_style_border_radius("1px 2em 3% 4rem").unwrap();
835        let printed = format!(
836            "{} {} {} {}",
837            StyleBorderTopLeftRadius {
838                inner: original.top_left
839            }
840            .print_as_css_value(),
841            StyleBorderTopRightRadius {
842                inner: original.top_right
843            }
844            .print_as_css_value(),
845            StyleBorderBottomRightRadius {
846                inner: original.bottom_right
847            }
848            .print_as_css_value(),
849            StyleBorderBottomLeftRadius {
850                inner: original.bottom_left
851            }
852            .print_as_css_value(),
853        );
854        assert_eq!(parse_style_border_radius(&printed).unwrap(), original);
855    }
856
857    #[test]
858    fn debug_output_matches_the_printed_css_value() {
859        // The Debug impl is hand-written to delegate to `inner`; if it ever
860        // reverts to the derived one, printed CSS and logs would diverge.
861        let pixel = PixelValue::em(1.5);
862        let tl = StyleBorderTopLeftRadius { inner: pixel };
863        assert_eq!(format!("{tl:?}"), "1.5em");
864        assert_eq!(format!("{tl:?}"), tl.print_as_css_value());
865    }
866
867    #[test]
868    fn vmin_radius_parses() {
869        // `border-radius: 3vmin` is valid CSS. parse_pixel_value used to check the
870        // "in" suffix before "vmin", so "3vmin" stripped to "3vm" and failed to
871        // parse as f32. The suffix table now orders "vmin"/"vmax" before "in"
872        // (css/src/props/basic/pixel.rs), so this round-trips like every other metric.
873        let printed = StyleBorderTopLeftRadius {
874            inner: PixelValue::from_metric(SizeMetric::Vmin, 3.0),
875        }
876        .print_as_css_value();
877        assert_eq!(printed, "3vmin");
878        assert_eq!(
879            parse_style_border_top_left_radius(&printed).unwrap().inner,
880            PixelValue::from_metric(SizeMetric::Vmin, 3.0)
881        );
882    }
883
884    // ================================================ constructors / getters ===
885
886    #[test]
887    fn default_and_zero_agree_for_every_corner_type() {
888        assert_eq!(
889            StyleBorderTopLeftRadius::default(),
890            StyleBorderTopLeftRadius::zero()
891        );
892        assert_eq!(
893            StyleBorderTopRightRadius::default(),
894            StyleBorderTopRightRadius::zero()
895        );
896        assert_eq!(
897            StyleBorderBottomLeftRadius::default(),
898            StyleBorderBottomLeftRadius::zero()
899        );
900        assert_eq!(
901            StyleBorderBottomRightRadius::default(),
902            StyleBorderBottomRightRadius::zero()
903        );
904        assert_eq!(
905            StyleBorderTopLeftRadius::default().inner,
906            PixelValue::px(0.0)
907        );
908    }
909
910    #[test]
911    fn const_constructors_agree_with_the_float_constructors() {
912        assert_eq!(
913            StyleBorderTopLeftRadius::const_px(10),
914            StyleBorderTopLeftRadius::px(10.0)
915        );
916        assert_eq!(
917            StyleBorderTopLeftRadius::const_em(2),
918            StyleBorderTopLeftRadius::em(2.0)
919        );
920        assert_eq!(
921            StyleBorderTopLeftRadius::const_percent(50),
922            StyleBorderTopLeftRadius::percent(50.0)
923        );
924        assert_eq!(
925            StyleBorderTopLeftRadius::const_pt(-3),
926            StyleBorderTopLeftRadius::pt(-3.0)
927        );
928        assert_eq!(
929            StyleBorderTopLeftRadius::from_pixel_value(PixelValue::px(4.0)),
930            StyleBorderTopLeftRadius::px(4.0)
931        );
932    }
933
934    #[test]
935    fn interpolate_returns_the_endpoints_for_matching_metrics() {
936        let a = StyleBorderTopLeftRadius::px(0.0);
937        let b = StyleBorderTopLeftRadius::px(10.0);
938        assert_eq!(a.interpolate(&b, 0.0), a);
939        assert_eq!(a.interpolate(&b, 1.0), b);
940        assert_eq!(a.interpolate(&b, 0.5), StyleBorderTopLeftRadius::px(5.0));
941    }
942
943    #[test]
944    fn interpolate_stays_finite_for_hostile_t_values() {
945        let a = StyleBorderTopLeftRadius::px(0.0);
946        let b = StyleBorderTopLeftRadius::px(10.0);
947        for t in [
948            -1.0,
949            2.0,
950            1e30,
951            -1e30,
952            f32::INFINITY,
953            f32::NEG_INFINITY,
954            f32::NAN,
955        ] {
956            let n = a.interpolate(&b, t).inner.number.get();
957            assert!(n.is_finite(), "t={t} produced {n}");
958            assert!(!n.is_nan(), "t={t} produced NaN");
959        }
960        // NaN t collapses to 0 rather than poisoning the value.
961        assert_eq!(a.interpolate(&b, f32::NAN).inner.number.get(), 0.0);
962    }
963
964    #[test]
965    fn interpolate_across_metrics_converts_to_px() {
966        // Mixed metrics fall back to px using the default 16px font size,
967        // so 1em (=16px) -> 10px at t=0.5 is 13px.
968        let em = StyleBorderTopLeftRadius::em(1.0);
969        let px = StyleBorderTopLeftRadius::px(10.0);
970        let mid = em.interpolate(&px, 0.5);
971        assert_eq!(mid.inner.metric, SizeMetric::Px);
972        assert_eq!(mid.inner.number.get(), 13.0);
973        // Note: unlike the same-metric case, t=0 does NOT return `em` itself.
974        assert_eq!(em.interpolate(&px, 0.0), StyleBorderTopLeftRadius::px(16.0));
975    }
976
977    // ========================================================= error getters ===
978
979    #[test]
980    fn shorthand_error_to_contained_round_trips_for_every_variant() {
981        let float_err = "x".parse::<f32>().unwrap_err();
982        let variants = [
983            CssBorderRadiusParseError::TooManyValues("1px 2px 3px 4px 5px"),
984            CssBorderRadiusParseError::PixelValue(CssPixelValueParseError::EmptyString),
985            CssBorderRadiusParseError::PixelValue(CssPixelValueParseError::NoValueGiven(
986                "px",
987                SizeMetric::Px,
988            )),
989            CssBorderRadiusParseError::PixelValue(CssPixelValueParseError::ValueParseErr(
990                float_err, "bad",
991            )),
992            CssBorderRadiusParseError::PixelValue(CssPixelValueParseError::InvalidPixelValue(
993                "nope",
994            )),
995        ];
996        for variant in variants {
997            let owned = variant.to_contained();
998            assert_eq!(owned.to_shared(), variant);
999            // Display must survive the round-trip too.
1000            assert_eq!(format!("{}", owned.to_shared()), format!("{variant}"));
1001        }
1002    }
1003
1004    #[test]
1005    fn shorthand_error_to_contained_handles_empty_unicode_and_huge_inputs() {
1006        let huge = "1px ".repeat(50_000);
1007        for input in ["", " ", "\u{1F600}\u{301}", "\0", huge.as_str()] {
1008            let err = CssBorderRadiusParseError::TooManyValues(input);
1009            let owned = err.to_contained();
1010            assert_eq!(owned.to_shared(), err);
1011            match owned {
1012                CssBorderRadiusParseErrorOwned::TooManyValues(s) => {
1013                    assert_eq!(s.as_str(), input);
1014                }
1015                CssBorderRadiusParseErrorOwned::PixelValue(_) => panic!("wrong variant"),
1016            }
1017        }
1018    }
1019
1020    #[test]
1021    fn shorthand_errors_from_the_parser_round_trip() {
1022        // Same as above, but with errors the parser actually produces rather
1023        // than hand-built ones.
1024        for input in ["", "1px 2px 3px 4px 5px", "1px bad 3px", "px", "\u{1F600}"] {
1025            let err = parse_style_border_radius(input).unwrap_err();
1026            let owned = err.to_contained();
1027            assert_eq!(owned.to_shared(), err);
1028        }
1029    }
1030
1031    #[test]
1032    fn error_debug_delegates_to_display() {
1033        let err = CssBorderRadiusParseError::TooManyValues("a b c d e");
1034        assert_eq!(format!("{err:?}"), format!("{err}"));
1035        assert!(format!("{err}").contains("a b c d e"));
1036
1037        let inner = CssBorderRadiusParseError::PixelValue(CssPixelValueParseError::EmptyString);
1038        assert_eq!(
1039            format!("{inner}"),
1040            format!("{}", CssPixelValueParseError::EmptyString)
1041        );
1042    }
1043
1044    #[test]
1045    fn longhand_error_types_round_trip() {
1046        // Each of the four longhand error enums has its own generated
1047        // to_contained/to_shared pair.
1048        let tl = parse_style_border_top_left_radius("bad").unwrap_err();
1049        assert_eq!(tl.to_contained().to_shared(), tl);
1050
1051        let tr = parse_style_border_top_right_radius("").unwrap_err();
1052        assert_eq!(tr.to_contained().to_shared(), tr);
1053
1054        let bl = parse_style_border_bottom_left_radius("px").unwrap_err();
1055        assert_eq!(bl.to_contained().to_shared(), bl);
1056
1057        let br = parse_style_border_bottom_right_radius("\u{1F600}").unwrap_err();
1058        assert_eq!(br.to_contained().to_shared(), br);
1059
1060        // ... and each keeps the underlying pixel error intact.
1061        assert_eq!(
1062            tl.to_contained(),
1063            StyleBorderTopLeftRadiusParseErrorOwned::PixelValue(
1064                CssPixelValueParseError::InvalidPixelValue("bad").to_contained()
1065            )
1066        );
1067    }
1068
1069    #[test]
1070    fn owned_error_newtype_wrapper_preserves_the_inner_error() {
1071        let owned = CssBorderRadiusParseError::TooManyValues("1 2 3 4 5").to_contained();
1072        let wrapped = CssStyleBorderRadiusParseErrorOwned::from(owned.clone());
1073        assert_eq!(wrapped.inner, owned);
1074        assert_eq!(wrapped.inner.to_shared().to_contained(), owned);
1075    }
1076}