1
//! Shared types for CSS shadow properties (used by both `box-shadow` and `text-shadow`).
2

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

            
7
use crate::props::{
8
    basic::{
9
        color::{parse_css_color, ColorU, CssColorParseError, CssColorParseErrorOwned},
10
        pixel::{
11
            parse_pixel_value_no_percent, CssPixelValueParseError, CssPixelValueParseErrorOwned,
12
            PixelValueNoPercent,
13
        },
14
    },
15
    formatter::PrintAsCssValue,
16
};
17

            
18
/// What direction should a `box-shadow` be clipped in (inset or outset).
19
#[derive(Debug, Default, Copy, Clone, PartialEq, Ord, PartialOrd, Eq, Hash)]
20
#[repr(C)]
21
pub enum BoxShadowClipMode {
22
    #[default]
23
    Outset,
24
    Inset,
25
}
26

            
27
impl fmt::Display for BoxShadowClipMode {
28
11
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29
11
        match self {
30
3
            Self::Outset => Ok(()), // Outset is the default, not written
31
8
            Self::Inset => write!(f, "inset"),
32
        }
33
11
    }
34
}
35

            
36
/// Represents a single CSS shadow value, shared by both `box-shadow` and `text-shadow`.
37
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
38
#[repr(C)]
39
pub struct StyleBoxShadow {
40
    pub offset_x: PixelValueNoPercent,
41
    pub offset_y: PixelValueNoPercent,
42
    pub blur_radius: PixelValueNoPercent,
43
    pub spread_radius: PixelValueNoPercent,
44
    pub clip_mode: BoxShadowClipMode,
45
    pub color: ColorU,
46
}
47

            
48
impl Default for StyleBoxShadow {
49
225
    fn default() -> Self {
50
225
        Self {
51
225
            offset_x: PixelValueNoPercent::default(),
52
225
            offset_y: PixelValueNoPercent::default(),
53
225
            blur_radius: PixelValueNoPercent::default(),
54
225
            spread_radius: PixelValueNoPercent::default(),
55
225
            clip_mode: BoxShadowClipMode::default(),
56
225
            color: ColorU::BLACK,
57
225
        }
58
225
    }
59
}
60

            
61
impl StyleBoxShadow {
62
    /// Scales the pixel values of the shadow for a given DPI factor.
63
36
    pub fn scale_for_dpi(&mut self, scale_factor: f32) {
64
36
        self.offset_x.scale_for_dpi(scale_factor);
65
36
        self.offset_y.scale_for_dpi(scale_factor);
66
36
        self.blur_radius.scale_for_dpi(scale_factor);
67
36
        self.spread_radius.scale_for_dpi(scale_factor);
68
36
    }
69
}
70

            
71
impl PrintAsCssValue for StyleBoxShadow {
72
37
    fn print_as_css_value(&self) -> String {
73
37
        let mut components = Vec::new();
74

            
75
37
        if self.clip_mode == BoxShadowClipMode::Inset {
76
13
            components.push("inset".to_string());
77
24
        }
78
37
        components.push(self.offset_x.to_string());
79
37
        components.push(self.offset_y.to_string());
80

            
81
        // Only print blur, spread, and color if they are not default, for brevity
82
37
        if self.blur_radius.inner.number.get() != 0.0
83
19
            || self.spread_radius.inner.number.get() != 0.0
84
23
        {
85
23
            components.push(self.blur_radius.to_string());
86
23
        }
87
37
        if self.spread_radius.inner.number.get() != 0.0 {
88
12
            components.push(self.spread_radius.to_string());
89
25
        }
90
37
        if self.color != ColorU::BLACK {
91
19
            // Assuming black is the default
92
19
            components.push(self.color.to_hash());
93
19
        }
94

            
95
37
        components.join(" ")
96
37
    }
97
}
98

            
99
// Formatting to Rust code for StyleBoxShadow
100
impl crate::codegen::format::FormatAsRustCode for StyleBoxShadow {
101
9
    fn format_as_rust_code(&self, tabs: usize) -> String {
102
9
        let t = String::from("    ").repeat(tabs);
103
9
        format!(
104
9
            "StyleBoxShadow {{\r\n{}    offset_x: {},\r\n{}    offset_y: {},\r\n{}    color: \
105
9
             {},\r\n{}    blur_radius: {},\r\n{}    spread_radius: {},\r\n{}    clip_mode: \
106
9
             BoxShadowClipMode::{:?},\r\n{}}}",
107
            t,
108
9
            crate::codegen::format::format_pixel_value_no_percent(&self.offset_x),
109
            t,
110
9
            crate::codegen::format::format_pixel_value_no_percent(&self.offset_y),
111
            t,
112
9
            crate::codegen::format::format_color_value(&self.color),
113
            t,
114
9
            crate::codegen::format::format_pixel_value_no_percent(&self.blur_radius),
115
            t,
116
9
            crate::codegen::format::format_pixel_value_no_percent(&self.spread_radius),
117
            t,
118
            self.clip_mode,
119
            t
120
        )
121
9
    }
