1
//! Hash-able floating-point wrappers, percentage values, and CSS size
2
//! metric types used by the CSS property system.
3

            
4
use core::fmt;
5
use std::num::ParseFloatError;
6

            
7
use crate::corety::AzString;
8

            
9
/// Multiplier for floating point accuracy.
10
///
11
/// Elements such as px or %
12
/// are only accurate until a certain number of decimal points, therefore
13
/// they have to be casted to isizes in order to make the f32 values
14
/// hash-able: Css has a relatively low precision here, roughly 3 digits, i.e
15
/// `1.001 == 1.0`
16
pub const FP_PRECISION_MULTIPLIER: f32 = 1000.0;
17
const FP_PRECISION_MULTIPLIER_CONST: isize = crate::cast::f32_to_isize(FP_PRECISION_MULTIPLIER);
18

            
19
/// Wrapper around `FloatValue`, represents a percentage instead
20
/// of just being a regular floating-point value, i.e `5` = `5%`
21
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
22
#[repr(C)]
23
pub struct PercentageValue {
24
    number: FloatValue,
25
}
26

            
27
impl_option!(
28
    PercentageValue,
29
    OptionPercentageValue,
30
    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
31
);
32

            
33
impl fmt::Display for PercentageValue {
34
53
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35
53
        write!(f, "{}%", self.normalized() * 100.0)
36
53
    }
37
}
38

            
39
impl PercentageValue {
40
    /// Same as `PercentageValue::new()`, but only accepts whole numbers.
41
    /// Uses isize arithmetic to avoid floating-point in const context.
42
    #[inline]
43
    #[must_use]
44
562546
    pub const fn const_new(value: isize) -> Self {
45
562546
        Self {
46
562546
            number: FloatValue::const_new(value),
47
562546
        }
48
562546
    }
49

            
50
    /// Creates a `PercentageValue` from a fractional number in const context.
51
    ///
52
    /// # Arguments
53
    /// * `pre_comma` - The integer part (e.g., 100 for 100.5%)
54
    /// * `post_comma` - The fractional part as digits (e.g., 5 for 0.5%)
55
    ///
56
    /// # Examples
57
    /// ```
58
    /// // 100% = const_new_fractional(100, 0)
59
    /// // 50.5% = const_new_fractional(50, 5)
60
    /// ```
61
    #[inline]
62
    #[must_use]
63
3
    pub const fn const_new_fractional(pre_comma: isize, post_comma: isize) -> Self {
64
3
        Self {
65
3
            number: FloatValue::const_new_fractional(pre_comma, post_comma),
66
3
        }
67
3
    }
68

            
69
    #[inline]
70
    #[must_use]
71
53379
    pub fn new(value: f32) -> Self {
72
53379
        Self {
73
53379
            number: value.into(),
74
53379
        }
75
53379
    }
76

            
77
    // NOTE: no get() function, to avoid confusion with "150%"
78

            
79
    #[inline]
80
    #[must_use]
81
81793
    pub fn normalized(&self) -> f32 {
82
81793
        self.number.get() / 100.0
83
81793
    }
84

            
85
    #[inline]
86
    #[must_use]
87
1566
    pub fn interpolate(&self, other: &Self, t: f32) -> Self {
88
1566
        Self {
89
1566
            number: self.number.interpolate(&other.number, t),
90
1566
        }
91
1566
    }
92
}
93

            
94
/// Wrapper around an f32 value that is internally casted to an isize,
95
/// in order to provide hash-ability (to avoid numerical instability).
96
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
97
#[repr(C)]
98
pub struct FloatValue {
99
    pub(crate) number: isize,
100
}
101

            
102
impl fmt::Display for FloatValue {
103
327879
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104
327879
        write!(f, "{}", self.get())
105
327879
    }
106
}
107

            
108
impl ::core::fmt::Debug for FloatValue {
109
5
    fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
110
5
        write!(f, "{self}")
111
5
    }
112
}
113

            
114
impl Default for FloatValue {
115
104251
    fn default() -> Self {
116
        const DEFAULT_FLV: FloatValue = FloatValue::const_new(0);
117
104251
        DEFAULT_FLV
118
104251
    }
119
}
120

            
121
impl FloatValue {
122
    /// Same as `FloatValue::new()`, but only accepts whole numbers.
123
    /// Uses isize arithmetic to avoid floating-point in const context.
124
    #[inline]
125
    #[must_use]
126
7073378
    pub const fn const_new(value: isize) -> Self {
127
7073378
        Self {
128
7073378
            number: value * FP_PRECISION_MULTIPLIER_CONST,
129
7073378
        }
130
7073378
    }
131

            
132
    /// Creates a `FloatValue` from a fractional number in const context.
133
    ///
134
    /// This uses integer arithmetic to represent fractional values like 1.5, 0.83, etc.
135
    /// in const context without relying on f32 operations.
136
    ///
137
    /// The function automatically detects the number of decimal places in `post_comma`
138
    /// and supports up to 3 decimal places. If more digits are provided, only the first
139
    /// 3 are used (truncation, not rounding).
140
    ///
141
    /// # Arguments
142
    /// * `pre_comma` - The integer part (e.g., 1 for 1.5)
143
    /// * `post_comma` - The fractional part as digits (e.g., 5 for 0.5, 52 for 0.52, 523 for 0.523)
144
    ///
145
    /// # Examples
146
    /// ```
147
    /// // 1.5 = const_new_fractional(1, 5)
148
    /// // 1.52 = const_new_fractional(1, 52)
149
    /// // 1.523 = const_new_fractional(1, 523)
150
    /// // 0.83 = const_new_fractional(0, 83)
151
    /// // 1.17 = const_new_fractional(1, 17)
152
    /// // 2.123456 -> 2.123 (truncated to 3 decimal places)
153
    /// ```
154
    #[inline]
155
    #[must_use]
156
101
    pub const fn const_new_fractional(pre_comma: isize, post_comma: isize) -> Self {
157
        // Get absolute value for digit counting
158
101
        let abs_post = if post_comma < 0 {
159
6
            -post_comma
160
        } else {
161
95
            post_comma
162
        };
163

            
164
        // Determine the number of digits and extract only the first 3
165
        // Note: We limit to values that fit in 32-bit isize for WASM compatibility
166
101
        let (normalized_post, divisor) = if abs_post < 10 {
167
            // 1 digit: 5 → 0.5
168
36
            (abs_post, 10)
169
65
        } else if abs_post < 100 {
170
            // 2 digits: 83 → 0.83
171
31
            (abs_post, 100)
172
34
        } else if abs_post < 1000 {
173
            // 3 digits: 523 → 0.523
174
12
            (abs_post, 1000)
175
        } else {
176
            // 4+ digits: keep only the first 3 (e.g. 5234 → 523 → 0.523).
177
            // A fixed division ladder cannot bound the digit count for
178
            // arbitrarily large `post_comma` (an 11-digit value keeps 4 digits,
179
            // etc.), letting the "fraction" grow past 1.0 and corrupt the
180
            // integer part. Reduce until strictly below 1000 so the result is
181
            // always a proper 3-digit fraction.
182
22
            let mut reduced = abs_post;
183
146
            while reduced >= 1000 {
184
124
                reduced /= 10;
185
124
            }
186
22
            (reduced, 1000)
187
        };
188

            
189
        // Calculate fractional part
190
101
        let fractional_part = normalized_post * (FP_PRECISION_MULTIPLIER_CONST / divisor);
191

            
192
        // Apply sign: if post_comma is negative, negate the fractional part
193
101
        let signed_fractional = if post_comma < 0 {
194
6
            -fractional_part
195
        } else {
196
95
            fractional_part
197
        };
198

            
199
        // For negative pre_comma, the fractional part should also be negative
200
        // E.g., -1.5 = -1 + (-0.5), not -1 + 0.5
201
101
        let final_fractional = if pre_comma < 0 && post_comma >= 0 {
202
5
            -signed_fractional
203
        } else {
204
96
            signed_fractional
205
        };
206

            
207
101
        Self {
208
101
            number: pre_comma * FP_PRECISION_MULTIPLIER_CONST + final_fractional,
209
101
        }
210
101
    }
211

            
212
    #[inline]
213
    #[must_use]
214
18504995
    pub fn new(value: f32) -> Self {
215
18504995
        Self {
216
18504995
            number: crate::cast::f32_to_isize(value * FP_PRECISION_MULTIPLIER),
217
18504995
        }
218
18504995
    }
219

            
220
    #[inline]
221
    #[must_use]
222
32324371
    pub fn get(&self) -> f32 {
223
32324371
        crate::cast::isize_to_f32(self.number) / FP_PRECISION_MULTIPLIER
224
32324371
    }
225

            
226
    /// Returns the raw encoded `isize` (the f32 value scaled by
227
    /// `FP_PRECISION_MULTIPLIER`). Exposed so external callers can
228
    /// round-trip the value through the compact-cache encoding without
229
    /// re-multiplying through f32.
230
    #[inline]
231
    #[must_use]
232
7332
    pub const fn number(&self) -> isize {
233
7332
        self.number
234
7332
    }
235

            
236
    #[inline]
237
    #[allow(clippy::suboptimal_flops)] // explicit FP; mul_add slower without +fma
238
    #[must_use]
239
4409
    pub fn interpolate(&self, other: &Self, t: f32) -> Self {
240
4409
        let self_val_f32 = self.get();
241
4409
        let other_val_f32 = other.get();
242
4409
        let interpolated = self_val_f32 + ((other_val_f32 - self_val_f32) * t);
243
4409
        Self::new(interpolated)
244
4409
    }
245
}
246

            
247
impl From<f32> for FloatValue {
248
    #[inline]
249
53379
    fn from(val: f32) -> Self {
250
53379
        Self::new(val)
251
53379
    }
252
}
253

            
254
/// Enum representing the metric associated with a number (px, pt, em, etc.)
255
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
256
#[repr(C)]
257
#[derive(Default)]
258
pub enum SizeMetric {
259
    #[default]
260
    Px,
261
    Pt,
262
    Em,
263
    Rem,
264
    In,
265
    Cm,
266
    Mm,
267
    Percent,
268
    /// Viewport width: 1vw = 1% of viewport width
269
    Vw,
270
    /// Viewport height: 1vh = 1% of viewport height
271
    Vh,
272
    /// Viewport minimum: 1vmin = 1% of smaller viewport dimension
273
    Vmin,
274
    /// Viewport maximum: 1vmax = 1% of larger viewport dimension
275
    Vmax,
276
}
277

            
278
impl fmt::Display for SizeMetric {
279
327546
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280
        use self::SizeMetric::{Cm, Em, In, Mm, Percent, Pt, Px, Rem, Vh, Vmax, Vmin, Vw};
281
327546
        match self {
282
324450
            Px => write!(f, "px"),
283
136
            Pt => write!(f, "pt"),
284
1847
            Em => write!(f, "em"),
285
133
            Rem => write!(f, "rem"),
286
108
            In => write!(f, "in"),
287
106
            Cm => write!(f, "cm"),
288
106
            Mm => write!(f, "mm"),
289
266
            Percent => write!(f, "%"),
290
105
            Vw => write!(f, "vw"),
291
117
            Vh => write!(f, "vh"),
292
68
            Vmin => write!(f, "vmin"),
293
104
            Vmax => write!(f, "vmax"),
294
        }
295
327546
    }
