1
//! CSS property types for angles (degrees, radians, etc.).
2

            
3
use alloc::string::{String, ToString};
4
use core::{fmt, num::ParseFloatError};
5

            
6
use crate::{
7
    corety::AzString,
8
    props::{
9
        basic::{error::ParseFloatErrorWithInput, length::FloatValue},
10
        formatter::PrintAsCssValue,
11
    },
12
};
13

            
14
/// Enum representing the metric associated with an angle (deg, rad, etc.)
15
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
16
#[repr(C)]
17
#[derive(Default)]
18
pub enum AngleMetric {
19
    #[default]
20
    Degree,
21
    Radians,
22
    Grad,
23
    Turn,
24
    Percent,
25
}
26

            
27
impl fmt::Display for AngleMetric {
28
252
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29
        use self::AngleMetric::{Degree, Grad, Percent, Radians, Turn};
30
252
        match self {
31
89
            Degree => write!(f, "deg"),
32
42
            Radians => write!(f, "rad"),
33
40
            Grad => write!(f, "grad"),
34
41
            Turn => write!(f, "turn"),
35
40
            Percent => write!(f, "%"),
36
        }
37
252
    }
38
}
39

            
40
/// `FloatValue`, but associated with a certain metric (i.e. deg, rad, etc.)
41
#[derive(Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
42
#[repr(C)]
43
pub struct AngleValue {
44
    pub metric: AngleMetric,
45
    pub number: FloatValue,
46
}
47

            
48
impl_option!(
49
    AngleValue,
50
    OptionAngleValue,
51
    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
52
);
53

            
54
impl fmt::Debug for AngleValue {
55
1
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56
1
        write!(f, "{self}")
57
1
    }
58
}
59

            
60
impl fmt::Display for AngleValue {
61
197
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62
197
        write!(f, "{}{}", self.number, self.metric)
63
197
    }
64
}
65

            
66
impl PrintAsCssValue for AngleValue {
67
51
    fn print_as_css_value(&self) -> String {
68
51
        format!("{self}")
69
51
    }
70
}
71

            
72
impl AngleValue {
73
    /// Returns an angle of zero degrees.
74
    #[inline]
75
    #[must_use]
76
5
    pub const fn zero() -> Self {
77
        const ZERO_DEG: AngleValue = AngleValue::const_deg(0);
78
5
        ZERO_DEG
79
5
    }
80

            
81
    /// Creates a const angle value in degrees from an integer.
82
    #[inline]
83
    #[must_use]
84
278
    pub const fn const_deg(value: isize) -> Self {
85
278
        Self::const_from_metric(AngleMetric::Degree, value)
86
278
    }
87

            
88
    /// Creates a const angle value in radians from an integer.
89
    #[inline]
90
    #[must_use]
91
3
    pub const fn const_rad(value: isize) -> Self {
92
3
        Self::const_from_metric(AngleMetric::Radians, value)
93
3
    }
94

            
95
    /// Creates a const angle value in gradians from an integer.
96
    #[inline]
97
    #[must_use]
98
3
    pub const fn const_grad(value: isize) -> Self {
99
3
        Self::const_from_metric(AngleMetric::Grad, value)
100
3
    }
101

            
102
    /// Creates a const angle value in turns from an integer.
103
    #[inline]
104
    #[must_use]
105
5
    pub const fn const_turn(value: isize) -> Self {
106
5
        Self::const_from_metric(AngleMetric::Turn, value)
107
5
    }
108

            
109
    /// Creates a const angle value in percent from an integer.
110
    #[inline]
111
    #[must_use]
112
2
    pub const fn const_percent(value: isize) -> Self {
113
2
        Self::const_from_metric(AngleMetric::Percent, value)
114
2
    }
115

            
116
    /// Creates a const angle value with the given metric from an integer.
117
    #[inline]
118
    #[must_use]
119
296
    pub const fn const_from_metric(metric: AngleMetric, value: isize) -> Self {
120
296
        Self {
121
296
            metric,
122
296
            number: FloatValue::const_new(value),
123
296
        }
124
296
    }
125

            
126
    /// Creates a const angle value with the given metric from a fractional number.
127
    ///
128
    /// # Arguments
129
    /// * `metric` - The angle metric (Degree, Radians, etc.)
130
    /// * `pre_comma` - The integer part (e.g., 45 for 45.5deg)
131
    /// * `post_comma` - The fractional part as digits (e.g., 5 for 0.5deg)
132
    #[inline]
133
    #[must_use]
134
12
    pub const fn const_from_metric_fractional(
135
12
        metric: AngleMetric,
136
12
        pre_comma: isize,
137
12
        post_comma: isize,
138
12
    ) -> Self {
139
12
        Self {
140
12
            metric,
141
12
            number: FloatValue::const_new_fractional(pre_comma, post_comma),
142
12
        }
143
12
    }
144

            
145
    /// Creates an angle value in degrees.
146
    #[inline]
147
    #[must_use]
148
587
    pub fn deg(value: f32) -> Self {
149
587
        Self::from_metric(AngleMetric::Degree, value)
150
587
    }
151

            
152
    /// Creates an angle value in radians.
153
    #[inline]
154
    #[must_use]
155
16
    pub fn rad(value: f32) -> Self {
156
16
        Self::from_metric(AngleMetric::Radians, value)
157
16
    }
158

            
159
    /// Creates an angle value in gradians.
160
    #[inline]
161
    #[must_use]
162
13
    pub fn grad(value: f32) -> Self {
163
13
        Self::from_metric(AngleMetric::Grad, value)
164
13
    }
165

            
166
    /// Creates an angle value in turns.
167
    #[inline]
168
    #[must_use]
169
20
    pub fn turn(value: f32) -> Self {
170
20
        Self::from_metric(AngleMetric::Turn, value)
171
20
    }
172

            
173
    /// Creates an angle value in percent.
174
    #[inline]
175
    #[must_use]
176
12
    pub fn percent(value: f32) -> Self {
177
12
        Self::from_metric(AngleMetric::Percent, value)
178
12
    }
179

            
180
    /// Creates an angle value with the given metric.
181
    #[inline]
182
    #[must_use]
183
2734
    pub fn from_metric(metric: AngleMetric, value: f32) -> Self {
184
2734
        Self {
185
2734
            metric,
186
2734
            number: FloatValue::new(value),
187
2734
        }
188
2734
    }
189

            
190
    /// Convert to degrees, normalized to [0, 360) range.
191
    /// Note: 360.0 becomes 0.0 due to modulo operation.
192
    /// For conic gradients where 360.0 is meaningful, use `to_degrees_raw()`.
193
    #[inline]
194
    #[must_use]
195
12878
    pub fn to_degrees(&self) -> f32 {
196
12878
        let mut val = self.to_degrees_raw() % 360.0;
197
12878
        if val < 0.0 {
198
100
            val += 360.0;
199
12778
        }
200
12878
        val
201
12878
    }
202

            
203
    /// Convert to degrees without normalization (raw value).
204
    /// Use this for conic gradients where 360.0 is a meaningful distinct value from 0.0.
205
    #[inline]
206
    #[must_use]
207
13195
    pub fn to_degrees_raw(&self) -> f32 {
208
13195
        match self.metric {
209
13047
            AngleMetric::Degree => self.number.get(),
210
39
            AngleMetric::Grad => self.number.get() / 400.0 * 360.0,
211
40
            AngleMetric::Radians => self.number.get().to_degrees(),
212
47
            AngleMetric::Turn => self.number.get() * 360.0,
213
22
            AngleMetric::Percent => self.number.get() / 100.0 * 360.0,
214
        }
215
13195
    }
216
}
217

            
218
// -- Parser
219

            
220
/// Error returned when parsing a CSS angle value from a string.
221
#[derive(Clone, PartialEq, Eq)]
222
pub enum CssAngleValueParseError<'a> {
223
    EmptyString,
