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 alloc::string::{String, ToString};
6use crate::corety::AzString;
7
8use crate::{
9    props::{
10        basic::pixel::{
11            parse_pixel_value, CssPixelValueParseError, CssPixelValueParseErrorOwned, PixelValue,
12        },
13        macros::PixelValueTaker,
14    },
15};
16
17// --- Property Struct Definitions ---
18
19macro_rules! define_border_radius_property {
20    ($struct_name:ident) => {
21        #[derive(Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
22        #[repr(C)]
23        pub struct $struct_name {
24            pub inner: PixelValue,
25        }
26
27        impl ::core::fmt::Debug for $struct_name {
28            fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
29                write!(f, "{}", self.inner)
30            }
31        }
32
33        impl PixelValueTaker for $struct_name {
34            fn from_pixel_value(inner: PixelValue) -> Self {
35                Self { inner }
36            }
37        }
38
39        impl_pixel_value!($struct_name);
40    };
41}
42
43/// CSS `border-top-left-radius` property value.
44define_border_radius_property!(StyleBorderTopLeftRadius);
45/// CSS `border-top-right-radius` property value.
46define_border_radius_property!(StyleBorderTopRightRadius);
47/// CSS `border-bottom-left-radius` property value.
48define_border_radius_property!(StyleBorderBottomLeftRadius);
49/// CSS `border-bottom-right-radius` property value.
50define_border_radius_property!(StyleBorderBottomRightRadius);
51
52// --- Parser-only Struct ---
53
54/// A temporary struct used only during the parsing of the `border-radius` shorthand property.
55#[cfg(feature = "parser")]
56#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub struct StyleBorderRadius {
58    pub top_left: PixelValue,
59    pub top_right: PixelValue,
60    pub bottom_left: PixelValue,
61    pub bottom_right: PixelValue,
62}
63
64// --- Error Types ---
65
66/// Error for the shorthand `border-radius` property.
67#[derive(Clone, PartialEq, Eq)]
68pub enum CssBorderRadiusParseError<'a> {
69    /// Too many values were provided (max is 4).
70    TooManyValues(&'a str),
71    /// An underlying pixel value could not be parsed.
72    PixelValue(CssPixelValueParseError<'a>),
73}
74
75impl_debug_as_display!(CssBorderRadiusParseError<'a>);
76impl_display! { CssBorderRadiusParseError<'a>, {
77    TooManyValues(val) => format!("Too many values for border-radius: \"{}\"", val),
78    PixelValue(e) => format!("{}", e),
79}}
80impl_from!(
81    CssPixelValueParseError<'a>,
82    CssBorderRadiusParseError::PixelValue
83);
84
85/// Owned version of `CssBorderRadiusParseError`.
86#[derive(Debug, Clone, PartialEq, Eq)]
87#[repr(C, u8)]
88pub enum CssBorderRadiusParseErrorOwned {
89    TooManyValues(AzString),
90    PixelValue(CssPixelValueParseErrorOwned),
91}
92
93/// Newtype wrapper around `CssBorderRadiusParseErrorOwned` for the `border-radius` shorthand.
94#[derive(Debug, Clone, PartialEq, Eq)]
95#[repr(C)]
96pub struct CssStyleBorderRadiusParseErrorOwned {
97    pub inner: CssBorderRadiusParseErrorOwned,
98}
99
100impl From<CssBorderRadiusParseErrorOwned> for CssStyleBorderRadiusParseErrorOwned {
101    fn from(v: CssBorderRadiusParseErrorOwned) -> Self {
102        Self { inner: v }
103    }
104}
105
106impl CssBorderRadiusParseError<'_> {
107    #[must_use] pub fn to_contained(&self) -> CssBorderRadiusParseErrorOwned {
108        match self {
109            CssBorderRadiusParseError::TooManyValues(s) => {
110                CssBorderRadiusParseErrorOwned::TooManyValues((*s).to_string().into())
111            }
112            CssBorderRadiusParseError::PixelValue(e) => {
113                CssBorderRadiusParseErrorOwned::PixelValue(e.to_contained())
114            }
115        }
116    }
117}
118
119impl CssBorderRadiusParseErrorOwned {
120    #[must_use] pub fn to_shared(&self) -> CssBorderRadiusParseError<'_> {
121        match self {
122            Self::TooManyValues(s) => {
123                CssBorderRadiusParseError::TooManyValues(s)
124            }
125            Self::PixelValue(e) => {
126                CssBorderRadiusParseError::PixelValue(e.to_shared())
127            }
128        }
129    }
130}
131
132/// Macro to generate error types for individual radius properties.
133macro_rules! define_border_radius_parse_error {
134    ($error_name:ident, $error_name_owned:ident) => {
135        #[derive(Clone, PartialEq, Eq)]
136        pub enum $error_name<'a> {
137            PixelValue(CssPixelValueParseError<'a>),
138        }
139
140        impl_debug_as_display!($error_name<'a>);
141        impl_display! { $error_name<'a>, {
142            PixelValue(e) => format!("{}", e),
143        }}
144
145        impl_from!(CssPixelValueParseError<'a>, $error_name::PixelValue);
146
147        #[derive(Debug, Clone, PartialEq, Eq)]
148        #[repr(C, u8)]
149        pub enum $error_name_owned {
150            PixelValue(CssPixelValueParseErrorOwned),
151        }
152
153        impl $error_name<'_> {
154            #[must_use] pub fn to_contained(&self) -> $error_name_owned {
155                match self {
156                    $error_name::PixelValue(e) => $error_name_owned::PixelValue(e.to_contained()),
157                }
158            }
159        }
160
161        impl $error_name_owned {
162            #[must_use] pub fn to_shared(&self) -> $error_name<'_> {
163                match self {
164                    $error_name_owned::PixelValue(e) => $error_name::PixelValue(e.to_shared()),
165                }
166            }
167        }
168    };
169}
170
171define_border_radius_parse_error!(
172    StyleBorderTopLeftRadiusParseError,
173    StyleBorderTopLeftRadiusParseErrorOwned
174);
175define_border_radius_parse_error!(
176    StyleBorderTopRightRadiusParseError,
177    StyleBorderTopRightRadiusParseErrorOwned
178);
179define_border_radius_parse_error!(
180    StyleBorderBottomLeftRadiusParseError,
181    StyleBorderBottomLeftRadiusParseErrorOwned
182);
183define_border_radius_parse_error!(
184    StyleBorderBottomRightRadiusParseError,
185    StyleBorderBottomRightRadiusParseErrorOwned
186);
187
188// --- Parsing Functions ---
189
190/// Parse the CSS `border-radius` shorthand into individual corner values.
191#[cfg(feature = "parser")]
192/// # Errors
193///
194/// Returns an error if `input` is not a valid CSS `border-radius` value.
195pub fn parse_style_border_radius(
196    input: &str,
197) -> Result<StyleBorderRadius, CssBorderRadiusParseError<'_>> {
198    let components: Vec<_> = input.split_whitespace().collect();
199    let mut values = Vec::with_capacity(components.len());
200    for comp in &components {
201        values.push(parse_pixel_value(comp)?);
202    }
203
204    match values.len() {
205        1 => Ok(StyleBorderRadius {
206            top_left: values[0],
207            top_right: values[0],
208            bottom_right: values[0],
209            bottom_left: values[0],
210        }),
211        2 => Ok(StyleBorderRadius {
212            top_left: values[0],
213            top_right: values[1],
214            bottom_right: values[0],
215            bottom_left: values[1],
216        }),
217        3 => Ok(StyleBorderRadius {
218            top_left: values[0],
219            top_right: values[1],
220            bottom_right: values[2],
221            bottom_left: values[1],
222        }),
223        4 => Ok(StyleBorderRadius {
224            top_left: values[0],
225            top_right: values[1],
226            bottom_right: values[2],
227            bottom_left: values[3],
228        }),
229        _ => Err(CssBorderRadiusParseError::TooManyValues(input)),
230    }
231}
232
233/// Parse the CSS `border-top-left-radius` longhand property.
234#[cfg(feature = "parser")]
235/// # Errors
236///
237/// Returns an error if `input` is not a valid CSS `border-top-left-radius` value.
238pub fn parse_style_border_top_left_radius(
239    input: &str,
240) -> Result<StyleBorderTopLeftRadius, StyleBorderTopLeftRadiusParseError<'_>> {
241    let pixel_value = parse_pixel_value(input)?;
242    Ok(StyleBorderTopLeftRadius { inner: pixel_value })
243}
244
245/// Parse the CSS `border-top-right-radius` longhand property.
246#[cfg(feature = "parser")]
247/// # Errors
248///
249/// Returns an error if `input` is not a valid CSS `border-top-right-radius` value.
250pub fn parse_style_border_top_right_radius(
251    input: &str,
252) -> Result<StyleBorderTopRightRadius, StyleBorderTopRightRadiusParseError<'_>> {
253    let pixel_value = parse_pixel_value(input)?;
254    Ok(StyleBorderTopRightRadius { inner: pixel_value })
255}
256
257/// Parse the CSS `border-bottom-left-radius` longhand property.
258#[cfg(feature = "parser")]
259/// # Errors
260///
261/// Returns an error if `input` is not a valid CSS `border-bottom-left-radius` value.
262pub fn parse_style_border_bottom_left_radius(
263    input: &str,
264) -> Result<StyleBorderBottomLeftRadius, StyleBorderBottomLeftRadiusParseError<'_>> {
265    let pixel_value = parse_pixel_value(input)?;
266    Ok(StyleBorderBottomLeftRadius { inner: pixel_value })
267}
268
269/// Parse the CSS `border-bottom-right-radius` longhand property.
270#[cfg(feature = "parser")]
271/// # Errors
272///
273/// Returns an error if `input` is not a valid CSS `border-bottom-right-radius` value.
274pub fn parse_style_border_bottom_right_radius(
275    input: &str,
276) -> Result<StyleBorderBottomRightRadius, StyleBorderBottomRightRadiusParseError<'_>> {
277    let pixel_value = parse_pixel_value(input)?;
278    Ok(StyleBorderBottomRightRadius { inner: pixel_value })
279}
280
281#[cfg(all(test, feature = "parser"))]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_parse_border_radius_shorthand() {
287        // One value
288        let result = parse_style_border_radius("10px").unwrap();
289        assert_eq!(result.top_left, PixelValue::px(10.0));
290        assert_eq!(result.top_right, PixelValue::px(10.0));
291        assert_eq!(result.bottom_right, PixelValue::px(10.0));
292        assert_eq!(result.bottom_left, PixelValue::px(10.0));
293
294        // Two values
295        let result = parse_style_border_radius("10px 5%").unwrap();
296        assert_eq!(result.top_left, PixelValue::px(10.0));
297        assert_eq!(result.top_right, PixelValue::percent(5.0));
298        assert_eq!(result.bottom_right, PixelValue::px(10.0));
299        assert_eq!(result.bottom_left, PixelValue::percent(5.0));
300
301        // Three values
302        let result = parse_style_border_radius("2px 4px 8px").unwrap();
303        assert_eq!(result.top_left, PixelValue::px(2.0));
304        assert_eq!(result.top_right, PixelValue::px(4.0));
305        assert_eq!(result.bottom_right, PixelValue::px(8.0));
306        assert_eq!(result.bottom_left, PixelValue::px(4.0));
307
308        // Four values
309        let result = parse_style_border_radius("1px 0 3px 4px").unwrap();
310        assert_eq!(result.top_left, PixelValue::px(1.0));
311        assert_eq!(result.top_right, PixelValue::px(0.0));
312        assert_eq!(result.bottom_right, PixelValue::px(3.0));
313        assert_eq!(result.bottom_left, PixelValue::px(4.0));
314
315        // Weird whitespace
316        let result = parse_style_border_radius("  1em   2em  ").unwrap();
317        assert_eq!(result.top_left, PixelValue::em(1.0));
318        assert_eq!(result.top_right, PixelValue::em(2.0));
319    }
320
321    #[test]
322    fn test_parse_border_radius_shorthand_errors() {
323        assert!(parse_style_border_radius("").is_err());
324        assert!(parse_style_border_radius("1px 2px 3px 4px 5px").is_err());
325        assert!(parse_style_border_radius("1px bad 3px").is_err());
326    }
327
328    #[test]
329    fn test_parse_longhand_radius() {
330        let result = parse_style_border_top_left_radius("25%").unwrap();
331        assert_eq!(result.inner, PixelValue::percent(25.0));
332    }
333}
334
335#[cfg(all(test, feature = "parser"))]
336#[allow(
337    clippy::float_cmp,
338    clippy::unreadable_literal,
339    clippy::too_many_lines,
340    clippy::cast_precision_loss
341)]
342mod autotest_generated {
343    use super::*;
344    use crate::{css::PrintAsCssValue, props::basic::length::SizeMetric};
345
346    /// Every metric that `parse_pixel_value` has a suffix for. `Vmin` is
347    /// deliberately absent — see `vmin_radius_should_parse_but_is_shadowed_by_in`.
348    const ROUNDTRIPPABLE_METRICS: [SizeMetric; 11] = [
349        SizeMetric::Px,
350        SizeMetric::Pt,
351        SizeMetric::Em,
352        SizeMetric::Rem,
353        SizeMetric::In,
354        SizeMetric::Cm,
355        SizeMetric::Mm,
356        SizeMetric::Percent,
357        SizeMetric::Vw,
358        SizeMetric::Vh,
359        SizeMetric::Vmax,
360    ];
361
362    /// Values that survive the 1/1000 fixed-point quantization of `FloatValue`
363    /// exactly, so a failed round-trip means a real parser/printer bug and not
364    /// a rounding artifact.
365    const EXACT_VALUES: [f32; 6] = [0.0, 1.0, 12.5, -3.25, 0.125, 1000.5];
366
367    /// Inputs that are not valid CSS lengths in any position.
368    const GARBAGE: [&str; 14] = [
369        "bad",
370        "px",
371        "%",
372        "em",
373        "-",
374        "+",
375        ".",
376        "e",
377        "1..2px",
378        "1,2px",
379        "10px;",
380        "10px!important",
381        "\0",
382        "\u{7f}\u{1}",
383    ];
384
385    fn all_longhands(input: &str) -> [Result<PixelValue, ()>; 4] {
386        [
387            parse_style_border_top_left_radius(input)
388                .map(|v| v.inner)
389                .map_err(|_| ()),
390            parse_style_border_top_right_radius(input)
391                .map(|v| v.inner)
392                .map_err(|_| ()),
393            parse_style_border_bottom_left_radius(input)
394                .map(|v| v.inner)
395                .map_err(|_| ()),
396            parse_style_border_bottom_right_radius(input)
397                .map(|v| v.inner)
398                .map_err(|_| ()),
399        ]
400    }
401
402    // ================================================= shorthand: malformed ===
403
404    #[test]
405    fn shorthand_rejects_empty_and_whitespace_only() {
406        // `split_whitespace` yields zero components, which falls into the `_`
407        // arm — so a *missing* value is reported as TooManyValues. That variant
408        // is a misnomer for this input (see report), but it is still an Err.
409        for input in ["", " ", "   ", "\t\n", "\r\n\t ", "\u{a0}"] {
410            let err = parse_style_border_radius(input)
411                .expect_err("whitespace-only border-radius must not parse");
412            assert!(
413                matches!(err, CssBorderRadiusParseError::TooManyValues(_)),
414                "unexpected error for {input:?}: {err}"
415            );
416        }
417    }
418
419    #[test]
420    fn shorthand_rejects_garbage_without_panicking() {
421        for input in GARBAGE {
422            assert!(
423                parse_style_border_radius(input).is_err(),
424                "garbage {input:?} was accepted"
425            );
426        }
427    }
428
429    #[test]
430    fn shorthand_rejects_more_than_four_values() {
431        let input = "1px 2px 3px 4px 5px";
432        let err = parse_style_border_radius(input).unwrap_err();
433        assert_eq!(err, CssBorderRadiusParseError::TooManyValues(input));
434        // The error carries the *whole* input back, not just the extra value.
435        assert!(format!("{err}").contains(input));
436    }
437
438    #[test]
439    fn shorthand_reports_the_bad_component_before_counting_values() {
440        // Values are parsed eagerly, so a malformed component wins over the
441        // arity check even when the arity is also wrong.
442        let err = parse_style_border_radius("1px 2px 3px 4px 5px bad").unwrap_err();
443        assert_eq!(
444            err,
445            CssBorderRadiusParseError::PixelValue(CssPixelValueParseError::InvalidPixelValue("bad"))
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", "10px", "-3.25em", "50%", "1.5rem", "2pt", "1in", "2.54cm", "10mm", "5vw", "5vh",
714            "5vmax", "bad", "", "   ", "\u{1F600}", "1e30px", "NaNpx",
715        ];
716        for input in inputs {
717            let [tl, tr, bl, br] = all_longhands(input);
718            assert_eq!(tl, tr, "top-left vs top-right disagree on {input:?}");
719            assert_eq!(tl, bl, "top-left vs bottom-left disagree on {input:?}");
720            assert_eq!(tl, br, "top-left vs bottom-right disagree on {input:?}");
721        }
722    }
723
724    #[test]
725    fn longhands_agree_with_the_shorthand_on_single_values() {
726        for input in ["0", "10px", "-3.25em", "50%", "1.5rem", "2pt"] {
727            let shorthand = parse_style_border_radius(input).unwrap();
728            let [tl, tr, bl, br] = all_longhands(input);
729            assert_eq!(tl, Ok(shorthand.top_left), "{input:?}");
730            assert_eq!(tr, Ok(shorthand.top_right), "{input:?}");
731            assert_eq!(bl, Ok(shorthand.bottom_left), "{input:?}");
732            assert_eq!(br, Ok(shorthand.bottom_right), "{input:?}");
733        }
734    }
735
736    #[test]
737    fn longhands_accept_a_gap_between_number_and_unit_but_the_shorthand_does_not() {
738        // `parse_pixel_value` trims the value before parsing, so "10 px" is a
739        // valid longhand — while the shorthand splits it into two components
740        // and fails. Divergent, but deterministic; pinning both sides.
741        for result in all_longhands("10 px") {
742            assert_eq!(result, Ok(PixelValue::px(10.0)));
743        }
744        assert!(parse_style_border_radius("10 px").is_err());
745    }
746
747    #[test]
748    fn longhands_survive_extremely_long_and_nested_input() {
749        let long = format!("{}px", "9".repeat(100_000));
750        for result in all_longhands(&long) {
751            assert!(result.unwrap().number.get().is_finite());
752        }
753        let nested = format!("{}1px{}", "(".repeat(10_000), ")".repeat(10_000));
754        for result in all_longhands(&nested) {
755            assert_eq!(result, Err(()));
756        }
757    }
758
759    #[test]
760    fn longhands_boundary_numbers_stay_finite() {
761        for input in [
762            "0",
763            "-0",
764            "1e-30px",
765            "1e30px",
766            "-1e30px",
767            "9223372036854775807px",
768            "NaNpx",
769            "infpx",
770            "-infpx",
771        ] {
772            for result in all_longhands(input) {
773                let value = result.unwrap_or_else(|()| panic!("{input:?} rejected"));
774                let n = value.number.get();
775                assert!(n.is_finite() && !n.is_nan(), "{input:?} produced {n}");
776            }
777        }
778    }
779
780    // =========================================================== round-trips ===
781
782    #[test]
783    fn print_as_css_value_round_trips_through_every_longhand_parser() {
784        for metric in ROUNDTRIPPABLE_METRICS {
785            for value in EXACT_VALUES {
786                let pixel = PixelValue::from_metric(metric, value);
787
788                let tl = StyleBorderTopLeftRadius { inner: pixel };
789                let reparsed = parse_style_border_top_left_radius(&tl.print_as_css_value())
790                    .unwrap_or_else(|e| panic!("{:?} did not re-parse: {e}", tl.print_as_css_value()));
791                assert_eq!(reparsed, tl);
792
793                let tr = StyleBorderTopRightRadius { inner: pixel };
794                assert_eq!(
795                    parse_style_border_top_right_radius(&tr.print_as_css_value()).unwrap(),
796                    tr
797                );
798
799                let bl = StyleBorderBottomLeftRadius { inner: pixel };
800                assert_eq!(
801                    parse_style_border_bottom_left_radius(&bl.print_as_css_value()).unwrap(),
802                    bl
803                );
804
805                let br = StyleBorderBottomRightRadius { inner: pixel };
806                assert_eq!(
807                    parse_style_border_bottom_right_radius(&br.print_as_css_value()).unwrap(),
808                    br
809                );
810            }
811        }
812    }
813
814    #[test]
815    fn shorthand_round_trips_through_the_printed_corner_values() {
816        let original = parse_style_border_radius("1px 2em 3% 4rem").unwrap();
817        let printed = format!(
818            "{} {} {} {}",
819            StyleBorderTopLeftRadius {
820                inner: original.top_left
821            }
822            .print_as_css_value(),
823            StyleBorderTopRightRadius {
824                inner: original.top_right
825            }
826            .print_as_css_value(),
827            StyleBorderBottomRightRadius {
828                inner: original.bottom_right
829            }
830            .print_as_css_value(),
831            StyleBorderBottomLeftRadius {
832                inner: original.bottom_left
833            }
834            .print_as_css_value(),
835        );
836        assert_eq!(parse_style_border_radius(&printed).unwrap(), original);
837    }
838
839    #[test]
840    fn debug_output_matches_the_printed_css_value() {
841        // The Debug impl is hand-written to delegate to `inner`; if it ever
842        // reverts to the derived one, printed CSS and logs would diverge.
843        let pixel = PixelValue::em(1.5);
844        let tl = StyleBorderTopLeftRadius { inner: pixel };
845        assert_eq!(format!("{tl:?}"), "1.5em");
846        assert_eq!(format!("{tl:?}"), tl.print_as_css_value());
847    }
848
849    #[test]
850    fn vmin_radius_parses() {
851        // `border-radius: 3vmin` is valid CSS. parse_pixel_value used to check the
852        // "in" suffix before "vmin", so "3vmin" stripped to "3vm" and failed to
853        // parse as f32. The suffix table now orders "vmin"/"vmax" before "in"
854        // (css/src/props/basic/pixel.rs), so this round-trips like every other metric.
855        let printed = StyleBorderTopLeftRadius {
856            inner: PixelValue::from_metric(SizeMetric::Vmin, 3.0),
857        }
858        .print_as_css_value();
859        assert_eq!(printed, "3vmin");
860        assert_eq!(
861            parse_style_border_top_left_radius(&printed).unwrap().inner,
862            PixelValue::from_metric(SizeMetric::Vmin, 3.0)
863        );
864    }
865
866    // ================================================ constructors / getters ===
867
868    #[test]
869    fn default_and_zero_agree_for_every_corner_type() {
870        assert_eq!(
871            StyleBorderTopLeftRadius::default(),
872            StyleBorderTopLeftRadius::zero()
873        );
874        assert_eq!(
875            StyleBorderTopRightRadius::default(),
876            StyleBorderTopRightRadius::zero()
877        );
878        assert_eq!(
879            StyleBorderBottomLeftRadius::default(),
880            StyleBorderBottomLeftRadius::zero()
881        );
882        assert_eq!(
883            StyleBorderBottomRightRadius::default(),
884            StyleBorderBottomRightRadius::zero()
885        );
886        assert_eq!(
887            StyleBorderTopLeftRadius::default().inner,
888            PixelValue::px(0.0)
889        );
890    }
891
892    #[test]
893    fn const_constructors_agree_with_the_float_constructors() {
894        assert_eq!(
895            StyleBorderTopLeftRadius::const_px(10),
896            StyleBorderTopLeftRadius::px(10.0)
897        );
898        assert_eq!(
899            StyleBorderTopLeftRadius::const_em(2),
900            StyleBorderTopLeftRadius::em(2.0)
901        );
902        assert_eq!(
903            StyleBorderTopLeftRadius::const_percent(50),
904            StyleBorderTopLeftRadius::percent(50.0)
905        );
906        assert_eq!(
907            StyleBorderTopLeftRadius::const_pt(-3),
908            StyleBorderTopLeftRadius::pt(-3.0)
909        );
910        assert_eq!(
911            StyleBorderTopLeftRadius::from_pixel_value(PixelValue::px(4.0)),
912            StyleBorderTopLeftRadius::px(4.0)
913        );
914    }
915
916    #[test]
917    fn interpolate_returns_the_endpoints_for_matching_metrics() {
918        let a = StyleBorderTopLeftRadius::px(0.0);
919        let b = StyleBorderTopLeftRadius::px(10.0);
920        assert_eq!(a.interpolate(&b, 0.0), a);
921        assert_eq!(a.interpolate(&b, 1.0), b);
922        assert_eq!(a.interpolate(&b, 0.5), StyleBorderTopLeftRadius::px(5.0));
923    }
924
925    #[test]
926    fn interpolate_stays_finite_for_hostile_t_values() {
927        let a = StyleBorderTopLeftRadius::px(0.0);
928        let b = StyleBorderTopLeftRadius::px(10.0);
929        for t in [
930            -1.0,
931            2.0,
932            1e30,
933            -1e30,
934            f32::INFINITY,
935            f32::NEG_INFINITY,
936            f32::NAN,
937        ] {
938            let n = a.interpolate(&b, t).inner.number.get();
939            assert!(n.is_finite(), "t={t} produced {n}");
940            assert!(!n.is_nan(), "t={t} produced NaN");
941        }
942        // NaN t collapses to 0 rather than poisoning the value.
943        assert_eq!(
944            a.interpolate(&b, f32::NAN).inner.number.get(),
945            0.0
946        );
947    }
948
949    #[test]
950    fn interpolate_across_metrics_converts_to_px() {
951        // Mixed metrics fall back to px using the default 16px font size,
952        // so 1em (=16px) -> 10px at t=0.5 is 13px.
953        let em = StyleBorderTopLeftRadius::em(1.0);
954        let px = StyleBorderTopLeftRadius::px(10.0);
955        let mid = em.interpolate(&px, 0.5);
956        assert_eq!(mid.inner.metric, SizeMetric::Px);
957        assert_eq!(mid.inner.number.get(), 13.0);
958        // Note: unlike the same-metric case, t=0 does NOT return `em` itself.
959        assert_eq!(em.interpolate(&px, 0.0), StyleBorderTopLeftRadius::px(16.0));
960    }
961
962    // ========================================================= error getters ===
963
964    #[test]
965    fn shorthand_error_to_contained_round_trips_for_every_variant() {
966        let float_err = "x".parse::<f32>().unwrap_err();
967        let variants = [
968            CssBorderRadiusParseError::TooManyValues("1px 2px 3px 4px 5px"),
969            CssBorderRadiusParseError::PixelValue(CssPixelValueParseError::EmptyString),
970            CssBorderRadiusParseError::PixelValue(CssPixelValueParseError::NoValueGiven(
971                "px",
972                SizeMetric::Px,
973            )),
974            CssBorderRadiusParseError::PixelValue(CssPixelValueParseError::ValueParseErr(
975                float_err, "bad",
976            )),
977            CssBorderRadiusParseError::PixelValue(CssPixelValueParseError::InvalidPixelValue(
978                "nope",
979            )),
980        ];
981        for variant in variants {
982            let owned = variant.to_contained();
983            assert_eq!(owned.to_shared(), variant);
984            // Display must survive the round-trip too.
985            assert_eq!(format!("{}", owned.to_shared()), format!("{variant}"));
986        }
987    }
988
989    #[test]
990    fn shorthand_error_to_contained_handles_empty_unicode_and_huge_inputs() {
991        let huge = "1px ".repeat(50_000);
992        for input in ["", " ", "\u{1F600}\u{301}", "\0", huge.as_str()] {
993            let err = CssBorderRadiusParseError::TooManyValues(input);
994            let owned = err.to_contained();
995            assert_eq!(owned.to_shared(), err);
996            match owned {
997                CssBorderRadiusParseErrorOwned::TooManyValues(s) => {
998                    assert_eq!(s.as_str(), input);
999                }
1000                CssBorderRadiusParseErrorOwned::PixelValue(_) => panic!("wrong variant"),
1001            }
1002        }
1003    }
1004
1005    #[test]
1006    fn shorthand_errors_from_the_parser_round_trip() {
1007        // Same as above, but with errors the parser actually produces rather
1008        // than hand-built ones.
1009        for input in ["", "1px 2px 3px 4px 5px", "1px bad 3px", "px", "\u{1F600}"] {
1010            let err = parse_style_border_radius(input).unwrap_err();
1011            let owned = err.to_contained();
1012            assert_eq!(owned.to_shared(), err);
1013        }
1014    }
1015
1016    #[test]
1017    fn error_debug_delegates_to_display() {
1018        let err = CssBorderRadiusParseError::TooManyValues("a b c d e");
1019        assert_eq!(format!("{err:?}"), format!("{err}"));
1020        assert!(format!("{err}").contains("a b c d e"));
1021
1022        let inner = CssBorderRadiusParseError::PixelValue(CssPixelValueParseError::EmptyString);
1023        assert_eq!(
1024            format!("{inner}"),
1025            format!("{}", CssPixelValueParseError::EmptyString)
1026        );
1027    }
1028
1029    #[test]
1030    fn longhand_error_types_round_trip() {
1031        // Each of the four longhand error enums has its own generated
1032        // to_contained/to_shared pair.
1033        let tl = parse_style_border_top_left_radius("bad").unwrap_err();
1034        assert_eq!(tl.to_contained().to_shared(), tl);
1035
1036        let tr = parse_style_border_top_right_radius("").unwrap_err();
1037        assert_eq!(tr.to_contained().to_shared(), tr);
1038
1039        let bl = parse_style_border_bottom_left_radius("px").unwrap_err();
1040        assert_eq!(bl.to_contained().to_shared(), bl);
1041
1042        let br = parse_style_border_bottom_right_radius("\u{1F600}").unwrap_err();
1043        assert_eq!(br.to_contained().to_shared(), br);
1044
1045        // ... and each keeps the underlying pixel error intact.
1046        assert_eq!(
1047            tl.to_contained(),
1048            StyleBorderTopLeftRadiusParseErrorOwned::PixelValue(
1049                CssPixelValueParseError::InvalidPixelValue("bad").to_contained()
1050            )
1051        );
1052    }
1053
1054    #[test]
1055    fn owned_error_newtype_wrapper_preserves_the_inner_error() {
1056        let owned = CssBorderRadiusParseError::TooManyValues("1 2 3 4 5").to_contained();
1057        let wrapped = CssStyleBorderRadiusParseErrorOwned::from(owned.clone());
1058        assert_eq!(wrapped.inner, owned);
1059        assert_eq!(wrapped.inner.to_shared().to_contained(), owned);
1060    }
1061}