1
//! CSS properties for styling text selections (`-azul-selection-*`).
2
//!
3
//! Defines the following properties (analogous to the `::selection` pseudo-element):
4
//!
5
//! - `-azul-selection-background-color` ([`SelectionBackgroundColor`])
6
//! - `-azul-selection-color` ([`SelectionColor`])
7
//! - `-azul-selection-radius` ([`SelectionRadius`])
8

            
9
use alloc::string::String;
10

            
11
use crate::props::{
12
    basic::color::{parse_css_color, ColorU, CssColorParseError, CssColorParseErrorOwned},
13
    formatter::PrintAsCssValue,
14
};
15

            
16
/// Default selection highlight background — light blue, similar to macOS.
17
const DEFAULT_SELECTION_BG: ColorU = ColorU::new(173, 214, 255, 255);
18

            
19
// --- -azul-selection-background-color ---
20

            
21
/// Parsed value for the `-azul-selection-background-color` CSS property.
22
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
23
#[repr(C)]
24
pub struct SelectionBackgroundColor {
25
    pub inner: ColorU,
26
}
27

            
28
impl Default for SelectionBackgroundColor {
29
6
    fn default() -> Self {
30
6
        Self {
31
6
            inner: DEFAULT_SELECTION_BG,
32
6
        }
33
6
    }
34
}
35

            
36
impl PrintAsCssValue for SelectionBackgroundColor {
37
17
    fn print_as_css_value(&self) -> String {
38
17
        self.inner.to_hash()
39
17
    }
40
}
41

            
42
impl crate::codegen::format::FormatAsRustCode for SelectionBackgroundColor {
43
1
    fn format_as_rust_code(&self, _tabs: usize) -> String {
44
1
        format!(
45
1
            "SelectionBackgroundColor {{ inner: {} }}",
46
1
            crate::codegen::format::format_color_value(&self.inner)
47
        )
48
1
    }
49
}
50

            
51
/// Parses a `-azul-selection-background-color` CSS value.
52
#[cfg(feature = "parser")]
53
/// # Errors
54
///
55
/// Returns an error if `input` is not a valid CSS `selection-background-color` value.
56
188
pub fn parse_selection_background_color(
57
188
    input: &str,
58
188
) -> Result<SelectionBackgroundColor, CssColorParseError<'_>> {
59
188
    parse_css_color(input).map(|inner| SelectionBackgroundColor { inner })
60
188
}
61

            
62
// --- -azul-selection-color ---
63

            
64
/// Parsed value for the `-azul-selection-color` CSS property.
65
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
66
#[repr(C)]
67
pub struct SelectionColor {
68
    pub inner: ColorU,
69
}
70

            
71
impl Default for SelectionColor {
72
5
    fn default() -> Self {
73
5
        Self {
74
5
            inner: ColorU::BLACK,
75
5
        }
76
5
    }
77
}
78

            
79
impl PrintAsCssValue for SelectionColor {
80
267
    fn print_as_css_value(&self) -> String {
81
267
        self.inner.to_hash()
82
267
    }
83
}
84

            
85
impl crate::codegen::format::FormatAsRustCode for SelectionColor {
86
1
    fn format_as_rust_code(&self, _tabs: usize) -> String {
87
1
        format!(
88
1
            "SelectionColor {{ inner: {} }}",
89
1
            crate::codegen::format::format_color_value(&self.inner)
90
        )
91
1
    }
92
}
93

            
94
/// Parses a `-azul-selection-color` CSS value.
95
#[cfg(feature = "parser")]
96
/// # Errors
97
///
98
/// Returns an error if `input` is not a valid CSS `selection-color` value.
99
447
pub fn parse_selection_color(input: &str) -> Result<SelectionColor, CssColorParseError<'_>> {
100
447
    parse_css_color(input).map(|inner| SelectionColor { inner })