122
}
123

            
124
// --- PARSER ---
125

            
126
/// Error returned when parsing a CSS shadow value fails.
127
#[derive(Clone, PartialEq)]
128
pub enum CssShadowParseError<'a> {
129
    TooManyOrTooFewComponents(&'a str),
130
    ValueParseErr(CssPixelValueParseError<'a>),
131
    ColorParseError(CssColorParseError<'a>),
132
}
133

            
134
impl_debug_as_display!(CssShadowParseError<'a>);
135
impl_display! { CssShadowParseError<'a>, {
136
    TooManyOrTooFewComponents(e) => format!("Expected 2 to 4 length values for box-shadow, found an invalid number of components in: \"{}\"", e),
137
    ValueParseErr(e) => format!("Invalid length value in box-shadow: {}", e),
138
    ColorParseError(e) => format!("Invalid color value in box-shadow: {}", e),
139
}}
140

            
141
impl_from!(
142
    CssPixelValueParseError<'a>,
143
    CssShadowParseError::ValueParseErr
144
);
145
impl_from!(CssColorParseError<'a>, CssShadowParseError::ColorParseError);
146

            
147
/// Owned version of `CssShadowParseError`.
148
#[derive(Debug, Clone, PartialEq)]
149
#[repr(C, u8)]
150
pub enum CssShadowParseErrorOwned {
151
    TooManyOrTooFewComponents(AzString),
152
    ValueParseErr(CssPixelValueParseErrorOwned),
153
    ColorParseError(CssColorParseErrorOwned),
154
}
155

            
156
impl CssShadowParseError<'_> {
157
    /// Converts the borrowed error into an owned version for storage.
158
58
    #[must_use] pub fn to_contained(&self) -> CssShadowParseErrorOwned {
159
58
        match self {
160
24
            CssShadowParseError::TooManyOrTooFewComponents(s) => {
161
24
                CssShadowParseErrorOwned::TooManyOrTooFewComponents((*s).to_string().into())
162
            }
163
22
            CssShadowParseError::ValueParseErr(e) => {
164
22
                CssShadowParseErrorOwned::ValueParseErr(e.to_contained())
165
            }
166
12
            CssShadowParseError::ColorParseError(e) => {
167
12
                CssShadowParseErrorOwned::ColorParseError(e.to_contained())
168
            }
169
        }
170
58
    }
171
}
172

            
173
impl CssShadowParseErrorOwned {
174
    /// Converts the owned error back into a borrowed version.
175
30
    #[must_use] pub fn to_shared(&self) -> CssShadowParseError<'_> {
176
30
        match self {
177
12
            Self::TooManyOrTooFewComponents(s) => {
178
12
                CssShadowParseError::TooManyOrTooFewComponents(s.as_str())
179
            }
180
12
            Self::ValueParseErr(e) => {
181
12
                CssShadowParseError::ValueParseErr(e.to_shared())
182
            }
183
6
            Self::ColorParseError(e) => {
184
6
                CssShadowParseError::ColorParseError(e.to_shared())
185
            }
186
        }
187
30
    }
188
}
189

            
190
/// Parses a CSS box-shadow, such as `"5px 10px #888 inset"`.
191
///
192
/// Note: This parser does not handle the `none` keyword, as that is handled by the
193
/// `CssPropertyValue` enum wrapper. It also does not handle comma-separated lists
194
/// of multiple shadows; it only parses a single shadow value.
195
#[cfg(feature = "parser")]
196
/// # Errors
197
///
198
/// Returns an error if `input` is not a valid CSS `box-shadow` value.
199
189
pub fn parse_style_box_shadow(
200
189
    input: &str,
201
189
) -> Result<StyleBoxShadow, CssShadowParseError<'_>> {
202
189
    let mut parts: Vec<&str> = input.split_whitespace().collect();
203
189
    let mut shadow = StyleBoxShadow::default();
204

            
205
    // The `inset` keyword can appear anywhere. Find it, set the flag, and remove it.
206
50526
    if let Some(pos) = parts.iter().position(|&p| p == "inset") {
207
24
        shadow.clip_mode = BoxShadowClipMode::Inset;
208
24
        parts.remove(pos);
209
165
    }
210

            
211
    // The color can also be anywhere. Find it, set the color, and remove it.
212
    // It's the only part that isn't a length. We iterate from the back because
213
    // it's slightly more common for the color to be last.
214
189
    if let Some((pos, color)) = parts
215
189
        .iter()
216
189
        .enumerate()
217
189
        .rev()
218
50353
        .find_map(|(i, p)| parse_css_color(p).ok().map(|c| (i, c)))
219
81
    {
220
81
        shadow.color = color;
221
81
        parts.remove(pos);
222
144
    }
223

            
224
    // The remaining parts must be 2, 3, or 4 length values.
225
189
    match parts.len() {
226
162
        2..=4 => {
227
148
            shadow.offset_x = parse_pixel_value_no_percent(parts[0])?;
228
133
            shadow.offset_y = parse_pixel_value_no_percent(parts[1])?;
229
130
            if parts.len() > 2 {
230
71
                shadow.blur_radius = parse_pixel_value_no_percent(parts[2])?;
231
59
            }
232
119
            if parts.len() > 3 {
233
12
                shadow.spread_radius = parse_pixel_value_no_percent(parts[3])?;
234
107
            }
235
        }
236
41
        _ => return Err(CssShadowParseError::TooManyOrTooFewComponents(input)),
237
    }
238

            
239
119
    Ok(shadow)