296
}
297

            
298
/// # Errors
299
///
300
/// Returns an error if `input` is not a valid CSS `float-value` value.
301
6570
pub fn parse_float_value(input: &str) -> Result<FloatValue, ParseFloatError> {
302
6570
    Ok(FloatValue::new(input.trim().parse::<f32>()?))
303
6570
}
304
#[allow(variant_size_differences)]
305
// repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size
306
// disparity accepted
307
#[derive(Clone, PartialEq, Eq)]
308
#[repr(C, u8)]
309
pub enum PercentageParseError {
310
    ValueParseErr(crate::props::basic::error::ParseFloatError),
311
    NoPercentSign,
312
    InvalidUnit(AzString),
313
}
314

            
315
impl_debug_as_display!(PercentageParseError);
316

            
317
impl From<ParseFloatError> for PercentageParseError {
318
    fn from(e: ParseFloatError) -> Self {
319
        Self::ValueParseErr(crate::props::basic::error::ParseFloatError::from(e))
320
    }
321
}
322

            
323
impl_display! { PercentageParseError, {
324
    ValueParseErr(e) => format!("\"{}\"", e),
325
    NoPercentSign => format!("No percent sign after number"),
326
    InvalidUnit(u) => format!("Error parsing percentage: invalid unit \"{}\"", u.as_str()),
327
}}
328
#[allow(variant_size_differences)]
329
// repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size
330
// disparity accepted
331
#[derive(Debug, Clone, PartialEq, Eq)]
332
#[repr(C, u8)]
333
pub enum PercentageParseErrorOwned {
334
    ValueParseErr(crate::props::basic::error::ParseFloatError),
335
    NoPercentSign,
336
    InvalidUnit(AzString),
337
}
338

            
339
impl PercentageParseError {
340
    #[must_use]
341
14
    pub fn to_contained(&self) -> PercentageParseErrorOwned {
342
14
        match self {
343
3
            Self::ValueParseErr(e) => PercentageParseErrorOwned::ValueParseErr(*e),
344
3
            Self::NoPercentSign => PercentageParseErrorOwned::NoPercentSign,
345
8
            Self::InvalidUnit(u) => PercentageParseErrorOwned::InvalidUnit(u.clone()),
346
        }
347
14
    }
348
}
349

            
350
impl PercentageParseErrorOwned {
351
    #[must_use]
352
14
    pub fn to_shared(&self) -> PercentageParseError {
353
14
        match self {
354
3
            Self::ValueParseErr(e) => PercentageParseError::ValueParseErr(*e),
355
3
            Self::NoPercentSign => PercentageParseError::NoPercentSign,
356
8
            Self::InvalidUnit(u) => PercentageParseError::InvalidUnit(u.clone()),
357
        }
358
14
    }
359
}
360

            
361
/// Parse "1.2" or "120%" (similar to `parse_pixel_value`)
362
/// # Errors
363
///
364
/// Returns an error if `input` is not a valid CSS `percentage-value` value.
365
19483
pub fn parse_percentage_value(input: &str) -> Result<PercentageValue, PercentageParseError> {
366
19483
    let input = input.trim();
367

            
368
19483
    if input.is_empty() {
369
15
        return Err(PercentageParseError::ValueParseErr(
370
15
            crate::props::basic::error::ParseFloatError::from(
371
15
                "empty string".parse::<f32>().unwrap_err(),
372
15
            ),
373
15
        ));
374
19468
    }
375

            
376
19468
    let mut split_pos = 0;
377
19468
    let mut found_numeric = false;
378
1830955
    for (idx, ch) in input.char_indices() {
379
1830955
        if ch.is_numeric() || ch == '.' || ch == '-' {
380
447356
            // Advance past the *whole* char: `is_numeric()` matches multi-byte
381
447356
            // Unicode digits (½ U+00BD, ٥ U+0665, 5 U+FF15). Using `idx + 1`
382
447356
            // would land inside the codepoint and panic on the slice below.
383
447356
            split_pos = idx + ch.len_utf8();
384
447356
            found_numeric = true;
385
1401902
        }
386
    }
387

            
388
19468
    if !found_numeric {
389
81
        return Err(PercentageParseError::ValueParseErr(
390
81
            crate::props::basic::error::ParseFloatError::from(
391
81
                "no numeric value".parse::<f32>().unwrap_err(),
392
81
            ),
393
81
        ));
394
19387
    }
395

            
396
19387
    let unit = input[split_pos..].trim();
397
19387
    let mut number = input[..split_pos].trim().parse::<f32>().map_err(|e| {
398
34
        PercentageParseError::ValueParseErr(crate::props::basic::error::ParseFloatError::from(e))
399
34
    })?;
400

            
401
19353
    match unit {
402
19353
        "" => {
403
6290
            number *= 100.0;
404
6290
        } // 0.5 => 50%
405
13063
        "%" => {} // 50% => PercentageValue(50.0)
406
242
        other => {
407
242
            return Err(PercentageParseError::InvalidUnit(other.to_string().into()));
408
        }
409
    }
410

            
411
19111
    Ok(PercentageValue::new(number))
412
19483
}
413

            
414
#[cfg(all(test, feature = "parser"))]
415
mod tests {
416
    // Tests assert that parsed values equal the exact source literals.
417
    #![allow(clippy::float_cmp)]
418
    use super::*;
419

            
420
    #[test]
421
1
    fn test_parse_float_value() {
422
1
        assert_eq!(parse_float_value("10").unwrap().get(), 10.0);
423
1
        assert_eq!(parse_float_value("2.5").unwrap().get(), 2.5);
424
1
        assert_eq!(parse_float_value("-50.2").unwrap().get(), -50.2);
425
1
        assert_eq!(parse_float_value("  0  ").unwrap().get(), 0.0);
426
1
        assert!(parse_float_value("10a").is_err());
427
1
        assert!(parse_float_value("").is_err());
428
1
    }
429

            
430
    #[test]
431
1
    fn test_parse_percentage_value() {
432
        // With percent sign
433
1
        assert_eq!(parse_percentage_value("50%").unwrap().normalized(), 0.5);
434
1
        assert_eq!(parse_percentage_value("120%").unwrap().normalized(), 1.2);
435
1
        assert_eq!(parse_percentage_value("-25%").unwrap().normalized(), -0.25);
436
1
        assert_eq!(
437
1
            parse_percentage_value("  75.5%  ").unwrap().normalized(),
438
            0.755
439
        );
440

            
441
        // As a ratio
442
1
        assert!((parse_percentage_value("0.5").unwrap().normalized() - 0.5).abs() < 1e-6);
443
1
        assert!((parse_percentage_value("1.2").unwrap().normalized() - 1.2).abs() < 1e-6);
444
1
        assert!((parse_percentage_value("1").unwrap().normalized() - 1.0).abs() < 1e-6);
445

            
446
        // Errors
447
1
        assert!(matches!(
448
1
            parse_percentage_value("50px").err().unwrap(),
449
            PercentageParseError::InvalidUnit(_)
450
        ));
451
1
        assert!(parse_percentage_value("fifty%").is_err());
452
1
        assert!(parse_percentage_value("").is_err());
453
1
    }
454

            
455
    #[test]
456
1
    fn test_const_new_fractional_single_digit() {
457
        // Single digit post_comma (1 decimal place)
458
1
        let val = FloatValue::const_new_fractional(1, 5);
459
1
        assert_eq!(val.get(), 1.5);
460

            
461
1
        let val = FloatValue::const_new_fractional(0, 5);
462
1
        assert_eq!(val.get(), 0.5);
463

            
464
1
        let val = FloatValue::const_new_fractional(2, 3);
465
1
        assert_eq!(val.get(), 2.3);
466

            
467
1
        let val = FloatValue::const_new_fractional(0, 0);
468
1
        assert_eq!(val.get(), 0.0);
469

            
470
1
        let val = FloatValue::const_new_fractional(10, 9);
471
1
        assert_eq!(val.get(), 10.9);
472
1
    }
473

            
474
    #[test]
475
1
    fn test_const_new_fractional_two_digits() {
476
        // Two digits post_comma (2 decimal places)
477
1
        let val = FloatValue::const_new_fractional(0, 83);
478
1
        assert!((val.get() - 0.83).abs() < 0.001);
479

            
480
1
        let val = FloatValue::const_new_fractional(1, 17);
481
1
        assert!((val.get() - 1.17).abs() < 0.001);
482

            
483
1
        let val = FloatValue::const_new_fractional(1, 52);
484
1
        assert!((val.get() - 1.52).abs() < 0.001);
485

            
486
1
        let val = FloatValue::const_new_fractional(0, 33);
487
1
        assert!((val.get() - 0.33).abs() < 0.001);
488

            
489
1
        let val = FloatValue::const_new_fractional(2, 67);
490
1
        assert!((val.get() - 2.67).abs() < 0.001);
491

            
492
1
        let val = FloatValue::const_new_fractional(0, 10);
493
1
        assert!((val.get() - 0.10).abs() < 0.001);
494

            
495
1
        let val = FloatValue::const_new_fractional(0, 99);
496
1
        assert!((val.get() - 0.99).abs() < 0.001);
497
1
    }
498

            
499
    #[test]
500
1
    fn test_const_new_fractional_three_digits() {
501
        // Three digits post_comma (3 decimal places)
502
1
        let val = FloatValue::const_new_fractional(1, 523);
503
1
        assert!((val.get() - 1.523).abs() < 0.001);
504

            
505
1
        let val = FloatValue::const_new_fractional(0, 123);
506
1
        assert!((val.get() - 0.123).abs() < 0.001);
507

            
508
1
        let val = FloatValue::const_new_fractional(2, 999);
509
1
        assert!((val.get() - 2.999).abs() < 0.001);
510

            
511
1
        let val = FloatValue::const_new_fractional(0, 100);
512
1
        assert!((val.get() - 0.100).abs() < 0.001);
513

            
514
1
        let val = FloatValue::const_new_fractional(5, 1);
515
1
        assert!((val.get() - 5.1).abs() < 0.001);
516
1
    }
517

            
518
    #[test]
519
1
    fn test_const_new_fractional_truncation() {
520
        // More than 3 digits should be truncated (not rounded)
521

            
522
        // 4 digits: 5234 → 523 → 0.523
523
1
        let val = FloatValue::const_new_fractional(0, 5234);
524
1
        assert!((val.get() - 0.523).abs() < 0.001);
525

            
526
        // 5 digits: 12345 → 123 → 0.123
527
1
        let val = FloatValue::const_new_fractional(1, 12345);
528
1
        assert!((val.get() - 1.123).abs() < 0.001);
529

            
530
        // 6 digits: 123456 → 123 → 1.123
531
1
        let val = FloatValue::const_new_fractional(1, 123_456);
532
1
        assert!((val.get() - 1.123).abs() < 0.001);
533

            
534
        // 7 digits: 9876543 → 987 → 0.987
535
1
        let val = FloatValue::const_new_fractional(0, 9_876_543);
536
1
        assert!((val.get() - 0.987).abs() < 0.001);
537

            
538
        // 10 digits
539
1
        let val = FloatValue::const_new_fractional(2, 1_234_567_890);
540
1
        assert!((val.get() - 2.123).abs() < 0.001);
541
1
    }
542

            
543
    #[test]
544
1
    fn test_const_new_fractional_negative() {
545
        // Negative pre_comma values
546
1
        let val = FloatValue::const_new_fractional(-1, 5);
547
1
        assert_eq!(val.get(), -1.5);
548

            
549
1
        let val = FloatValue::const_new_fractional(0, 83);
550
1
        assert!((val.get() - 0.83).abs() < 0.001);
551

            
552
1
        let val = FloatValue::const_new_fractional(-2, 123);
553
1
        assert!((val.get() - -2.123).abs() < 0.001);
554

            
555
        // Negative post_comma (unusual case - treated as negative fractional part)
556
1
        let val = FloatValue::const_new_fractional(1, -5);
557
1
        assert_eq!(val.get(), 0.5); // 1 + (-0.5) = 0.5
558

            
559
1
        let val = FloatValue::const_new_fractional(0, -50);
560
1
        assert!((val.get() - -0.5).abs() < 0.001); // 0 + (-0.5) = -0.5
561
1
    }
562

            
563
    #[test]
564
1
    fn test_const_new_fractional_edge_cases() {
565
        // Zero
566
1
        let val = FloatValue::const_new_fractional(0, 0);
567
1
        assert_eq!(val.get(), 0.0);
568

            
569
        // Large integer part
570
1
        let val = FloatValue::const_new_fractional(100, 5);
571
1
        assert_eq!(val.get(), 100.5);
572

            
573
1
        let val = FloatValue::const_new_fractional(1000, 99);
574
1
        assert!((val.get() - 1000.99).abs() < 0.001);
575

            
576
        // Maximum precision (3 digits)
577
1
        let val = FloatValue::const_new_fractional(0, 999);
578
1
        assert!((val.get() - 0.999).abs() < 0.001);
579

            
580
        // Small fractional values
581
1
        let val = FloatValue::const_new_fractional(1, 1);
582
1
        assert!((val.get() - 1.1).abs() < 0.001);
583

            
584
1
        let val = FloatValue::const_new_fractional(1, 10);
585
1
        assert!((val.get() - 1.10).abs() < 0.001);
586
1
    }
587

            
588
    #[test]
589
1
    fn test_const_new_fractional_ua_css_values() {
590
        // Test actual values used in ua_css.rs
591

            
592
        // H1: 2em
593
1
        let val = FloatValue::const_new_fractional(2, 0);
594
1
        assert_eq!(val.get(), 2.0);
595

            
596
        // H2: 1.5em
597
1
        let val = FloatValue::const_new_fractional(1, 5);
598
1
        assert_eq!(val.get(), 1.5);
599

            
600
        // H3: 1.17em
601
1
        let val = FloatValue::const_new_fractional(1, 17);
602
1
        assert!((val.get() - 1.17).abs() < 0.001);
603

            
604
        // H4: 1em
605
1
        let val = FloatValue::const_new_fractional(1, 0);
606
1
        assert_eq!(val.get(), 1.0);
607

            
608
        // H5: 0.83em
609
1
        let val = FloatValue::const_new_fractional(0, 83);
610
1
        assert!((val.get() - 0.83).abs() < 0.001);
611

            
612
        // H6: 0.67em
613
1
        let val = FloatValue::const_new_fractional(0, 67);
614
1
        assert!((val.get() - 0.67).abs() < 0.001);
615

            
616
        // Margins: 0.67em
617
1
        let val = FloatValue::const_new_fractional(0, 67);
618
1
        assert!((val.get() - 0.67).abs() < 0.001);
619

            
620
        // Margins: 0.83em
621
1
        let val = FloatValue::const_new_fractional(0, 83);
622
1
        assert!((val.get() - 0.83).abs() < 0.001);
623

            
624
        // Margins: 1.33em
625
1
        let val = FloatValue::const_new_fractional(1, 33);
626
1
        assert!((val.get() - 1.33).abs() < 0.001);
627

            
628
        // Margins: 1.67em
629
1
        let val = FloatValue::const_new_fractional(1, 67);
630
1
        assert!((val.get() - 1.67).abs() < 0.001);
631

            
632
        // Margins: 2.33em
633
1
        let val = FloatValue::const_new_fractional(2, 33);
634
1
        assert!((val.get() - 2.33).abs() < 0.001);
635
1
    }
636

            
637
    #[test]
638
1
    fn test_const_new_fractional_consistency() {
639
        // Verify consistency between const_new_fractional and new()
640

            
641
1
        let const_val = FloatValue::const_new_fractional(1, 5);
642
1
        let runtime_val = FloatValue::new(1.5);
643
1
        assert_eq!(const_val.get(), runtime_val.get());
644

            
645
1
        let const_val = FloatValue::const_new_fractional(0, 83);
646
1
        let runtime_val = FloatValue::new(0.83);
647
1
        assert!((const_val.get() - runtime_val.get()).abs() < 0.001);
648

            
649
1
        let const_val = FloatValue::const_new_fractional(1, 523);
650
1
        let runtime_val = FloatValue::new(1.523);
651
1
        assert!((const_val.get() - runtime_val.get()).abs() < 0.001);
652

            
653
1
        let const_val = FloatValue::const_new_fractional(2, 99);
654
1
        let runtime_val = FloatValue::new(2.99);
655
1
        assert!((const_val.get() - runtime_val.get()).abs() < 0.001);
656
1
    }
657
}
658

            
659
#[cfg(test)]
660
#[allow(
661
    clippy::float_cmp,
