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

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

            
7
use crate::props::basic::error::ParseFloatErrorWithInput;
8

            
9
use 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)]
15
pub enum AngleMetric {
16
    #[default]
17
    Degree,
18
    Radians,
19
    Grad,
20
    Turn,
21
    Percent,
22
}
23

            
24

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

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

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

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

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

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

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

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

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

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

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

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

            
108
    /// Creates a const angle value with the given metric from an integer.
109
    #[inline]
110
296
    #[must_use] pub const fn const_from_metric(metric: AngleMetric, value: isize) -> Self {
111
296
        Self {
112
296
            metric,
113
296
            number: FloatValue::const_new(value),
114
296
        }
115
296
    }
116

            
117
    /// Creates a const angle value with the given metric from a fractional number.
118
    ///
119
    /// # Arguments
120
    /// * `metric` - The angle metric (Degree, Radians, etc.)
121
    /// * `pre_comma` - The integer part (e.g., 45 for 45.5deg)
122
    /// * `post_comma` - The fractional part as digits (e.g., 5 for 0.5deg)
123
    #[inline]
124
12
    #[must_use] pub const fn const_from_metric_fractional(metric: AngleMetric, pre_comma: isize, post_comma: isize) -> Self {
125
12
        Self {
126
12
            metric,
127
12
            number: FloatValue::const_new_fractional(pre_comma, post_comma),
128
12
        }
129
12
    }
130

            
131
    /// Creates an angle value in degrees.
132
    #[inline]
133
549
    #[must_use] pub fn deg(value: f32) -> Self {
134
549
        Self::from_metric(AngleMetric::Degree, value)
135
549
    }
136

            
137
    /// Creates an angle value in radians.
138
    #[inline]
139
16
    #[must_use] pub fn rad(value: f32) -> Self {
140
16
        Self::from_metric(AngleMetric::Radians, value)
141
16
    }
142

            
143
    /// Creates an angle value in gradians.
144
    #[inline]
145
13
    #[must_use] pub fn grad(value: f32) -> Self {
146
13
        Self::from_metric(AngleMetric::Grad, value)
147
13
    }
148

            
149
    /// Creates an angle value in turns.
150
    #[inline]
151
20
    #[must_use] pub fn turn(value: f32) -> Self {
152
20
        Self::from_metric(AngleMetric::Turn, value)
153
20
    }
154

            
155
    /// Creates an angle value in percent.
156
    #[inline]
157
12
    #[must_use] pub fn percent(value: f32) -> Self {
158
12
        Self::from_metric(AngleMetric::Percent, value)
159
12
    }
160

            
161
    /// Creates an angle value with the given metric.
162
    #[inline]
163
1200
    #[must_use] pub fn from_metric(metric: AngleMetric, value: f32) -> Self {
164
1200
        Self {
165
1200
            metric,
166
1200
            number: FloatValue::new(value),
167
1200
        }
168
1200
    }
169

            
170
    /// Convert to degrees, normalized to [0, 360) range.
171
    /// Note: 360.0 becomes 0.0 due to modulo operation.
172
    /// For conic gradients where 360.0 is meaningful, use `to_degrees_raw()`.
173
    #[inline]
174
11359
    #[must_use] pub fn to_degrees(&self) -> f32 {
175
11359
        let mut val = self.to_degrees_raw() % 360.0;
176
11359
        if val < 0.0 {
177
100
            val += 360.0;
178
11259
        }
179
11359
        val
180
11359
    }
181

            
182
    /// Convert to degrees without normalization (raw value).
183
    /// Use this for conic gradients where 360.0 is a meaningful distinct value from 0.0.
184
    #[inline]
185
11662
    #[must_use] pub fn to_degrees_raw(&self) -> f32 {
186
11662
        match self.metric {
187
11514
            AngleMetric::Degree => self.number.get(),
188
39
            AngleMetric::Grad => self.number.get() / 400.0 * 360.0,
189
40
            AngleMetric::Radians => self.number.get().to_degrees(),
190
47
            AngleMetric::Turn => self.number.get() * 360.0,
191
22
            AngleMetric::Percent => self.number.get() / 100.0 * 360.0,
192
        }
193
11662
    }
194
}
195

            
196
// -- Parser
197

            
198
/// Error returned when parsing a CSS angle value from a string.
199
#[derive(Clone, PartialEq, Eq)]
200
pub enum CssAngleValueParseError<'a> {
201
    EmptyString,