240
189
}
241

            
242
#[cfg(all(test, feature = "parser"))]
243
mod tests {
244
    use super::*;
245
    use crate::props::basic::pixel::PixelValue;
246

            
247
17
    fn px_no_percent(val: f32) -> PixelValueNoPercent {
248
17
        PixelValueNoPercent {
249
17
            inner: PixelValue::px(val),
250
17
        }
251
17
    }
252

            
253
    #[test]
254
1
    fn test_parse_box_shadow_simple() {
255
1
        let result = parse_style_box_shadow("10px 5px").unwrap();
256
1
        assert_eq!(result.offset_x, px_no_percent(10.0));
257
1
        assert_eq!(result.offset_y, px_no_percent(5.0));
258
1
        assert_eq!(result.blur_radius, px_no_percent(0.0));
259
1
        assert_eq!(result.spread_radius, px_no_percent(0.0));
260
1
        assert_eq!(result.color, ColorU::BLACK);
261
1
        assert_eq!(result.clip_mode, BoxShadowClipMode::Outset);
262
1
    }
263

            
264
    #[test]
265
1
    fn test_parse_box_shadow_with_color() {
266
1
        let result = parse_style_box_shadow("10px 5px #888").unwrap();
267
1
        assert_eq!(result.offset_x, px_no_percent(10.0));
268
1
        assert_eq!(result.offset_y, px_no_percent(5.0));
269
1
        assert_eq!(result.color, ColorU::new_rgb(0x88, 0x88, 0x88));
270
1
    }
271

            
272
    #[test]
273
1
    fn test_parse_box_shadow_with_blur() {
274
1
        let result = parse_style_box_shadow("5px 10px 20px").unwrap();
275
1
        assert_eq!(result.offset_x, px_no_percent(5.0));
276
1
        assert_eq!(result.offset_y, px_no_percent(10.0));
277
1
        assert_eq!(result.blur_radius, px_no_percent(20.0));
278
1
    }
279

            
280
    #[test]
281
1
    fn test_parse_box_shadow_with_spread() {
282
1
        let result = parse_style_box_shadow("2px 2px 2px 1px rgba(0,0,0,0.2)").unwrap();
283
1
        assert_eq!(result.offset_x, px_no_percent(2.0));
284
1
        assert_eq!(result.offset_y, px_no_percent(2.0));
285
1
        assert_eq!(result.blur_radius, px_no_percent(2.0));
286
1
        assert_eq!(result.spread_radius, px_no_percent(1.0));
287
1
        assert_eq!(result.color, ColorU::new(0, 0, 0, 51));
288
1
    }
289

            
290
    #[test]
291
1
    fn test_parse_box_shadow_inset() {
292
1
        let result = parse_style_box_shadow("inset 0 0 10px #000").unwrap();
293
1
        assert_eq!(result.clip_mode, BoxShadowClipMode::Inset);
294
1
        assert_eq!(result.offset_x, px_no_percent(0.0));
295
1
        assert_eq!(result.offset_y, px_no_percent(0.0));
296
1
        assert_eq!(result.blur_radius, px_no_percent(10.0));
297
1
        assert_eq!(result.color, ColorU::BLACK);
298
1
    }
299

            
300
    #[test]
301
1
    fn test_parse_box_shadow_mixed_order() {
302
1
        let result = parse_style_box_shadow("5px 1em red inset").unwrap();
303
1
        assert_eq!(result.clip_mode, BoxShadowClipMode::Inset);
304
1
        assert_eq!(result.offset_x, px_no_percent(5.0));
305
1
        assert_eq!(
306
            result.offset_y,
307
1
            PixelValueNoPercent {
308
1
                inner: PixelValue::em(1.0)
309
1
            }
310
        );
311
1
        assert_eq!(result.color, ColorU::RED);
312
1
    }
313

            
314
    #[test]
315
1
    fn test_parse_box_shadow_invalid() {
316
1
        assert!(parse_style_box_shadow("10px").is_err());
317
1
        assert!(parse_style_box_shadow("10px 5px 4px 3px 2px").is_err());
318
        // Two colors: rposition picks "blue" as the color, leaving "red" which
319
        // fails to parse as a pixel value.
320
1
        assert!(parse_style_box_shadow("10px 5px red blue").is_err());
321
1
        assert!(parse_style_box_shadow("10% 5px").is_err()); // No percent allowed
322
1
    }
323
}
324

            
325
#[cfg(all(test, feature = "parser"))]
326
mod autotest_generated {
327
    use super::*;
328
    use crate::{
329
        codegen::format::FormatAsRustCode,
330
        props::basic::{
331
            pixel::{CssPixelValueParseError, PixelValue},
332
            SizeMetric,
333
        },
334
    };
335

            
336
    fn px(val: f32) -> PixelValueNoPercent {
337
        PixelValueNoPercent {
338
            inner: PixelValue::px(val),
339
        }
340
    }
341

            
342
    const fn shadow(
343
        offset_x: PixelValueNoPercent,
344
        offset_y: PixelValueNoPercent,
345
        blur_radius: PixelValueNoPercent,
346
        spread_radius: PixelValueNoPercent,
347
        clip_mode: BoxShadowClipMode,
348
        color: ColorU,
349
    ) -> StyleBoxShadow {
350
        StyleBoxShadow {
351
            offset_x,
352
            offset_y,
353
            blur_radius,
354
            spread_radius,
355
            clip_mode,
356
            color,
357
        }
358
    }
359

            
360
    /// Every component of a shadow, as raw f32s.
361
    fn numbers(s: &StyleBoxShadow) -> [f32; 4] {
362
        [
363
            s.offset_x.inner.number.get(),
364
            s.offset_y.inner.number.get(),
365
            s.blur_radius.inner.number.get(),
366
            s.spread_radius.inner.number.get(),
367
        ]
368
    }
369

            
370
    /// A representative corpus of shadows that are exactly representable in the
371
    /// fixed-point `FloatValue` encoding (<= 3 decimal places).
372
    fn round_trip_corpus() -> Vec<StyleBoxShadow> {
373
        vec![
374
            StyleBoxShadow::default(),
375
            shadow(
376
                px(1.0),
377
                px(2.0),
378
                px(0.0),
379
                px(0.0),
380
                BoxShadowClipMode::Outset,
381
                ColorU::BLACK,
382
            ),
383
            // blur only
384
            shadow(
385
                px(1.0),
386
                px(2.0),
387
                px(3.0),
388
                px(0.0),
389
                BoxShadowClipMode::Outset,
390
                ColorU::BLACK,
391
            ),
392
            // spread only: forces a "0px" blur to be printed as a placeholder
393
            shadow(
394
                px(1.0),
395
                px(2.0),
396
                px(0.0),
397
                px(4.0),
398
                BoxShadowClipMode::Outset,
399
                ColorU::BLACK,
400
            ),
401
            // negative offsets + inset + named color
402
            shadow(
403
                px(-5.5),
404
                px(-7.25),
405
                px(2.5),
406
                px(1.125),
407
                BoxShadowClipMode::Inset,
408
                ColorU::RED,
409
            ),
410
            // non-px metric
411
            shadow(
412
                PixelValueNoPercent {
413
                    inner: PixelValue::em(1.5),
414
                },
415
                PixelValueNoPercent {
416
                    inner: PixelValue::pt(2.0),
417
                },
418
                px(0.0),
419
                px(0.0),
420
                BoxShadowClipMode::Inset,
421
                ColorU::BLACK,
422
            ),
423
            // fully transparent black -- differs from ColorU::BLACK only in alpha
424
            shadow(
425
                px(0.0),
426
                px(0.0),
427
                px(0.0),
428
                px(0.0),
429
                BoxShadowClipMode::Outset,
430
                ColorU::new(0, 0, 0, 0),
431
            ),
432
            // every channel distinct, incl. a non-opaque alpha
433
            shadow(
434
                px(0.0),
435
                px(0.0),
436
                px(9.0),
437
                px(0.0),
438
                BoxShadowClipMode::Inset,
439
                ColorU::new(1, 2, 3, 4),
440
            ),
441
        ]
442
    }
443

            
444
    // ---------------------------------------------------------------
445
    // serializer: BoxShadowClipMode::fmt
446
    // ---------------------------------------------------------------
447

            
448
    #[test]
449
    fn clip_mode_display_outset_is_empty_inset_is_keyword() {
450
        // Outset is the CSS default and is deliberately NOT written out.
451
        assert_eq!(BoxShadowClipMode::Outset.to_string(), "");
452
        assert_eq!(BoxShadowClipMode::Inset.to_string(), "inset");
453
    }
454

            
455
    #[test]
456
    fn clip_mode_display_default_does_not_panic() {
457
        let default: BoxShadowClipMode = Default::default();
458
        assert_eq!(default, BoxShadowClipMode::Outset);
459
        assert_eq!(default.to_string(), "");
460
        // Debug (derived) must stay non-empty even though Display is empty.
461
        assert_eq!(format!("{:?}", BoxShadowClipMode::Outset), "Outset");
462
        assert_eq!(format!("{:?}", BoxShadowClipMode::Inset), "Inset");
463
    }
464

            
465
    #[test]
466
    fn clip_mode_display_with_format_flags_does_not_panic() {
467
        // The impl writes straight to the formatter, so width/fill/precision are
468
        // ignored rather than applied -- assert only that nothing panics and the
469
        // keyword survives.
470
        assert!(format!("{:>16}", BoxShadowClipMode::Inset).contains("inset"));
471
        assert!(format!("{:*<16}", BoxShadowClipMode::Inset).contains("inset"));
472
        assert!(format!("{:.2}", BoxShadowClipMode::Inset).contains("inset"));
473
        // Outset writes nothing at all, whatever the flags.
474
        assert_eq!(format!("{:>16}", BoxShadowClipMode::Outset), "");
475
    }
476

            
477
    #[test]
478
    fn clip_mode_display_is_repeatable_and_ordered() {
479
        // Same value must serialize identically every time.
480
        for _ in 0..4 {
481
            assert_eq!(BoxShadowClipMode::Inset.to_string(), "inset");
482
        }
483
        // Derived Ord: the default variant sorts first.
484
        assert!(BoxShadowClipMode::Outset < BoxShadowClipMode::Inset);
485
    }
486

            
487
    // ---------------------------------------------------------------
488
    // numeric: StyleBoxShadow::scale_for_dpi
489
    // ---------------------------------------------------------------
490

            
491
    fn scaled(mut s: StyleBoxShadow, factor: f32) -> StyleBoxShadow {
492
        s.scale_for_dpi(factor);
493
        s
494
    }
495

            
496
    fn all_ones() -> StyleBoxShadow {
497
        shadow(
498
            px(1.0),
499
            px(2.0),
500
            px(4.0),
501
            px(8.0),
502
            BoxShadowClipMode::Inset,
503
            ColorU::RED,
504
        )
505
    }
506

            
507
    #[test]
508
    fn scale_for_dpi_zero_zeroes_every_length() {
509
        let s = scaled(all_ones(), 0.0);
510
        assert_eq!(numbers(&s), [0.0, 0.0, 0.0, 0.0]);
511
        // -0.0 must not leak a negative zero into the fixed-point encoding.
512
        let neg = scaled(all_ones(), -0.0);
513
        assert_eq!(numbers(&neg), [0.0, 0.0, 0.0, 0.0]);
514
        assert_eq!(neg.offset_x.inner.number.number(), 0);
515
    }
516

            
517
    #[test]
518
    fn scale_for_dpi_identity_and_double() {
519
        let s = scaled(all_ones(), 1.0);
520
        assert_eq!(numbers(&s), [1.0, 2.0, 4.0, 8.0]);
521
        let d = scaled(all_ones(), 2.0);
522
        assert_eq!(numbers(&d), [2.0, 4.0, 8.0, 16.0]);
523
        let h = scaled(all_ones(), 0.5);
524
        assert_eq!(numbers(&h), [0.5, 1.0, 2.0, 4.0]);
525
    }
526

            
527
    #[test]
528
    fn scale_for_dpi_negative_flips_sign_deterministically() {
529
        let s = scaled(all_ones(), -1.5);
530
        assert_eq!(numbers(&s), [-1.5, -3.0, -6.0, -12.0]);
531
    }
532

            
533
    #[test]
534
    fn scale_for_dpi_nan_yields_zero_never_nan() {
535
        // `f32 as isize` saturates and maps NaN -> 0, so a NaN scale factor
536
        // collapses the shadow to zero instead of poisoning it with NaN.
537
        let s = scaled(all_ones(), f32::NAN);
538
        for n in numbers(&s) {
539
            assert!(!n.is_nan(), "NaN leaked into the fixed-point encoding");
540
            assert_eq!(n, 0.0);
541
        }
542
    }
543

            
544
    #[test]
545
    fn scale_for_dpi_infinity_saturates_to_finite() {
546
        let pos = scaled(all_ones(), f32::INFINITY);
547
        for n in numbers(&pos) {
548
            assert!(n.is_finite(), "+inf scale produced a non-finite value");
549
            assert!(n > 0.0);
550
        }
551

            
552
        let neg = scaled(all_ones(), f32::NEG_INFINITY);
553
        for n in numbers(&neg) {
554
            assert!(n.is_finite(), "-inf scale produced a non-finite value");
555
            assert!(n < 0.0);
556
        }
557
    }
558

            
559
    #[test]
560
    fn scale_for_dpi_float_extremes_do_not_panic() {
561
        // MAX: 1.0 * f32::MAX * 1000.0 overflows f32 -> inf -> saturates on cast.
562
        for n in numbers(&scaled(all_ones(), f32::MAX)) {
563
            assert!(n.is_finite());
564
            assert!(n > 0.0);
565
        }
566
        for n in numbers(&scaled(all_ones(), f32::MIN)) {
567
            assert!(n.is_finite());
568
            assert!(n < 0.0);
569
        }
570
        // Subnormal / tiny factors underflow to exactly zero (3-decimal precision).
571
        assert_eq!(numbers(&scaled(all_ones(), f32::MIN_POSITIVE)), [0.0; 4]);
572
        assert_eq!(numbers(&scaled(all_ones(), f32::EPSILON)), [0.0; 4]);
573
    }
574

            
575
    #[test]
576
    fn scale_for_dpi_saturation_is_stable_under_repetition() {
577
        // Scaling an already-saturated shadow again must stay finite (no panic,
578
        // no NaN, no wraparound to the opposite sign).
579
        let mut s = all_ones();
580
        for _ in 0..8 {
581
            s.scale_for_dpi(1e30);
582
            for n in numbers(&s) {
583
                assert!(n.is_finite());
584
                assert!(n > 0.0, "saturating scale wrapped to a negative value");
585
            }
586
        }
587
    }
588

            
589
    #[test]
590
    fn scale_for_dpi_quantizes_below_the_fixed_point_precision() {
591
        // FloatValue keeps 3 decimals; anything smaller truncates to zero.
592
        let s = scaled(all_ones(), 0.0001);
593
        assert_eq!(numbers(&s), [0.0, 0.0, 0.0, 0.0]);
594
    }
595

            
596
    #[test]
597
    fn scale_for_dpi_on_default_is_a_noop() {
598
        for factor in [0.0, 1.0, 2.0, -3.0, f32::NAN, f32::INFINITY, f32::MAX] {
599
            let s = scaled(StyleBoxShadow::default(), factor);
600
            assert_eq!(
601
                numbers(&s),
602
                [0.0, 0.0, 0.0, 0.0],
603
                "default shadow changed under scale {factor}"
604
            );
605
        }
606
    }
607

            
608
    #[test]
609
    fn scale_for_dpi_preserves_metric_color_and_clip_mode() {
610
        let mut s = shadow(
611
            PixelValueNoPercent {
612
                inner: PixelValue::em(2.0),
613
            },
614
            PixelValueNoPercent {
615
                inner: PixelValue::pt(3.0),
616
            },
617
            PixelValueNoPercent {
618
                inner: PixelValue::rem(4.0),
619
            },
620
            px(5.0),
621
            BoxShadowClipMode::Inset,
622
            ColorU::new(1, 2, 3, 4),
623
        );
624
        s.scale_for_dpi(2.0);
625

            
626
        // Only the *numbers* are scaled -- units are never converted.
627
        assert_eq!(s.offset_x.inner.metric, SizeMetric::Em);
628
        assert_eq!(s.offset_y.inner.metric, SizeMetric::Pt);
629
        assert_eq!(s.blur_radius.inner.metric, SizeMetric::Rem);
630
        assert_eq!(s.spread_radius.inner.metric, SizeMetric::Px);
631
        assert_eq!(numbers(&s), [4.0, 6.0, 8.0, 10.0]);
632

            
633
        // ... and the non-numeric fields are untouched.
634
        assert_eq!(s.clip_mode, BoxShadowClipMode::Inset);
635
        assert_eq!(s.color, ColorU::new(1, 2, 3, 4));
636
    }
637

            
638
    // ---------------------------------------------------------------
639
    // getters: CssShadowParseError::to_contained / ..Owned::to_shared
640
    // ---------------------------------------------------------------
641

            
642
    /// One error of each `CssShadowParseError` variant.
643
    fn error_corpus() -> Vec<CssShadowParseError<'static>> {
644
        vec![
645
            CssShadowParseError::TooManyOrTooFewComponents("1px"),
646
            CssShadowParseError::TooManyOrTooFewComponents(""),
647
            CssShadowParseError::TooManyOrTooFewComponents("\u{1F600}\u{0301} \u{202E}"),
648
            CssShadowParseError::ValueParseErr(CssPixelValueParseError::EmptyString),
649
            CssShadowParseError::ValueParseErr(CssPixelValueParseError::InvalidPixelValue("abc")),
650
            CssShadowParseError::ValueParseErr(CssPixelValueParseError::NoValueGiven(
651
                "px",
652
                SizeMetric::Px,
653
            )),
654
            CssShadowParseError::ValueParseErr(CssPixelValueParseError::ValueParseErr(
655
                "x".parse::<f32>().unwrap_err(),
656
                "x",
657
            )),
658
            CssShadowParseError::ValueParseErr(CssPixelValueParseError::ValueParseErr(
659
                "".parse::<f32>().unwrap_err(),
660
                "",
661
            )),
662
            CssShadowParseError::ColorParseError(parse_css_color("notacolor").unwrap_err()),
663
            CssShadowParseError::ColorParseError(parse_css_color("#gg").unwrap_err()),
664
            CssShadowParseError::ColorParseError(parse_css_color("rgb(1,2").unwrap_err()),
665
        ]
666
    }