662
    clippy::unreadable_literal,
663
    clippy::excessive_precision
664
)]
665
mod autotest_generated {
666
    use std::{
667
        collections::{hash_map::DefaultHasher, HashSet},
668
        hash::{Hash, Hasher},
669
    };
670

            
671
    use super::*;
672
    use crate::props::basic::error::ParseFloatError as CssParseFloatError;
673

            
674
    /// Largest `isize` that `const_new` can scale by `FP_PRECISION_MULTIPLIER`
675
    /// without overflowing the multiplication.
676
    const MAX_SAFE_CONST_NEW: isize = isize::MAX / 1000;
677
    const MIN_SAFE_CONST_NEW: isize = isize::MIN / 1000;
678

            
679
    fn hash_of<T: Hash>(v: &T) -> u64 {
680
        let mut h = DefaultHasher::new();
681
        v.hash(&mut h);
682
        h.finish()
683
    }
684

            
685
    // ------------------------------------------------------- FloatValue::new ---
686

            
687
    #[test]
688
    fn float_value_new_never_produces_a_non_finite_get() {
689
        // `get()` decodes an isize, so it must be finite for *every* input,
690
        // including the ones that overflow the f32 multiply inside `new()`.
691
        for v in [
692
            f32::NAN,
693
            f32::INFINITY,
694
            f32::NEG_INFINITY,
695
            f32::MAX,
696
            f32::MIN,
697
            f32::MIN_POSITIVE,
698
            -f32::MIN_POSITIVE,
699
            0.0,
700
            -0.0,
701
            1e30,
702
            -1e30,
703
        ] {
704
            let got = FloatValue::new(v).get();
705
            assert!(
706
                got.is_finite(),
707
                "FloatValue::new({v}).get() leaked a non-finite value: {got}"
708
            );
709
        }
710
    }
711

            
712
    #[test]
713
    fn float_value_new_saturates_at_the_isize_bounds() {
714
        // f32 -> isize `as` casts saturate; +inf/-inf and anything that overflows
715
        // the *1000 multiply must clamp instead of wrapping.
716
        assert_eq!(FloatValue::new(f32::INFINITY).number(), isize::MAX);
717
        assert_eq!(FloatValue::new(f32::NEG_INFINITY).number(), isize::MIN);
718
        // f32::MAX * 1000.0 overflows to +inf before the cast.
719
        assert_eq!(FloatValue::new(f32::MAX).number(), isize::MAX);
720
        assert_eq!(FloatValue::new(f32::MIN).number(), isize::MIN);
721
    }
722

            
723
    #[test]
724
    fn float_value_new_collapses_nan_to_zero() {
725
        // NaN `as isize` is defined to be 0 — assert it, so a future hand-rolled
726
        // cast that panics or wraps is caught.
727
        let nan = FloatValue::new(f32::NAN);
728
        assert_eq!(nan.number(), 0);
729
        assert_eq!(nan.get(), 0.0);
730
        // ...and NaN is therefore *equal* to the default value, not unequal-to-itself.
731
        assert_eq!(nan, FloatValue::default());
732
        assert_eq!(hash_of(&nan), hash_of(&FloatValue::default()));
733
    }
734

            
735
    #[test]
736
    fn float_value_new_does_not_leak_negative_zero() {
737
        let neg_zero = FloatValue::new(-0.0);
738
        assert_eq!(neg_zero.number(), 0);
739
        assert!(
740
            neg_zero.get().is_sign_positive(),
741
            "-0.0 round-tripped back out as a negative zero"
742
        );
743
        assert_eq!(neg_zero, FloatValue::new(0.0));
744
    }
745

            
746
    #[test]
747
    fn float_value_new_underflows_subnormals_to_zero() {
748
        // Anything below 1/1000 truncates away entirely.
749
        assert_eq!(FloatValue::new(f32::MIN_POSITIVE).number(), 0);
750
        assert_eq!(FloatValue::new(1e-30).number(), 0);
751
        assert_eq!(FloatValue::new(0.0009).number(), 0);
752
    }
753

            
754
    #[test]
755
    fn float_value_new_truncates_toward_zero_not_to_nearest() {
756
        // Encoding is `(v * 1000) as isize`, i.e. truncation — 0.0019 must NOT
757
        // round up to 0.002, and the negative side must truncate toward zero too.
758
        assert_eq!(FloatValue::new(0.0019).number(), 1);
759
        assert_eq!(FloatValue::new(0.0019).get(), 0.001);
760
        assert_eq!(FloatValue::new(-0.0019).number(), -1);
761
        assert_eq!(FloatValue::new(-0.0019).get(), -0.001);
762
    }
763

            
764
    #[test]
765
    fn float_value_quantizes_below_the_precision_limit() {
766
        // The type's whole purpose: sub-precision differences collapse, so that
767
        // Eq/Hash are stable. 4th decimal is dropped, 3rd is kept.
768
        assert_eq!(FloatValue::new(1.0001), FloatValue::new(1.0));
769
        assert_ne!(FloatValue::new(1.001), FloatValue::new(1.0));
770
    }
771

            
772
    #[test]
773
    fn float_value_eq_implies_equal_hash() {
774
        // Eq + Hash must agree — the type exists purely to be hash-able.
775
        for (a, b) in [
776
            (1.0_f32, 1.0004_f32),
777
            (-2.5, -2.5001),
778
            (0.0, -0.0),
779
            (f32::NAN, f32::NAN),
780
        ] {
781
            let (a, b) = (FloatValue::new(a), FloatValue::new(b));
782
            assert_eq!(a, b, "expected {a:?} == {b:?}");
783
            assert_eq!(hash_of(&a), hash_of(&b), "{a:?} == {b:?} but hashes differ");
784
        }
785
    }
786

            
787
    #[test]
788
    fn float_value_ord_agrees_with_get() {
789
        // Ord is derived on the encoded isize; it must stay monotonic w.r.t. get().
790
        let mut vals: Vec<FloatValue> = [3.5_f32, -1.0, 0.0, 100.25, -0.001, 2.0]
791
            .into_iter()
792
            .map(FloatValue::new)
793
            .collect();
794
        vals.sort();
795
        for w in vals.windows(2) {
796
            assert!(
797
                w[0].get() <= w[1].get(),
798
                "sort order disagrees with get(): {:?} then {:?}",
799
                w[0],
800
                w[1]
801
            );
802
        }
803
    }
804

            
805
    // -------------------------------------------------- FloatValue::const_new ---
806

            
807
    #[test]
808
    fn const_new_matches_the_documented_encoding() {
809
        assert_eq!(FP_PRECISION_MULTIPLIER, 1000.0);
810
        assert_eq!(FloatValue::const_new(0).number(), 0);
811
        assert_eq!(FloatValue::const_new(1).number(), 1000);
812
        assert_eq!(FloatValue::const_new(-1).number(), -1000);
813
        assert_eq!(FloatValue::const_new(0), FloatValue::default());
814
    }
815

            
816
    #[test]
817
    fn const_new_agrees_with_new_for_whole_numbers() {
818
        for n in [-1000_isize, -7, -1, 0, 1, 7, 1000, 65_536] {
819
            let c = FloatValue::const_new(n);
820
            let r = FloatValue::new(n as f32);
821
            assert_eq!(
822
                c, r,
823
                "const_new({n}) = {c:?} disagrees with new({n}.0) = {r:?}"
824
            );
825
        }
826
    }
827

            
828
    #[test]
829
    fn const_new_survives_the_largest_non_overflowing_inputs() {
830
        // `const_new` is a bare `value * 1000`, so isize::MAX/1000 is the last
831
        // input it can take without overflowing. Pin that boundary: anything at
832
        // or below it must be exact and must not panic.
833
        let hi = FloatValue::const_new(MAX_SAFE_CONST_NEW);
834
        assert_eq!(hi.number(), MAX_SAFE_CONST_NEW * 1000);
835
        assert!(hi.get().is_finite());
836

            
837
        let lo = FloatValue::const_new(MIN_SAFE_CONST_NEW);
838
        assert_eq!(lo.number(), MIN_SAFE_CONST_NEW * 1000);
839
        assert!(lo.get().is_finite());
840

            
841
        assert!(lo < hi);
842
    }
843

            
844
    // --------------------------------------- FloatValue::const_new_fractional ---
845

            
846
    #[test]
847
    fn const_new_fractional_zero_and_sign_handling() {
848
        assert_eq!(FloatValue::const_new_fractional(0, 0).number(), 0);
849
        // Negative pre_comma pulls the fraction negative too (-1.5, not -0.5).
850
        assert_eq!(FloatValue::const_new_fractional(-1, 5).number(), -1500);
851
        // Negative post_comma subtracts from a positive pre_comma.
852
        assert_eq!(FloatValue::const_new_fractional(1, -5).number(), 500);
853
        assert_eq!(FloatValue::const_new_fractional(0, -50).number(), -500);
854
    }
855

            
856
    #[test]
857
    fn const_new_fractional_never_panics_on_extreme_post_comma() {
858
        // post_comma is an unbounded isize; the digit-count ladder must not
859
        // divide by zero, overflow, or produce a non-finite decode.
860
        for post in [
861
            9_isize,
862
            99,
863
            999,
864
            9_999,
865
            99_999,
866
            999_999,
867
            9_999_999,
868
            99_999_999,
869
            999_999_999,
870
            isize::MAX,
871
        ] {
872
            let v = FloatValue::const_new_fractional(0, post);
873
            assert!(
874
                v.get().is_finite(),
875
                "const_new_fractional(0, {post}) decoded to a non-finite value"
876
            );
877
        }
878
    }
879

            
880
    #[test]
881
    fn const_new_fractional_truncates_to_three_decimals() {
882
        // Documented: only the first 3 digits of post_comma are used, truncated.
883
        assert_eq!(FloatValue::const_new_fractional(0, 5234).number(), 523);
884
        assert_eq!(FloatValue::const_new_fractional(1, 123_456).number(), 1123);
885
        // 10 digits is the largest post_comma the ladder still truncates correctly.
886
        assert_eq!(
887
            FloatValue::const_new_fractional(2, 1_234_567_890).number(),
888
            2123
889
        );
890
    }
891

            
892
    #[test]
893
    fn const_new_fractional_boundary_between_digit_buckets() {
894
        // Every `abs_post < 10^k` bucket edge: 9/10, 99/100, 999/1000.
895
        assert_eq!(FloatValue::const_new_fractional(0, 9).get(), 0.9);
896
        assert_eq!(FloatValue::const_new_fractional(0, 10).get(), 0.1);
897
        assert_eq!(FloatValue::const_new_fractional(0, 99).get(), 0.99);
898
        assert_eq!(FloatValue::const_new_fractional(0, 100).get(), 0.1);
899
        assert_eq!(FloatValue::const_new_fractional(0, 999).get(), 0.999);
900
    }
901

            
902
    #[test]
903
    fn const_new_fractional_cannot_express_a_leading_zero_fraction() {
904
        // The bucket is picked from the *digit count* of post_comma, so a leading
905
        // zero is unrepresentable in an integer argument: 0.05 has no spelling.
906
        // Both of the obvious attempts land on 0.5 instead. Pin the footgun so a
907
        // caller writing `(0, 50)` for "0.05em" is caught by this test, not by a
908
        // 10x-too-large margin on screen.
909
        assert_eq!(FloatValue::const_new_fractional(0, 5).get(), 0.5);
910
        assert_eq!(FloatValue::const_new_fractional(0, 50).get(), 0.5);
911
        assert_eq!(FloatValue::const_new_fractional(0, 500).get(), 0.5);
912
    }
913

            
914
    // ------------------------------------------------- FloatValue::interpolate ---
915

            
916
    #[test]
917
    fn interpolate_endpoints_are_exact() {
918
        let a = FloatValue::new(0.0);
919
        let b = FloatValue::new(10.0);
920
        assert_eq!(a.interpolate(&b, 0.0), a);
921
        assert_eq!(a.interpolate(&b, 1.0), b);
922
        assert_eq!(a.interpolate(&b, 0.5).get(), 5.0);
923
        // Reversed direction.
924
        assert_eq!(b.interpolate(&a, 0.5).get(), 5.0);
925
    }
926

            
927
    #[test]
928
    fn interpolate_extrapolates_outside_zero_one() {
929
        // t is not clamped — assert the (documented-by-absence) extrapolation
930
        // rather than silently assuming a clamp that isn't there.
931
        let a = FloatValue::new(0.0);
932
        let b = FloatValue::new(10.0);
933
        assert_eq!(a.interpolate(&b, 2.0).get(), 20.0);
934
        assert_eq!(a.interpolate(&b, -1.0).get(), -10.0);
935
    }
936

            
937
    #[test]
938
    fn interpolate_with_nan_or_infinite_t_stays_finite() {
939
        let a = FloatValue::new(0.0);
940
        let b = FloatValue::new(10.0);
941

            
942
        // NaN t -> NaN interpolant -> `as isize` collapses to 0.
943
        assert_eq!(a.interpolate(&b, f32::NAN).number(), 0);
944

            
945
        // +inf t with a non-zero delta -> +inf -> saturates to isize::MAX.
946
        assert_eq!(a.interpolate(&b, f32::INFINITY).number(), isize::MAX);
947
        assert_eq!(a.interpolate(&b, f32::NEG_INFINITY).number(), isize::MIN);
948

            
949
        // inf * 0.0 delta is NaN -> collapses to 0 (self is NOT preserved here).
950
        assert_eq!(a.interpolate(&a, f32::INFINITY).number(), 0);
951

            
952
        for t in [
953
            f32::NAN,
954
            f32::INFINITY,
955
            f32::NEG_INFINITY,
956
            f32::MAX,
957
            f32::MIN,
958
        ] {
959
            assert!(
960
                a.interpolate(&b, t).get().is_finite(),
961
                "interpolate(t = {t}) leaked a non-finite value"
962
            );
963
        }
964
    }
965

            
966
    #[test]
967
    fn interpolate_between_saturated_extremes_does_not_panic() {
968
        let lo = FloatValue::new(f32::NEG_INFINITY); // isize::MIN
969
        let hi = FloatValue::new(f32::INFINITY); // isize::MAX
970
        for t in [0.0, 0.5, 1.0, -1.0, 2.0, f32::NAN] {
971
            assert!(lo.interpolate(&hi, t).get().is_finite());
972
            assert!(hi.interpolate(&lo, t).get().is_finite());
973
        }
974
    }
975

            
976
    // -------------------------------------------------------- round-tripping ---
977

            
978
    #[test]
979
    fn float_value_round_trips_through_display_and_parse() {
980
        // encode == decode: every value that is exactly representable at 3
981
        // decimals must survive Display -> parse_float_value -> FloatValue.
982
        for v in [0.0_f32, 1.5, -2.25, 100.0, 0.001, -0.001, 999.999, -0.5] {
983
            let fv = FloatValue::new(v);
984
            let round_tripped = parse_float_value(&fv.to_string())
985
                .unwrap_or_else(|e| panic!("Display of {fv:?} did not re-parse: {e}"));
986
            assert_eq!(
987
                fv, round_tripped,
988
                "round-trip changed {fv:?} into {round_tripped:?}"
989
            );
990
        }
991
    }
992

            
993
    #[test]
994
    fn float_value_number_round_trips_through_get() {
995
        // number() is the compact-cache encoding; get() must be its exact inverse
996
        // (scaled) for values inside the f32-exact integer range.
997
        for raw in [0_isize, 1, -1, 1500, -1500, 999_999, -999_999] {
998
            let fv = FloatValue::new(raw as f32 / 1000.0);
999
            assert_eq!(fv.number(), raw, "number() lost the encoding for {raw}");
        }
    }
    #[test]
    fn float_value_display_and_debug_agree() {
        // Debug is hand-written to forward to Display; a divergence means the
        // manual impl drifted.
        for v in [0.0_f32, -1.25, 1e6, f32::INFINITY, f32::NAN] {
            let fv = FloatValue::new(v);
            assert_eq!(format!("{fv:?}"), format!("{fv}"));
            assert!(!format!("{fv}").is_empty());
            // Whatever we print must itself be a parseable float.
            assert!(fv.to_string().parse::<f32>().is_ok());
        }
        assert_eq!(FloatValue::default().to_string(), "0");
    }
    // ---------------------------------------------------------- SizeMetric ---
    #[test]
    fn size_metric_display_is_non_empty_and_unique() {
        use SizeMetric::{Cm, Em, In, Mm, Percent, Pt, Px, Rem, Vh, Vmax, Vmin, Vw};
        let all = [Px, Pt, Em, Rem, In, Cm, Mm, Percent, Vw, Vh, Vmin, Vmax];
        let mut seen = HashSet::new();
        for m in all {
            let s = m.to_string();
            assert!(!s.is_empty(), "{m:?} renders as an empty string");
            assert!(
                seen.insert(s.clone()),
                "two SizeMetric variants both render as {s:?} (copy-paste in Display)"
            );
        }
        assert_eq!(seen.len(), all.len());
    }
    #[test]
    fn size_metric_display_matches_the_css_unit_tokens() {
        assert_eq!(SizeMetric::Px.to_string(), "px");
        assert_eq!(SizeMetric::Pt.to_string(), "pt");
        assert_eq!(SizeMetric::Em.to_string(), "em");
        assert_eq!(SizeMetric::Rem.to_string(), "rem");
        assert_eq!(SizeMetric::In.to_string(), "in");
        assert_eq!(SizeMetric::Cm.to_string(), "cm");
        assert_eq!(SizeMetric::Mm.to_string(), "mm");
        assert_eq!(SizeMetric::Percent.to_string(), "%");
        assert_eq!(SizeMetric::Vw.to_string(), "vw");
        assert_eq!(SizeMetric::Vh.to_string(), "vh");
        assert_eq!(SizeMetric::Vmin.to_string(), "vmin");
        assert_eq!(SizeMetric::Vmax.to_string(), "vmax");
    }
    #[test]
    fn size_metric_default_is_px() {
        assert_eq!(SizeMetric::default(), SizeMetric::Px);
        assert_eq!(SizeMetric::default().to_string(), "px");
    }
    // -------------------------------------------------------- PercentageValue ---
    #[test]
    fn percentage_value_normalized_divides_by_a_hundred() {
        assert_eq!(PercentageValue::new(50.0).normalized(), 0.5);
        assert_eq!(PercentageValue::new(0.0).normalized(), 0.0);
        assert_eq!(PercentageValue::new(-25.0).normalized(), -0.25);
        assert_eq!(PercentageValue::const_new(100).normalized(), 1.0);
        assert_eq!(PercentageValue::default().normalized(), 0.0);
    }
    #[test]
    fn percentage_value_normalized_is_always_finite() {
        for v in [
            f32::NAN,
            f32::INFINITY,
            f32::NEG_INFINITY,
            f32::MAX,
            f32::MIN,
        ] {
            let n = PercentageValue::new(v).normalized();
            assert!(
                n.is_finite(),
                "PercentageValue::new({v}).normalized() leaked {n}"
            );
        }
        // NaN collapses to the default, exactly like FloatValue.
        assert_eq!(PercentageValue::new(f32::NAN), PercentageValue::default());
    }
    #[test]
    fn percentage_value_const_new_boundaries_do_not_panic() {
        assert_eq!(PercentageValue::const_new(0), PercentageValue::default());
        assert!(PercentageValue::const_new(MAX_SAFE_CONST_NEW)
            .normalized()
            .is_finite());
        assert!(PercentageValue::const_new(MIN_SAFE_CONST_NEW)
            .normalized()
            .is_finite());
        assert!(
            PercentageValue::const_new(MIN_SAFE_CONST_NEW)
                < PercentageValue::const_new(MAX_SAFE_CONST_NEW)
        );
    }
    #[test]
    fn percentage_value_const_new_fractional_matches_the_docs() {
        // 100% = const_new_fractional(100, 0); 50.5% = const_new_fractional(50, 5)
        assert_eq!(
            PercentageValue::const_new_fractional(100, 0).normalized(),
            1.0
        );
        assert!((PercentageValue::const_new_fractional(50, 5).normalized() - 0.505).abs() < 1e-5);
        assert_eq!(
            PercentageValue::const_new_fractional(100, 0),
            PercentageValue::const_new(100)
        );
    }
    #[test]
    fn percentage_value_interpolate_endpoints_and_nan() {
        let a = PercentageValue::new(0.0);
        let b = PercentageValue::new(100.0);
        assert_eq!(a.interpolate(&b, 0.0), a);
        assert_eq!(a.interpolate(&b, 1.0), b);
        assert_eq!(a.interpolate(&b, 0.5).normalized(), 0.5);
        // NaN / inf t must not panic and must stay finite.
        assert_eq!(a.interpolate(&b, f32::NAN).normalized(), 0.0);
        assert!(a.interpolate(&b, f32::INFINITY).normalized().is_finite());
        assert!(a
            .interpolate(&b, f32::NEG_INFINITY)
            .normalized()
            .is_finite());
    }
    #[test]
    fn percentage_value_display_round_trips_through_the_parser() {
        for v in [0.0_f32, 50.0, 100.0, 150.0, -25.0, 75.5, 0.5] {
            let p = PercentageValue::new(v);
            let s = p.to_string();
            assert!(s.ends_with('%'), "Display lost the percent sign: {s:?}");
            let back = parse_percentage_value(&s)
                .unwrap_or_else(|e| panic!("Display of {p:?} ({s:?}) did not re-parse: {e}"));
            assert!(
                (back.normalized() - p.normalized()).abs() < 1e-4,
                "round-trip drifted: {p:?} -> {s:?} -> {back:?}"
            );
        }
    }
    // ----------------------------------------------------- parse_float_value ---
    #[test]
    fn parse_float_value_positive_control() {
        assert_eq!(parse_float_value("0").unwrap().number(), 0);
        assert_eq!(parse_float_value("1.5").unwrap().number(), 1500);
        assert_eq!(parse_float_value("-1.5").unwrap().number(), -1500);
        assert_eq!(parse_float_value("+2").unwrap().number(), 2000);
        // Rust's f32 parser accepts these shorthand forms.
        assert_eq!(parse_float_value(".5").unwrap().number(), 500);
        assert_eq!(parse_float_value("5.").unwrap().number(), 5000);
    }
    #[test]
    fn parse_float_value_rejects_empty_and_whitespace() {
        assert!(parse_float_value("").is_err());
        assert!(parse_float_value("   ").is_err());
        assert!(parse_float_value("\t\n\r ").is_err());
    }
    #[test]
    fn parse_float_value_rejects_garbage() {
        for input in [
            "abc",
            "1_000",
            "1,5",
            "0x10",
            "1.2.3",
            "--1",
            "1e",
            "e5",
            "5 5",
            "1/2",
            ";",
            "\0",
            "5;garbage",
            "50px",
            "5%",
        ] {
            assert!(
                parse_float_value(input).is_err(),
                "garbage input {input:?} was accepted"
            );
        }
    }
    #[test]
    fn parse_float_value_trims_but_does_not_tolerate_inner_junk() {
        assert_eq!(parse_float_value("  1.5  ").unwrap().number(), 1500);
        assert!(parse_float_value("1.5 garbage").is_err());
    }
    #[test]
    fn parse_float_value_boundary_numbers_saturate_instead_of_panicking() {
        // -0 must not leak a negative zero out of the encoding.
        assert_eq!(parse_float_value("-0").unwrap().number(), 0);
        assert!(parse_float_value("-0").unwrap().get().is_sign_positive());
        // Rust parses "NaN"/"inf" successfully; the encoding must then defuse them.
        assert_eq!(parse_float_value("NaN").unwrap().number(), 0);
        assert_eq!(parse_float_value("inf").unwrap().number(), isize::MAX);
        assert_eq!(parse_float_value("infinity").unwrap().number(), isize::MAX);
        assert_eq!(parse_float_value("-inf").unwrap().number(), isize::MIN);
        // Overflow of the f32 parse itself is Ok(inf) in Rust, then saturates.
        assert_eq!(parse_float_value("1e400").unwrap().number(), isize::MAX);
        assert_eq!(parse_float_value("-1e400").unwrap().number(), isize::MIN);
        // Underflow is Ok(0.0).
        assert_eq!(parse_float_value("1e-400").unwrap().number(), 0);
        // i64::MAX / f64::MAX as literals: no panic, still finite after decode.
        for input in [
            "9223372036854775807",
            "-9223372036854775808",
            "179769313486231570000000000000000000000000000000000",
        ] {
            let v = parse_float_value(input)
                .unwrap_or_else(|e| panic!("{input:?} should parse as f32, got {e}"));
            assert!(v.get().is_finite(), "{input:?} decoded to {}", v.get());
        }
    }
    #[test]
    fn parse_float_value_unicode_does_not_panic() {
        // Multi-byte input must be rejected, never sliced mid-codepoint.
        for input in [
            "\u{1F600}",  // emoji
            "5\u{1F600}", // digit + emoji
            "\u{0665}",   // ARABIC-INDIC DIGIT FIVE (is_numeric() == true)
            "5\u{0301}",  // digit + combining acute
            "\u{00BD}",   // ½ (No category, is_numeric() == true)
            "\u{FF15}",   // FULLWIDTH DIGIT FIVE
            "\u{200B}5",  // zero-width space + digit
            "\u{2212}5",  // U+2212 MINUS SIGN (not ASCII '-')
        ] {
            assert!(
                parse_float_value(input).is_err(),
                "non-ASCII input {input:?} was accepted as a float"
            );
        }
    }
    #[test]
    fn parse_float_value_extremely_long_input_terminates() {
        // 200k digits: must not hang, must not panic; Rust yields Ok(inf), which
        // then saturates in the encoding.
        let huge = "9".repeat(200_000);
        // Rejecting is acceptable too — just don't panic/hang on the huge input.
        if let Ok(v) = parse_float_value(&huge) {
            assert!(v.get().is_finite(), "200k digits decoded to {}", v.get());
        }
        // Long *garbage* must be rejected rather than scanned quadratically.
        let long_junk = "a".repeat(200_000);
        assert!(parse_float_value(&long_junk).is_err());
    }
    #[test]
    fn parse_float_value_deeply_nested_input_does_not_stack_overflow() {
        let nested = "(".repeat(10_000);
        assert!(parse_float_value(&nested).is_err());
        let nested_pair = format!("{}5{}", "(".repeat(10_000), ")".repeat(10_000));
        assert!(parse_float_value(&nested_pair).is_err());
    }
    // ------------------------------------------------ parse_percentage_value ---
    #[test]
    fn parse_percentage_value_positive_control() {
        assert_eq!(parse_percentage_value("50%").unwrap().normalized(), 0.5);
        assert_eq!(parse_percentage_value("0%").unwrap().normalized(), 0.0);
        assert_eq!(parse_percentage_value("-25%").unwrap().normalized(), -0.25);
        // A bare number is a *ratio*, not a percent: "0.5" == "50%".
        assert_eq!(
            parse_percentage_value("0.5").unwrap(),
            parse_percentage_value("50%").unwrap()
        );
    }
    #[test]
    fn parse_percentage_value_bare_number_is_multiplied_by_a_hundred() {
        // Easy to misread: "50" (no sign) is 5000%, not 50%.
        assert_eq!(parse_percentage_value("50").unwrap().normalized(), 50.0);
        assert_ne!(
            parse_percentage_value("50").unwrap(),
            parse_percentage_value("50%").unwrap()
        );
    }
    #[test]
    fn parse_percentage_value_rejects_empty_and_whitespace() {
        assert!(matches!(
            parse_percentage_value(""),
            Err(PercentageParseError::ValueParseErr(_))
        ));
        assert!(matches!(
            parse_percentage_value("   "),
            Err(PercentageParseError::ValueParseErr(_))
        ));
        assert!(matches!(
            parse_percentage_value("\t\n"),
            Err(PercentageParseError::ValueParseErr(_))
        ));
        assert!(parse_percentage_value("%").is_err());
    }
    #[test]
    fn parse_percentage_value_rejects_garbage_without_panicking() {
        for input in [
            "abc", "fifty%", "%50", "50%%", "5 0 %", "--5%", "1.2.3%", ";", "\0", "NaN", "inf",
            "-inf",
        ] {
            assert!(
                parse_percentage_value(input).is_err(),
                "garbage input {input:?} was accepted"
            );
        }
    }
    #[test]
    fn parse_percentage_value_reports_invalid_units() {
        for (input, unit) in [("50px", "px"), ("50em", "em"), ("1.5rem", "rem")] {
            match parse_percentage_value(input) {
                Err(PercentageParseError::InvalidUnit(u)) => assert_eq!(u.as_str(), unit),
                other => panic!("{input:?} should be InvalidUnit({unit:?}), got {other:?}"),
            }
        }
    }
    #[test]
    fn parse_percentage_value_trims_leading_and_trailing_whitespace() {
        assert_eq!(
            parse_percentage_value("  75.5%  ").unwrap().normalized(),
            0.755
        );
        // Whitespace *between* the number and the unit is trimmed as well.
        assert_eq!(parse_percentage_value("50 %").unwrap().normalized(), 0.5);
    }
    #[test]
    fn parse_percentage_value_boundary_numbers_stay_finite() {
        // -0 must not leak a negative zero.
        let neg_zero = parse_percentage_value("-0%").unwrap();
        assert_eq!(neg_zero.normalized(), 0.0);
        assert!(neg_zero.normalized().is_sign_positive());
        // Overflowing exponent parses to inf, then saturates in the encoding.
        let huge = parse_percentage_value("1e400%").unwrap();
        assert!(
            huge.normalized().is_finite(),
            "1e400% leaked {}",
            huge.normalized()
        );
        let huge_neg = parse_percentage_value("-1e400%").unwrap();
        assert!(huge_neg.normalized().is_finite());
        // Underflowing exponent parses to 0.
        assert_eq!(parse_percentage_value("1e-400%").unwrap().normalized(), 0.0);
        // i64::MAX-sized literal: no panic, still finite.
        let big = parse_percentage_value("9223372036854775807%").unwrap();
        assert!(big.normalized().is_finite());
    }
    #[test]
    fn parse_percentage_value_ascii_unicode_neighbours_do_not_panic() {
        // Multi-byte chars that are NOT `char::is_numeric()` are safe to slice
        // around; they must be rejected, not panic.
        for input in [
            "\u{1F600}",   // emoji only
            "50\u{1F600}", // digits then emoji -> InvalidUnit
            "\u{20AC}50",  // €50 -> unparseable number
            "abc\u{00E9}%",
            "\u{200B}%", // zero-width space
        ] {
            assert!(
                parse_percentage_value(input).is_err(),
                "{input:?} was accepted"
            );
        }
        // The emoji suffix is reported as an invalid unit, not a parse error.
        assert!(matches!(
            parse_percentage_value("50\u{1F600}"),
            Err(PercentageParseError::InvalidUnit(_))
        ));
    }
    #[test]
    fn parse_percentage_value_extremely_long_input_terminates() {
        let huge = format!("{}%", "9".repeat(200_000));
        if let Ok(v) = parse_percentage_value(&huge) {
            assert!(v.normalized().is_finite())
        }
        let long_junk = format!("{}%", "a".repeat(200_000));
        assert!(parse_percentage_value(&long_junk).is_err());
    }
    #[test]
    fn parse_percentage_value_deeply_nested_input_does_not_stack_overflow() {
        assert!(parse_percentage_value(&"(".repeat(10_000)).is_err());
        // A numeric char buried behind 10k brackets: the scanner must still just
        // split and fail on the number, not recurse.
        let nested = format!("{}5%", "(".repeat(10_000));
        assert!(parse_percentage_value(&nested).is_err());
    }
    // --------------------------------------------- PercentageParseError glue ---
    #[test]
    fn percentage_parse_error_round_trips_through_owned() {
        let variants = [
            PercentageParseError::ValueParseErr(CssParseFloatError::Empty),
            PercentageParseError::ValueParseErr(CssParseFloatError::Invalid),
            PercentageParseError::NoPercentSign,
            PercentageParseError::InvalidUnit(String::new().into()),
            PercentageParseError::InvalidUnit("px".to_string().into()),
            // A unit that is itself multi-byte must survive the AzString clone.
            PercentageParseError::InvalidUnit("\u{1F600}".to_string().into()),
        ];
        for e in variants {
            let round_tripped = e.to_contained().to_shared();
            assert_eq!(
                e, round_tripped,
                "to_contained/to_shared is not the identity for {e:?}"
            );
        }
    }
    #[test]
    fn percentage_parse_error_owned_round_trips_through_shared() {
        let variants = [
            PercentageParseErrorOwned::ValueParseErr(CssParseFloatError::Invalid),
            PercentageParseErrorOwned::NoPercentSign,
            PercentageParseErrorOwned::InvalidUnit("vh".to_string().into()),
        ];
        for e in variants {
            assert_eq!(e.to_shared().to_contained(), e);
        }
    }
    #[test]
    fn percentage_parse_error_display_is_non_empty() {
        // Debug forwards to Display (impl_debug_as_display); neither may be empty
        // nor panic, including for an empty invalid unit.
        for e in [
            PercentageParseError::ValueParseErr(CssParseFloatError::Empty),
            PercentageParseError::NoPercentSign,
            PercentageParseError::InvalidUnit(String::new().into()),
        ] {
            let shown = e.to_string();
            assert!(!shown.is_empty(), "{e:?} renders as an empty message");
            assert_eq!(format!("{e:?}"), shown);
        }
    }
    // ---------------------------------------------------- former known bugs ---
    //
    // The two regression tests below pin behaviour these functions used to get
    // wrong (a multi-byte-digit slice panic and a fraction escaping [0, 1)).
    // Both are now fixed and asserted un-ignored.
    #[test]
    fn known_bug_percentage_multibyte_numeric_char_panics() {
        // `char::is_numeric()` is true for Nd/Nl/No — including multi-byte chars
        // like '½' (U+00BD, 2 bytes) and '٥' (U+0665, 2 bytes). The scanner
        // records their *start* byte index, then slices at `split_pos + 1`, which
        // lands inside the codepoint => `input[split_pos..]` panics.
        //
        // Reachable from any author stylesheet (`width: ½%`), so this panics the
        // CSS parser on untrusted input.
        for input in ["\u{00BD}%", "\u{0665}%", "5\u{00BD}", "\u{FF15}%"] {
            assert!(
                parse_percentage_value(input).is_err(),
                "{input:?} should be rejected"
            );
        }
    }
    #[test]
    #[cfg(target_pointer_width = "64")]
    fn known_bug_const_new_fractional_huge_post_comma_escapes_the_fraction() {
        // The digit-count ladder's last arm divides by 10_000_000, which only
        // truncates a 10-digit post_comma down to 3 digits. An 11-digit value
        // keeps 4 digits, a 12-digit value keeps 5, ... so the "fractional" part
        // grows past 1.0 and corrupts the integer part.
        for post in [12_345_678_901_isize, 123_456_789_012, isize::MAX] {
            let frac = FloatValue::const_new_fractional(0, post).get();
            assert!(
                (0.0..1.0).contains(&frac),
                "const_new_fractional(0, {post}) produced {frac}, which is not a fraction"
            );
        }
    }
}