202
    NoValueGiven(&'a str, AngleMetric),
203
    ValueParseErr(ParseFloatError, &'a str),
204
    InvalidAngle(&'a str),
205
}
206

            
207
impl_debug_as_display!(CssAngleValueParseError<'a>);
208
impl_display! { CssAngleValueParseError<'a>, {
209
    EmptyString => format!("Missing [rad / deg / turn / %] value"),
210
    NoValueGiven(input, metric) => format!("Expected floating-point angle value, got: \"{}{}\"", input, metric),
211
    ValueParseErr(err, number_str) => format!("Could not parse \"{}\" as floating-point value: \"{}\"", number_str, err),
212
    InvalidAngle(s) => format!("Invalid angle value: \"{}\"", s),
213
}}
214

            
215
/// Wrapper for `NoValueGiven` error in angle parsing.
216
#[derive(Debug, Clone, PartialEq, Eq)]
217
#[repr(C)]
218
pub struct AngleNoValueGivenError {
219
    pub value: AzString,
220
    pub metric: AngleMetric,
221
}
222

            
223
/// Owned version of [`CssAngleValueParseError`] for FFI and storage.
224
#[derive(Debug, Clone, PartialEq, Eq)]
225
#[repr(C, u8)]
226
pub enum CssAngleValueParseErrorOwned {
227
    EmptyString,
228
    NoValueGiven(AngleNoValueGivenError),
229
    ValueParseErr(ParseFloatErrorWithInput),
230
    InvalidAngle(AzString),
231
}
232

            
233
impl CssAngleValueParseError<'_> {
234
31
    #[must_use] pub fn to_contained(&self) -> CssAngleValueParseErrorOwned {
235
31
        match self {
236
10
            CssAngleValueParseError::EmptyString => CssAngleValueParseErrorOwned::EmptyString,
237
5
            CssAngleValueParseError::NoValueGiven(s, metric) => {
238
5
                CssAngleValueParseErrorOwned::NoValueGiven(AngleNoValueGivenError { value: (*s).to_string().into(), metric: *metric })
239
            }
240
4
            CssAngleValueParseError::ValueParseErr(err, s) => {
241
4
                CssAngleValueParseErrorOwned::ValueParseErr(ParseFloatErrorWithInput { error: err.clone().into(), input: (*s).to_string().into() })
242
            }
243
12
            CssAngleValueParseError::InvalidAngle(s) => {
244
12
                CssAngleValueParseErrorOwned::InvalidAngle((*s).to_string().into())
245
            }
246
        }
247
31
    }
248
}
249

            
250
impl CssAngleValueParseErrorOwned {
251
26
    #[must_use] pub fn to_shared(&self) -> CssAngleValueParseError<'_> {
252
26
        match self {
253
8
            Self::EmptyString => CssAngleValueParseError::EmptyString,
254
4
            Self::NoValueGiven(e) => {
255
4
                CssAngleValueParseError::NoValueGiven(e.value.as_str(), e.metric)
256
            }
257
4
            Self::ValueParseErr(e) => {
258
4
                CssAngleValueParseError::ValueParseErr(e.error.to_std(), e.input.as_str())
259
            }
260
10
            Self::InvalidAngle(s) => {
261
10
                CssAngleValueParseError::InvalidAngle(s.as_str())
262
            }
263
        }
264
26
    }
265
}
266

            
267
/// Parse a CSS angle value string (e.g. `"90deg"`, `"1.57rad"`, `"0.5turn"`, `"50%"`).
268
/// A bare number without a unit suffix is interpreted as degrees.
269
#[cfg(feature = "parser")]
270
/// # Errors
271
///
272
/// Returns an error if `input` is not a valid CSS `angle-value` value.
273
793
pub fn parse_angle_value(input: &str) -> Result<AngleValue, CssAngleValueParseError<'_>> {
274
793
    let input = input.trim();
275

            
276
793
    if input.is_empty() {
277
46
        return Err(CssAngleValueParseError::EmptyString);
278
747
    }
279

            
280
747
    let match_values = &[
281
747
        ("deg", AngleMetric::Degree),
282
747
        ("turn", AngleMetric::Turn),
283
747
        ("grad", AngleMetric::Grad),
284
747
        ("rad", AngleMetric::Radians),
285
747
        ("%", AngleMetric::Percent),
286
747
    ];
287

            
288
3269
    for (match_val, metric) in match_values {
289
2810
        if let Some(value) = input.strip_suffix(match_val) {
290
288
            let value = value.trim();
291
288
            if value.is_empty() {
292
12
                return Err(CssAngleValueParseError::NoValueGiven(input, *metric));
293
276
            }
294
276
            match value.parse::<f32>() {
295
256
                Ok(o) => return Ok(AngleValue::from_metric(*metric, o)),
296
20
                Err(e) => return Err(CssAngleValueParseError::ValueParseErr(e, value)),
297
            }
298
2522
        }
299
    }
300

            
301
    // bare number is degrees
302
459
    input.parse::<f32>().map_or_else(
303
315
        |_| Err(CssAngleValueParseError::InvalidAngle(input)),
304
144
        |o| Ok(AngleValue::from_metric(AngleMetric::Degree, o)),
305
    )