667

            
668
    #[test]
669
    fn shadow_error_to_contained_to_shared_is_lossless() {
670
        for e in error_corpus() {
671
            let owned = e.to_contained();
672
            assert_eq!(owned.to_shared(), e, "borrow -> own -> borrow lost data");
673
        }
674
    }
675

            
676
    #[test]
677
    fn shadow_error_owned_to_shared_to_contained_is_lossless() {
678
        for e in error_corpus() {
679
            let owned = e.to_contained();
680
            assert_eq!(
681
                owned.to_shared().to_contained(),
682
                owned,
683
                "own -> borrow -> own lost data"
684
            );
685
        }
686
    }
687

            
688
    #[test]
689
    fn shadow_error_to_contained_preserves_the_input_string_verbatim() {
690
        for input in [
691
            "",
692
            "   ",
693
            "\u{1F600}",
694
            "e\u{0301}\u{0301}\u{0301}",
695
            "10px 5px 4px 3px 2px",
696
        ] {
697
            let owned = CssShadowParseError::TooManyOrTooFewComponents(input).to_contained();
698
            match owned {
699
                CssShadowParseErrorOwned::TooManyOrTooFewComponents(s) => {
700
                    assert_eq!(s.as_str(), input);
701
                }
702
                other => panic!("wrong variant: {other:?}"),
703
            }
704
        }
705
    }