224
    NoValueGiven(&'a str, AngleMetric),
225
    ValueParseErr(ParseFloatError, &'a str),
226
    InvalidAngle(&'a str),
227
}
228

            
229
impl_debug_as_display!(CssAngleValueParseError<'a>);
230
impl_display! { CssAngleValueParseError<'a>, {
231
    EmptyString => format!("Missing [rad / deg / turn / %] value"),
232
    NoValueGiven(input, metric) => format!("Expected floating-point angle value, got: \"{}{}\"", input, metric),
233
    ValueParseErr(err, number_str) => format!("Could not parse \"{}\" as floating-point value: \"{}\"", number_str, err),
234
    InvalidAngle(s) => format!("Invalid angle value: \"{}\"", s),
235
}}
236

            
237
/// Wrapper for `NoValueGiven` error in angle parsing.
238
#[derive(Debug, Clone, PartialEq, Eq)]
239
#[repr(C)]
240
pub struct AngleNoValueGivenError {
241
    pub value: AzString,
242
    pub metric: AngleMetric,
243
}
244

            
245
/// Owned version of [`CssAngleValueParseError`] for FFI and storage.
246
#[derive(Debug, Clone, PartialEq, Eq)]
247
#[repr(C, u8)]
248
pub enum CssAngleValueParseErrorOwned {
249
    EmptyString,
250
    NoValueGiven(AngleNoValueGivenError),
251
    ValueParseErr(ParseFloatErrorWithInput),
252
    InvalidAngle(AzString),
253
}
254

            
255
impl CssAngleValueParseError<'_> {
256
    #[must_use]
257
31
    pub fn to_contained(&self) -> CssAngleValueParseErrorOwned {
258
31
        match self {
259
10
            CssAngleValueParseError::EmptyString => CssAngleValueParseErrorOwned::EmptyString,
260
5
            CssAngleValueParseError::NoValueGiven(s, metric) => {
261
5
                CssAngleValueParseErrorOwned::NoValueGiven(AngleNoValueGivenError {
262
5
                    value: (*s).to_string().into(),
263
5
                    metric: *metric,
264
5
                })
265
            }
266
4
            CssAngleValueParseError::ValueParseErr(err, s) => {
267
4
                CssAngleValueParseErrorOwned::ValueParseErr(ParseFloatErrorWithInput {
268
4
                    error: err.clone().into(),
269
4
                    input: (*s).to_string().into(),
270
4
                })
271
            }
272
12
            CssAngleValueParseError::InvalidAngle(s) => {
273
12
                CssAngleValueParseErrorOwned::InvalidAngle((*s).to_string().into())
274
            }
275
        }
276
31
    }
277
}
278

            
279
impl CssAngleValueParseErrorOwned {
280
    #[must_use]
281
26
    pub fn to_shared(&self) -> CssAngleValueParseError<'_> {
282
26
        match self {
283
8
            Self::EmptyString => CssAngleValueParseError::EmptyString,
284
4
            Self::NoValueGiven(e) => {
285
4
                CssAngleValueParseError::NoValueGiven(e.value.as_str(), e.metric)
286
            }
287
4
            Self::ValueParseErr(e) => {
288
4
                CssAngleValueParseError::ValueParseErr(e.error.to_std(), e.input.as_str())
289
            }
290
10
            Self::InvalidAngle(s) => CssAngleValueParseError::InvalidAngle(s.as_str()),
291
        }
292
26
    }
293
}
294

            
295
/// Parse a CSS angle value string (e.g. `"90deg"`, `"1.57rad"`, `"0.5turn"`, `"50%"`).
296
/// A bare number without a unit suffix is interpreted as degrees.
297
#[cfg(feature = "parser")]
298
/// # Errors
299
///
300
/// Returns an error if `input` is not a valid CSS `angle-value` value.
301
8000
pub fn parse_angle_value(input: &str) -> Result<AngleValue, CssAngleValueParseError<'_>> {
302
8000
    let input = input.trim();
303

            
304
8000
    if input.is_empty() {
305
46
        return Err(CssAngleValueParseError::EmptyString);
306
7954
    }
307

            
308
7954
    let match_values = &[
309
7954
        ("deg", AngleMetric::Degree),
310
7954
        ("turn", AngleMetric::Turn),
311
7954
        ("grad", AngleMetric::Grad),
312
7954
        ("rad", AngleMetric::Radians),
313
7954
        ("%", AngleMetric::Percent),
314
7954
    ];