306
793
}
307

            
308
#[cfg(all(test, feature = "parser"))]
309
mod tests {
310
    // Tests assert parsed values equal the exact source literals; the rad inputs
311
    // (1.57, 3.14) are literal test data, not approximations of FRAC_PI_2/PI.
312
    #![allow(clippy::float_cmp, clippy::approx_constant)]
313
    use super::*;
314

            
315
    #[test]
316
1
    fn test_parse_angle_value_deg() {
317
1
        assert_eq!(parse_angle_value("90deg").unwrap(), AngleValue::deg(90.0));
318
1
        assert_eq!(
319
1
            parse_angle_value("-45.5deg").unwrap(),
320
1
            AngleValue::deg(-45.5)
321
        );
322
        // Bare number defaults to degrees
323
1
        assert_eq!(parse_angle_value("180").unwrap(), AngleValue::deg(180.0));
324
1
    }
325

            
326
    #[test]
327
1
    fn test_parse_angle_value_rad() {
328
1
        assert_eq!(parse_angle_value("1.57rad").unwrap(), AngleValue::rad(1.57));
329
1
        assert_eq!(
330
1
            parse_angle_value(" -3.14rad ").unwrap(),
331
1
            AngleValue::rad(-3.14)
332
        );
333
1
    }
334

            
335
    #[test]
336
1
    fn test_parse_angle_value_grad() {
337
1
        assert_eq!(
338
1
            parse_angle_value("100grad").unwrap(),
339
1
            AngleValue::grad(100.0)
340
        );
341
1
        assert_eq!(
342
1
            parse_angle_value("400grad").unwrap(),
343
1
            AngleValue::grad(400.0)
344
        );
345
1
    }
346

            
347
    #[test]
348
1
    fn test_parse_angle_value_turn() {
349
1
        assert_eq!(
350
1
            parse_angle_value("0.25turn").unwrap(),
351
1
            AngleValue::turn(0.25)
352
        );
353
1
        assert_eq!(parse_angle_value("1turn").unwrap(), AngleValue::turn(1.0));
354
1
    }
355

            
356
    #[test]
357
1
    fn test_parse_angle_value_percent() {
358
1
        assert_eq!(parse_angle_value("50%").unwrap(), AngleValue::percent(50.0));
359
1
    }
360

            
361
    #[test]
362
1
    fn test_parse_angle_value_errors() {
363
1
        assert!(parse_angle_value("").is_err());
364
1
        assert!(parse_angle_value("deg").is_err());
365
1
        assert!(parse_angle_value("90 degs").is_err());
366
1
        assert!(parse_angle_value("ninety-deg").is_err());
367
1
        assert!(parse_angle_value("1.57 rads").is_err());
368
1
    }
369

            
370
    #[test]
371
1
    fn test_to_degrees_conversion() {
372
1
        assert_eq!(AngleValue::deg(90.0).to_degrees(), 90.0);
373
        // Use 0.1 tolerance due to FloatValue fixed-point precision (multiplier = 1000.0)
374
1
        assert!((AngleValue::rad(core::f32::consts::PI).to_degrees() - 180.0).abs() < 0.1);
375
1
        assert_eq!(AngleValue::grad(100.0).to_degrees(), 90.0);
376
1
        assert_eq!(AngleValue::turn(0.5).to_degrees(), 180.0);
377
1
        assert_eq!(AngleValue::deg(-90.0).to_degrees(), 270.0);
378
1
        assert_eq!(AngleValue::deg(450.0).to_degrees(), 90.0);
379
1
    }
380
}
381

            
382
#[cfg(test)]
383
#[allow(
384
    clippy::float_cmp,
385
    clippy::unreadable_literal,
386
    clippy::cast_precision_loss,
387
    clippy::too_many_lines