706

            
707
    #[test]
708
    fn shadow_error_to_contained_survives_a_very_long_input() {
709
        let long = "x".repeat(200_000);
710
        let owned = CssShadowParseError::TooManyOrTooFewComponents(&long).to_contained();
711
        match owned.to_shared() {
712
            CssShadowParseError::TooManyOrTooFewComponents(s) => {
713
                assert_eq!(s.len(), 200_000);
714
            }
715
            other => panic!("wrong variant: {other:?}"),
716
        }
717
    }
718

            
719
    #[test]
720
    fn shadow_error_display_and_debug_are_non_empty() {
721
        for e in error_corpus() {
722
            assert!(!e.to_string().is_empty(), "empty Display for {e:?}");
723
            // Debug is implemented as Display for this type.
724
            assert_eq!(format!("{e:?}"), e.to_string());
725
            assert!(!format!("{:?}", e.to_contained()).is_empty());
726
        }
727
    }
728

            
729
    #[test]
730
    fn shadow_error_to_contained_round_trips_real_parser_errors() {
731
        // Errors as they are actually produced by the parser, not hand-built.
732
        for bad in ["", "1px", "abc 1px", "10% 5px", "1 2 3 4 5"] {
733
            let e = parse_style_box_shadow(bad).unwrap_err();
734
            assert_eq!(e.to_contained().to_shared(), e);
735
        }
736
    }