315

            
316
46506
    for (match_val, metric) in match_values {
317
38841
        if let Some(value) = input.strip_suffix(match_val) {
318
289
            let value = value.trim();
319
289
            if value.is_empty() {
320
12
                return Err(CssAngleValueParseError::NoValueGiven(input, *metric));
321
277
            }
322
277
            match value.parse::<f32>() {
323
257
                Ok(o) => return Ok(AngleValue::from_metric(*metric, o)),
324
20
                Err(e) => return Err(CssAngleValueParseError::ValueParseErr(e, value)),
325
            }
326
38552
        }
327
    }
328

            
329
    // bare number is degrees
330
7665
    input.parse::<f32>().map_or_else(
331
6026
        |_| Err(CssAngleValueParseError::InvalidAngle(input)),
332
1639
        |o| Ok(AngleValue::from_metric(AngleMetric::Degree, o)),
333
    )
334
8000
}
335

            
336
#[cfg(all(test, feature = "parser"))]
337
mod tests {
338
    // Tests assert parsed values equal the exact source literals; the rad inputs
339
    // (1.57, 3.14) are literal test data, not approximations of FRAC_PI_2/PI.
340
    #![allow(clippy::float_cmp, clippy::approx_constant)]
341
    use super::*;
342

            
343
    #[test]
344
1
    fn test_parse_angle_value_deg() {
345
1
        assert_eq!(parse_angle_value("90deg").unwrap(), AngleValue::deg(90.0));
346
1
        assert_eq!(
347
1
            parse_angle_value("-45.5deg").unwrap(),
348
1
            AngleValue::deg(-45.5)
349
        );
350
        // Bare number defaults to degrees
351
1
        assert_eq!(parse_angle_value("180").unwrap(), AngleValue::deg(180.0));
352
1
    }
353

            
354
    #[test]
355
1
    fn test_parse_angle_value_rad() {
356
1
        assert_eq!(parse_angle_value("1.57rad").unwrap(), AngleValue::rad(1.57));
357
1
        assert_eq!(
358
1
            parse_angle_value(" -3.14rad ").unwrap(),
359
1
            AngleValue::rad(-3.14)
360
        );
361
1
    }
362

            
363
    #[test]
364
1
    fn test_parse_angle_value_grad() {
365
1
        assert_eq!(
366
1
            parse_angle_value("100grad").unwrap(),
367
1
            AngleValue::grad(100.0)
368
        );
369
1
        assert_eq!(
370
1
            parse_angle_value("400grad").unwrap(),
371
1
            AngleValue::grad(400.0)
372
        );
373
1
    }
374

            
375
    #[test]
376
1
    fn test_parse_angle_value_turn() {
377
1
        assert_eq!(
378
1
            parse_angle_value("0.25turn").unwrap(),
379
1
            AngleValue::turn(0.25)
380
        );
381
1
        assert_eq!(parse_angle_value("1turn").unwrap(), AngleValue::turn(1.0));
382
1
    }
383

            
384
    #[test]
385
1
    fn test_parse_angle_value_percent() {
386
1
        assert_eq!(parse_angle_value("50%").unwrap(), AngleValue::percent(50.0));
387
1
    }
388

            
389
    #[test]
390
1
    fn test_parse_angle_value_errors() {
391
1
        assert!(parse_angle_value("").is_err());
392
1
        assert!(parse_angle_value("deg").is_err());
393
1
        assert!(parse_angle_value("90 degs").is_err());
394
1
        assert!(parse_angle_value("ninety-deg").is_err());
395
1
        assert!(parse_angle_value("1.57 rads").is_err());
396
1
    }
397

            
398
    #[test]
399
1
    fn test_to_degrees_conversion() {
400
1
        assert_eq!(AngleValue::deg(90.0).to_degrees(), 90.0);
401
        // Use 0.1 tolerance due to FloatValue fixed-point precision (multiplier = 1000.0)
402
1
        assert!((AngleValue::rad(core::f32::consts::PI).to_degrees() - 180.0).abs() < 0.1);
403
1
        assert_eq!(AngleValue::grad(100.0).to_degrees(), 90.0);
404
1
        assert_eq!(AngleValue::turn(0.5).to_degrees(), 180.0);
405
1
        assert_eq!(AngleValue::deg(-90.0).to_degrees(), 270.0);
406
1
        assert_eq!(AngleValue::deg(450.0).to_degrees(), 90.0);
407
1
    }
408
}
409

            
410
#[cfg(test)]
411
#[allow(
412
    clippy::float_cmp,
413
    clippy::unreadable_literal,
414
    clippy::cast_precision_loss,
415
    clippy::too_many_lines