101
447
}
102

            
103
// --- -azul-selection-radius ---
104

            
105
use crate::props::basic::{
106
    pixel::{parse_pixel_value, CssPixelValueParseError, CssPixelValueParseErrorOwned, PixelValue},
107
    SizeMetric,
108
};
109

            
110
/// Parsed value for the `-azul-selection-radius` CSS property.
111
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
112
#[repr(C)]
113
pub struct SelectionRadius {
114
    pub inner: PixelValue,
115
}
116

            
117
impl Default for SelectionRadius {
118
4
    fn default() -> Self {
119
4
        Self {
120
4
            inner: PixelValue::zero(),
121
4
        }
122
4
    }
123
}
124

            
125
impl PrintAsCssValue for SelectionRadius {
126
179
    fn print_as_css_value(&self) -> String {
127
179
        self.inner.to_string()
128
179
    }
129
}
130

            
131
impl crate::codegen::format::FormatAsRustCode for SelectionRadius {
132
5
    fn format_as_rust_code(&self, _tabs: usize) -> String {
133
        // Use the Display implementation of PixelValue to get a string like "5px" or "1em"
134
5
        format!(
135
5
            "SelectionRadius {{ inner: PixelValue::from_metric(SizeMetric::{:?}, {}) }}",
136
            self.inner.metric,
137
5
            self.inner.number.get()
138
        )
139
5
    }
140
}
141

            
142
/// Parses a `-azul-selection-radius` CSS value.
143
#[cfg(feature = "parser")]
144
/// # Errors
145
///
146
/// Returns an error if `input` is not a valid CSS `selection-radius` value.
147
205
pub fn parse_selection_radius(input: &str) -> Result<SelectionRadius, CssPixelValueParseError<'_>> {
148
205
    parse_pixel_value(input).map(|inner| SelectionRadius { inner })