737

            
738
    // ---------------------------------------------------------------
739
    // parser: parse_style_box_shadow -- malformed / boundary / unicode
740
    // ---------------------------------------------------------------
741

            
742
    #[test]
743
    fn parse_valid_minimal_positive_control() {
744
        let s = parse_style_box_shadow("10px 5px").unwrap();
745
        assert_eq!(
746
            s,
747
            shadow(
748
                px(10.0),
749
                px(5.0),
750
                px(0.0),
751
                px(0.0),
752
                BoxShadowClipMode::Outset,
753
                ColorU::BLACK,
754
            )
755
        );
756
    }
757

            
758
    #[test]
759
    fn parse_empty_and_whitespace_only_input_is_err() {
760
        for input in ["", " ", "   ", "\t", "\n", "\t\n\r ", "\u{00a0}", "\u{3000}"] {
761
            let e = parse_style_box_shadow(input).unwrap_err();
762
            assert_eq!(
763
                e,
764
                CssShadowParseError::TooManyOrTooFewComponents(input),
765
                "unexpected error for {input:?}"
766
            );
767
        }
768
    }
769

            
770
    #[test]
771
    fn parse_garbage_is_err_and_never_panics() {
772
        for input in [
773
            "!!!",
774
            "@#$%^&*",
775
            "; drop table",
776
            "{}{}{}",
777
            "\\\\\\",
778
            "\0\0",
779
            "1px",                  // too few
780
            "1px 2px 3px 4px 5px",  // too many
781
            "inset",                // keyword only
782
            "red",                  // color only
783
            "inset red",            // keyword + color, no lengths
784
            "inset red 1px",        // only one length left
785
            "1px 2px red blue",     // two colors: "red" is left over as a length
786
            "inset inset 1px 2px",  // second "inset" is not removed
787
            "10% 5px",              // percent is rejected
788
            "1px 10%",
789
            "1px 2px 3%",
790
            "px px",
791
            "in in",
792
            "1px 2px 3px 4px 5px red inset",
793
        ] {
794
            assert!(
795
                parse_style_box_shadow(input).is_err(),
796
                "expected Err for {input:?}"
797
            );
798
        }
799
    }
800

            
801
    #[test]
802
    fn parse_leading_trailing_junk_is_trimmed_or_rejected_deterministically() {
803
        // Surrounding whitespace is absorbed by split_whitespace.
804
        let padded = parse_style_box_shadow("   10px    5px   ").unwrap();
805
        assert_eq!(padded, parse_style_box_shadow("10px 5px").unwrap());
806

            
807
        // Trailing punctuation is NOT stripped -- it makes the length unparseable.
808
        for input in ["10px 5px;", "10px, 5px", "10px 5px !important", "10px 5px}"] {
809
            let e = parse_style_box_shadow(input).unwrap_err();
810
            assert!(
811
                matches!(e, CssShadowParseError::ValueParseErr(_))
812
                    || matches!(e, CssShadowParseError::TooManyOrTooFewComponents(_)),
813
                "unexpected error kind for {input:?}: {e:?}"
814
            );
815
        }
816
    }
817

            
818
    #[test]
819
    fn parse_component_count_boundaries() {
820
        assert!(parse_style_box_shadow("1px").is_err()); // 1 -> too few
821
        assert!(parse_style_box_shadow("1px 2px").is_ok()); // 2 -> ok
822
        assert!(parse_style_box_shadow("1px 2px 3px").is_ok()); // 3 -> ok
823
        assert!(parse_style_box_shadow("1px 2px 3px 4px").is_ok()); // 4 -> ok
824
        assert!(parse_style_box_shadow("1px 2px 3px 4px 5px").is_err()); // 5 -> too many
825

            
826
        // The `inset` keyword and the color do not count toward the 2..=4 budget.
827
        assert!(parse_style_box_shadow("inset red 1px 2px 3px 4px").is_ok());
828
        // ... but five lengths are still too many, even with them present.
829
        assert!(parse_style_box_shadow("inset red 1px 2px 3px 4px 5px").is_err());
830
    }
831

            
832
    #[test]
833
    fn parse_is_insensitive_to_keyword_and_color_position() {
834
        let expected = shadow(
835
            px(1.0),
836
            px(2.0),
837
            px(0.0),
838
            px(0.0),
839
            BoxShadowClipMode::Inset,
840
            ColorU::RED,
841
        );
842
        for input in [
843
            "inset red 1px 2px",
844
            "red inset 1px 2px",
845
            "1px 2px red inset",
846
            "1px 2px inset red",
847
            "red 1px 2px inset",
848
            "1px red 2px inset",
849
            "inset 1px red 2px",
850
        ] {
851
            assert_eq!(
852
                parse_style_box_shadow(input).unwrap(),
853
                expected,
854
                "position of inset/color changed the result for {input:?}"
855
            );
856
        }
857
    }
858

            
859
    #[test]
860
    fn parse_accepts_every_color_syntax() {
861
        for (input, expected) in [
862
            ("1px 2px #888", ColorU::new_rgb(0x88, 0x88, 0x88)),
863
            ("1px 2px #ff0000", ColorU::RED),
864
            ("1px 2px #ff0000ff", ColorU::RED),
865
            ("1px 2px #f00f", ColorU::RED),
866
            ("1px 2px rgb(255,0,0)", ColorU::RED),
867
            ("1px 2px rgba(255,0,0,1.0)", ColorU::RED),
868
            ("1px 2px red", ColorU::RED),
869
            ("1px 2px RED", ColorU::RED),
870
            ("1px 2px transparent", ColorU::new(0, 0, 0, 0)),
871
        ] {
872
            let s = parse_style_box_shadow(input).unwrap();
873
            assert_eq!(s.color, expected, "wrong color for {input:?}");
874
            assert_eq!(s.offset_x, px(1.0));
875
            assert_eq!(s.offset_y, px(2.0));
876
        }
877
    }
878

            
879
    #[test]
880
    fn parse_bare_zero_is_a_pixel_value_not_a_color() {
881
        let s = parse_style_box_shadow("0 0").unwrap();
882
        assert_eq!(s.offset_x, px(0.0));
883
        assert_eq!(s.offset_y, px(0.0));
884
        assert_eq!(s.offset_x.inner.metric, SizeMetric::Px);
885
        assert_eq!(s.color, ColorU::BLACK, "a bare 0 was eaten by the color parser");
886
    }