416
)]
417
mod autotest_generated {
418
    use super::*;
419
    use crate::props::basic::error::{
420
        ParseFloatError as FfiParseFloatError, ParseFloatErrorWithInput,
421
    };
422

            
423
    /// `FloatValue` stores `f32 * 1000` truncated into an `isize`.
424
    const MULT: isize = 1000;
425

            
426
    /// Every `AngleMetric` variant, for exhaustive sweeps.
427
    const ALL_METRICS: [AngleMetric; 5] = [
428
        AngleMetric::Degree,
429
        AngleMetric::Radians,
430
        AngleMetric::Grad,
431
        AngleMetric::Turn,
432
        AngleMetric::Percent,
433
    ];
434

            
435
    // -------------------------------------------------------------------
436
    // serializers (Display / PrintAsCssValue)
437
    // -------------------------------------------------------------------
438

            
439
    #[test]
440
    fn autotest_angle_metric_display_is_non_empty_and_exact() {
441
        assert_eq!(AngleMetric::Degree.to_string(), "deg");
442
        assert_eq!(AngleMetric::Radians.to_string(), "rad");
443
        assert_eq!(AngleMetric::Grad.to_string(), "grad");
444
        assert_eq!(AngleMetric::Turn.to_string(), "turn");
445
        assert_eq!(AngleMetric::Percent.to_string(), "%");
446
        assert_eq!(AngleMetric::default(), AngleMetric::Degree);
447
        for m in ALL_METRICS {
448
            assert!(!m.to_string().is_empty(), "empty unit string for {m:?}");
449
        }
450
    }
451

            
452
    #[test]
453
    fn autotest_angle_value_display_default_and_zero() {
454
        assert_eq!(AngleValue::default().to_string(), "0deg");
455
        assert_eq!(AngleValue::zero().to_string(), "0deg");
456
        // Debug delegates to Display.
457
        assert_eq!(format!("{:?}", AngleValue::zero()), "0deg");
458
        assert_eq!(AngleValue::zero().print_as_css_value(), "0deg");
459
    }
460

            
461
    #[test]
462
    fn autotest_angle_value_display_never_emits_inf_or_nan() {
463
        // Saturating/clamping happens inside FloatValue, so no non-finite value
464
        // can ever reach the formatter -- assert the serializer stays CSS-safe.
465
        for m in ALL_METRICS {
466
            for v in [
467
                f32::NAN,
468
                f32::INFINITY,
469
                f32::NEG_INFINITY,
470
                f32::MAX,
471
                f32::MIN,
472
                f32::MIN_POSITIVE,
473
                -0.0,
474
            ] {
475
                let s = AngleValue::from_metric(m, v).to_string();
476
                assert!(!s.is_empty(), "empty serialization for {m:?} / {v}");
477
                assert!(!s.contains("inf"), "serialized infinity: {s}");
478
                assert!(!s.contains("NaN"), "serialized NaN: {s}");
479
                assert!(s.ends_with(&m.to_string()), "lost the unit suffix: {s}");
480
            }
481
        }
482
    }
483

            
484
    #[test]
485
    fn autotest_angle_value_nan_serializes_as_zero() {
486
        // NaN collapses to 0 (the `as isize` cast maps NaN -> 0), it is not preserved.
487
        assert_eq!(AngleValue::deg(f32::NAN).to_string(), "0deg");
488
        assert_eq!(AngleValue::rad(f32::NAN).to_string(), "0rad");
489
    }
490

            
491
    // -------------------------------------------------------------------
492
    // constructors
493
    // -------------------------------------------------------------------
494

            
495
    #[test]
496
    fn autotest_zero_is_the_neutral_element() {
497
        let z = AngleValue::zero();
498
        assert_eq!(z, AngleValue::default());
499
        assert_eq!(z, AngleValue::const_deg(0));
500
        assert_eq!(z, AngleValue::deg(0.0));
501
        assert_eq!(z.metric, AngleMetric::Degree);
502
        assert_eq!(z.number.number(), 0);
503
        assert_eq!(z.number.get(), 0.0);
504
        assert_eq!(z.to_degrees(), 0.0);
505
        assert_eq!(z.to_degrees_raw(), 0.0);
506
    }
507

            
508
    #[test]
509
    fn autotest_from_metric_fields_match_args() {
510
        for m in ALL_METRICS {
511
            let a = AngleValue::from_metric(m, 12.5);
512
            assert_eq!(a.metric, m);
513
            assert_eq!(a.number.get(), 12.5);
514
            assert_eq!(a.number.number(), 12_500);
515
        }
516
        // The per-metric helpers must agree with from_metric.
517
        assert_eq!(
518
            AngleValue::deg(1.5),
519
            AngleValue::from_metric(AngleMetric::Degree, 1.5)
520
        );
521
        assert_eq!(
522
            AngleValue::rad(1.5),
523
            AngleValue::from_metric(AngleMetric::Radians, 1.5)
524
        );
525
        assert_eq!(
526
            AngleValue::grad(1.5),
527
            AngleValue::from_metric(AngleMetric::Grad, 1.5)
528
        );
529
        assert_eq!(
530
            AngleValue::turn(1.5),
531
            AngleValue::from_metric(AngleMetric::Turn, 1.5)
532
        );
533
        assert_eq!(
534
            AngleValue::percent(1.5),
535
            AngleValue::from_metric(AngleMetric::Percent, 1.5)
536
        );
537
    }
538

            
539
    // -------------------------------------------------------------------
540
    // numeric: const constructors (isize -> fixed point)
541
    // -------------------------------------------------------------------
542

            
543
    #[test]
544
    fn autotest_const_ctors_zero_negative_and_metric() {
545
        for (built, metric) in [
546
            (AngleValue::const_deg(0), AngleMetric::Degree),
547
            (AngleValue::const_rad(0), AngleMetric::Radians),
548
            (AngleValue::const_grad(0), AngleMetric::Grad),
549
            (AngleValue::const_turn(0), AngleMetric::Turn),
550
            (AngleValue::const_percent(0), AngleMetric::Percent),
551
        ] {
552
            assert_eq!(built.metric, metric);
553
            assert_eq!(built.number.number(), 0);
554
        }
555
        assert_eq!(AngleValue::const_deg(-90).number.get(), -90.0);
556
        assert_eq!(AngleValue::const_rad(-3).number.number(), -3 * MULT);
557
        assert_eq!(AngleValue::const_turn(-1).to_degrees_raw(), -360.0);
558
    }
559

            
560
    #[test]
561
    fn autotest_const_from_metric_matches_specific_ctors() {
562
        for (m, specific) in [
563
            (AngleMetric::Degree, AngleValue::const_deg(7)),
564
            (AngleMetric::Radians, AngleValue::const_rad(7)),
565
            (AngleMetric::Grad, AngleValue::const_grad(7)),
566
            (AngleMetric::Turn, AngleValue::const_turn(7)),
567
            (AngleMetric::Percent, AngleValue::const_percent(7)),
568
        ] {
569
            assert_eq!(AngleValue::const_from_metric(m, 7), specific);
570
            assert_eq!(specific.number.number(), 7 * MULT);
571
        }
572
    }
573

            
574
    #[test]
575
    fn autotest_const_ctors_at_safe_isize_boundary() {
576
        // const_new multiplies by 1000, so |value| <= isize::MAX / 1000 is the
577
        // largest magnitude that cannot overflow. Assert exactness right at the edge.
578
        const MAX_SAFE: isize = isize::MAX / MULT;
579
        const MIN_SAFE: isize = isize::MIN / MULT;
580

            
581
        assert_eq!(
582
            AngleValue::const_deg(MAX_SAFE).number.number(),
583
            MAX_SAFE * MULT
584
        );
585
        assert_eq!(
586
            AngleValue::const_deg(MIN_SAFE).number.number(),
587
            MIN_SAFE * MULT
588
        );
589
        // ...and the round-trip back to f32 stays finite (no inf leaking into layout).
590
        assert!(AngleValue::const_deg(MAX_SAFE).number.get().is_finite());
591
        assert!(AngleValue::const_deg(MIN_SAFE).number.get().is_finite());
592
        assert!(AngleValue::const_turn(MAX_SAFE).to_degrees().is_finite());
593
        assert!(AngleValue::const_turn(MIN_SAFE).to_degrees().is_finite());
594
    }
595

            
596
    #[test]
597
    fn autotest_const_deg_isize_max_overflows_unchecked() {
598
        // Documents (does not bless) the unchecked `value * 1000` in FloatValue::const_new:
599
        // isize::MAX degrees panics on overflow in debug and wraps in release. Both are
600
        // accepted here; what must NOT happen is a silently plausible-looking angle.
601
        // black_box keeps const-propagation from turning this into a compile-time error.
602
        let huge = core::hint::black_box(isize::MAX);
603
        let prev = std::panic::take_hook();
604
        std::panic::set_hook(Box::new(|_| {}));
605
        let res = std::panic::catch_unwind(move || AngleValue::const_deg(huge).number.number());
606
        std::panic::set_hook(prev);
607

            
608
        match res {
609
            Err(_) => {} // debug build: "attempt to multiply with overflow"
610
            Ok(n) => assert_eq!(
611
                n,
612
                isize::MAX.wrapping_mul(MULT),
613
                "release build must wrap, not produce a sanitized value"
614
            ),
615
        }
616
    }
617

            
618
    #[test]
619
    fn autotest_const_from_metric_fractional_digit_truncation() {
620
        let f = |pre, post| {
621
            AngleValue::const_from_metric_fractional(AngleMetric::Degree, pre, post)
622
                .number
623
                .number()
624
        };
625
        assert_eq!(f(0, 0), 0);
626
        assert_eq!(f(45, 5), 45_500); // 45.5
627
        assert_eq!(f(0, 83), 830); // 0.83
628
        assert_eq!(f(1, 523), 1_523); // 1.523
629
                                      // More than 3 fractional digits: truncated (not rounded) to
630
                                      // 3.
631
        assert_eq!(f(2, 123456), 2_123); // 2.123456 -> 2.123, per the doc comment
632
        assert_eq!(f(0, 999_999_999), 999); // 0.999999999 -> 0.999
633
        assert_eq!(
634
            AngleValue::const_from_metric_fractional(AngleMetric::Turn, 0, 25).metric,
635
            AngleMetric::Turn
636
        );
637
    }
638

            
639
    #[test]
640
    fn autotest_const_fractional_sign_handling_and_negative_zero_trap() {
641
        let deg = |pre, post| {
642
            AngleValue::const_from_metric_fractional(AngleMetric::Degree, pre, post)
643
                .number
644
                .get()
645
        };
646
        assert_eq!(deg(-1, 5), -1.5); // negative pre drags the fraction negative
647
        assert_eq!(deg(0, -5), -0.5); // negative post encodes a negative fraction
648
        assert_eq!(deg(-1, -5), -1.5); // both negative must not double-negate
649
                                       // TRAP: isize has no -0, so `-0` is `0` and the sign is
650
                                       // lost. -0.5deg is NOT
651
                                       // expressible as (-0, 5); it yields +0.5deg. Callers must
652
                                       // use (0, -5).
653
        assert_eq!(deg(-0, 5), 0.5);
654
        assert_ne!(deg(-0, 5), -0.5);
655
    }
656

            
657
    // -------------------------------------------------------------------
658
    // numeric: f32 constructors (saturation / NaN / sub-precision)
659
    // -------------------------------------------------------------------
660

            
661
    #[test]
662
    fn autotest_f32_ctor_nan_collapses_to_zero() {
663
        for m in ALL_METRICS {
664
            let a = AngleValue::from_metric(m, f32::NAN);
665
            assert_eq!(a.number.number(), 0, "NaN did not clamp to 0 for {m:?}");
666
            assert_eq!(a.number.get(), 0.0);
667
            assert!(!a.number.get().is_nan());
668
            assert_eq!(a.to_degrees(), 0.0);
669
            assert_eq!(a.to_degrees_raw(), 0.0);
670
            // Consequence worth knowing: NaN is *equal* to zero after construction.
671
            assert_eq!(a, AngleValue::from_metric(m, 0.0));
672
        }
673
    }
674

            
675
    #[test]
676
    fn autotest_f32_ctor_infinities_saturate_to_isize_bounds() {
677
        assert_eq!(AngleValue::deg(f32::INFINITY).number.number(), isize::MAX);
678
        assert_eq!(
679
            AngleValue::deg(f32::NEG_INFINITY).number.number(),
680
            isize::MIN
681
        );
682
        // f32::MAX * 1000 overflows to +inf before the cast, so it saturates too.
683
        assert_eq!(AngleValue::deg(f32::MAX).number.number(), isize::MAX);
684
        assert_eq!(AngleValue::deg(f32::MIN).number.number(), isize::MIN);
685
        for m in ALL_METRICS {
686
            for v in [f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN] {
687
                let a = AngleValue::from_metric(m, v);
688
                assert!(
689
                    a.number.get().is_finite(),
690
                    "{m:?} / {v} leaked a non-finite"
691
                );
692
            }
693
        }
694
    }
695

            
696
    #[test]
697
    fn autotest_f32_ctor_truncates_toward_zero_below_precision() {
698
        // 3 decimal digits of precision; the 4th digit is truncated, not rounded,
699
        // and truncation is toward zero on both sides of 0.
700
        assert_eq!(AngleValue::deg(1.9999).number.number(), 1_999);
701
        assert_eq!(AngleValue::deg(-1.9999).number.number(), -1_999);
702
        assert_eq!(AngleValue::deg(0.0004).number.number(), 0);
703
        assert_eq!(AngleValue::deg(-0.0004).number.number(), 0);
704
        assert_eq!(AngleValue::deg(f32::EPSILON).number.number(), 0);
705
        assert_eq!(AngleValue::deg(f32::MIN_POSITIVE).number.number(), 0);
706
        // -0.0 must not become a negative encoded value.
707
        assert_eq!(AngleValue::deg(-0.0).number.number(), 0);
708
        assert_eq!(AngleValue::deg(-0.0), AngleValue::deg(0.0));
709
    }
710

            
711
    // -------------------------------------------------------------------
712
    // getters: to_degrees / to_degrees_raw
713
    // -------------------------------------------------------------------
714

            
715
    #[test]
716
    fn autotest_to_degrees_known_conversions() {
717
        assert_eq!(AngleValue::deg(90.0).to_degrees(), 90.0);
718
        assert_eq!(AngleValue::grad(100.0).to_degrees(), 90.0);
719
        assert_eq!(AngleValue::turn(0.25).to_degrees(), 90.0);
720
        assert_eq!(AngleValue::percent(50.0).to_degrees(), 180.0);
721
        assert_eq!(AngleValue::percent(25.0).to_degrees_raw(), 90.0);
722
        // rad goes through the 1/1000 quantization, so compare with a tolerance.
723
        assert!((AngleValue::rad(core::f32::consts::FRAC_PI_2).to_degrees() - 90.0).abs() < 0.1);
724
    }
725

            
726
    #[test]
727
    fn autotest_to_degrees_normalizes_but_raw_does_not() {
728
        // A full turn normalizes to 0 -- the documented 360 -> 0 collapse.
729
        assert_eq!(AngleValue::deg(360.0).to_degrees(), 0.0);
730
        assert_eq!(AngleValue::turn(1.0).to_degrees(), 0.0);
731
        assert_eq!(AngleValue::grad(400.0).to_degrees(), 0.0);
732
        assert_eq!(AngleValue::percent(100.0).to_degrees(), 0.0);
733
        // ...while the raw variant keeps 360 distinct from 0 (conic-gradient case).
734
        assert_eq!(AngleValue::deg(360.0).to_degrees_raw(), 360.0);
735
        assert_eq!(AngleValue::turn(1.0).to_degrees_raw(), 360.0);
736
        assert_eq!(AngleValue::grad(400.0).to_degrees_raw(), 360.0);
737
        assert_eq!(AngleValue::percent(100.0).to_degrees_raw(), 360.0);
738

            
739
        // Negative and out-of-range wrap into [0, 360).
740
        assert_eq!(AngleValue::deg(-90.0).to_degrees(), 270.0);
741
        assert_eq!(AngleValue::deg(-450.0).to_degrees(), 270.0);
742
        assert_eq!(AngleValue::deg(-0.5).to_degrees(), 359.5);
743
        assert_eq!(AngleValue::deg(720.0).to_degrees(), 0.0);
744
        assert_eq!(AngleValue::deg(450.0).to_degrees_raw(), 450.0);
745
        assert_eq!(AngleValue::turn(-2.0).to_degrees(), 0.0);
746
    }
747

            
748
    #[test]
749
    fn autotest_to_degrees_on_saturated_values_stays_finite_and_in_range() {
750
        // The nastiest inputs the type can hold: isize::MAX / isize::MIN encodings,
751
        // pushed through every unit conversion. Must never produce inf/NaN and must
752
        // honour the documented [0, 360) contract.
753
        for m in ALL_METRICS {
754
            for v in [
755
                f32::INFINITY,
756
                f32::NEG_INFINITY,
757
                f32::MAX,
758
                f32::MIN,
759
                f32::NAN,
760
            ] {
761
                let a = AngleValue::from_metric(m, v);
762
                let raw = a.to_degrees_raw();
763
                let norm = a.to_degrees();
764
                assert!(raw.is_finite(), "to_degrees_raw not finite: {m:?} / {v}");
765
                assert!(norm.is_finite(), "to_degrees not finite: {m:?} / {v}");
766
                assert!(
767
                    (0.0..360.0).contains(&norm),
768
                    "to_degrees out of [0,360): {m:?} / {v} -> {norm}"
769
                );
770
            }
771
        }
772
    }
773

            
774
    #[test]
775
    fn autotest_ord_is_metric_first_not_semantic_angle() {
776
        // Ord derives on (metric, number): the unit dominates. 1000deg sorts BEFORE
777
        // 0rad even though it is the larger angle -- do not use Ord to compare angles.
778
        assert!(AngleValue::deg(1000.0) < AngleValue::rad(0.0));
779
        assert!(AngleValue::turn(0.0) < AngleValue::percent(0.0));
780
        // Within one metric the ordering is numeric, as expected.
781
        assert!(AngleValue::deg(-1.0) < AngleValue::deg(1.0));
782
        // Eq/Hash agree with each other (no NaN poisoning, since NaN clamps to 0).
783
        use core::hash::{Hash, Hasher};
784
        let h = |a: AngleValue| {
785
            let mut s = std::collections::hash_map::DefaultHasher::new();
786
            a.hash(&mut s);
787
            s.finish()
788
        };
789
        assert_eq!(AngleValue::deg(1.0), AngleValue::deg(1.0));
790
        assert_eq!(h(AngleValue::deg(1.0)), h(AngleValue::deg(1.0)));
791
        assert_eq!(h(AngleValue::deg(f32::NAN)), h(AngleValue::deg(0.0)));
792
        assert_ne!(AngleValue::deg(1.0), AngleValue::rad(1.0));
793
    }
794

            
795
    // -------------------------------------------------------------------
796
    // parser (feature-gated, mirrors the #[cfg(feature = "parser")] on the fn)
797
    // -------------------------------------------------------------------
798

            
799
    #[cfg(feature = "parser")]
800
    #[test]
801
    fn autotest_parse_empty_and_whitespace_only() {
802
        for input in ["", "   ", "\t\n\r", "\u{a0}", " \u{2003} "] {
803
            assert!(
804
                matches!(
805
                    parse_angle_value(input),
806
                    Err(CssAngleValueParseError::EmptyString)
807
                ),
808
                "expected EmptyString for {input:?}"
809
            );
810
        }
811
    }
812

            
813
    #[cfg(feature = "parser")]
814
    #[test]
815
    fn autotest_parse_unit_without_number() {
816
        for (input, metric) in [
817
            ("deg", AngleMetric::Degree),
818
            ("rad", AngleMetric::Radians),
819
            ("grad", AngleMetric::Grad),
820
            ("turn", AngleMetric::Turn),
821
            ("%", AngleMetric::Percent),
822
            ("  deg  ", AngleMetric::Degree),
823
        ] {
824
            match parse_angle_value(input) {
825
                Err(CssAngleValueParseError::NoValueGiven(_, m)) => assert_eq!(m, metric),
826
                other => panic!("expected NoValueGiven for {input:?}, got {other:?}"),
827
            }
828
        }
829
    }
830

            
831
    #[cfg(feature = "parser")]
832
    #[test]
833
    fn autotest_parse_garbage_is_rejected_without_panicking() {
834
        for input in [
835
            "ninety",
836
            "!!!",
837
            "90 degs",
838
            "1.57 rads",
839
            "90degdeg",
840
            "--90deg",
841
            "1_0deg",
842
            "90;garbage",
843
            "deg90",
844
            "%50",
845
            "0x1Fdeg",
846
            "+-1turn",
847
            "9 0deg",
848
            "\0deg",
849
        ] {
850
            let res = parse_angle_value(input);
851
            assert!(res.is_err(), "garbage accepted: {input:?} -> {res:?}");
852
            // Error formatting must not panic either (it interpolates the input).
853
            assert!(!format!("{}", res.unwrap_err()).is_empty());
854
        }
855
    }
856

            
857
    #[cfg(feature = "parser")]
858
    #[test]
859
    fn autotest_parse_uppercase_units_are_rejected() {
860
        // CSS units are ASCII case-insensitive; this parser is case-SENSITIVE.
861
        // That is a spec deviation, but it fails closed (Err), never panics.
862
        for input in ["90DEG", "1RAD", "0.5TURN", "100GRAD", "90Deg"] {
863
            assert!(
864
                parse_angle_value(input).is_err(),
865
                "case-insensitive unit unexpectedly accepted: {input:?}"
866
            );
867
        }
868
    }
869

            
870
    #[cfg(feature = "parser")]
871
    #[test]
872
    fn autotest_parse_accepts_whitespace_between_number_and_unit() {
873
        // Lenient vs. the CSS grammar (no whitespace allowed inside a dimension token):
874
        // the unit suffix is stripped first, then the remainder is trimmed.
875
        assert_eq!(parse_angle_value("90 deg").unwrap(), AngleValue::deg(90.0));
876
        assert_eq!(parse_angle_value("90\tdeg").unwrap(), AngleValue::deg(90.0));
877
        assert_eq!(
878
            parse_angle_value("50 %").unwrap(),
879
            AngleValue::percent(50.0)
880
        );
881
        assert_eq!(parse_angle_value(" 90deg ").unwrap(), AngleValue::deg(90.0));
882
    }
883

            
884
    #[cfg(feature = "parser")]
885
    #[test]
886
    fn autotest_parse_accepts_float_keywords_and_neutralizes_them() {
887
        // "NaN"/"inf" are valid f32 literals, so they slip past the parser. They must
888
        // at least end up as defined, finite angles rather than poisoning layout.
889
        let nan = parse_angle_value("NaN").expect("f32::from_str accepts NaN");
890
        assert_eq!(nan.number.number(), 0);
891
        assert!(!nan.to_degrees().is_nan());
892

            
893
        let nan_rad = parse_angle_value("nanrad").expect("f32::from_str accepts nan");
894
        assert_eq!(nan_rad.metric, AngleMetric::Radians);
895
        assert_eq!(nan_rad.number.number(), 0);
896

            
897
        let inf = parse_angle_value("inf").expect("f32::from_str accepts inf");
898
        assert_eq!(inf.number.number(), isize::MAX);
899
        assert!(inf.to_degrees().is_finite());
900

            
901
        let neg_inf = parse_angle_value("-infdeg").expect("f32::from_str accepts -inf");
902
        assert_eq!(neg_inf.number.number(), isize::MIN);
903
        assert!(neg_inf.to_degrees_raw().is_finite());
904
    }
905

            
906
    #[cfg(feature = "parser")]
907
    #[test]
908
    fn autotest_parse_boundary_numbers_saturate() {
909
        assert_eq!(parse_angle_value("0").unwrap(), AngleValue::deg(0.0));
910
        assert_eq!(parse_angle_value("-0").unwrap().number.number(), 0);
911
        assert_eq!(parse_angle_value("+90deg").unwrap(), AngleValue::deg(90.0));
912
        assert_eq!(parse_angle_value(".5turn").unwrap(), AngleValue::turn(0.5));
913
        // i64::MAX / i64::MIN as bare degrees: overflow the fixed-point encoding and
914
        // must saturate rather than wrap into a bogus small angle.
915
        assert_eq!(
916
            parse_angle_value("9223372036854775807")
917
                .unwrap()
918
                .number
919
                .number(),
920
            isize::MAX
921
        );
922
        assert_eq!(
923
            parse_angle_value("-9223372036854775808")
924
                .unwrap()
925
                .number
926
                .number(),
927
            isize::MIN
928
        );
929
        // f32 exponent overflow -> inf -> saturates; underflow -> 0.
930
        assert_eq!(
931
            parse_angle_value("1e40deg").unwrap().number.number(),
932
            isize::MAX
933
        );
934
        assert_eq!(parse_angle_value("1e-40deg").unwrap().number.number(), 0);
935
        assert_eq!(parse_angle_value("0.0001deg").unwrap().number.number(), 0);
936
    }
937

            
938
    #[cfg(feature = "parser")]
939
    #[test]
940
    fn autotest_parse_extremely_long_input_terminates() {
941
        // 100k digits: linear-time float parse, no hang, saturating result.
942
        let long_digits = "9".repeat(100_000) + "deg";
943
        assert_eq!(
944
            parse_angle_value(&long_digits).unwrap().number.number(),
945
            isize::MAX
946
        );
947

            
948
        // 100k leading zeros still denote 1.
949
        let padded = "0".repeat(100_000) + "1deg";
950
        assert_eq!(parse_angle_value(&padded).unwrap(), AngleValue::deg(1.0));
951

            
952
        // 100k junk bytes: rejected, not truncated into something valid.
953
        let long_junk = "a".repeat(100_000);
954
        assert!(parse_angle_value(&long_junk).is_err());
955
    }
956

            
957
    #[cfg(feature = "parser")]
958
    #[test]
959
    fn autotest_parse_deeply_nested_brackets_does_not_stack_overflow() {
960
        // The parser is non-recursive; 10k nested brackets must simply be rejected.
961
        let nested = "(".repeat(10_000);
962
        assert!(parse_angle_value(&nested).is_err());
963
        let nested_unit = "[".repeat(10_000) + "deg";
964
        assert!(parse_angle_value(&nested_unit).is_err());
965
    }
966

            
967
    #[cfg(feature = "parser")]
968
    #[test]
969
    fn autotest_parse_unicode_input_never_panics() {
970
        // Multibyte input must not be sliced on a non-char boundary anywhere.
971
        for input in [
972
            "°",
973
            "90°",
974
            "\u{1F600}",
975
            "\u{1F600}deg",
976
            "90deg",       // fullwidth digits
977
            "9\u{0301}0deg", // combining acute accent
978
            "٩٠%",           // arabic-indic digits
979
            "\u{200b}90deg", // zero-width space (not trimmed: not White_Space)
980
            "90de\u{0261}",  // latin small script g
981
        ] {
982
            let res = parse_angle_value(input);
983
            assert!(
984
                res.is_err(),
985
                "unicode garbage accepted: {input:?} -> {res:?}"
986
            );
987
            assert!(!format!("{}", res.unwrap_err()).is_empty());
988
        }
989
    }
990

            
991
    #[cfg(feature = "parser")]
992
    #[test]
993
    fn autotest_parse_valid_minimal_positive_control() {
994
        assert_eq!(parse_angle_value("1deg").unwrap(), AngleValue::deg(1.0));
995
        assert_eq!(parse_angle_value("0").unwrap(), AngleValue::zero());
996
    }
997

            
998
    // -------------------------------------------------------------------
999
    // round-trip: encode == decode
    // -------------------------------------------------------------------
    #[cfg(feature = "parser")]
    #[test]
    fn autotest_round_trip_display_then_parse_all_metrics() {
        // Values chosen to be exactly representable in f32 *and* exact after the
        // x1000 fixed-point encoding, so the round-trip must be bit-exact.
        for m in ALL_METRICS {
            for v in [
                0.0_f32, 1.0, -1.0, 0.5, -0.25, 45.5, 90.0, 180.0, 359.0, 1000.0,
            ] {
                let angle = AngleValue::from_metric(m, v);
                let printed = angle.to_string();
                let reparsed = parse_angle_value(&printed)
                    .unwrap_or_else(|e| panic!("cannot re-parse own output {printed:?}: {e}"));
                assert_eq!(reparsed, angle, "round-trip changed value: {printed:?}");
                assert_eq!(reparsed.metric, m, "round-trip changed unit: {printed:?}");
                // print_as_css_value must agree with Display.
                assert_eq!(angle.print_as_css_value(), printed);
            }
        }
    }
    #[cfg(feature = "parser")]
    #[test]
    fn autotest_round_trip_metric_suffix_is_unambiguous() {
        // "1grad" must not be mis-lexed as "1g" + "rad" (suffix match order matters).
        for m in ALL_METRICS {
            let parsed = parse_angle_value(&format!("1{m}")).unwrap();
            assert_eq!(parsed.metric, m, "unit {m} did not round-trip");
            assert_eq!(parsed.number.get(), 1.0);
        }
        assert_eq!(
            parse_angle_value("1grad").unwrap().metric,
            AngleMetric::Grad
        );
        assert_eq!(
            parse_angle_value("1rad").unwrap().metric,
            AngleMetric::Radians
        );
    }
    #[cfg(feature = "parser")]
    #[test]
    fn autotest_round_trip_quantization_is_idempotent() {
        // Re-encoding an already-quantized value must be a fixed point, otherwise
        // repeated serialize/parse cycles would drift.
        for v in [0.0_f32, 1.0, -1.0, 0.5, -0.25, 45.5, 359.0] {
            let once = AngleValue::deg(v);
            let twice = AngleValue::deg(once.number.get());
            assert_eq!(once, twice, "quantization not idempotent for {v}");
        }
    }
    // -------------------------------------------------------------------
    // error types: to_contained / to_shared
    // -------------------------------------------------------------------
    #[cfg(feature = "parser")]
    #[test]
    fn autotest_error_owned_round_trip_from_real_parse_failures() {
        for input in ["", "deg", "%", "xdeg", "zzz", "\u{1F600}rad"] {
            let err = parse_angle_value(input).unwrap_err();
            let owned = err.to_contained();
            assert_eq!(
                owned.to_shared(),
                err,
                "to_contained/to_shared lost information for {input:?}"
            );
            // Both directions must be printable.
            assert!(!format!("{err}").is_empty());
            assert!(!format!("{owned:?}").is_empty());
        }
    }
    #[cfg(feature = "parser")]
    #[test]
    fn autotest_error_variants_are_the_expected_ones() {
        assert!(matches!(
            parse_angle_value("").unwrap_err(),
            CssAngleValueParseError::EmptyString
        ));
        assert!(matches!(
            parse_angle_value("turn").unwrap_err(),
            CssAngleValueParseError::NoValueGiven(_, AngleMetric::Turn)
        ));
        assert!(matches!(
            parse_angle_value("xdeg").unwrap_err(),
            CssAngleValueParseError::ValueParseErr(_, "x")
        ));
        assert!(matches!(
            parse_angle_value("zzz").unwrap_err(),
            CssAngleValueParseError::InvalidAngle("zzz")
        ));
    }
    #[test]
    fn autotest_error_to_shared_handles_empty_and_extreme_payloads() {
        // Hand-built owned errors (the FFI side can hand us anything, incl. empty
        // strings and the Empty float-error kind that the parser itself never emits).
        let cases = [
            CssAngleValueParseErrorOwned::EmptyString,
            CssAngleValueParseErrorOwned::NoValueGiven(AngleNoValueGivenError {
                value: String::new().into(),
                metric: AngleMetric::Percent,
            }),
            CssAngleValueParseErrorOwned::ValueParseErr(ParseFloatErrorWithInput {
                error: FfiParseFloatError::Empty,
                input: String::new().into(),
            }),
            CssAngleValueParseErrorOwned::ValueParseErr(ParseFloatErrorWithInput {
                error: FfiParseFloatError::Invalid,
                input: "\u{1F600}".to_string().into(),
            }),
            CssAngleValueParseErrorOwned::InvalidAngle(String::new().into()),
            CssAngleValueParseErrorOwned::InvalidAngle("\u{1F600}\u{0301}".to_string().into()),
        ];
        for owned in cases {
            let shared = owned.to_shared();
            assert!(!format!("{shared}").is_empty());
            // owned -> shared -> owned must be lossless, including the error *kind*.
            assert_eq!(shared.to_contained(), owned);
        }
    }
}