Skip to main content

azul_css/props/basic/
angle.rs

1//! CSS property types for angles (degrees, radians, etc.).
2
3use alloc::string::{String, ToString};
4use core::{fmt, num::ParseFloatError};
5use crate::corety::AzString;
6
7use crate::props::basic::error::ParseFloatErrorWithInput;
8
9use crate::props::{basic::length::FloatValue, formatter::PrintAsCssValue};
10
11/// Enum representing the metric associated with an angle (deg, rad, etc.)
12#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
13#[repr(C)]
14#[derive(Default)]
15pub enum AngleMetric {
16    #[default]
17    Degree,
18    Radians,
19    Grad,
20    Turn,
21    Percent,
22}
23
24
25impl fmt::Display for AngleMetric {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        use self::AngleMetric::{Degree, Radians, Grad, Turn, Percent};
28        match self {
29            Degree => write!(f, "deg"),
30            Radians => write!(f, "rad"),
31            Grad => write!(f, "grad"),
32            Turn => write!(f, "turn"),
33            Percent => write!(f, "%"),
34        }
35    }
36}
37
38/// `FloatValue`, but associated with a certain metric (i.e. deg, rad, etc.)
39#[derive(Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
40#[repr(C)]
41pub struct AngleValue {
42    pub metric: AngleMetric,
43    pub number: FloatValue,
44}
45
46impl_option!(
47    AngleValue,
48    OptionAngleValue,
49    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
50);
51
52impl fmt::Debug for AngleValue {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        write!(f, "{self}")
55    }
56}
57
58impl fmt::Display for AngleValue {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        write!(f, "{}{}", self.number, self.metric)
61    }
62}
63
64impl PrintAsCssValue for AngleValue {
65    fn print_as_css_value(&self) -> String {
66        format!("{self}")
67    }
68}
69
70impl AngleValue {
71    /// Returns an angle of zero degrees.
72    #[inline]
73    #[must_use] pub const fn zero() -> Self {
74        const ZERO_DEG: AngleValue = AngleValue::const_deg(0);
75        ZERO_DEG
76    }
77
78    /// Creates a const angle value in degrees from an integer.
79    #[inline]
80    #[must_use] pub const fn const_deg(value: isize) -> Self {
81        Self::const_from_metric(AngleMetric::Degree, value)
82    }
83
84    /// Creates a const angle value in radians from an integer.
85    #[inline]
86    #[must_use] pub const fn const_rad(value: isize) -> Self {
87        Self::const_from_metric(AngleMetric::Radians, value)
88    }
89
90    /// Creates a const angle value in gradians from an integer.
91    #[inline]
92    #[must_use] pub const fn const_grad(value: isize) -> Self {
93        Self::const_from_metric(AngleMetric::Grad, value)
94    }
95
96    /// Creates a const angle value in turns from an integer.
97    #[inline]
98    #[must_use] pub const fn const_turn(value: isize) -> Self {
99        Self::const_from_metric(AngleMetric::Turn, value)
100    }
101
102    /// Creates a const angle value in percent from an integer.
103    #[inline]
104    #[must_use] pub const fn const_percent(value: isize) -> Self {
105        Self::const_from_metric(AngleMetric::Percent, value)
106    }
107
108    /// Creates a const angle value with the given metric from an integer.
109    #[inline]
110    #[must_use] pub const fn const_from_metric(metric: AngleMetric, value: isize) -> Self {
111        Self {
112            metric,
113            number: FloatValue::const_new(value),
114        }
115    }
116
117    /// Creates a const angle value with the given metric from a fractional number.
118    ///
119    /// # Arguments
120    /// * `metric` - The angle metric (Degree, Radians, etc.)
121    /// * `pre_comma` - The integer part (e.g., 45 for 45.5deg)
122    /// * `post_comma` - The fractional part as digits (e.g., 5 for 0.5deg)
123    #[inline]
124    #[must_use] pub const fn const_from_metric_fractional(metric: AngleMetric, pre_comma: isize, post_comma: isize) -> Self {
125        Self {
126            metric,
127            number: FloatValue::const_new_fractional(pre_comma, post_comma),
128        }
129    }
130
131    /// Creates an angle value in degrees.
132    #[inline]
133    #[must_use] pub fn deg(value: f32) -> Self {
134        Self::from_metric(AngleMetric::Degree, value)
135    }
136
137    /// Creates an angle value in radians.
138    #[inline]
139    #[must_use] pub fn rad(value: f32) -> Self {
140        Self::from_metric(AngleMetric::Radians, value)
141    }
142
143    /// Creates an angle value in gradians.
144    #[inline]
145    #[must_use] pub fn grad(value: f32) -> Self {
146        Self::from_metric(AngleMetric::Grad, value)
147    }
148
149    /// Creates an angle value in turns.
150    #[inline]
151    #[must_use] pub fn turn(value: f32) -> Self {
152        Self::from_metric(AngleMetric::Turn, value)
153    }
154
155    /// Creates an angle value in percent.
156    #[inline]
157    #[must_use] pub fn percent(value: f32) -> Self {
158        Self::from_metric(AngleMetric::Percent, value)
159    }
160
161    /// Creates an angle value with the given metric.
162    #[inline]
163    #[must_use] pub fn from_metric(metric: AngleMetric, value: f32) -> Self {
164        Self {
165            metric,
166            number: FloatValue::new(value),
167        }
168    }
169
170    /// Convert to degrees, normalized to [0, 360) range.
171    /// Note: 360.0 becomes 0.0 due to modulo operation.
172    /// For conic gradients where 360.0 is meaningful, use `to_degrees_raw()`.
173    #[inline]
174    #[must_use] pub fn to_degrees(&self) -> f32 {
175        let mut val = self.to_degrees_raw() % 360.0;
176        if val < 0.0 {
177            val += 360.0;
178        }
179        val
180    }
181
182    /// Convert to degrees without normalization (raw value).
183    /// Use this for conic gradients where 360.0 is a meaningful distinct value from 0.0.
184    #[inline]
185    #[must_use] pub fn to_degrees_raw(&self) -> f32 {
186        match self.metric {
187            AngleMetric::Degree => self.number.get(),
188            AngleMetric::Grad => self.number.get() / 400.0 * 360.0,
189            AngleMetric::Radians => self.number.get().to_degrees(),
190            AngleMetric::Turn => self.number.get() * 360.0,
191            AngleMetric::Percent => self.number.get() / 100.0 * 360.0,
192        }
193    }
194}
195
196// -- Parser
197
198/// Error returned when parsing a CSS angle value from a string.
199#[derive(Clone, PartialEq, Eq)]
200pub enum CssAngleValueParseError<'a> {
201    EmptyString,
202    NoValueGiven(&'a str, AngleMetric),
203    ValueParseErr(ParseFloatError, &'a str),
204    InvalidAngle(&'a str),
205}
206
207impl_debug_as_display!(CssAngleValueParseError<'a>);
208impl_display! { CssAngleValueParseError<'a>, {
209    EmptyString => format!("Missing [rad / deg / turn / %] value"),
210    NoValueGiven(input, metric) => format!("Expected floating-point angle value, got: \"{}{}\"", input, metric),
211    ValueParseErr(err, number_str) => format!("Could not parse \"{}\" as floating-point value: \"{}\"", number_str, err),
212    InvalidAngle(s) => format!("Invalid angle value: \"{}\"", s),
213}}
214
215/// Wrapper for `NoValueGiven` error in angle parsing.
216#[derive(Debug, Clone, PartialEq, Eq)]
217#[repr(C)]
218pub struct AngleNoValueGivenError {
219    pub value: AzString,
220    pub metric: AngleMetric,
221}
222
223/// Owned version of [`CssAngleValueParseError`] for FFI and storage.
224#[derive(Debug, Clone, PartialEq, Eq)]
225#[repr(C, u8)]
226pub enum CssAngleValueParseErrorOwned {
227    EmptyString,
228    NoValueGiven(AngleNoValueGivenError),
229    ValueParseErr(ParseFloatErrorWithInput),
230    InvalidAngle(AzString),
231}
232
233impl CssAngleValueParseError<'_> {
234    #[must_use] pub fn to_contained(&self) -> CssAngleValueParseErrorOwned {
235        match self {
236            CssAngleValueParseError::EmptyString => CssAngleValueParseErrorOwned::EmptyString,
237            CssAngleValueParseError::NoValueGiven(s, metric) => {
238                CssAngleValueParseErrorOwned::NoValueGiven(AngleNoValueGivenError { value: (*s).to_string().into(), metric: *metric })
239            }
240            CssAngleValueParseError::ValueParseErr(err, s) => {
241                CssAngleValueParseErrorOwned::ValueParseErr(ParseFloatErrorWithInput { error: err.clone().into(), input: (*s).to_string().into() })
242            }
243            CssAngleValueParseError::InvalidAngle(s) => {
244                CssAngleValueParseErrorOwned::InvalidAngle((*s).to_string().into())
245            }
246        }
247    }
248}
249
250impl CssAngleValueParseErrorOwned {
251    #[must_use] pub fn to_shared(&self) -> CssAngleValueParseError<'_> {
252        match self {
253            Self::EmptyString => CssAngleValueParseError::EmptyString,
254            Self::NoValueGiven(e) => {
255                CssAngleValueParseError::NoValueGiven(e.value.as_str(), e.metric)
256            }
257            Self::ValueParseErr(e) => {
258                CssAngleValueParseError::ValueParseErr(e.error.to_std(), e.input.as_str())
259            }
260            Self::InvalidAngle(s) => {
261                CssAngleValueParseError::InvalidAngle(s.as_str())
262            }
263        }
264    }
265}
266
267/// Parse a CSS angle value string (e.g. `"90deg"`, `"1.57rad"`, `"0.5turn"`, `"50%"`).
268/// A bare number without a unit suffix is interpreted as degrees.
269#[cfg(feature = "parser")]
270/// # Errors
271///
272/// Returns an error if `input` is not a valid CSS `angle-value` value.
273pub fn parse_angle_value(input: &str) -> Result<AngleValue, CssAngleValueParseError<'_>> {
274    let input = input.trim();
275
276    if input.is_empty() {
277        return Err(CssAngleValueParseError::EmptyString);
278    }
279
280    let match_values = &[
281        ("deg", AngleMetric::Degree),
282        ("turn", AngleMetric::Turn),
283        ("grad", AngleMetric::Grad),
284        ("rad", AngleMetric::Radians),
285        ("%", AngleMetric::Percent),
286    ];
287
288    for (match_val, metric) in match_values {
289        if let Some(value) = input.strip_suffix(match_val) {
290            let value = value.trim();
291            if value.is_empty() {
292                return Err(CssAngleValueParseError::NoValueGiven(input, *metric));
293            }
294            match value.parse::<f32>() {
295                Ok(o) => return Ok(AngleValue::from_metric(*metric, o)),
296                Err(e) => return Err(CssAngleValueParseError::ValueParseErr(e, value)),
297            }
298        }
299    }
300
301    // bare number is degrees
302    input.parse::<f32>().map_or_else(
303        |_| Err(CssAngleValueParseError::InvalidAngle(input)),
304        |o| Ok(AngleValue::from_metric(AngleMetric::Degree, o)),
305    )
306}
307
308#[cfg(all(test, feature = "parser"))]
309mod tests {
310    // Tests assert parsed values equal the exact source literals; the rad inputs
311    // (1.57, 3.14) are literal test data, not approximations of FRAC_PI_2/PI.
312    #![allow(clippy::float_cmp, clippy::approx_constant)]
313    use super::*;
314
315    #[test]
316    fn test_parse_angle_value_deg() {
317        assert_eq!(parse_angle_value("90deg").unwrap(), AngleValue::deg(90.0));
318        assert_eq!(
319            parse_angle_value("-45.5deg").unwrap(),
320            AngleValue::deg(-45.5)
321        );
322        // Bare number defaults to degrees
323        assert_eq!(parse_angle_value("180").unwrap(), AngleValue::deg(180.0));
324    }
325
326    #[test]
327    fn test_parse_angle_value_rad() {
328        assert_eq!(parse_angle_value("1.57rad").unwrap(), AngleValue::rad(1.57));
329        assert_eq!(
330            parse_angle_value(" -3.14rad ").unwrap(),
331            AngleValue::rad(-3.14)
332        );
333    }
334
335    #[test]
336    fn test_parse_angle_value_grad() {
337        assert_eq!(
338            parse_angle_value("100grad").unwrap(),
339            AngleValue::grad(100.0)
340        );
341        assert_eq!(
342            parse_angle_value("400grad").unwrap(),
343            AngleValue::grad(400.0)
344        );
345    }
346
347    #[test]
348    fn test_parse_angle_value_turn() {
349        assert_eq!(
350            parse_angle_value("0.25turn").unwrap(),
351            AngleValue::turn(0.25)
352        );
353        assert_eq!(parse_angle_value("1turn").unwrap(), AngleValue::turn(1.0));
354    }
355
356    #[test]
357    fn test_parse_angle_value_percent() {
358        assert_eq!(parse_angle_value("50%").unwrap(), AngleValue::percent(50.0));
359    }
360
361    #[test]
362    fn test_parse_angle_value_errors() {
363        assert!(parse_angle_value("").is_err());
364        assert!(parse_angle_value("deg").is_err());
365        assert!(parse_angle_value("90 degs").is_err());
366        assert!(parse_angle_value("ninety-deg").is_err());
367        assert!(parse_angle_value("1.57 rads").is_err());
368    }
369
370    #[test]
371    fn test_to_degrees_conversion() {
372        assert_eq!(AngleValue::deg(90.0).to_degrees(), 90.0);
373        // Use 0.1 tolerance due to FloatValue fixed-point precision (multiplier = 1000.0)
374        assert!((AngleValue::rad(core::f32::consts::PI).to_degrees() - 180.0).abs() < 0.1);
375        assert_eq!(AngleValue::grad(100.0).to_degrees(), 90.0);
376        assert_eq!(AngleValue::turn(0.5).to_degrees(), 180.0);
377        assert_eq!(AngleValue::deg(-90.0).to_degrees(), 270.0);
378        assert_eq!(AngleValue::deg(450.0).to_degrees(), 90.0);
379    }
380}
381
382#[cfg(test)]
383#[allow(
384    clippy::float_cmp,
385    clippy::unreadable_literal,
386    clippy::cast_precision_loss,
387    clippy::too_many_lines
388)]
389mod autotest_generated {
390    use super::*;
391    use crate::props::basic::error::{
392        ParseFloatError as FfiParseFloatError, ParseFloatErrorWithInput,
393    };
394
395    /// `FloatValue` stores `f32 * 1000` truncated into an `isize`.
396    const MULT: isize = 1000;
397
398    /// Every `AngleMetric` variant, for exhaustive sweeps.
399    const ALL_METRICS: [AngleMetric; 5] = [
400        AngleMetric::Degree,
401        AngleMetric::Radians,
402        AngleMetric::Grad,
403        AngleMetric::Turn,
404        AngleMetric::Percent,
405    ];
406
407    // -------------------------------------------------------------------
408    // serializers (Display / PrintAsCssValue)
409    // -------------------------------------------------------------------
410
411    #[test]
412    fn autotest_angle_metric_display_is_non_empty_and_exact() {
413        assert_eq!(AngleMetric::Degree.to_string(), "deg");
414        assert_eq!(AngleMetric::Radians.to_string(), "rad");
415        assert_eq!(AngleMetric::Grad.to_string(), "grad");
416        assert_eq!(AngleMetric::Turn.to_string(), "turn");
417        assert_eq!(AngleMetric::Percent.to_string(), "%");
418        assert_eq!(AngleMetric::default(), AngleMetric::Degree);
419        for m in ALL_METRICS {
420            assert!(!m.to_string().is_empty(), "empty unit string for {m:?}");
421        }
422    }
423
424    #[test]
425    fn autotest_angle_value_display_default_and_zero() {
426        assert_eq!(AngleValue::default().to_string(), "0deg");
427        assert_eq!(AngleValue::zero().to_string(), "0deg");
428        // Debug delegates to Display.
429        assert_eq!(format!("{:?}", AngleValue::zero()), "0deg");
430        assert_eq!(AngleValue::zero().print_as_css_value(), "0deg");
431    }
432
433    #[test]
434    fn autotest_angle_value_display_never_emits_inf_or_nan() {
435        // Saturating/clamping happens inside FloatValue, so no non-finite value
436        // can ever reach the formatter -- assert the serializer stays CSS-safe.
437        for m in ALL_METRICS {
438            for v in [
439                f32::NAN,
440                f32::INFINITY,
441                f32::NEG_INFINITY,
442                f32::MAX,
443                f32::MIN,
444                f32::MIN_POSITIVE,
445                -0.0,
446            ] {
447                let s = AngleValue::from_metric(m, v).to_string();
448                assert!(!s.is_empty(), "empty serialization for {m:?} / {v}");
449                assert!(!s.contains("inf"), "serialized infinity: {s}");
450                assert!(!s.contains("NaN"), "serialized NaN: {s}");
451                assert!(s.ends_with(&m.to_string()), "lost the unit suffix: {s}");
452            }
453        }
454    }
455
456    #[test]
457    fn autotest_angle_value_nan_serializes_as_zero() {
458        // NaN collapses to 0 (the `as isize` cast maps NaN -> 0), it is not preserved.
459        assert_eq!(AngleValue::deg(f32::NAN).to_string(), "0deg");
460        assert_eq!(AngleValue::rad(f32::NAN).to_string(), "0rad");
461    }
462
463    // -------------------------------------------------------------------
464    // constructors
465    // -------------------------------------------------------------------
466
467    #[test]
468    fn autotest_zero_is_the_neutral_element() {
469        let z = AngleValue::zero();
470        assert_eq!(z, AngleValue::default());
471        assert_eq!(z, AngleValue::const_deg(0));
472        assert_eq!(z, AngleValue::deg(0.0));
473        assert_eq!(z.metric, AngleMetric::Degree);
474        assert_eq!(z.number.number(), 0);
475        assert_eq!(z.number.get(), 0.0);
476        assert_eq!(z.to_degrees(), 0.0);
477        assert_eq!(z.to_degrees_raw(), 0.0);
478    }
479
480    #[test]
481    fn autotest_from_metric_fields_match_args() {
482        for m in ALL_METRICS {
483            let a = AngleValue::from_metric(m, 12.5);
484            assert_eq!(a.metric, m);
485            assert_eq!(a.number.get(), 12.5);
486            assert_eq!(a.number.number(), 12_500);
487        }
488        // The per-metric helpers must agree with from_metric.
489        assert_eq!(
490            AngleValue::deg(1.5),
491            AngleValue::from_metric(AngleMetric::Degree, 1.5)
492        );
493        assert_eq!(
494            AngleValue::rad(1.5),
495            AngleValue::from_metric(AngleMetric::Radians, 1.5)
496        );
497        assert_eq!(
498            AngleValue::grad(1.5),
499            AngleValue::from_metric(AngleMetric::Grad, 1.5)
500        );
501        assert_eq!(
502            AngleValue::turn(1.5),
503            AngleValue::from_metric(AngleMetric::Turn, 1.5)
504        );
505        assert_eq!(
506            AngleValue::percent(1.5),
507            AngleValue::from_metric(AngleMetric::Percent, 1.5)
508        );
509    }
510
511    // -------------------------------------------------------------------
512    // numeric: const constructors (isize -> fixed point)
513    // -------------------------------------------------------------------
514
515    #[test]
516    fn autotest_const_ctors_zero_negative_and_metric() {
517        for (built, metric) in [
518            (AngleValue::const_deg(0), AngleMetric::Degree),
519            (AngleValue::const_rad(0), AngleMetric::Radians),
520            (AngleValue::const_grad(0), AngleMetric::Grad),
521            (AngleValue::const_turn(0), AngleMetric::Turn),
522            (AngleValue::const_percent(0), AngleMetric::Percent),
523        ] {
524            assert_eq!(built.metric, metric);
525            assert_eq!(built.number.number(), 0);
526        }
527        assert_eq!(AngleValue::const_deg(-90).number.get(), -90.0);
528        assert_eq!(AngleValue::const_rad(-3).number.number(), -3 * MULT);
529        assert_eq!(AngleValue::const_turn(-1).to_degrees_raw(), -360.0);
530    }
531
532    #[test]
533    fn autotest_const_from_metric_matches_specific_ctors() {
534        for (m, specific) in [
535            (AngleMetric::Degree, AngleValue::const_deg(7)),
536            (AngleMetric::Radians, AngleValue::const_rad(7)),
537            (AngleMetric::Grad, AngleValue::const_grad(7)),
538            (AngleMetric::Turn, AngleValue::const_turn(7)),
539            (AngleMetric::Percent, AngleValue::const_percent(7)),
540        ] {
541            assert_eq!(AngleValue::const_from_metric(m, 7), specific);
542            assert_eq!(specific.number.number(), 7 * MULT);
543        }
544    }
545
546    #[test]
547    fn autotest_const_ctors_at_safe_isize_boundary() {
548        // const_new multiplies by 1000, so |value| <= isize::MAX / 1000 is the
549        // largest magnitude that cannot overflow. Assert exactness right at the edge.
550        const MAX_SAFE: isize = isize::MAX / MULT;
551        const MIN_SAFE: isize = isize::MIN / MULT;
552
553        assert_eq!(
554            AngleValue::const_deg(MAX_SAFE).number.number(),
555            MAX_SAFE * MULT
556        );
557        assert_eq!(
558            AngleValue::const_deg(MIN_SAFE).number.number(),
559            MIN_SAFE * MULT
560        );
561        // ...and the round-trip back to f32 stays finite (no inf leaking into layout).
562        assert!(AngleValue::const_deg(MAX_SAFE).number.get().is_finite());
563        assert!(AngleValue::const_deg(MIN_SAFE).number.get().is_finite());
564        assert!(AngleValue::const_turn(MAX_SAFE).to_degrees().is_finite());
565        assert!(AngleValue::const_turn(MIN_SAFE).to_degrees().is_finite());
566    }
567
568    #[test]
569    fn autotest_const_deg_isize_max_overflows_unchecked() {
570        // Documents (does not bless) the unchecked `value * 1000` in FloatValue::const_new:
571        // isize::MAX degrees panics on overflow in debug and wraps in release. Both are
572        // accepted here; what must NOT happen is a silently plausible-looking angle.
573        // black_box keeps const-propagation from turning this into a compile-time error.
574        let huge = core::hint::black_box(isize::MAX);
575        let prev = std::panic::take_hook();
576        std::panic::set_hook(Box::new(|_| {}));
577        let res = std::panic::catch_unwind(move || AngleValue::const_deg(huge).number.number());
578        std::panic::set_hook(prev);
579
580        match res {
581            Err(_) => {} // debug build: "attempt to multiply with overflow"
582            Ok(n) => assert_eq!(
583                n,
584                isize::MAX.wrapping_mul(MULT),
585                "release build must wrap, not produce a sanitized value"
586            ),
587        }
588    }
589
590    #[test]
591    fn autotest_const_from_metric_fractional_digit_truncation() {
592        let f = |pre, post| {
593            AngleValue::const_from_metric_fractional(AngleMetric::Degree, pre, post)
594                .number
595                .number()
596        };
597        assert_eq!(f(0, 0), 0);
598        assert_eq!(f(45, 5), 45_500); // 45.5
599        assert_eq!(f(0, 83), 830); // 0.83
600        assert_eq!(f(1, 523), 1_523); // 1.523
601        // More than 3 fractional digits: truncated (not rounded) to 3.
602        assert_eq!(f(2, 123456), 2_123); // 2.123456 -> 2.123, per the doc comment
603        assert_eq!(f(0, 999_999_999), 999); // 0.999999999 -> 0.999
604        assert_eq!(
605            AngleValue::const_from_metric_fractional(AngleMetric::Turn, 0, 25).metric,
606            AngleMetric::Turn
607        );
608    }
609
610    #[test]
611    fn autotest_const_fractional_sign_handling_and_negative_zero_trap() {
612        let deg = |pre, post| {
613            AngleValue::const_from_metric_fractional(AngleMetric::Degree, pre, post)
614                .number
615                .get()
616        };
617        assert_eq!(deg(-1, 5), -1.5); // negative pre drags the fraction negative
618        assert_eq!(deg(0, -5), -0.5); // negative post encodes a negative fraction
619        assert_eq!(deg(-1, -5), -1.5); // both negative must not double-negate
620        // TRAP: isize has no -0, so `-0` is `0` and the sign is lost. -0.5deg is NOT
621        // expressible as (-0, 5); it yields +0.5deg. Callers must use (0, -5).
622        assert_eq!(deg(-0, 5), 0.5);
623        assert_ne!(deg(-0, 5), -0.5);
624    }
625
626    // -------------------------------------------------------------------
627    // numeric: f32 constructors (saturation / NaN / sub-precision)
628    // -------------------------------------------------------------------
629
630    #[test]
631    fn autotest_f32_ctor_nan_collapses_to_zero() {
632        for m in ALL_METRICS {
633            let a = AngleValue::from_metric(m, f32::NAN);
634            assert_eq!(a.number.number(), 0, "NaN did not clamp to 0 for {m:?}");
635            assert_eq!(a.number.get(), 0.0);
636            assert!(!a.number.get().is_nan());
637            assert_eq!(a.to_degrees(), 0.0);
638            assert_eq!(a.to_degrees_raw(), 0.0);
639            // Consequence worth knowing: NaN is *equal* to zero after construction.
640            assert_eq!(a, AngleValue::from_metric(m, 0.0));
641        }
642    }
643
644    #[test]
645    fn autotest_f32_ctor_infinities_saturate_to_isize_bounds() {
646        assert_eq!(AngleValue::deg(f32::INFINITY).number.number(), isize::MAX);
647        assert_eq!(
648            AngleValue::deg(f32::NEG_INFINITY).number.number(),
649            isize::MIN
650        );
651        // f32::MAX * 1000 overflows to +inf before the cast, so it saturates too.
652        assert_eq!(AngleValue::deg(f32::MAX).number.number(), isize::MAX);
653        assert_eq!(AngleValue::deg(f32::MIN).number.number(), isize::MIN);
654        for m in ALL_METRICS {
655            for v in [f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN] {
656                let a = AngleValue::from_metric(m, v);
657                assert!(a.number.get().is_finite(), "{m:?} / {v} leaked a non-finite");
658            }
659        }
660    }
661
662    #[test]
663    fn autotest_f32_ctor_truncates_toward_zero_below_precision() {
664        // 3 decimal digits of precision; the 4th digit is truncated, not rounded,
665        // and truncation is toward zero on both sides of 0.
666        assert_eq!(AngleValue::deg(1.9999).number.number(), 1_999);
667        assert_eq!(AngleValue::deg(-1.9999).number.number(), -1_999);
668        assert_eq!(AngleValue::deg(0.0004).number.number(), 0);
669        assert_eq!(AngleValue::deg(-0.0004).number.number(), 0);
670        assert_eq!(AngleValue::deg(f32::EPSILON).number.number(), 0);
671        assert_eq!(AngleValue::deg(f32::MIN_POSITIVE).number.number(), 0);
672        // -0.0 must not become a negative encoded value.
673        assert_eq!(AngleValue::deg(-0.0).number.number(), 0);
674        assert_eq!(AngleValue::deg(-0.0), AngleValue::deg(0.0));
675    }
676
677    // -------------------------------------------------------------------
678    // getters: to_degrees / to_degrees_raw
679    // -------------------------------------------------------------------
680
681    #[test]
682    fn autotest_to_degrees_known_conversions() {
683        assert_eq!(AngleValue::deg(90.0).to_degrees(), 90.0);
684        assert_eq!(AngleValue::grad(100.0).to_degrees(), 90.0);
685        assert_eq!(AngleValue::turn(0.25).to_degrees(), 90.0);
686        assert_eq!(AngleValue::percent(50.0).to_degrees(), 180.0);
687        assert_eq!(AngleValue::percent(25.0).to_degrees_raw(), 90.0);
688        // rad goes through the 1/1000 quantization, so compare with a tolerance.
689        assert!((AngleValue::rad(core::f32::consts::FRAC_PI_2).to_degrees() - 90.0).abs() < 0.1);
690    }
691
692    #[test]
693    fn autotest_to_degrees_normalizes_but_raw_does_not() {
694        // A full turn normalizes to 0 -- the documented 360 -> 0 collapse.
695        assert_eq!(AngleValue::deg(360.0).to_degrees(), 0.0);
696        assert_eq!(AngleValue::turn(1.0).to_degrees(), 0.0);
697        assert_eq!(AngleValue::grad(400.0).to_degrees(), 0.0);
698        assert_eq!(AngleValue::percent(100.0).to_degrees(), 0.0);
699        // ...while the raw variant keeps 360 distinct from 0 (conic-gradient case).
700        assert_eq!(AngleValue::deg(360.0).to_degrees_raw(), 360.0);
701        assert_eq!(AngleValue::turn(1.0).to_degrees_raw(), 360.0);
702        assert_eq!(AngleValue::grad(400.0).to_degrees_raw(), 360.0);
703        assert_eq!(AngleValue::percent(100.0).to_degrees_raw(), 360.0);
704
705        // Negative and out-of-range wrap into [0, 360).
706        assert_eq!(AngleValue::deg(-90.0).to_degrees(), 270.0);
707        assert_eq!(AngleValue::deg(-450.0).to_degrees(), 270.0);
708        assert_eq!(AngleValue::deg(-0.5).to_degrees(), 359.5);
709        assert_eq!(AngleValue::deg(720.0).to_degrees(), 0.0);
710        assert_eq!(AngleValue::deg(450.0).to_degrees_raw(), 450.0);
711        assert_eq!(AngleValue::turn(-2.0).to_degrees(), 0.0);
712    }
713
714    #[test]
715    fn autotest_to_degrees_on_saturated_values_stays_finite_and_in_range() {
716        // The nastiest inputs the type can hold: isize::MAX / isize::MIN encodings,
717        // pushed through every unit conversion. Must never produce inf/NaN and must
718        // honour the documented [0, 360) contract.
719        for m in ALL_METRICS {
720            for v in [f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, f32::NAN] {
721                let a = AngleValue::from_metric(m, v);
722                let raw = a.to_degrees_raw();
723                let norm = a.to_degrees();
724                assert!(raw.is_finite(), "to_degrees_raw not finite: {m:?} / {v}");
725                assert!(norm.is_finite(), "to_degrees not finite: {m:?} / {v}");
726                assert!(
727                    (0.0..360.0).contains(&norm),
728                    "to_degrees out of [0,360): {m:?} / {v} -> {norm}"
729                );
730            }
731        }
732    }
733
734    #[test]
735    fn autotest_ord_is_metric_first_not_semantic_angle() {
736        // Ord derives on (metric, number): the unit dominates. 1000deg sorts BEFORE
737        // 0rad even though it is the larger angle -- do not use Ord to compare angles.
738        assert!(AngleValue::deg(1000.0) < AngleValue::rad(0.0));
739        assert!(AngleValue::turn(0.0) < AngleValue::percent(0.0));
740        // Within one metric the ordering is numeric, as expected.
741        assert!(AngleValue::deg(-1.0) < AngleValue::deg(1.0));
742        // Eq/Hash agree with each other (no NaN poisoning, since NaN clamps to 0).
743        use core::hash::{Hash, Hasher};
744        let h = |a: AngleValue| {
745            let mut s = std::collections::hash_map::DefaultHasher::new();
746            a.hash(&mut s);
747            s.finish()
748        };
749        assert_eq!(AngleValue::deg(1.0), AngleValue::deg(1.0));
750        assert_eq!(h(AngleValue::deg(1.0)), h(AngleValue::deg(1.0)));
751        assert_eq!(h(AngleValue::deg(f32::NAN)), h(AngleValue::deg(0.0)));
752        assert_ne!(AngleValue::deg(1.0), AngleValue::rad(1.0));
753    }
754
755    // -------------------------------------------------------------------
756    // parser (feature-gated, mirrors the #[cfg(feature = "parser")] on the fn)
757    // -------------------------------------------------------------------
758
759    #[cfg(feature = "parser")]
760    #[test]
761    fn autotest_parse_empty_and_whitespace_only() {
762        for input in ["", "   ", "\t\n\r", "\u{a0}", " \u{2003} "] {
763            assert!(
764                matches!(
765                    parse_angle_value(input),
766                    Err(CssAngleValueParseError::EmptyString)
767                ),
768                "expected EmptyString for {input:?}"
769            );
770        }
771    }
772
773    #[cfg(feature = "parser")]
774    #[test]
775    fn autotest_parse_unit_without_number() {
776        for (input, metric) in [
777            ("deg", AngleMetric::Degree),
778            ("rad", AngleMetric::Radians),
779            ("grad", AngleMetric::Grad),
780            ("turn", AngleMetric::Turn),
781            ("%", AngleMetric::Percent),
782            ("  deg  ", AngleMetric::Degree),
783        ] {
784            match parse_angle_value(input) {
785                Err(CssAngleValueParseError::NoValueGiven(_, m)) => assert_eq!(m, metric),
786                other => panic!("expected NoValueGiven for {input:?}, got {other:?}"),
787            }
788        }
789    }
790
791    #[cfg(feature = "parser")]
792    #[test]
793    fn autotest_parse_garbage_is_rejected_without_panicking() {
794        for input in [
795            "ninety",
796            "!!!",
797            "90 degs",
798            "1.57 rads",
799            "90degdeg",
800            "--90deg",
801            "1_0deg",
802            "90;garbage",
803            "deg90",
804            "%50",
805            "0x1Fdeg",
806            "+-1turn",
807            "9 0deg",
808            "\0deg",
809        ] {
810            let res = parse_angle_value(input);
811            assert!(res.is_err(), "garbage accepted: {input:?} -> {res:?}");
812            // Error formatting must not panic either (it interpolates the input).
813            assert!(!format!("{}", res.unwrap_err()).is_empty());
814        }
815    }
816
817    #[cfg(feature = "parser")]
818    #[test]
819    fn autotest_parse_uppercase_units_are_rejected() {
820        // CSS units are ASCII case-insensitive; this parser is case-SENSITIVE.
821        // That is a spec deviation, but it fails closed (Err), never panics.
822        for input in ["90DEG", "1RAD", "0.5TURN", "100GRAD", "90Deg"] {
823            assert!(
824                parse_angle_value(input).is_err(),
825                "case-insensitive unit unexpectedly accepted: {input:?}"
826            );
827        }
828    }
829
830    #[cfg(feature = "parser")]
831    #[test]
832    fn autotest_parse_accepts_whitespace_between_number_and_unit() {
833        // Lenient vs. the CSS grammar (no whitespace allowed inside a dimension token):
834        // the unit suffix is stripped first, then the remainder is trimmed.
835        assert_eq!(parse_angle_value("90 deg").unwrap(), AngleValue::deg(90.0));
836        assert_eq!(parse_angle_value("90\tdeg").unwrap(), AngleValue::deg(90.0));
837        assert_eq!(
838            parse_angle_value("50 %").unwrap(),
839            AngleValue::percent(50.0)
840        );
841        assert_eq!(parse_angle_value(" 90deg ").unwrap(), AngleValue::deg(90.0));
842    }
843
844    #[cfg(feature = "parser")]
845    #[test]
846    fn autotest_parse_accepts_float_keywords_and_neutralizes_them() {
847        // "NaN"/"inf" are valid f32 literals, so they slip past the parser. They must
848        // at least end up as defined, finite angles rather than poisoning layout.
849        let nan = parse_angle_value("NaN").expect("f32::from_str accepts NaN");
850        assert_eq!(nan.number.number(), 0);
851        assert!(!nan.to_degrees().is_nan());
852
853        let nan_rad = parse_angle_value("nanrad").expect("f32::from_str accepts nan");
854        assert_eq!(nan_rad.metric, AngleMetric::Radians);
855        assert_eq!(nan_rad.number.number(), 0);
856
857        let inf = parse_angle_value("inf").expect("f32::from_str accepts inf");
858        assert_eq!(inf.number.number(), isize::MAX);
859        assert!(inf.to_degrees().is_finite());
860
861        let neg_inf = parse_angle_value("-infdeg").expect("f32::from_str accepts -inf");
862        assert_eq!(neg_inf.number.number(), isize::MIN);
863        assert!(neg_inf.to_degrees_raw().is_finite());
864    }
865
866    #[cfg(feature = "parser")]
867    #[test]
868    fn autotest_parse_boundary_numbers_saturate() {
869        assert_eq!(parse_angle_value("0").unwrap(), AngleValue::deg(0.0));
870        assert_eq!(parse_angle_value("-0").unwrap().number.number(), 0);
871        assert_eq!(parse_angle_value("+90deg").unwrap(), AngleValue::deg(90.0));
872        assert_eq!(parse_angle_value(".5turn").unwrap(), AngleValue::turn(0.5));
873        // i64::MAX / i64::MIN as bare degrees: overflow the fixed-point encoding and
874        // must saturate rather than wrap into a bogus small angle.
875        assert_eq!(
876            parse_angle_value("9223372036854775807").unwrap().number.number(),
877            isize::MAX
878        );
879        assert_eq!(
880            parse_angle_value("-9223372036854775808").unwrap().number.number(),
881            isize::MIN
882        );
883        // f32 exponent overflow -> inf -> saturates; underflow -> 0.
884        assert_eq!(parse_angle_value("1e40deg").unwrap().number.number(), isize::MAX);
885        assert_eq!(parse_angle_value("1e-40deg").unwrap().number.number(), 0);
886        assert_eq!(parse_angle_value("0.0001deg").unwrap().number.number(), 0);
887    }
888
889    #[cfg(feature = "parser")]
890    #[test]
891    fn autotest_parse_extremely_long_input_terminates() {
892        // 100k digits: linear-time float parse, no hang, saturating result.
893        let long_digits = "9".repeat(100_000) + "deg";
894        assert_eq!(
895            parse_angle_value(&long_digits).unwrap().number.number(),
896            isize::MAX
897        );
898
899        // 100k leading zeros still denote 1.
900        let padded = "0".repeat(100_000) + "1deg";
901        assert_eq!(parse_angle_value(&padded).unwrap(), AngleValue::deg(1.0));
902
903        // 100k junk bytes: rejected, not truncated into something valid.
904        let long_junk = "a".repeat(100_000);
905        assert!(parse_angle_value(&long_junk).is_err());
906    }
907
908    #[cfg(feature = "parser")]
909    #[test]
910    fn autotest_parse_deeply_nested_brackets_does_not_stack_overflow() {
911        // The parser is non-recursive; 10k nested brackets must simply be rejected.
912        let nested = "(".repeat(10_000);
913        assert!(parse_angle_value(&nested).is_err());
914        let nested_unit = "[".repeat(10_000) + "deg";
915        assert!(parse_angle_value(&nested_unit).is_err());
916    }
917
918    #[cfg(feature = "parser")]
919    #[test]
920    fn autotest_parse_unicode_input_never_panics() {
921        // Multibyte input must not be sliced on a non-char boundary anywhere.
922        for input in [
923            "°",
924            "90°",
925            "\u{1F600}",
926            "\u{1F600}deg",
927            "90deg",          // fullwidth digits
928            "9\u{0301}0deg",    // combining acute accent
929            "٩٠%",              // arabic-indic digits
930            "\u{200b}90deg",    // zero-width space (not trimmed: not White_Space)
931            "90de\u{0261}",     // latin small script g
932        ] {
933            let res = parse_angle_value(input);
934            assert!(res.is_err(), "unicode garbage accepted: {input:?} -> {res:?}");
935            assert!(!format!("{}", res.unwrap_err()).is_empty());
936        }
937    }
938
939    #[cfg(feature = "parser")]
940    #[test]
941    fn autotest_parse_valid_minimal_positive_control() {
942        assert_eq!(parse_angle_value("1deg").unwrap(), AngleValue::deg(1.0));
943        assert_eq!(parse_angle_value("0").unwrap(), AngleValue::zero());
944    }
945
946    // -------------------------------------------------------------------
947    // round-trip: encode == decode
948    // -------------------------------------------------------------------
949
950    #[cfg(feature = "parser")]
951    #[test]
952    fn autotest_round_trip_display_then_parse_all_metrics() {
953        // Values chosen to be exactly representable in f32 *and* exact after the
954        // x1000 fixed-point encoding, so the round-trip must be bit-exact.
955        for m in ALL_METRICS {
956            for v in [
957                0.0_f32, 1.0, -1.0, 0.5, -0.25, 45.5, 90.0, 180.0, 359.0, 1000.0,
958            ] {
959                let angle = AngleValue::from_metric(m, v);
960                let printed = angle.to_string();
961                let reparsed = parse_angle_value(&printed)
962                    .unwrap_or_else(|e| panic!("cannot re-parse own output {printed:?}: {e}"));
963                assert_eq!(reparsed, angle, "round-trip changed value: {printed:?}");
964                assert_eq!(reparsed.metric, m, "round-trip changed unit: {printed:?}");
965                // print_as_css_value must agree with Display.
966                assert_eq!(angle.print_as_css_value(), printed);
967            }
968        }
969    }
970
971    #[cfg(feature = "parser")]
972    #[test]
973    fn autotest_round_trip_metric_suffix_is_unambiguous() {
974        // "1grad" must not be mis-lexed as "1g" + "rad" (suffix match order matters).
975        for m in ALL_METRICS {
976            let parsed = parse_angle_value(&format!("1{m}")).unwrap();
977            assert_eq!(parsed.metric, m, "unit {m} did not round-trip");
978            assert_eq!(parsed.number.get(), 1.0);
979        }
980        assert_eq!(parse_angle_value("1grad").unwrap().metric, AngleMetric::Grad);
981        assert_eq!(
982            parse_angle_value("1rad").unwrap().metric,
983            AngleMetric::Radians
984        );
985    }
986
987    #[cfg(feature = "parser")]
988    #[test]
989    fn autotest_round_trip_quantization_is_idempotent() {
990        // Re-encoding an already-quantized value must be a fixed point, otherwise
991        // repeated serialize/parse cycles would drift.
992        for v in [0.0_f32, 1.0, -1.0, 0.5, -0.25, 45.5, 359.0] {
993            let once = AngleValue::deg(v);
994            let twice = AngleValue::deg(once.number.get());
995            assert_eq!(once, twice, "quantization not idempotent for {v}");
996        }
997    }
998
999    // -------------------------------------------------------------------
1000    // error types: to_contained / to_shared
1001    // -------------------------------------------------------------------
1002
1003    #[cfg(feature = "parser")]
1004    #[test]
1005    fn autotest_error_owned_round_trip_from_real_parse_failures() {
1006        for input in ["", "deg", "%", "xdeg", "zzz", "\u{1F600}rad"] {
1007            let err = parse_angle_value(input).unwrap_err();
1008            let owned = err.to_contained();
1009            assert_eq!(
1010                owned.to_shared(),
1011                err,
1012                "to_contained/to_shared lost information for {input:?}"
1013            );
1014            // Both directions must be printable.
1015            assert!(!format!("{err}").is_empty());
1016            assert!(!format!("{owned:?}").is_empty());
1017        }
1018    }
1019
1020    #[cfg(feature = "parser")]
1021    #[test]
1022    fn autotest_error_variants_are_the_expected_ones() {
1023        assert!(matches!(
1024            parse_angle_value("").unwrap_err(),
1025            CssAngleValueParseError::EmptyString
1026        ));
1027        assert!(matches!(
1028            parse_angle_value("turn").unwrap_err(),
1029            CssAngleValueParseError::NoValueGiven(_, AngleMetric::Turn)
1030        ));
1031        assert!(matches!(
1032            parse_angle_value("xdeg").unwrap_err(),
1033            CssAngleValueParseError::ValueParseErr(_, "x")
1034        ));
1035        assert!(matches!(
1036            parse_angle_value("zzz").unwrap_err(),
1037            CssAngleValueParseError::InvalidAngle("zzz")
1038        ));
1039    }
1040
1041    #[test]
1042    fn autotest_error_to_shared_handles_empty_and_extreme_payloads() {
1043        // Hand-built owned errors (the FFI side can hand us anything, incl. empty
1044        // strings and the Empty float-error kind that the parser itself never emits).
1045        let cases = [
1046            CssAngleValueParseErrorOwned::EmptyString,
1047            CssAngleValueParseErrorOwned::NoValueGiven(AngleNoValueGivenError {
1048                value: String::new().into(),
1049                metric: AngleMetric::Percent,
1050            }),
1051            CssAngleValueParseErrorOwned::ValueParseErr(ParseFloatErrorWithInput {
1052                error: FfiParseFloatError::Empty,
1053                input: String::new().into(),
1054            }),
1055            CssAngleValueParseErrorOwned::ValueParseErr(ParseFloatErrorWithInput {
1056                error: FfiParseFloatError::Invalid,
1057                input: "\u{1F600}".to_string().into(),
1058            }),
1059            CssAngleValueParseErrorOwned::InvalidAngle(String::new().into()),
1060            CssAngleValueParseErrorOwned::InvalidAngle("\u{1F600}\u{0301}".to_string().into()),
1061        ];
1062        for owned in cases {
1063            let shared = owned.to_shared();
1064            assert!(!format!("{shared}").is_empty());
1065            // owned -> shared -> owned must be lossless, including the error *kind*.
1066            assert_eq!(shared.to_contained(), owned);
1067        }
1068    }
1069}