887

            
888
    #[test]
889
    fn parse_boundary_numbers_are_finite_and_defined() {
890
        // Signed zeroes collapse to a single +0 in the fixed-point encoding.
891
        let zeroes = parse_style_box_shadow("-0 -0").unwrap();
892
        assert_eq!(numbers(&zeroes)[0], 0.0);
893
        assert_eq!(numbers(&zeroes)[1], 0.0);
894
        assert!(!numbers(&zeroes)[0].is_sign_negative());
895

            
896
        // Huge finite values saturate on the f32 -> isize cast rather than wrap.
897
        for input in [
898
            "1e30px 1e30px",
899
            "9223372036854775807px 9223372036854775807px", // i64::MAX
900
            "340282350000000000000000000000000000000px 1px", // ~f32::MAX
901
        ] {
902
            let s = parse_style_box_shadow(input).unwrap();
903
            let n = numbers(&s)[0];
904
            assert!(n.is_finite(), "{input:?} produced a non-finite offset");
905
            assert!(n > 0.0, "{input:?} saturated to the wrong sign");
906
        }
907
        let neg = parse_style_box_shadow("-1e30px 1px").unwrap();
908
        assert!(numbers(&neg)[0].is_finite() && numbers(&neg)[0] < 0.0);
909

            
910
        // Tiny values underflow to exactly zero (3 decimals of precision).
911
        let tiny = parse_style_box_shadow("1e-30px 0.00001px").unwrap();
912
        assert_eq!(numbers(&tiny)[0], 0.0);
913
        assert_eq!(numbers(&tiny)[1], 0.0);
914
    }
915

            
916
    #[test]
917
    fn parse_nan_and_infinity_tokens_are_defined_not_poisonous() {
918
        // f32::from_str accepts "NaN"/"inf", so these reach the fixed-point cast.
919
        // NaN as isize == 0, so the value collapses to zero rather than staying NaN.
920
        let nan = parse_style_box_shadow("NaN NaN").unwrap();
921
        for n in numbers(&nan) {
922
            assert!(!n.is_nan(), "NaN survived into a parsed shadow");
923
            assert_eq!(n, 0.0);
924
        }
925
        let nan_px = parse_style_box_shadow("NaNpx 1px").unwrap();
926
        assert_eq!(numbers(&nan_px)[0], 0.0);
927

            
928
        // inf saturates to isize::MAX / 1000 -- finite, signed correctly.
929
        let inf = parse_style_box_shadow("inf 1px").unwrap();
930
        assert!(numbers(&inf)[0].is_finite() && numbers(&inf)[0] > 0.0);
931
        let neg_inf = parse_style_box_shadow("-inf 1px").unwrap();
932
        assert!(numbers(&neg_inf)[0].is_finite() && numbers(&neg_inf)[0] < 0.0);
933
        let infinity = parse_style_box_shadow("1px infinity").unwrap();
934
        assert!(numbers(&infinity)[1].is_finite());
935

            
936
        // A bare "in" is the inch metric with no value -> Err, not a panic.
937
        assert!(parse_style_box_shadow("in 1px").is_err());
938
    }
939

            
940
    #[test]
941
    fn parse_never_yields_a_percent_metric() {
942
        for input in [
943
            "1px 2px",
944
            "0 0",
945
            "1em 2rem 3pt 4in",
946
            "5vw 5vh 5vmin 5vmax",
947
            "1cm 2mm",
948
            "inset 1px 2px red",
949
        ] {
950
            let s = parse_style_box_shadow(input).unwrap();
951
            for m in [
952
                s.offset_x.inner.metric,
953
                s.offset_y.inner.metric,
954
                s.blur_radius.inner.metric,
955
                s.spread_radius.inner.metric,
956
            ] {
957
                assert_ne!(m, SizeMetric::Percent, "percent metric leaked from {input:?}");
958
            }
959
        }
960
    }
961

            
962
    #[test]
963
    fn parse_unicode_input_does_not_panic() {
964
        // parse_color_no_hash indexes by BYTE length, so multi-byte tokens behind
965
        // a '#' must error instead of slicing through a char boundary.
966
        for input in [
967
            "\u{1F600}",
968
            "\u{1F600} \u{1F600}",
969
            "1px 2px \u{1F600}",
970
            "1px 2px #\u{1F600}",  // 4-byte char -> hits the len==4 hex branch
971
            "1px 2px #\u{e9}1",    // 3 bytes -> hits the len==3 hex branch
972
            "1px 2px #\u{e9}\u{e9}\u{e9}\u{e9}", // 8 bytes -> hits the len==8 branch
973
            "e\u{0301}\u{0301} 1px",
974
            "\u{202E}1px 2px",
975
            "\u{0661}px \u{0662}px", // arabic-indic digits
976
            "1px 2px",             // fullwidth digits
977
            "1px 2px \u{fffd}",
978
        ] {
979
            let _ = parse_style_box_shadow(input); // must not panic
980
        }
981

            
982
        // Unicode whitespace still splits tokens, so this is a valid shadow.
983
        let nbsp = parse_style_box_shadow("10px\u{00a0}5px").unwrap();
984
        assert_eq!(nbsp, parse_style_box_shadow("10px 5px").unwrap());
985
    }
986

            
987
    #[test]