388
)]
389
mod autotest_generated {
390
    use super::*;
391
    use crate::props::basic::error::{
392
        ParseFloatError as FfiParseFloatError, ParseFloatErrorWithInput,
393
    };
394

            
395
    /// `FloatValue` stores `f32 * 1000` truncated into an `isize`.
396
    const MULT: isize = 1000;
397

            
398
    /// Every `AngleMetric` variant, for exhaustive sweeps.
399
    const ALL_METRICS: [AngleMetric; 5] = [
400
        AngleMetric::Degree,
401
        AngleMetric::Radians,
402
        AngleMetric::Grad,
403
        AngleMetric::Turn,
404
        AngleMetric::Percent,
405
    ];
406

            
407
    // -------------------------------------------------------------------
408
    // serializers (Display / PrintAsCssValue)
409
    // -------------------------------------------------------------------
410

            
411
    #[test]
412
    fn autotest_angle_metric_display_is_non_empty_and_exact() {
413
        assert_eq!(AngleMetric::Degree.to_string(), "deg");
414
        assert_eq!(AngleMetric::Radians.to_string(), "rad");
415
        assert_eq!(AngleMetric::Grad.to_string(), "grad");
416
        assert_eq!(AngleMetric::Turn.to_string(), "turn");
417
        assert_eq!(AngleMetric::Percent.to_string(), "%");
418
        assert_eq!(AngleMetric::default(), AngleMetric::Degree);
419
        for m in ALL_METRICS {
420
            assert!(!m.to_string().is_empty(), "empty unit string for {m:?}");
421
        }
422
    }
423

            
424
    #[test]
425
    fn autotest_angle_value_display_default_and_zero() {
426
        assert_eq!(AngleValue::default().to_string(), "0deg");
427
        assert_eq!(AngleValue::zero().to_string(), "0deg");
428
        // Debug delegates to Display.
429
        assert_eq!(format!("{:?}", AngleValue::zero()), "0deg");
430
        assert_eq!(AngleValue::zero().print_as_css_value(), "0deg");
431
    }
432

            
433
    #[test]
434
    fn autotest_angle_value_display_never_emits_inf_or_nan() {
435
        // Saturating/clamping happens inside FloatValue, so no non-finite value
436
        // can ever reach the formatter -- assert the serializer stays CSS-safe.
437
        for m in ALL_METRICS {
438
            for v in [
439
                f32::NAN,
440
                f32::INFINITY,
441
                f32::NEG_INFINITY,
442
                f32::MAX,
443
                f32::MIN,
444
                f32::MIN_POSITIVE,
445
                -0.0,
446
            ] {
447
                let s = AngleValue::from_metric(m, v).to_string();
448
                assert!(!s.is_empty(), "empty serialization for {m:?} / {v}");
449
                assert!(!s.contains("inf"), "serialized infinity: {s}");
450
                assert!(!s.contains("NaN"), "serialized NaN: {s}");
451
                assert!(s.ends_with(&m.to_string()), "lost the unit suffix: {s}");
452
            }
453
        }
454
    }
455

            
456
    #[test]
457
    fn autotest_angle_value_nan_serializes_as_zero() {
458
        // NaN collapses to 0 (the `as isize` cast maps NaN -> 0), it is not preserved.
459
        assert_eq!(AngleValue::deg(f32::NAN).to_string(), "0deg");
460
        assert_eq!(AngleValue::rad(f32::NAN).to_string(), "0rad");
461
    }
462

            
463
    // -------------------------------------------------------------------
464
    // constructors
465
    // -------------------------------------------------------------------
466

            
467
    #[test]
468
    fn autotest_zero_is_the_neutral_element() {
469
        let z = AngleValue::zero();
470
        assert_eq!(z, AngleValue::default());
471
        assert_eq!(z, AngleValue::const_deg(0));
472
        assert_eq!(z, AngleValue::deg(0.0));
473
        assert_eq!(z.metric, AngleMetric::Degree);
474
        assert_eq!(z.number.number(), 0);
475
        assert_eq!(z.number.get(), 0.0);
476
        assert_eq!(z.to_degrees(), 0.0);
477
        assert_eq!(z.to_degrees_raw(), 0.0);
478
    }
479

            
480
    #[test]
481
    fn autotest_from_metric_fields_match_args() {
482
        for m in ALL_METRICS {
483
            let a = AngleValue::from_metric(m, 12.5);
484
            assert_eq!(a.metric, m);
485
            assert_eq!(a.number.get(), 12.5);
486
            assert_eq!(a.number.number(), 12_500);
487
        }
488
        // The per-metric helpers must agree with from_metric.
489
        assert_eq!(
490
            AngleValue::deg(1.5),
491
            AngleValue::from_metric(AngleMetric::Degree, 1.5)
492
        );
493
        assert_eq!(
494
            AngleValue::rad(1.5),
495
            AngleValue::from_metric(AngleMetric::Radians, 1.5)
496
        );
497
        assert_eq!(
498
            AngleValue::grad(1.5),
499
            AngleValue::from_metric(AngleMetric::Grad, 1.5)
500
        );
501
        assert_eq!(
502
            AngleValue::turn(1.5),
503
            AngleValue::from_metric(AngleMetric::Turn, 1.5)
504
        );
505
        assert_eq!(
506
            AngleValue::percent(1.5),
507
            AngleValue::from_metric(AngleMetric::Percent, 1.5)
508
        );
509
    }
510

            
511
    // -------------------------------------------------------------------
512
    // numeric: const constructors (isize -> fixed point)
513
    // -------------------------------------------------------------------
514

            
515
    #[test]
516
    fn autotest_const_ctors_zero_negative_and_metric() {
517
        for (built, metric) in [
518
            (AngleValue::const_deg(0), AngleMetric::Degree),
519
            (AngleValue::const_rad(0), AngleMetric::Radians),
520
            (AngleValue::const_grad(0), AngleMetric::Grad),
521
            (AngleValue::const_turn(0), AngleMetric::Turn),
522
            (AngleValue::const_percent(0), AngleMetric::Percent),
523
        ] {
524
            assert_eq!(built.metric, metric);
525
            assert_eq!(built.number.number(), 0);
526
        }
527
        assert_eq!(AngleValue::const_deg(-90).number.get(), -90.0);
528
        assert_eq!(AngleValue::const_rad(-3).number.number(), -3 * MULT);
529
        assert_eq!(AngleValue::const_turn(-1).to_degrees_raw(), -360.0);
530
    }
531

            
532
    #[test]
533
    fn autotest_const_from_metric_matches_specific_ctors() {
534
        for (m, specific) in [
535
            (AngleMetric::Degree, AngleValue::const_deg(7)),
536
            (AngleMetric::Radians, AngleValue::const_rad(7)),
537
            (AngleMetric::Grad, AngleValue::const_grad(7)),
538
            (AngleMetric::Turn, AngleValue::const_turn(7)),
539
            (AngleMetric::Percent, AngleValue::const_percent(7)),
540
        ] {
541
            assert_eq!(AngleValue::const_from_metric(m, 7), specific);
542
            assert_eq!(specific.number.number(), 7 * MULT);
543
        }
544
    }
545

            
546
    #[test]
547
    fn autotest_const_ctors_at_safe_isize_boundary() {
548
        // const_new multiplies by 1000, so |value| <= isize::MAX / 1000 is the
549
        // largest magnitude that cannot overflow. Assert exactness right at the edge.
550
        const MAX_SAFE: isize = isize::MAX / MULT;
551
        const MIN_SAFE: isize = isize::MIN / MULT;
552

            
553
        assert_eq!(
554
            AngleValue::const_deg(MAX_SAFE).number.number(),
555
            MAX_SAFE * MULT
556
        );
557
        assert_eq!(
558
            AngleValue::const_deg(MIN_SAFE).number.number(),
559
            MIN_SAFE * MULT
560
        );
561
        // ...and the round-trip back to f32 stays finite (no inf leaking into layout).
562
        assert!(AngleValue::const_deg(MAX_SAFE).number.get().is_finite());
563
        assert!(AngleValue::const_deg(MIN_SAFE).number.get().is_finite());
564
        assert!(AngleValue::const_turn(MAX_SAFE).to_degrees().is_finite());
565
        assert!(AngleValue::const_turn(MIN_SAFE).to_degrees().is_finite());
566
    }
567

            
568
    #[test]
569
    fn autotest_const_deg_isize_max_overflows_unchecked() {
570
        // Documents (does not bless) the unchecked `value * 1000` in FloatValue::const_new:
571
        // isize::MAX degrees panics on overflow in debug and wraps in release. Both are
572
        // accepted here; what must NOT happen is a silently plausible-looking angle.
573
        // black_box keeps const-propagation from turning this into a compile-time error.
574
        let huge = core::hint::black_box(isize::MAX);
575
        let prev = std::panic::take_hook();
576
        std::panic::set_hook(Box::new(|_| {}));
577
        let res = std::panic::catch_unwind(move || AngleValue::const_deg(huge).number.number());
578
        std::panic::set_hook(prev);
579

            
580
        match res {
581
            Err(_) => {} // debug build: "attempt to multiply with overflow"
582
            Ok(n) => assert_eq!(
583
                n,
584
                isize::MAX.wrapping_mul(MULT),
585
                "release build must wrap, not produce a sanitized value"
586
            ),
587
        }
588
    }
589

            
590
    #[test]
591
    fn autotest_const_from_metric_fractional_digit_truncation() {
592
        let f = |pre, post| {
593
            AngleValue::const_from_metric_fractional(AngleMetric::Degree, pre, post)
594
                .number
595
                .number()
596
        };
597
        assert_eq!(f(0, 0), 0);
598
        assert_eq!(f(45, 5), 45_500); // 45.5
599
        assert_eq!(f(0, 83), 830); // 0.83
600
        assert_eq!(f(1, 523), 1_523); // 1.523
601
        // More than 3 fractional digits: truncated (not rounded) to 3.
602
        assert_eq!(f(2, 123456), 2_123); // 2.123456 -> 2.123, per the doc comment
603
        assert_eq!(f(0, 999_999_999), 999); // 0.999999999 -> 0.999
604
        assert_eq!(
605
            AngleValue::const_from_metric_fractional(AngleMetric::Turn, 0, 25).metric,
606
            AngleMetric::Turn
607
        );
608
    }
609

            
610
    #[test]
611
    fn autotest_const_fractional_sign_handling_and_negative_zero_trap() {
612
        let deg = |pre, post| {
613
            AngleValue::const_from_metric_fractional(AngleMetric::Degree, pre, post)
614
                .number
615
                .get()
616
        };
617
        assert_eq!(deg(-1, 5), -1.5); // negative pre drags the fraction negative
618
        assert_eq!(deg(0, -5), -0.5); // negative post encodes a negative fraction
619
        assert_eq!(deg(-1, -5), -1.5); // both negative must not double-negate
620
        // TRAP: isize has no -0, so `-0` is `0` and the sign is lost. -0.5deg is NOT
621
        // expressible as (-0, 5); it yields +0.5deg. Callers must use (0, -5).
622
        assert_eq!(deg(-0, 5), 0.5);
623
        assert_ne!(deg(-0, 5), -0.5);
624
    }
625

            
626
    // -------------------------------------------------------------------
627
    // numeric: f32 constructors (saturation / NaN / sub-precision)
628
    // -------------------------------------------------------------------
629

            
630
    #[test]
631
    fn autotest_f32_ctor_nan_collapses_to_zero() {
632
        for m in ALL_METRICS {
633
            let a = AngleValue::from_metric(m, f32::NAN);
634
            assert_eq!(a.number.number(), 0, "NaN did not clamp to 0 for {m:?}");
635
            assert_eq!(a.number.get(), 0.0);
636
            assert!(!a.number.get().is_nan());
637
            assert_eq!(a.to_degrees(), 0.0);
638
            assert_eq!(a.to_degrees_raw(), 0.0);
639
            // Consequence worth knowing: NaN is *equal* to zero after construction.
640
            assert_eq!(a, AngleValue::from_metric(m, 0.0));
641
        }
642
    }
643

            
644
    #[test]
645
    fn autotest_f32_ctor_infinities_saturate_to_isize_bounds() {
646
        assert_eq!(AngleValue::deg(f32::INFINITY).number.number(), isize::MAX);
647
        assert_eq!(
648
            AngleValue::deg(f32::NEG_INFINITY).number.number(),
649
            isize::MIN
650
        );
651
        // f32::MAX * 1000 overflows to +inf before the cast, so it saturates too.
652
        assert_eq!(AngleValue::deg(f32::MAX).number.number(), isize::MAX);
653
        assert_eq!(AngleValue::deg(f32::MIN).number.number(), isize::MIN);
654
        for m in ALL_METRICS {
655
            for v in [f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN] {
656
                let a = AngleValue::from_metric(m, v);
657
                assert!(a.number.get().is_finite(), "{m:?} / {v} leaked a non-finite");
658
            }
659
        }
660
    }
661

            
662
    #[test]
663
    fn autotest_f32_ctor_truncates_toward_zero_below_precision() {
664
        // 3 decimal digits of precision; the 4th digit is truncated, not rounded,
665
        // and truncation is toward zero on both sides of 0.
666
        assert_eq!(AngleValue::deg(1.9999).number.number(), 1_999);
667
        assert_eq!(AngleValue::deg(-1.9999).number.number(), -1_999);
668
        assert_eq!(AngleValue::deg(0.0004).number.number(), 0);
669
        assert_eq!(AngleValue::deg(-0.0004).number.number(), 0);
670
        assert_eq!(AngleValue::deg(f32::EPSILON).number.number(), 0);
671
        assert_eq!(AngleValue::deg(f32::MIN_POSITIVE).number.number(), 0);
672
        // -0.0 must not become a negative encoded value.
673
        assert_eq!(AngleValue::deg(-0.0).number.number(), 0);
674
        assert_eq!(AngleValue::deg(-0.0), AngleValue::deg(0.0));
675
    }
676

            
677
    // -------------------------------------------------------------------
678
    // getters: to_degrees / to_degrees_raw
679
    // -------------------------------------------------------------------
680

            
681
    #[test]
682
    fn autotest_to_degrees_known_conversions() {
683
        assert_eq!(AngleValue::deg(90.0).to_degrees(), 90.0);
684
        assert_eq!(AngleValue::grad(100.0).to_degrees(), 90.0);
685
        assert_eq!(AngleValue::turn(0.25).to_degrees(), 90.0);
686
        assert_eq!(AngleValue::percent(50.0).to_degrees(), 180.0);
687
        assert_eq!(AngleValue::percent(25.0).to_degrees_raw(), 90.0);
688
        // rad goes through the 1/1000 quantization, so compare with a tolerance.
689
        assert!((AngleValue::rad(core::f32::consts::FRAC_PI_2).to_degrees() - 90.0).abs() < 0.1);
690
    }
691

            
692
    #[test]
693
    fn autotest_to_degrees_normalizes_but_raw_does_not() {
694
        // A full turn normalizes to 0 -- the documented 360 -> 0 collapse.
695
        assert_eq!(AngleValue::deg(360.0).to_degrees(), 0.0);
696
        assert_eq!(AngleValue::turn(1.0).to_degrees(), 0.0);
697
        assert_eq!(AngleValue::grad(400.0).to_degrees(), 0.0);
698
        assert_eq!(AngleValue::percent(100.0).to_degrees(), 0.0);
699
        // ...while the raw variant keeps 360 distinct from 0 (conic-gradient case).
700
        assert_eq!(AngleValue::deg(360.0).to_degrees_raw(), 360.0);
701
        assert_eq!(AngleValue::turn(1.0).to_degrees_raw(), 360.0);
702
        assert_eq!(AngleValue::grad(400.0).to_degrees_raw(), 360.0);
703
        assert_eq!(AngleValue::percent(100.0).to_degrees_raw(), 360.0);
704

            
705
        // Negative and out-of-range wrap into [0, 360).
706
        assert_eq!(AngleValue::deg(-90.0).to_degrees(), 270.0);
707
        assert_eq!(AngleValue::deg(-450.0).to_degrees(), 270.0);
708
        assert_eq!(AngleValue::deg(-0.5).to_degrees(), 359.5);
709
        assert_eq!(AngleValue::deg(720.0).to_degrees(), 0.0);
710
        assert_eq!(AngleValue::deg(450.0).to_degrees_raw(), 450.0);
711
        assert_eq!(AngleValue::turn(-2.0).to_degrees(), 0.0);
712
    }
713

            
714
    #[test]
715
    fn autotest_to_degrees_on_saturated_values_stays_finite_and_in_range() {
716
        // The nastiest inputs the type can hold: isize::MAX / isize::MIN encodings,
717
        // pushed through every unit conversion. Must never produce inf/NaN and must
718
        // honour the documented [0, 360) contract.
719
        for m in ALL_METRICS {
720
            for v in [f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, f32::NAN] {
721
                let a = AngleValue::from_metric(m, v);
722
                let raw = a.to_degrees_raw();
723
                let norm = a.to_degrees();
724
                assert!(raw.is_finite(), "to_degrees_raw not finite: {m:?} / {v}");
725
                assert!(norm.is_finite(), "to_degrees not finite: {m:?} / {v}");
726
                assert!(
727
                    (0.0..360.0).contains(&norm),
728
                    "to_degrees out of [0,360): {m:?} / {v} -> {norm}"
729
                );
730
            }
731
        }
732
    }
733

            
734
    #[test]
735
    fn autotest_ord_is_metric_first_not_semantic_angle() {
736
        // Ord derives on (metric, number): the unit dominates. 1000deg sorts BEFORE
737
        // 0rad even though it is the larger angle -- do not use Ord to compare angles.
738
        assert!(AngleValue::deg(1000.0) < AngleValue::rad(0.0));
739
        assert!(AngleValue::turn(0.0) < AngleValue::percent(0.0));
740
        // Within one metric the ordering is numeric, as expected.
741
        assert!(AngleValue::deg(-1.0) < AngleValue::deg(1.0));
742
        // Eq/Hash agree with each other (no NaN poisoning, since NaN clamps to 0).
743
        use core::hash::{Hash, Hasher};
744
        let h = |a: AngleValue| {
745
            let mut s = std::collections::hash_map::DefaultHasher::new();
746
            a.hash(&mut s);
747
            s.finish()
748
        };
749
        assert_eq!(AngleValue::deg(1.0), AngleValue::deg(1.0));
750
        assert_eq!(h(AngleValue::deg(1.0)), h(AngleValue::deg(1.0)));
751
        assert_eq!(h(AngleValue::deg(f32::NAN)), h(AngleValue::deg(0.0)));
752
        assert_ne!(AngleValue::deg(1.0), AngleValue::rad(1.0));
753
    }
754

            
755
    // -------------------------------------------------------------------
756
    // parser (feature-gated, mirrors the #[cfg(feature = "parser")] on the fn)
757
    // -------------------------------------------------------------------
758

            
759
    #[cfg(feature = "parser")]
760
    #[test]
761
    fn autotest_parse_empty_and_whitespace_only() {
762
        for input in ["", "   ", "\t\n\r", "\u{a0}", " \u{2003} "] {
763
            assert!(
764
                matches!(
765
                    parse_angle_value(input),
766
                    Err(CssAngleValueParseError::EmptyString)
767
                ),
768
                "expected EmptyString for {input:?}"
769
            );
770
        }
771
    }
772

            
773
    #[cfg(feature = "parser")]
774
    #[test]
775
    fn autotest_parse_unit_without_number() {
776
        for (input, metric) in [
777
            ("deg", AngleMetric::Degree),
778
            ("rad", AngleMetric::Radians),
779
            ("grad", AngleMetric::Grad),
780
            ("turn", AngleMetric::Turn),
781
            ("%", AngleMetric::Percent),
782
            ("  deg  ", AngleMetric::Degree),
783
        ] {
784
            match parse_angle_value(input) {
785
                Err(CssAngleValueParseError::NoValueGiven(_, m)) => assert_eq!(m, metric),
786
                other => panic!("expected NoValueGiven for {input:?}, got {other:?}"),
787
            }
788
        }
789
    }
790

            
791
    #[cfg(feature = "parser")]
792
    #[test]
793
    fn autotest_parse_garbage_is_rejected_without_panicking() {
794
        for input in [
795
            "ninety",
796
            "!!!",
797
            "90 degs",
798
            "1.57 rads",
799
            "90degdeg",
800
            "--90deg",
801
            "1_0deg",
802
            "90;garbage",
803
            "deg90",
804
            "%50",
805
            "0x1Fdeg",
806
            "+-1turn",
807
            "9 0deg",
808
            "\0deg",
809
        ] {
810
            let res = parse_angle_value(input);
811
            assert!(res.is_err(), "garbage accepted: {input:?} -> {res:?}");
812
            // Error formatting must not panic either (it interpolates the input).
813
            assert!(!format!("{}", res.unwrap_err()).is_empty());
814
        }
815
    }
816

            
817
    #[cfg(feature = "parser")]
818
    #[test]
819
    fn autotest_parse_uppercase_units_are_rejected() {
820
        // CSS units are ASCII case-insensitive; this parser is case-SENSITIVE.
821
        // That is a spec deviation, but it fails closed (Err), never panics.
822
        for input in ["90DEG", "1RAD", "0.5TURN", "100GRAD", "90Deg"] {
823
            assert!(
824
                parse_angle_value(input).is_err(),
825
                "case-insensitive unit unexpectedly accepted: {input:?}"
826
            );
827
        }
828
    }
829

            
830
    #[cfg(feature = "parser")]
831
    #[test]
832
    fn autotest_parse_accepts_whitespace_between_number_and_unit() {
833
        // Lenient vs. the CSS grammar (no whitespace allowed inside a dimension token):
834
        // the unit suffix is stripped first, then the remainder is trimmed.
835
        assert_eq!(parse_angle_value("90 deg").unwrap(), AngleValue::deg(90.0));
836
        assert_eq!(parse_angle_value("90\tdeg").unwrap(), AngleValue::deg(90.0));
837
        assert_eq!(
838
            parse_angle_value("50 %").unwrap(),
839
            AngleValue::percent(50.0)
840
        );
841
        assert_eq!(parse_angle_value(" 90deg ").unwrap(), AngleValue::deg(90.0));
842
    }
843

            
844
    #[cfg(feature = "parser")]
845
    #[test]
846
    fn autotest_parse_accepts_float_keywords_and_neutralizes_them() {
847
        // "NaN"/"inf" are valid f32 literals, so they slip past the parser. They must
848
        // at least end up as defined, finite angles rather than poisoning layout.
849
        let nan = parse_angle_value("NaN").expect("f32::from_str accepts NaN");
850
        assert_eq!(nan.number.number(), 0);
851
        assert!(!nan.to_degrees().is_nan());
852

            
853
        let nan_rad = parse_angle_value("nanrad").expect("f32::from_str accepts nan");
854
        assert_eq!(nan_rad.metric, AngleMetric::Radians);
855
        assert_eq!(nan_rad.number.number(), 0);
856

            
857
        let inf = parse_angle_value("inf").expect("f32::from_str accepts inf");
858
        assert_eq!(inf.number.number(), isize::MAX);
859
        assert!(inf.to_degrees().is_finite());
860

            
861
        let neg_inf = parse_angle_value("-infdeg").expect("f32::from_str accepts -inf");
862
        assert_eq!(neg_inf.number.number(), isize::MIN);
863
        assert!(neg_inf.to_degrees_raw().is_finite());
864
    }
865

            
866
    #[cfg(feature = "parser")]
867
    #[test]
868
    fn autotest_parse_boundary_numbers_saturate() {
869
        assert_eq!(parse_angle_value("0").unwrap(), AngleValue::deg(0.0));
870
        assert_eq!(parse_angle_value("-0").unwrap().number.number(), 0);
871
        assert_eq!(parse_angle_value("+90deg").unwrap(), AngleValue::deg(90.0));
872
        assert_eq!(parse_angle_value(".5turn").unwrap(), AngleValue::turn(0.5));
873
        // i64::MAX / i64::MIN as bare degrees: overflow the fixed-point encoding and
874
        // must saturate rather than wrap into a bogus small angle.
875
        assert_eq!(
876
            parse_angle_value("9223372036854775807").unwrap().number.number(),
877
            isize::MAX
878
        );
879
        assert_eq!(
880
            parse_angle_value("-9223372036854775808").unwrap().number.number(),
881
            isize::MIN
882
        );
883
        // f32 exponent overflow -> inf -> saturates; underflow -> 0.
884
        assert_eq!(parse_angle_value("1e40deg").unwrap().number.number(), isize::MAX);
885
        assert_eq!(parse_angle_value("1e-40deg").unwrap().number.number(), 0);
886
        assert_eq!(parse_angle_value("0.0001deg").unwrap().number.number(), 0);
887
    }
888

            
889
    #[cfg(feature = "parser")]
890
    #[test]
891
    fn autotest_parse_extremely_long_input_terminates() {
892
        // 100k digits: linear-time float parse, no hang, saturating result.
893
        let long_digits = "9".repeat(100_000) + "deg";
894
        assert_eq!(
895
            parse_angle_value(&long_digits).unwrap().number.number(),
896
            isize::MAX
897
        );
898

            
899
        // 100k leading zeros still denote 1.
900
        let padded = "0".repeat(100_000) + "1deg";
901
        assert_eq!(parse_angle_value(&padded).unwrap(), AngleValue::deg(1.0));
902

            
903
        // 100k junk bytes: rejected, not truncated into something valid.
904
        let long_junk = "a".repeat(100_000);
905
        assert!(parse_angle_value(&long_junk).is_err());
906
    }
907

            
908
    #[cfg(feature = "parser")]
909
    #[test]
910
    fn autotest_parse_deeply_nested_brackets_does_not_stack_overflow() {
911
        // The parser is non-recursive; 10k nested brackets must simply be rejected.
912
        let nested = "(".repeat(10_000);
913
        assert!(parse_angle_value(&nested).is_err());
914
        let nested_unit = "[".repeat(10_000) + "deg";
915
        assert!(parse_angle_value(&nested_unit).is_err());
916
    }
917

            
918
    #[cfg(feature = "parser")]
919
    #[test]
920
    fn autotest_parse_unicode_input_never_panics() {
921
        // Multibyte input must not be sliced on a non-char boundary anywhere.
922
        for input in [
923
            "°",
924
            "90°",
925
            "\u{1F600}",
926
            "\u{1F600}deg",
927
            "90deg",          // fullwidth digits
928
            "9\u{0301}0deg",    // combining acute accent
929
            "٩٠%",              // arabic-indic digits
930
            "\u{200b}90deg",    // zero-width space (not trimmed: not White_Space)
931
            "90de\u{0261}",     // latin small script g
932
        ] {
933
            let res = parse_angle_value(input);
934
            assert!(res.is_err(), "unicode garbage accepted: {input:?} -> {res:?}");
935
            assert!(!format!("{}", res.unwrap_err()).is_empty());
936
        }
937
    }
938

            
939
    #[cfg(feature = "parser")]
940
    #[test]
941
    fn autotest_parse_valid_minimal_positive_control() {
942
        assert_eq!(parse_angle_value("1deg").unwrap(), AngleValue::deg(1.0));
943
        assert_eq!(parse_angle_value("0").unwrap(), AngleValue::zero());
944
    }
945

            
946
    // -------------------------------------------------------------------
947
    // round-trip: encode == decode
948
    // -------------------------------------------------------------------
949

            
950
    #[cfg(feature = "parser")]
951
    #[test]
952
    fn autotest_round_trip_display_then_parse_all_metrics() {
953
        // Values chosen to be exactly representable in f32 *and* exact after the
954
        // x1000 fixed-point encoding, so the round-trip must be bit-exact.
955
        for m in ALL_METRICS {
956
            for v in [
957
                0.0_f32, 1.0, -1.0, 0.5, -0.25, 45.5, 90.0, 180.0, 359.0, 1000.0,
958
            ] {
959
                let angle = AngleValue::from_metric(m, v);
960
                let printed = angle.to_string();
961
                let reparsed = parse_angle_value(&printed)
962
                    .unwrap_or_else(|e| panic!("cannot re-parse own output {printed:?}: {e}"));
963
                assert_eq!(reparsed, angle, "round-trip changed value: {printed:?}");
964
                assert_eq!(reparsed.metric, m, "round-trip changed unit: {printed:?}");
965
                // print_as_css_value must agree with Display.
966
                assert_eq!(angle.print_as_css_value(), printed);
967
            }
968
        }
969
    }
970

            
971
    #[cfg(feature = "parser")]
972
    #[test]
973
    fn autotest_round_trip_metric_suffix_is_unambiguous() {
974
        // "1grad" must not be mis-lexed as "1g" + "rad" (suffix match order matters).
975
        for m in ALL_METRICS {
976
            let parsed = parse_angle_value(&format!("1{m}")).unwrap();
977
            assert_eq!(parsed.metric, m, "unit {m} did not round-trip");
978
            assert_eq!(parsed.number.get(), 1.0);
979
        }
980
        assert_eq!(parse_angle_value("1grad").unwrap().metric, AngleMetric::Grad);
981
        assert_eq!(
982
            parse_angle_value("1rad").unwrap().metric,
983
            AngleMetric::Radians
984
        );
985
    }
986

            
987
    #[cfg(feature = "parser")]
988
    #[test]
989
    fn autotest_round_trip_quantization_is_idempotent() {
990
        // Re-encoding an already-quantized value must be a fixed point, otherwise
991
        // repeated serialize/parse cycles would drift.
992
        for v in [0.0_f32, 1.0, -1.0, 0.5, -0.25, 45.5, 359.0] {
993
            let once = AngleValue::deg(v);
994
            let twice = AngleValue::deg(once.number.get());
995
            assert_eq!(once, twice, "quantization not idempotent for {v}");
996
        }
997
    }
998

            
999
    // -------------------------------------------------------------------
    // 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);
        }
    }
}