Skip to main content

azul_css/props/basic/
angle.rs

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