988
    fn parse_extremely_long_input_terminates() {
989
        // 50k length tokens: rejected on the component count, no hang.
990
        let many = "1px ".repeat(50_000);
991
        assert!(matches!(
992
            parse_style_box_shadow(&many),
993
            Err(CssShadowParseError::TooManyOrTooFewComponents(_))
994
        ));
995

            
996
        // A single 1M-char garbage token.
997
        let long_garbage = "a".repeat(1_000_000);
998
        assert!(parse_style_box_shadow(&long_garbage).is_err());
999

            
        // A single token of 50k digits: f32 parses it as inf, which then saturates.
        let huge = format!("{}px 1px", "9".repeat(50_000));
        let s = parse_style_box_shadow(&huge).unwrap();
        assert!(numbers(&s)[0].is_finite());
        assert!(numbers(&s)[0] > 0.0);
        // A very long *valid* input padded with whitespace.
        let padded = format!("{}1px 2px{}", " ".repeat(100_000), " ".repeat(100_000));
        assert_eq!(
            parse_style_box_shadow(&padded).unwrap(),
            parse_style_box_shadow("1px 2px").unwrap()
        );
    }
    #[test]
    fn parse_deeply_nested_input_does_not_stack_overflow() {
        let open = "(".repeat(10_000);
        let nested = format!("{}{}", open, ")".repeat(10_000));
        assert!(parse_style_box_shadow(&nested).is_err());
        assert!(parse_style_box_shadow(&open).is_err());
        assert!(parse_style_box_shadow(&format!("1px 2px rgb{nested}")).is_err());
        assert!(parse_style_box_shadow(&format!("1px 2px {}", "[".repeat(10_000))).is_err());
    }
    // ---------------------------------------------------------------
    // round-trip: print_as_css_value <-> parse_style_box_shadow
    // ---------------------------------------------------------------
    #[test]
    fn print_then_parse_is_the_identity() {
        for original in round_trip_corpus() {
            let printed = original.print_as_css_value();
            let reparsed = parse_style_box_shadow(&printed)
                .unwrap_or_else(|e| panic!("printed {printed:?} does not re-parse: {e:?}"));
            assert_eq!(reparsed, original, "round-trip changed {printed:?}");
        }
    }
    #[test]
    fn parse_then_print_then_parse_is_idempotent() {
        for input in [
            "10px 5px",
            "5px 10px 20px",
            "2px 2px 2px 1px rgba(0,0,0,0.2)",
            "inset 0 0 10px #000",
            "5px 1em red inset",
            "1px 2px transparent",
            "-3px -4px 0 2px #12345678",
        ] {
            let first = parse_style_box_shadow(input).unwrap();
            let printed = first.print_as_css_value();
            let second = parse_style_box_shadow(&printed).unwrap();
            assert_eq!(first, second, "{input:?} -> {printed:?} was not idempotent");
            // Printing is stable: same value, same string.
            assert_eq!(printed, second.print_as_css_value());
        }
    }
    #[test]
    fn print_omits_defaults_for_brevity() {
        let default = StyleBoxShadow::default();
        assert_eq!(default.print_as_css_value(), "0px 0px");
        // A black shadow never writes a color...
        let black = shadow(
            px(1.0),
            px(2.0),
            px(3.0),
            px(0.0),
            BoxShadowClipMode::Outset,
            ColorU::BLACK,
        );
        assert_eq!(black.print_as_css_value(), "1px 2px 3px");
        assert!(!black.print_as_css_value().contains('#'));
        // ...and an outset shadow never writes the `inset` keyword.
        assert!(!black.print_as_css_value().contains("inset"));
    }
    #[test]
    fn print_writes_inset_first_and_color_last() {
        let s = shadow(
            px(1.0),
            px(2.0),
            px(0.0),
            px(0.0),
            BoxShadowClipMode::Inset,
            ColorU::RED,
        );
        assert_eq!(s.print_as_css_value(), "inset 1px 2px #ff0000ff");
    }
    #[test]
    fn print_emits_a_placeholder_blur_when_only_the_spread_is_set() {
        // A spread cannot be positional without a blur before it, so a zero blur
        // must still be written -- otherwise the spread would re-parse as a blur.
        let s = shadow(
            px(1.0),
            px(2.0),
            px(0.0),
            px(4.0),
            BoxShadowClipMode::Outset,
            ColorU::BLACK,
        );
        assert_eq!(s.print_as_css_value(), "1px 2px 0px 4px");
        let reparsed = parse_style_box_shadow(&s.print_as_css_value()).unwrap();
        assert_eq!(reparsed.blur_radius, px(0.0));
        assert_eq!(reparsed.spread_radius, px(4.0));
    }
    #[test]
    fn print_of_extreme_values_does_not_panic() {
        // Saturated / NaN-scaled shadows must still serialize to *something*.
        for factor in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, 1e30] {
            let s = scaled(all_ones(), factor);
            let printed = s.print_as_css_value();
            assert!(!printed.is_empty());
            assert!(!printed.contains("NaN"), "NaN reached the CSS output: {printed}");
            assert!(!printed.contains("inf"), "inf reached the CSS output: {printed}");
        }
    }
    #[test]
    fn format_as_rust_code_is_well_formed_for_extremes() {
        for s in round_trip_corpus() {
            let code = s.format_as_rust_code(0);
            assert!(code.starts_with("StyleBoxShadow {"));
            assert!(code.contains("offset_x:"));
            assert!(code.contains("offset_y:"));
            assert!(code.contains("blur_radius:"));
            assert!(code.contains("spread_radius:"));
            assert!(code.contains("clip_mode: BoxShadowClipMode::"));
            assert!(code.contains("color:"));
        }
        // Indentation is applied, and extreme values do not panic the formatter.
        let inset = scaled(all_ones(), f32::INFINITY);
        let code = inset.format_as_rust_code(3);
        assert!(code.contains("clip_mode: BoxShadowClipMode::Inset"));
        assert!(code.contains("            offset_x:"));
    }
    // ---------------------------------------------------------------
    // predicates / invariants on StyleBoxShadow itself
    // ---------------------------------------------------------------
    #[test]
    fn default_shadow_is_an_opaque_black_outset_at_the_origin() {
        let d = StyleBoxShadow::default();
        assert_eq!(d.clip_mode, BoxShadowClipMode::Outset);
        assert_eq!(d.color, ColorU::BLACK);
        assert_eq!(numbers(&d), [0.0, 0.0, 0.0, 0.0]);
        assert_eq!(d.offset_x.inner.metric, SizeMetric::Px);
        // The parser's baseline must agree with Default.
        assert_eq!(parse_style_box_shadow("0 0").unwrap(), d);
    }
    #[test]
    fn equality_is_component_wise() {
        let base = all_ones();
        assert_eq!(base, base);
        assert_ne!(base, StyleBoxShadow::default());
        let mut clip = base;
        clip.clip_mode = BoxShadowClipMode::Outset;
        assert_ne!(base, clip, "clip_mode is ignored by PartialEq");
        let mut color = base;
        color.color = ColorU::BLACK;
        assert_ne!(base, color, "color is ignored by PartialEq");
        let mut spread = base;
        spread.spread_radius = px(9.0);
        assert_ne!(base, spread, "spread_radius is ignored by PartialEq");
    }
}