149
205
}
150

            
151
#[cfg(all(test, feature = "parser"))]
152
mod autotest_generated {
153
    use std::{
154
        collections::hash_map::DefaultHasher,
155
        hash::{Hash, Hasher},
156
    };
157

            
158
    use super::*;
159
    use crate::codegen::format::FormatAsRustCode;
160

            
161
    fn hash_of<T: Hash>(t: &T) -> u64 {
162
        let mut h = DefaultHasher::new();
163
        t.hash(&mut h);
164
        h.finish()
165
    }
166

            
167
    /// Every metric whose `Display` string round-trips through `parse_pixel_value`.
168
    ///
169
    /// `Vmin` is deliberately absent — see
170
    /// [`known_bug_vmin_radius_is_rejected_by_metric_table_order`].
171
    const ROUND_TRIPPABLE_METRICS: [SizeMetric; 11] = [
172
        SizeMetric::Px,
173
        SizeMetric::Pt,
174
        SizeMetric::Em,
175
        SizeMetric::Rem,
176
        SizeMetric::In,
177
        SizeMetric::Cm,
178
        SizeMetric::Mm,
179
        SizeMetric::Percent,
180
        SizeMetric::Vw,
181
        SizeMetric::Vh,
182
        SizeMetric::Vmax,
183
    ];
184

            
185
    /// Inputs that must never panic in *any* of the three parsers in this module,
186
    /// regardless of which one they were aimed at.
187
    fn hostile_corpus() -> Vec<String> {
188
        vec![
189
            String::new(),
190
            " ".to_string(),
191
            "\t\n\r\u{b}\u{c}".to_string(),
192
            "\u{a0}".to_string(),   // NBSP: is White_Space, so trim() eats it
193
            "\u{200b}".to_string(), // ZWSP: NOT White_Space, survives the trim
194
            "\0".to_string(),
195
            "#\0\0\0".to_string(),
196
            ";".to_string(),
197
            "}{".to_string(),
198
            ")rgb(".to_string(),
199
            "#".to_string(),
200
            "##".to_string(),
201
            "#zzz".to_string(),
202
            "#-fff000f".to_string(), // from_str_radix must not accept a sign here
203
            "rgb(".to_string(),
204
            "rgb()".to_string(),
205
            "rgb(1,2)".to_string(),
206
            "rgb(-1,-1,-1)".to_string(),
207
            "rgb(999,999,999)".to_string(),
208
            "rgba(0,0,0,NaN)".to_string(),
209
            "rgba(255,255,255,inf)".to_string(),
210
            "hsl(400,-10%,999%)".to_string(),
211
            "-".to_string(),
212
            "--".to_string(),
213
            "+".to_string(),
214
            "e".to_string(),
215
            "E9".to_string(),
216
            "NaN".to_string(),
217
            "inf".to_string(),
218
            "-inf".to_string(),
219
            "infinity".to_string(),
220
            "0x10px".to_string(),
221
            "px".to_string(),
222
            "vmin".to_string(),
223
            "5 5px".to_string(),
224
            "5px;".to_string(),
225
            "%".to_string(),
226
            "-0".to_string(),
227
            i64::MAX.to_string(),
228
            i64::MIN.to_string(),
229
            format!("{}px", i64::MAX),
230
            "1e400px".to_string(),
231
            "\u{1F600}".to_string(),
232
            "#\u{1F600}".to_string(),           // 4 *bytes* -> hits the #rgba branch
233
            "#\u{e9}1".to_string(),             // 3 *bytes* -> hits the #rgb branch
234
            "a\u{0301}\u{0301}\u{0301}".to_string(), // stacked combining marks
235
            "\u{202e}der".to_string(),          // RTL override
236
            "\u{130}".to_string(),              // dotted capital I: to_lowercase() expands
237
            "RED".to_string(),
238
            "red red".to_string(),
239
        ]
240
    }
241

            
242
    #[test]
243
    fn hostile_inputs_never_panic_in_any_selection_parser() {
244
        for input in hostile_corpus() {
245
            let _ = parse_selection_background_color(&input);
246
            let _ = parse_selection_color(&input);
247

            
248
            // Anything the radius parser *accepts* must be a finite fixed-point
249
            // value: `FloatValue` stores `value * 1000` in an isize, and the `as`
250
            // cast saturates (NaN -> 0, +-inf -> isize::MAX/MIN). No input may
251
            // smuggle a NaN/infinite length into the layout engine.
252
            if let Ok(r) = parse_selection_radius(&input) {
253
                assert!(
254
                    r.inner.number.get().is_finite(),
255
                    "{input:?} produced a non-finite radius"
256
                );
257
            }
258
        }
259
    }
260

            
261
    // --- empty / whitespace ---
262

            
263
    #[test]
264
    fn empty_and_whitespace_only_input_is_rejected() {
265
        // All of these trim to "" (U+00A0 and U+2003 have White_Space=yes).
266
        for blank in ["", " ", "   ", "\t\n", "\r\n\t ", "\u{a0}", "\u{2003}"] {
267
            assert!(
268
                matches!(
269
                    parse_selection_background_color(blank),
270
                    Err(CssColorParseError::EmptyInput)
271
                ),
272
                "{blank:?} should be EmptyInput"
273
            );
274
            assert!(
275
                matches!(
276
                    parse_selection_color(blank),
277
                    Err(CssColorParseError::EmptyInput)
278
                ),
279
                "{blank:?} should be EmptyInput"
280
            );
281
            assert!(
282
                matches!(
283
                    parse_selection_radius(blank),
284
                    Err(CssPixelValueParseError::EmptyString)
285
                ),
286
                "{blank:?} should be EmptyString"
287
            );
288
        }
289

            
290
        // ZWSP is *not* White_Space, so it is not trimmed away and must be
291
        // rejected as a value rather than reported as "empty".
292
        assert!(parse_selection_color("\u{200b}").is_err());
293
        assert!(parse_selection_radius("\u{200b}").is_err());
294
    }
295

            
296
    // --- garbage / malformed ---
297

            
298
    #[test]
299
    fn malformed_color_input_is_rejected_without_panicking() {
300
        for bad in [
301
            "#", "##", "#f", "#ff", "#fffff", "#zzz", "#gggggg", "rgb(", "rgb()", "rgb(1,2)",
302
            "rgb(1,2,3", "rgba(1,2,3)", "hsl(1,2)", "not-a-color", "}{", ";", ")rgb(",
303
            "rgb (0,0,0)", // CSS forbids a space before the paren
304
        ] {
305
            assert!(
306
                parse_selection_background_color(bad).is_err(),
307
                "{bad:?} was accepted as a background color"
308
            );
309
            assert!(
310
                parse_selection_color(bad).is_err(),
311
                "{bad:?} was accepted as a color"
312
            );
313
        }
314
    }
315

            
316
    #[test]
317
    fn malformed_radius_input_is_rejected_without_panicking() {
318
        for bad in [
319
            "px", "em", "%", "5 5px", "5px;", "px5", "5pxpx", "--5px", "5,px", "#5px", "5 px x",
320
            "0x10px", "e", "+",
321
        ] {
322
            assert!(
323
                parse_selection_radius(bad).is_err(),
324
                "{bad:?} was accepted as a radius"
325
            );
326
        }
327

            
328
        // A bare unit reports "the number is missing", not a generic parse error.
329
        assert!(matches!(
330
            parse_selection_radius("px"),
331
            Err(CssPixelValueParseError::NoValueGiven("px", SizeMetric::Px))
332
        ));
333
    }
334

            
335
    // --- leading / trailing junk ---
336

            
337
    #[test]
338
    fn surrounding_whitespace_is_trimmed_but_trailing_junk_is_rejected() {
339
        // Trimmed, deterministically.
340
        assert_eq!(
341
            parse_selection_background_color("  #ff0000ff  ").unwrap().inner,
342
            ColorU::new(255, 0, 0, 255)
343
        );
344
        assert_eq!(
345
            parse_selection_color("\t red \n").unwrap().inner,
346
            ColorU::new(255, 0, 0, 255)
347
        );
348
        assert_eq!(
349
            parse_selection_radius("   5px \n").unwrap().inner,
350
            PixelValue::px(5.0)
351
        );
352
        // Internal whitespace between number and unit is trimmed too.
353
        assert_eq!(
354
            parse_selection_radius("5   px").unwrap().inner,
355
            PixelValue::px(5.0)
356
        );
357

            
358
        // Rejected: a valid value followed by junk is never silently truncated.
359
        for junk in ["#ff0000;", "red;", "red garbage", "#ff0000ff extra"] {
360
            assert!(
361
                parse_selection_color(junk).is_err(),
362
                "{junk:?} was silently truncated to a valid color"
363
            );
364
        }
365
        for junk in ["5px;", "5px garbage", "5px 6px"] {
366
            assert!(
367
                parse_selection_radius(junk).is_err(),
368
                "{junk:?} was silently truncated to a valid radius"
369
            );
370
        }
371
    }
372

            
373
    // --- extremely long / deeply nested ---
374

            
375
    #[test]
376
    fn extremely_long_input_neither_panics_nor_hangs() {
377
        let long_hex = format!("#{}", "f".repeat(1_000_000));
378
        assert!(parse_selection_background_color(&long_hex).is_err());
379
        assert!(parse_selection_color(&long_hex).is_err());
380

            
381
        let long_word = "z".repeat(1_000_000);
382
        assert!(parse_selection_color(&long_word).is_err());
383
        assert!(parse_selection_radius(&long_word).is_err());
384

            
385
        // 1M digits overflow f32 to +inf, which the fixed-point cast then
386
        // saturates to isize::MAX -> a huge but *finite* length.
387
        let long_number = format!("{}px", "9".repeat(1_000_000));
388
        let r = parse_selection_radius(&long_number).unwrap();
389
        assert!(r.inner.number.get().is_finite());
390
        assert_eq!(r.inner, PixelValue::px(f32::INFINITY));
391
    }
392

            
393
    #[test]
394
    fn deeply_nested_input_does_not_stack_overflow() {
395
        let open = "(".repeat(10_000);
396
        assert!(parse_selection_color(&open).is_err());
397
        assert!(parse_selection_radius(&open).is_err());
398

            
399
        let unclosed = "rgb(".repeat(10_000);
400
        assert!(parse_selection_background_color(&unclosed).is_err());
401

            
402
        let balanced = format!("{}{}", "rgb(".repeat(10_000), ")".repeat(10_000));
403
        assert!(parse_selection_background_color(&balanced).is_err());
404
    }
405

            
406
    // --- unicode ---
407

            
408
    #[test]
409
    fn unicode_input_is_rejected_without_slicing_through_a_char() {
410
        // `parse_color_no_hash` dispatches on the *byte* length, so multi-byte
411
        // input can reach the 3-/4-byte hex branches. It must error there rather
412
        // than index into the middle of a char.
413
        for u in [
414
            "#\u{e9}1",       // 2-byte é + '1' == 3 bytes
415
            "#\u{1F600}",     // 4-byte emoji == the #rgba branch
416
            "#\u{e9}\u{e9}\u{e9}", // 6 bytes == the from_str_radix branch
417
            "\u{1F600}",
418
            "a\u{0301}\u{0301}",
419
            "\u{202e}red",
420
            "\u{130}", // to_lowercase() expands this to two chars
421
            "r\u{e9}d",
422
        ] {
423
            assert!(
424
                parse_selection_background_color(u).is_err(),
425
                "{u:?} was accepted as a color"
426
            );
427
            assert!(parse_selection_color(u).is_err(), "{u:?} was accepted");
428
            assert!(parse_selection_radius(u).is_err(), "{u:?} was accepted");
429
        }
430
    }
431

            
432
    // --- numeric boundaries / saturation ---
433

            
434
    #[test]
435
    fn radius_nan_and_infinity_saturate_instead_of_leaking_into_layout() {
436
        // LENIENCY (worth knowing): `parse_pixel_value` falls back to
437
        // `str::parse::<f32>()`, which accepts "NaN"/"inf"/"infinity". CSS does
438
        // not. The values are at least clamped by the fixed-point cast, so no
439
        // NaN/inf ever reaches layout — that saturation is what is pinned here.
440
        let nan = parse_selection_radius("NaN").unwrap();
441
        assert_eq!(nan.inner.number.get(), 0.0, "NaN must collapse to 0");
442
        assert!(nan.inner.number.get().is_finite());
443

            
444
        let pos_inf = parse_selection_radius("inf").unwrap();
445
        assert!(pos_inf.inner.number.get().is_finite());
446
        assert!(pos_inf.inner.number.get() > 0.0);
447

            
448
        let neg_inf = parse_selection_radius("-inf").unwrap();
449
        assert!(neg_inf.inner.number.get().is_finite());
450
        assert!(neg_inf.inner.number.get() < 0.0);
451

            
452
        // Every route to +inf saturates to the *same* clamped value.
453
        assert_eq!(pos_inf.inner, PixelValue::px(f32::INFINITY));
454
        assert_eq!(
455
            parse_selection_radius("1e40px").unwrap().inner,
456
            PixelValue::px(f32::INFINITY)
457
        );
458
        assert_eq!(
459
            parse_selection_radius(&format!("{}px", i64::MAX))
460
                .unwrap()
461
                .inner,
462
            PixelValue::px(f32::INFINITY)
463
        );
464
        assert_eq!(
465
            parse_selection_radius(&format!("{}px", i64::MIN))
466
                .unwrap()
467
                .inner,
468
            PixelValue::px(f32::NEG_INFINITY)
469
        );
470
    }
471

            
472
    #[test]
473
    fn radius_zero_boundaries_are_normalized() {
474
        assert_eq!(parse_selection_radius("0px").unwrap().inner, PixelValue::px(0.0));
475
        // -0.0 * 1000 -> -0.0 -> `as isize` -> 0: no distinct negative zero survives.
476
        assert_eq!(
477
            parse_selection_radius("-0px").unwrap().inner,
478
            parse_selection_radius("0px").unwrap().inner
479
        );
480
        assert_eq!(
481
            parse_selection_radius("-0.0px").unwrap().inner,
482
            PixelValue::zero()
483
        );
484

            
485
        // Sub-milli values truncate to zero (the fixed-point grid is 1/1000).
486
        assert_eq!(
487
            parse_selection_radius("1e-45px").unwrap().inner.number.get(),
488
            0.0
489
        );
490
        assert_eq!(
491
            parse_selection_radius("0.0001px").unwrap().inner.number.get(),
492
            0.0
493
        );
494
        // ...but the smallest representable step survives intact.
495
        assert_eq!(
496
            parse_selection_radius("0.001px").unwrap().inner.number.get(),
497
            0.001
498
        );
499
    }
500

            
501
    #[test]
502
    fn radius_accepts_unitless_numbers_which_css_would_reject() {
503
        // LENIENCY: CSS only allows a unitless length for `0`. This parser treats
504
        // *any* bare number as px. Pinned so a future tightening is a visible,
505
        // deliberate change rather than a silent one.
506
        assert_eq!(parse_selection_radius("12").unwrap().inner, PixelValue::px(12.0));
507
        assert_eq!(parse_selection_radius("-7.5").unwrap().inner, PixelValue::px(-7.5));
508
        assert_eq!(parse_selection_radius("1e3").unwrap().inner, PixelValue::px(1000.0));
509
    }
510

            
511
    #[test]
512
    fn color_component_boundaries_are_enforced() {
513
        assert_eq!(
514
            parse_selection_color("rgb(0,0,0)").unwrap().inner,
515
            ColorU::new(0, 0, 0, 255)
516
        );
517
        assert_eq!(
518
            parse_selection_color("rgb(255,255,255)").unwrap().inner,
519
            ColorU::new(255, 255, 255, 255)
520
        );
521
        // Out-of-range / negative / non-numeric components must not wrap around.
522
        for bad in [
523
            "rgb(256,0,0)",
524
            "rgb(-1,0,0)",
525
            "rgb(999999999999999999999,0,0)",
526
            "rgba(0,0,0,2.5)",
527
            "rgba(0,0,0,-1)",
528
        ] {
529
            assert!(
530
                parse_selection_background_color(bad).is_err(),
531
                "{bad:?} was accepted — a component wrapped instead of erroring"
532
            );
533
        }
534
    }
535

            
536
    // --- round-trip: encode == decode ---
537

            
538
    #[test]
539
    fn background_color_round_trips_through_print_as_css_value() {
540
        for c in [
541
            ColorU::new(0, 0, 0, 0),
542
            ColorU::new(255, 255, 255, 255),
543
            ColorU::new(0, 0, 0, 255),
544
            ColorU::new(173, 214, 255, 255),
545
            ColorU::new(1, 2, 3, 4),
546
            ColorU::new(254, 253, 252, 251),
547
            ColorU::new(255, 0, 128, 1),
548
            SelectionBackgroundColor::default().inner,
549
        ] {
550
            let v = SelectionBackgroundColor { inner: c };
551
            let printed = v.print_as_css_value();
552
            let reparsed = parse_selection_background_color(&printed)
553
                .unwrap_or_else(|e| panic!("{printed:?} did not re-parse: {e:?}"));
554
            assert_eq!(reparsed, v, "round-trip changed the value via {printed:?}");
555
            // ...and printing is idempotent (a fixpoint, not a drift).
556
            assert_eq!(reparsed.print_as_css_value(), printed);
557
        }
558
    }
559

            
560
    #[test]
561
    fn selection_color_round_trips_through_print_as_css_value() {
562
        for c in [
563
            ColorU::new(0, 0, 0, 0),
564
            ColorU::new(255, 255, 255, 255),
565
            ColorU::new(18, 52, 86, 120),
566
            ColorU::new(255, 0, 0, 255),
567
            SelectionColor::default().inner,
568
        ] {
569
            let v = SelectionColor { inner: c };
570
            let printed = v.print_as_css_value();
571
            let reparsed = parse_selection_color(&printed)
572
                .unwrap_or_else(|e| panic!("{printed:?} did not re-parse: {e:?}"));
573
            assert_eq!(reparsed, v);
574
            assert_eq!(reparsed.print_as_css_value(), printed);
575
        }
576
    }
577

            
578
    #[test]
579
    fn every_byte_of_the_color_channels_survives_the_round_trip() {
580
        // The 8-digit hex writer and the from_str_radix reader must agree on
581
        // channel order for *every* channel value, not just the pretty ones.
582
        for b in 0u8..=255 {
583
            let c = ColorU::new(b, 255 - b, b.wrapping_mul(3), 255 - b / 2);
584
            let v = SelectionColor { inner: c };
585
            assert_eq!(
586
                parse_selection_color(&v.print_as_css_value()).unwrap(),
587
                v,
588
                "channel round-trip failed for {c:?}"
589
            );
590
        }
591
    }
592

            
593
    #[test]
594
    fn radius_round_trips_for_every_metric_except_vmin() {
595
        for metric in ROUND_TRIPPABLE_METRICS {
596
            for value in [0.0_f32, 1.0, 1.5, 12.0, -3.25, 0.001, -0.5, 999.999] {
597
                let v = SelectionRadius {
598
                    inner: PixelValue::from_metric(metric, value),
599
                };
600
                let printed = v.print_as_css_value();
601
                let reparsed = parse_selection_radius(&printed)
602
                    .unwrap_or_else(|e| panic!("{printed:?} did not re-parse: {e:?}"));
603
                assert_eq!(
604
                    reparsed, v,
605
                    "round-trip changed {value} {metric:?} via {printed:?}"
606
                );
607
                assert_eq!(reparsed.inner.metric, metric);
608
                assert_eq!(reparsed.print_as_css_value(), printed);
609
            }
610
        }
611
    }
612

            
613
    /// KNOWN BUG (root cause characterized in `props::basic::pixel`): the metric
614
    /// table in `parse_pixel_value` tests the `"in"` suffix *before* `"vmin"`, so
615
    /// `5vmin` strips to `5vm`, which is not an f32. Every `vmin` selection radius
616
    /// is therefore rejected outright, even though it is valid CSS.
617
    ///
618
    /// WHEN pixel.rs IS FIXED (longest-suffix-first, or move vmax/vmin ahead of
619
    /// "in"), this test fails — replace it with the positive assertion:
620
    ///     `assert_eq!(parse_selection_radius("5vmin").unwrap().inner.metric`, `SizeMetric::Vmin`);
621
    /// and add `SizeMetric::Vmin` to `ROUND_TRIPPABLE_METRICS`.
622
    #[test]
623
    fn known_bug_vmin_radius_is_rejected_by_metric_table_order() {
624
        // FIXED (as this pin's own message instructed): the metric-order bug is fixed,
625
        // so "5vmin" now parses to SizeMetric::Vmin.
626
        assert_eq!(
627
            parse_selection_radius("5vmin").unwrap().inner.metric,
628
            SizeMetric::Vmin
629
        );
630

            
631
        // And it now round-trips instead of being print-only:
632
        let v = SelectionRadius {
633
            inner: PixelValue::from_metric(SizeMetric::Vmin, 5.0),
634
        };
635
        assert_eq!(v.print_as_css_value(), "5vmin");
636
        assert_eq!(
637
            parse_selection_radius(&v.print_as_css_value()).unwrap().inner.metric,
638
            SizeMetric::Vmin
639
        );
640

            
641
        // The sibling viewport units are fine — only the unit that *ends in* an
642
        // earlier metric is shadowed, which is what makes this easy to miss.
643
        assert_eq!(
644
            parse_selection_radius("5vmax").unwrap().inner.metric,
645
            SizeMetric::Vmax
646
        );
647
        assert_eq!(
648
            parse_selection_radius("5vw").unwrap().inner.metric,
649
            SizeMetric::Vw
650
        );
651
        assert_eq!(
652
            parse_selection_radius("5vh").unwrap().inner.metric,
653
            SizeMetric::Vh
654
        );
655
        assert_eq!(
656
            parse_selection_radius("5in").unwrap().inner.metric,
657
            SizeMetric::In
658
        );
659
    }
660

            
661
    /// KNOWN BUG: the 6-/8-digit hex branches parse with `u32::from_str_radix`,
662
    /// which accepts a leading `+`. So `#+fff000f` is accepted as a color even
663
    /// though `+` is not a hex digit and this is not valid CSS. The 3-/4-digit
664
    /// branches use a per-byte hex decoder and correctly reject it.
665
    ///
666
    /// WHEN color.rs IS FIXED (reject any non-hex-digit byte before the radix
667
    /// conversion), this test fails — flip the two `unwrap()`s to `is_err()`.
668
    #[test]
669
    fn hex_color_rejects_a_leading_plus_sign() {
670
        // FIXED (as this pin's own message instructed): the 6-/8-digit branches now
671
        // reject any non-hex-digit byte before the radix conversion, so a leading '+'
672
        // (which u32::from_str_radix used to swallow) is an error like every other
673
        // non-hex character.
674
        assert!(parse_selection_color("#+fff000f").is_err());
675
        assert!(parse_selection_background_color("#+fff00").is_err());
676

            
677
        // A leading '-' was already rejected (unsigned from_str_radix refuses it), and
678
        // the short branches reject '+' too via the per-byte hex decoder.
679
        assert!(parse_selection_color("#-fff000f").is_err());
680
        assert!(parse_selection_color("#+ff").is_err());
681
        assert!(parse_selection_color("#+fff").is_err());
682
    }
683

            
684
    // --- defaults / getters / invariants ---
685

            
686
    #[test]
687
    fn defaults_are_the_documented_values_and_re_parse_to_themselves() {
688
        assert_eq!(
689
            SelectionBackgroundColor::default().inner,
690
            ColorU::new(173, 214, 255, 255)
691
        );
692
        assert_eq!(SelectionBackgroundColor::default().inner, DEFAULT_SELECTION_BG);
693
        assert_eq!(SelectionColor::default().inner, ColorU::BLACK);
694
        assert_eq!(SelectionRadius::default().inner, PixelValue::zero());
695

            
696
        assert_eq!(
697
            SelectionBackgroundColor::default().print_as_css_value(),
698
            "#add6ffff"
699
        );
700
        assert_eq!(SelectionColor::default().print_as_css_value(), "#000000ff");
701
        assert_eq!(SelectionRadius::default().print_as_css_value(), "0px");
702

            
703
        assert_eq!(
704
            parse_selection_background_color("#add6ffff").unwrap(),
705
            SelectionBackgroundColor::default()
706
        );
707
        assert_eq!(
708
            parse_selection_color("#000000ff").unwrap(),
709
            SelectionColor::default()
710
        );
711
        assert_eq!(
712
            parse_selection_radius("0px").unwrap(),
713
            SelectionRadius::default()
714
        );
715
    }
716

            
717
    #[test]
718
    fn equal_values_hash_and_compare_equal() {
719
        let a = SelectionColor { inner: ColorU::new(1, 2, 3, 4) };
720
        let b = SelectionColor { inner: ColorU::new(1, 2, 3, 4) };
721
        assert_eq!(a, b);
722
        assert_eq!(hash_of(&a), hash_of(&b));
723
        assert_eq!(a.cmp(&b), core::cmp::Ordering::Equal);
724

            
725
        let black = SelectionColor { inner: ColorU::new(0, 0, 0, 255) };
726
        let white = SelectionColor { inner: ColorU::new(255, 255, 255, 255) };
727
        assert!(black < white);
728
        assert!(white > black);
729

            
730
        // Parsing the same input twice is deterministic (no interior state).
731
        assert_eq!(
732
            parse_selection_radius("1.5em").unwrap(),
733
            parse_selection_radius("1.5em").unwrap()
734
        );
735
        assert_eq!(
736
            hash_of(&parse_selection_radius("1.5em").unwrap()),
737
            hash_of(&parse_selection_radius("1.5em").unwrap())
738
        );
739
    }
740

            
741
    #[test]
742
    fn format_as_rust_code_emits_a_constructor_for_each_type() {
743
        let radius = SelectionRadius {
744
            inner: PixelValue::px(5.0),
745
        };
746
        assert_eq!(
747
            radius.format_as_rust_code(0),
748
            "SelectionRadius { inner: PixelValue::from_metric(SizeMetric::Px, 5) }"
749
        );
750
        assert_eq!(
751
            SelectionRadius {
752
                inner: PixelValue::from_metric(SizeMetric::Percent, 50.0),
753
            }
754
            .format_as_rust_code(0),
755
            "SelectionRadius { inner: PixelValue::from_metric(SizeMetric::Percent, 50) }"
756
        );
757
        assert_eq!(
758
            SelectionRadius::default().format_as_rust_code(0),
759
            "SelectionRadius { inner: PixelValue::from_metric(SizeMetric::Px, 0) }"
760
        );
761

            
762
        // The colour formatter's exact spelling belongs to codegen::format; only
763
        // the wrapper shape is this module's contract.
764
        let bg = SelectionBackgroundColor::default().format_as_rust_code(0);
765
        assert!(bg.starts_with("SelectionBackgroundColor { inner: "), "{bg}");
766
        assert!(bg.ends_with(" }"), "{bg}");
767
        let fg = SelectionColor::default().format_as_rust_code(0);
768
        assert!(fg.starts_with("SelectionColor { inner: "), "{fg}");
769
        assert!(fg.ends_with(" }"), "{fg}");
770

            
771
        // Indentation is documented as ignored — every depth prints the same.
772
        assert_eq!(radius.format_as_rust_code(0), radius.format_as_rust_code(9));
773
    }
774

            
775
    #[test]
776
    fn parse_errors_survive_the_owned_round_trip() {
777
        // The borrowed errors are re-hydrated from their owned form (used to send
778
        // errors across the FFI boundary); the message must not change.
779
        let color_err = parse_selection_color("not-a-color").unwrap_err();
780
        let owned: CssColorParseErrorOwned = color_err.to_contained();
781
        assert_eq!(format!("{:?}", owned.to_shared()), format!("{color_err:?}"));
782

            
783
        let px_err = parse_selection_radius("5 5px").unwrap_err();
784
        let owned_px: CssPixelValueParseErrorOwned = px_err.to_contained();
785
        assert_eq!(format!("{:?}", owned_px.to_shared()), format!("{px_err:?}"));
786

            
787
        // An error message must never be empty — it is surfaced to stylesheet authors.
788
        assert!(!format!("{color_err:?}").is_empty());
789
        assert!(!format!("{px_err:?}").is_empty());
790
    }
791

            
792
    #[test]
793
    fn the_two_color_properties_agree_on_every_input() {
794
        // Both delegate to `parse_css_color`; the only difference is the wrapper.
795
        // A divergence would mean one of them grew its own (wrong) grammar.
796
        for input in hostile_corpus() {
797
            let bg = parse_selection_background_color(&input);
798
            let fg = parse_selection_color(&input);
799
            match (bg, fg) {
800
                (Ok(b), Ok(f)) => assert_eq!(b.inner, f.inner, "diverged on {input:?}"),
801
                (Err(_), Err(_)) => {}
802
                (b, f) => panic!("{input:?} parsed inconsistently: {b:?} vs {f:?}"),
803
            }
804
        }
805
    }
806
}