1
//! CSS properties for table layout and styling.
2
//!
3
//! This module contains properties specific to CSS table formatting:
4
//! - `table-layout`: Controls the algorithm used to layout table cells, rows, and columns
5
//! - `border-collapse`: Specifies whether cell borders are collapsed into a single border or
6
//!   separated
7
//! - `border-spacing`: Sets the distance between borders of adjacent cells (separate borders only)
8
//! - `caption-side`: Specifies the placement of a table caption
9
//! - `empty-cells`: Specifies whether or not to display borders on empty cells in a table
10

            
11
use alloc::string::{String, ToString};
12

            
13
use crate::{
14
    codegen::format::FormatAsRustCode,
15
    props::{
16
        basic::pixel::{CssPixelValueParseError, PixelValue},
17
        formatter::PrintAsCssValue,
18
    },
19
};
20

            
21
// table-layout
22

            
23
/// Controls the algorithm used to lay out table cells, rows, and columns.
24
///
25
/// The `table-layout` property determines whether the browser should use:
26
/// - **auto**: Column widths are determined by the content (slower but flexible)
27
/// - **fixed**: Column widths are determined by the first row (faster and predictable)
28
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
29
#[repr(C)]
30
#[derive(Default)]
31
pub enum LayoutTableLayout {
32
    /// Use automatic table layout algorithm (content-based, default).
33
    /// Column width is set by the widest unbreakable content in the cells.
34
    #[default]
35
    Auto,
36
    /// Use fixed table layout algorithm (first-row-based).
37
    /// Column width is set by the width property of the column or first-row cell.
38
    /// Renders faster than auto.
39
    Fixed,
40
}
41

            
42

            
43
impl PrintAsCssValue for LayoutTableLayout {
44
5
    fn print_as_css_value(&self) -> String {
45
5
        match self {
46
3
            Self::Auto => "auto".to_string(),
47
2
            Self::Fixed => "fixed".to_string(),
48
        }
49
5
    }
50
}
51

            
52
impl FormatAsRustCode for LayoutTableLayout {
53
3
    fn format_as_rust_code(&self, _tabs: usize) -> String {
54
3
        match self {
55
2
            Self::Auto => "LayoutTableLayout::Auto".to_string(),
56
1
            Self::Fixed => "LayoutTableLayout::Fixed".to_string(),
57
        }
58
3
    }
59
}
60

            
61
// border-collapse
62

            
63
/// Specifies whether cell borders are collapsed into a single border or separated.
64
///
65
/// The `border-collapse` property determines the border rendering model:
66
/// - **separate**: Each cell has its own border (default, uses border-spacing)
67
/// - **collapse**: Adjacent cells share borders (ignores border-spacing)
68
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
69
#[repr(C)]
70
#[derive(Default)]
71
pub enum StyleBorderCollapse {
72
    /// Borders are separated (default). Each cell has its own border.
73
    /// The `border-spacing` property defines the distance between borders.
74
    #[default]
75
    Separate,
76
    /// Borders are collapsed. Adjacent cells share a single border.
77
    /// Border conflict resolution rules apply when borders differ.
78
    Collapse,
79
}
80

            
81

            
82
impl PrintAsCssValue for StyleBorderCollapse {
83
4
    fn print_as_css_value(&self) -> String {
84
4
        match self {
85
2
            Self::Separate => "separate".to_string(),
86
2
            Self::Collapse => "collapse".to_string(),
87
        }
88
4
    }
89
}
90

            
91
impl FormatAsRustCode for StyleBorderCollapse {
92
1
    fn format_as_rust_code(&self, _tabs: usize) -> String {
93
1
        match self {
94
            Self::Separate => "StyleBorderCollapse::Separate".to_string(),
95
1
            Self::Collapse => "StyleBorderCollapse::Collapse".to_string(),
96
        }
97
1
    }
98
}
99

            
100
// border-spacing
101

            
102
/// Sets the distance between the borders of adjacent cells.
103
///
104
/// The `border-spacing` property is only applicable when `border-collapse` is set to `separate`.
105
/// It can have one or two values:
106
/// - One value: Sets both horizontal and vertical spacing
107
/// - Two values: First is horizontal, second is vertical
108
///
109
/// This struct represents a single spacing value (either horizontal or vertical).
110
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
111
#[repr(C)]
112
pub struct LayoutBorderSpacing {
113
    /// Horizontal spacing between cell borders
114
    pub horizontal: PixelValue,
115
    /// Vertical spacing between cell borders
116
    pub vertical: PixelValue,
117
}
118

            
119
impl Default for LayoutBorderSpacing {
120
434
    fn default() -> Self {
121
        // Default border-spacing is 0 (no spacing)
122
434
        Self {
123
434
            horizontal: PixelValue::const_px(0),
124
434
            vertical: PixelValue::const_px(0),
125
434
        }
126
434
    }
127
}
128

            
129
impl LayoutBorderSpacing {
130
    /// Creates a new border spacing with the same value for horizontal and vertical
131
116
    #[must_use] pub const fn new(spacing: PixelValue) -> Self {
132
116
        Self {
133
116
            horizontal: spacing,
134
116
            vertical: spacing,
135
116
        }
136
116
    }
137

            
138
    /// Creates a new border spacing with different horizontal and vertical values
139
796
    #[must_use] pub const fn new_separate(horizontal: PixelValue, vertical: PixelValue) -> Self {
140
796
        Self {
141
796
            horizontal,
142
796
            vertical,
143
796
        }
144
796
    }
145
}
146

            
147
impl PrintAsCssValue for LayoutBorderSpacing {
148
128
    fn print_as_css_value(&self) -> String {
149
128
        if self.horizontal == self.vertical {
150
            // Single value: same for both dimensions
151
36
            self.horizontal.to_string()
152
        } else {
153
            // Two values: horizontal vertical
154
92
            format!("{} {}", self.horizontal, self.vertical)
155
        }
156
128
    }
157
}
158

            
159
impl FormatAsRustCode for LayoutBorderSpacing {
160
7
    fn format_as_rust_code(&self, _tabs: usize) -> String {
161
        use crate::codegen::format::format_pixel_value;
162
7
        format!(
163
7
            "LayoutBorderSpacing {{ horizontal: {}, vertical: {} }}",
164
7
            format_pixel_value(&self.horizontal),
165
7
            format_pixel_value(&self.vertical)
166
        )
167
7
    }
168
}
169

            
170
// caption-side
171

            
172
/// Specifies the placement of a table caption.
173
///
174
/// The `caption-side` property positions the caption either above or below the table.
175
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
176
#[repr(C)]
177
#[derive(Default)]
178
pub enum StyleCaptionSide {
179
    /// Caption is placed above the table (default)
180
    #[default]
181
    Top,
182
    /// Caption is placed below the table
183
    Bottom,
184
}
185

            
186

            
187
impl PrintAsCssValue for StyleCaptionSide {
188
4
    fn print_as_css_value(&self) -> String {
189
4
        match self {
190
2
            Self::Top => "top".to_string(),
191
2
            Self::Bottom => "bottom".to_string(),
192
        }
193
4
    }
194
}
195

            
196
impl FormatAsRustCode for StyleCaptionSide {
197
1
    fn format_as_rust_code(&self, _tabs: usize) -> String {
198
1
        match self {
199
            Self::Top => "StyleCaptionSide::Top".to_string(),
200
1
            Self::Bottom => "StyleCaptionSide::Bottom".to_string(),
201
        }
202
1
    }
203
}
204

            
205
// empty-cells
206

            
207
/// Specifies whether or not to display borders and background on empty cells.
208
///
209
/// The `empty-cells` property only applies when `border-collapse` is set to `separate`.
210
/// A cell is considered empty if it contains no visible content.
211
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
212
#[repr(C)]
213
#[derive(Default)]
214
pub enum StyleEmptyCells {
215
    /// Show borders and background on empty cells (default)
216
    #[default]
217
    Show,
218
    /// Hide borders and background on empty cells
219
    Hide,
220
}
221

            
222

            
223
impl PrintAsCssValue for StyleEmptyCells {
224
4
    fn print_as_css_value(&self) -> String {
225
4
        match self {
226
2
            Self::Show => "show".to_string(),
227
2
            Self::Hide => "hide".to_string(),
228
        }
229
4
    }
230
}
231

            
232
impl FormatAsRustCode for StyleEmptyCells {
233
1
    fn format_as_rust_code(&self, _tabs: usize) -> String {
234
1
        match self {
235
            Self::Show => "StyleEmptyCells::Show".to_string(),
236
1
            Self::Hide => "StyleEmptyCells::Hide".to_string(),
237
        }
238
1
    }
239
}
240

            
241
// Parsing Functions
242

            
243
/// Parse errors for table-layout property
244
#[derive(Debug, Clone, PartialEq)]
245
pub(crate) enum LayoutTableLayoutParseError<'a> {
246
    InvalidKeyword(&'a str),
247
}
248

            
249
/// Parse a table-layout value from a string
250
89
pub(crate) fn parse_table_layout(
251
89
    input: &str,
252
89
) -> Result<LayoutTableLayout, LayoutTableLayoutParseError<'_>> {
253
89
    match input.trim() {
254
89
        "auto" => Ok(LayoutTableLayout::Auto),
255
81
        "fixed" => Ok(LayoutTableLayout::Fixed),
256
77
        other => Err(LayoutTableLayoutParseError::InvalidKeyword(other)),
257
    }
258
89
}
259

            
260
/// Parse errors for border-collapse property
261
#[derive(Debug, Clone, PartialEq)]
262
pub(crate) enum StyleBorderCollapseParseError<'a> {
263
    InvalidKeyword(&'a str),
264
}
265

            
266
/// Parse a border-collapse value from a string
267
130
pub(crate) fn parse_border_collapse(
268
130
    input: &str,
269
130
) -> Result<StyleBorderCollapse, StyleBorderCollapseParseError<'_>> {
270
130
    match input.trim() {
271
130
        "separate" => Ok(StyleBorderCollapse::Separate),
272
127
        "collapse" => Ok(StyleBorderCollapse::Collapse),
273
74
        other => Err(StyleBorderCollapseParseError::InvalidKeyword(other)),
274
    }
275
130
}
276

            
277
/// Parse errors for border-spacing property
278
#[derive(Debug, Clone, PartialEq)]
279
pub(crate) enum LayoutBorderSpacingParseError<'a> {
280
    PixelValue(CssPixelValueParseError<'a>),
281
    InvalidFormat,
282
}
283

            
284
/// Parse a border-spacing value from a string
285
/// Accepts: "5px" or "5px 10px"
286
198
pub(crate) fn parse_border_spacing(
287
198
    input: &str,
288
198
) -> Result<LayoutBorderSpacing, LayoutBorderSpacingParseError<'_>> {
289
    use crate::props::basic::parse_pixel_value;
290

            
291
198
    let parts: Vec<&str> = input.split_whitespace().collect();
292

            
293
198
    match parts.len() {
294
        1 => {
295
            // Single value: use for both horizontal and vertical
296
69
            let value =
297
81
                parse_pixel_value(parts[0]).map_err(LayoutBorderSpacingParseError::PixelValue)?;
298
69
            Ok(LayoutBorderSpacing::new(value))
299
        }
300
        2 => {
301
            // Two values: horizontal vertical
302
106
            let horizontal =
303
106
                parse_pixel_value(parts[0]).map_err(LayoutBorderSpacingParseError::PixelValue)?;
304
103
            let vertical =
305
106
                parse_pixel_value(parts[1]).map_err(LayoutBorderSpacingParseError::PixelValue)?;
306
103
            Ok(LayoutBorderSpacing::new_separate(horizontal, vertical))
307
        }
308
11
        _ => Err(LayoutBorderSpacingParseError::InvalidFormat),
309
    }
310
198
}
311

            
312
/// Parse errors for caption-side property
313
#[derive(Debug, Clone, PartialEq)]
314
pub(crate) enum StyleCaptionSideParseError<'a> {
315
    InvalidKeyword(&'a str),
316
}
317

            
318
/// Parse a caption-side value from a string
319
82
pub(crate) fn parse_caption_side(
320
82
    input: &str,
321
82
) -> Result<StyleCaptionSide, StyleCaptionSideParseError<'_>> {
322
82
    match input.trim() {
323
82
        "top" => Ok(StyleCaptionSide::Top),
324
78
        "bottom" => Ok(StyleCaptionSide::Bottom),
325
74
        other => Err(StyleCaptionSideParseError::InvalidKeyword(other)),
326
    }
327
82
}
328

            
329
/// Parse errors for empty-cells property
330
#[derive(Debug, Clone, PartialEq)]
331
pub(crate) enum StyleEmptyCellsParseError<'a> {
332
    InvalidKeyword(&'a str),
333
}
334

            
335
/// Parse an empty-cells value from a string
336
83
pub(crate) fn parse_empty_cells(
337
83
    input: &str,
338
83
) -> Result<StyleEmptyCells, StyleEmptyCellsParseError<'_>> {
339
83
    match input.trim() {
340
83
        "show" => Ok(StyleEmptyCells::Show),
341
79
        "hide" => Ok(StyleEmptyCells::Hide),
342
74
        other => Err(StyleEmptyCellsParseError::InvalidKeyword(other)),
343
    }
344
83
}
345

            
346
#[cfg(test)]
347
mod tests {
348
    use super::*;
349

            
350
    #[test]
351
1
    fn test_parse_table_layout() {
352
1
        assert_eq!(parse_table_layout("auto").unwrap(), LayoutTableLayout::Auto);
353
1
        assert_eq!(
354
1
            parse_table_layout("fixed").unwrap(),
355
            LayoutTableLayout::Fixed
356
        );
357
1
        assert!(parse_table_layout("invalid").is_err());
358
1
    }
359

            
360
    #[test]
361
1
    fn test_parse_border_collapse() {
362
1
        assert_eq!(
363
1
            parse_border_collapse("separate").unwrap(),
364
            StyleBorderCollapse::Separate
365
        );
366
1
        assert_eq!(
367
1
            parse_border_collapse("collapse").unwrap(),
368
            StyleBorderCollapse::Collapse
369
        );
370
1
        assert!(parse_border_collapse("invalid").is_err());
371
1
    }
372

            
373
    #[test]
374
1
    fn test_parse_border_spacing() {
375
1
        let spacing1 = parse_border_spacing("5px").unwrap();
376
1
        assert_eq!(spacing1.horizontal, PixelValue::const_px(5));
377
1
        assert_eq!(spacing1.vertical, PixelValue::const_px(5));
378

            
379
1
        let spacing2 = parse_border_spacing("5px 10px").unwrap();
380
1
        assert_eq!(spacing2.horizontal, PixelValue::const_px(5));
381
1
        assert_eq!(spacing2.vertical, PixelValue::const_px(10));
382
1
    }
383

            
384
    #[test]
385
1
    fn test_parse_caption_side() {
386
1
        assert_eq!(parse_caption_side("top").unwrap(), StyleCaptionSide::Top);
387
1
        assert_eq!(
388
1
            parse_caption_side("bottom").unwrap(),
389
            StyleCaptionSide::Bottom
390
        );
391
1
        assert!(parse_caption_side("invalid").is_err());
392
1
    }
393

            
394
    #[test]
395
1
    fn test_parse_empty_cells() {
396
1
        assert_eq!(parse_empty_cells("show").unwrap(), StyleEmptyCells::Show);
397
1
        assert_eq!(parse_empty_cells("hide").unwrap(), StyleEmptyCells::Hide);
398
1
        assert!(parse_empty_cells("invalid").is_err());
399
1
    }
400
}
401

            
402
#[cfg(test)]
403
#[allow(clippy::float_cmp)] // exact comparisons are the point: FloatValue is fixed-point
404
mod autotest_generated {
405
    use crate::props::basic::SizeMetric;
406

            
407
    use super::*;
408

            
409
    // `FloatValue` stores millipixels in an `isize` (value * 1000, truncated via
410
    // `as`). `.number.number()` is that raw integer -- asserting on it keeps the
411
    // numeric tests exact instead of comparing floats.
412
    fn raw(p: PixelValue) -> isize {
413
        p.number.number()
414
    }
415

            
416
    fn table_layout_ok(s: &str) -> bool {
417
        parse_table_layout(s).is_ok()
418
    }
419
    fn border_collapse_ok(s: &str) -> bool {
420
        parse_border_collapse(s).is_ok()
421
    }
422
    fn caption_side_ok(s: &str) -> bool {
423
        parse_caption_side(s).is_ok()
424
    }
425
    fn empty_cells_ok(s: &str) -> bool {
426
        parse_empty_cells(s).is_ok()
427
    }
428

            
429
    /// Every keyword parser, type-erased to `&str -> bool` (is it accepted?), so
430
    /// the malformed-input cases can be asserted against all four at once.
431
    type KeywordParser = (&'static str, fn(&str) -> bool);
432
    const KEYWORD_PARSERS: &[KeywordParser] = &[
433
        ("table-layout", table_layout_ok),
434
        ("border-collapse", border_collapse_ok),
435
        ("caption-side", caption_side_ok),
436
        ("empty-cells", empty_cells_ok),
437
    ];
438

            
439
    fn assert_all_keyword_parsers_reject(input: &str, why: &str) {
440
        for (prop, parse) in KEYWORD_PARSERS {
441
            assert!(
442
                !parse(input),
443
                "{prop}: expected {input:?} to be rejected ({why}), but it parsed"
444
            );
445
        }
446
    }
447

            
448
    // ------------------------------------------------------------------------
449
    // constructors
450
    // ------------------------------------------------------------------------
451

            
452
    #[test]
453
    fn new_applies_the_same_spacing_to_both_axes() {
454
        let s = LayoutBorderSpacing::new(PixelValue::const_px(7));
455
        assert_eq!(s.horizontal, PixelValue::const_px(7));
456
        assert_eq!(s.vertical, PixelValue::const_px(7));
457
        assert_eq!(s.horizontal, s.vertical);
458
    }
459

            
460
    #[test]
461
    fn new_separate_preserves_argument_order() {
462
        // Asymmetric on purpose: a swapped assignment would still pass if both
463
        // axes shared a metric *and* a value.
464
        let s = LayoutBorderSpacing::new_separate(
465
            PixelValue::const_px(3),
466
            PixelValue::percent(50.0),
467
        );
468
        assert_eq!(s.horizontal, PixelValue::const_px(3));
469
        assert_eq!(s.vertical, PixelValue::percent(50.0));
470
        assert_ne!(s.horizontal, s.vertical);
471
    }
472

            
473
    #[test]
474
    fn constructors_do_not_panic_on_extreme_floats() {
475
        let extremes = [
476
            0.0f32,
477
            -0.0,
478
            1.0,
479
            -1.0,
480
            f32::EPSILON,
481
            f32::MIN_POSITIVE,
482
            -f32::MIN_POSITIVE,
483
            f32::MAX,
484
            f32::MIN,
485
            f32::INFINITY,
486
            f32::NEG_INFINITY,
487
            f32::NAN,
488
        ];
489

            
490
        for v in extremes {
491
            let same = LayoutBorderSpacing::new(PixelValue::px(v));
492
            assert_eq!(same.horizontal, same.vertical, "new({v}) must be symmetric");
493

            
494
            for w in extremes {
495
                let sep =
496
                    LayoutBorderSpacing::new_separate(PixelValue::px(v), PixelValue::em(w));
497
                assert_eq!(sep.horizontal.metric, SizeMetric::Px);
498
                assert_eq!(sep.vertical.metric, SizeMetric::Em);
499
                // Whatever the input float was, the stored value is a plain isize,
500
                // so it can never be NaN/inf once it lands in the struct.
501
                assert!(sep.horizontal.number.get().is_finite());
502
                assert!(sep.vertical.number.get().is_finite());
503
            }
504
        }
505
    }
506

            
507
    #[test]
508
    fn nan_spacing_is_flattened_to_zero_and_stays_comparable() {
509
        // `FloatValue::new` does `NaN * 1000.0 as isize`, and an `as` cast maps NaN
510
        // to 0. So a NaN spacing silently becomes 0px rather than poisoning Eq/Ord
511
        // (which are derived over the isize, not the float).
512
        let nan = LayoutBorderSpacing::new(PixelValue::px(f32::NAN));
513
        assert_eq!(raw(nan.horizontal), 0);
514
        assert_eq!(raw(nan.vertical), 0);
515

            
516
        // Reflexivity would fail here if equality were float-based.
517
        assert_eq!(nan, nan);
518
        assert_eq!(nan, LayoutBorderSpacing::new(PixelValue::px(f32::NAN)));
519
        assert_eq!(nan, LayoutBorderSpacing::new(PixelValue::px(0.0)));
520
        assert_eq!(nan, LayoutBorderSpacing::default());
521
    }
522

            
523
    #[test]
524
    fn infinite_spacing_saturates_instead_of_wrapping() {
525
        // `inf * 1000.0 as isize` saturates at the isize bounds; ditto any finite
526
        // f32 whose *1000 scaling overflows (f32::MAX).
527
        for input in [f32::INFINITY, f32::MAX, 1e38] {
528
            let s = LayoutBorderSpacing::new(PixelValue::px(input));
529
            assert_eq!(raw(s.horizontal), isize::MAX, "{input} must saturate high");
530
            assert!(s.horizontal.number.get().is_finite());
531
        }
532
        for input in [f32::NEG_INFINITY, f32::MIN, -1e38] {
533
            let s = LayoutBorderSpacing::new(PixelValue::px(input));
534
            assert_eq!(raw(s.horizontal), isize::MIN, "{input} must saturate low");
535
            assert!(s.horizontal.number.get().is_finite());
536
        }
537
    }
538

            
539
    #[test]
540
    fn equal_border_spacings_hash_equal() {
541
        use std::{
542
            collections::hash_map::DefaultHasher,
543
            hash::{Hash, Hasher},
544
        };
545

            
546
        fn hash(s: LayoutBorderSpacing) -> u64 {
547
            let mut h = DefaultHasher::new();
548
            s.hash(&mut h);
549
            h.finish()
550
        }
551

            
552
        // NaN and 0.0 collapse to the same stored value, so the Hash/Eq contract
553
        // must hold across them too.
554
        assert_eq!(
555
            hash(LayoutBorderSpacing::new(PixelValue::px(f32::NAN))),
556
            hash(LayoutBorderSpacing::default())
557
        );
558
        assert_eq!(
559
            hash(LayoutBorderSpacing::new(PixelValue::const_px(4))),
560
            hash(LayoutBorderSpacing::new_separate(
561
                PixelValue::const_px(4),
562
                PixelValue::const_px(4)
563
            ))
564
        );
565
    }
566

            
567
    #[test]
568
    fn defaults_are_the_css_initial_values() {
569
        assert_eq!(LayoutTableLayout::default(), LayoutTableLayout::Auto);
570
        assert_eq!(StyleBorderCollapse::default(), StyleBorderCollapse::Separate);
571
        assert_eq!(StyleCaptionSide::default(), StyleCaptionSide::Top);
572
        assert_eq!(StyleEmptyCells::default(), StyleEmptyCells::Show);
573

            
574
        let d = LayoutBorderSpacing::default();
575
        assert_eq!(d, LayoutBorderSpacing::new(PixelValue::const_px(0)));
576
        assert_eq!(raw(d.horizontal), 0);
577
        assert_eq!(raw(d.vertical), 0);
578
        assert_eq!(d.horizontal.metric, SizeMetric::Px);
579
    }
580

            
581
    // ------------------------------------------------------------------------
582
    // keyword parsers: table-layout / border-collapse / caption-side / empty-cells
583
    // ------------------------------------------------------------------------
584

            
585
    #[test]
586
    fn keyword_parsers_accept_every_variant() {
587
        // Positive controls -- exhaustive over the variants of each enum.
588
        assert_eq!(parse_table_layout("auto").unwrap(), LayoutTableLayout::Auto);
589
        assert_eq!(parse_table_layout("fixed").unwrap(), LayoutTableLayout::Fixed);
590
        assert_eq!(
591
            parse_border_collapse("separate").unwrap(),
592
            StyleBorderCollapse::Separate
593
        );
594
        assert_eq!(
595
            parse_border_collapse("collapse").unwrap(),
596
            StyleBorderCollapse::Collapse
597
        );
598
        assert_eq!(parse_caption_side("top").unwrap(), StyleCaptionSide::Top);
599
        assert_eq!(parse_caption_side("bottom").unwrap(), StyleCaptionSide::Bottom);
600
        assert_eq!(parse_empty_cells("show").unwrap(), StyleEmptyCells::Show);
601
        assert_eq!(parse_empty_cells("hide").unwrap(), StyleEmptyCells::Hide);
602
    }
603

            
604
    #[test]
605
    fn keyword_parsers_reject_empty_input() {
606
        assert_all_keyword_parsers_reject("", "empty input");
607
        assert_eq!(
608
            parse_table_layout(""),
609
            Err(LayoutTableLayoutParseError::InvalidKeyword(""))
610
        );
611
        assert_eq!(
612
            parse_border_collapse(""),
613
            Err(StyleBorderCollapseParseError::InvalidKeyword(""))
614
        );
615
        assert_eq!(
616
            parse_caption_side(""),
617
            Err(StyleCaptionSideParseError::InvalidKeyword(""))
618
        );
619
        assert_eq!(
620
            parse_empty_cells(""),
621
            Err(StyleEmptyCellsParseError::InvalidKeyword(""))
622
        );
623
    }
624

            
625
    #[test]
626
    fn keyword_parsers_reject_whitespace_only_input() {
627
        for input in ["   ", "\t", "\n", "\r\n", "\t\n \x0c", "\u{a0}", "\u{2028}"] {
628
            assert_all_keyword_parsers_reject(input, "whitespace only");
629
        }
630
        // The trim happens before the match, so the error payload is the *trimmed*
631
        // input -- i.e. the empty string, not the original blanks.
632
        assert_eq!(
633
            parse_table_layout("  \t\n "),
634
            Err(LayoutTableLayoutParseError::InvalidKeyword(""))
635
        );
636
    }
637

            
638
    #[test]
639
    fn keyword_parsers_trim_surrounding_whitespace() {
640
        assert_eq!(
641
            parse_table_layout("   auto\t\n").unwrap(),
642
            LayoutTableLayout::Auto
643
        );
644
        assert_eq!(
645
            parse_border_collapse("\r\n collapse  ").unwrap(),
646
            StyleBorderCollapse::Collapse
647
        );
648
        assert_eq!(
649
            parse_caption_side("\t bottom \t").unwrap(),
650
            StyleCaptionSide::Bottom
651
        );
652
        assert_eq!(parse_empty_cells("\n hide \n").unwrap(), StyleEmptyCells::Hide);
653
    }
654

            
655
    #[test]
656
    fn keyword_parsers_also_trim_non_css_unicode_whitespace() {
657
        // Characterized leniency: `str::trim` strips everything with the Unicode
658
        // White_Space property, but CSS whitespace is only space/tab/LF/CR/FF. So
659
        // NBSP- and LINE-SEPARATOR-padded keywords are accepted here even though a
660
        // conformant tokenizer would reject them.
661
        assert_eq!(
662
            parse_table_layout("\u{a0}auto\u{a0}").unwrap(),
663
            LayoutTableLayout::Auto
664
        );
665
        assert_eq!(
666
            parse_empty_cells("\u{2028}show\u{2029}").unwrap(),
667
            StyleEmptyCells::Show
668
        );
669
    }
670

            
671
    #[test]
672
    fn keyword_parse_errors_carry_the_trimmed_input() {
673
        // The error borrows from `input` (lifetime-tied) and reports the trimmed
674
        // slice, not the raw one.
675
        assert_eq!(
676
            parse_table_layout("  bogus  "),
677
            Err(LayoutTableLayoutParseError::InvalidKeyword("bogus"))
678
        );
679
        assert_eq!(
680
            parse_border_collapse(" separate collapse "),
681
            Err(StyleBorderCollapseParseError::InvalidKeyword(
682
                "separate collapse"
683
            ))
684
        );
685
        assert_eq!(
686
            parse_caption_side("\ttop;\t"),
687
            Err(StyleCaptionSideParseError::InvalidKeyword("top;"))
688
        );
689
        assert_eq!(
690
            parse_empty_cells(" show hide "),
691
            Err(StyleEmptyCellsParseError::InvalidKeyword("show hide"))
692
        );
693
    }
694

            
695
    #[test]
696
    fn keyword_parsers_are_case_sensitive() {
697
        // Conformance gap (characterized, matching `parse_pixel_value`): CSS
698
        // keywords are ASCII case-insensitive, so `table-layout: AUTO` is valid CSS
699
        // -- but the match arms only cover the lowercase spelling.
700
        for input in [
701
            "AUTO", "Auto", "aUtO", "FIXED", "SEPARATE", "Collapse", "TOP", "Bottom",
702
            "SHOW", "Hide",
703
        ] {
704
            assert_all_keyword_parsers_reject(input, "keyword matching is case-sensitive");
705
        }
706
    }
707

            
708
    #[test]
709
    fn keyword_parsers_reject_garbage() {
710
        for input in [
711
            "invalid",
712
            "auto fixed",
713
            "auto;",
714
            "auto;garbage",
715
            "auto/*c*/",
716
            "/*auto*/",
717
            "au to",
718
            "a\0uto",
719
            "\0",
720
            "\u{7f}",
721
            "-->",
722
            "<!--",
723
            "!important",
724
            "\\61 uto",
725
            "'auto'",
726
            "\"auto\"",
727
            "auto()",
728
            "url(auto)",
729
        ] {
730
            assert_all_keyword_parsers_reject(input, "garbage");
731
        }
732
    }
733

            
734
    #[test]
735
    fn keyword_parsers_reject_boundary_numeric_strings() {
736
        // None of these properties take a number; the numeric boundary cases must
737
        // not be coerced into a keyword.
738
        for input in [
739
            "0",
740
            "-0",
741
            "0.0",
742
            "1",
743
            "-1",
744
            "9223372036854775807",  // i64::MAX
745
            "-9223372036854775808", // i64::MIN
746
            "18446744073709551616", // u64::MAX + 1
747
            "1e400",
748
            "-1e400",
749
            "3.4028235e38",
750
            "1.17549435e-38",
751
            "NaN",
752
            "nan",
753
            "inf",
754
            "-inf",
755
            "infinity",
756
        ] {
757
            assert_all_keyword_parsers_reject(input, "numeric input for a keyword property");
758
        }
759
    }
760

            
761
    #[test]
762
    fn keyword_parsers_reject_leading_and_trailing_junk() {
763
        assert_all_keyword_parsers_reject("xauto", "leading junk");
764
        assert_all_keyword_parsers_reject("autox", "trailing junk");
765
        assert_all_keyword_parsers_reject("auto!", "trailing junk");
766
        assert_all_keyword_parsers_reject("(fixed)", "wrapped in parens");
767
        assert_all_keyword_parsers_reject("collapse,", "trailing comma");
768
        // Trailing/leading *whitespace* is the one thing that is trimmed away.
769
        assert_eq!(parse_table_layout("auto ").unwrap(), LayoutTableLayout::Auto);
770
    }
771

            
772
    #[test]
773
    fn keyword_parsers_survive_unicode_input() {
774
        for input in [
775
            "\u{1F600}",             // emoji
776
            "auto\u{0301}",          // combining acute on the final char
777
            "\u{FF41}\u{FF55}\u{FF54}\u{FF4F}", // fullwidth "auto"
778
            "\u{202E}auto",          // RTL override prefix
779
            "\u{FEFF}auto",          // BOM prefix (not Unicode whitespace)
780
            "аuto",                  // leading CYRILLIC A homoglyph
781
            "🇩🇪",                    // regional indicator pair
782
            "e\u{0301}\u{0301}\u{0301}",
783
            "\u{10FFFF}",            // highest scalar value
784
        ] {
785
            assert_all_keyword_parsers_reject(input, "non-ASCII input");
786
        }
787
        // Slicing must stay on char boundaries: the error payload borrows the input.
788
        assert_eq!(
789
            parse_table_layout("  \u{1F600}  "),
790
            Err(LayoutTableLayoutParseError::InvalidKeyword("\u{1F600}"))
791
        );
792
    }
793

            
794
    #[test]
795
    fn keyword_parsers_survive_extremely_long_input() {
796
        let long = "a".repeat(1_000_000);
797
        assert_all_keyword_parsers_reject(&long, "1M-char input");
798
        assert_eq!(
799
            parse_table_layout(&long),
800
            Err(LayoutTableLayoutParseError::InvalidKeyword(long.as_str()))
801
        );
802

            
803
        // A million repetitions of a *valid* token is still not the token.
804
        let repeated = "auto".repeat(250_000);
805
        assert_all_keyword_parsers_reject(&repeated, "repeated valid token");
806

            
807
        // Padding a valid keyword with a megabyte of whitespace still parses.
808
        let padded = format!("{}auto{}", " ".repeat(500_000), "\n".repeat(500_000));
809
        assert_eq!(parse_table_layout(&padded).unwrap(), LayoutTableLayout::Auto);
810
    }
811

            
812
    #[test]
813
    fn keyword_parsers_survive_deeply_nested_input() {
814
        // These parsers are non-recursive, so 10k nesting levels must not blow the
815
        // stack -- assert that explicitly so a future recursive-descent rewrite of
816
        // the value parser trips here.
817
        let nested = format!("{}auto{}", "(".repeat(10_000), ")".repeat(10_000));
818
        assert_all_keyword_parsers_reject(&nested, "10k nested brackets");
819

            
820
        let braces = "{".repeat(50_000);
821
        assert_all_keyword_parsers_reject(&braces, "50k open braces");
822
    }
823

            
824
    // ------------------------------------------------------------------------
825
    // border-spacing
826
    // ------------------------------------------------------------------------
827

            
828
    #[test]
829
    fn border_spacing_single_value_applies_to_both_axes() {
830
        let s = parse_border_spacing("5px").unwrap();
831
        assert_eq!(s.horizontal, PixelValue::const_px(5));
832
        assert_eq!(s.vertical, PixelValue::const_px(5));
833
        assert_eq!(s, LayoutBorderSpacing::new(PixelValue::const_px(5)));
834
    }
835

            
836
    #[test]
837
    fn border_spacing_two_values_are_horizontal_then_vertical() {
838
        let s = parse_border_spacing("5px 10px").unwrap();
839
        assert_eq!(s.horizontal, PixelValue::const_px(5));
840
        assert_eq!(s.vertical, PixelValue::const_px(10));
841
        // Order matters: the reversed input must not compare equal.
842
        assert_ne!(s, parse_border_spacing("10px 5px").unwrap());
843
    }
844

            
845
    #[test]
846
    fn border_spacing_accepts_mixed_metrics_per_axis() {
847
        let s = parse_border_spacing("1em 50%").unwrap();
848
        assert_eq!(s.horizontal.metric, SizeMetric::Em);
849
        assert_eq!(raw(s.horizontal), 1000);
850
        assert_eq!(s.vertical.metric, SizeMetric::Percent);
851
        assert_eq!(raw(s.vertical), 50_000);
852

            
853
        // "rem" must win over "em" in the suffix table.
854
        assert_eq!(
855
            parse_border_spacing("2rem").unwrap().horizontal.metric,
856
            SizeMetric::Rem
857
        );
858
        // ...and "vmax"/"vmin" over "vw"/"vh".
859
        assert_eq!(
860
            parse_border_spacing("2vmax 3vmin").unwrap().horizontal.metric,
861
            SizeMetric::Vmax
862
        );
863
    }
864

            
865
    #[test]
866
    fn border_spacing_rejects_empty_and_whitespace_only_input() {
867
        // `split_whitespace` yields zero parts, which falls through to the `_` arm.
868
        for input in ["", "   ", "\t\n", "\r\n\x0c ", "\u{a0}", "\u{2028}\u{2029}"] {
869
            assert_eq!(
870
                parse_border_spacing(input),
871
                Err(LayoutBorderSpacingParseError::InvalidFormat),
872
                "expected InvalidFormat for {input:?}"
873
            );
874
        }
875
    }
876

            
877
    #[test]
878
    fn border_spacing_rejects_more_than_two_components() {
879
        for input in ["5px 10px 15px", "1px 2px 3px 4px", "0 0 0"] {
880
            assert_eq!(
881
                parse_border_spacing(input),
882
                Err(LayoutBorderSpacingParseError::InvalidFormat),
883
                "expected InvalidFormat for {input:?}"
884
            );
885
        }
886
    }
887

            
888
    #[test]
889
    fn border_spacing_collapses_arbitrary_internal_whitespace() {
890
        let expected = LayoutBorderSpacing::new_separate(
891
            PixelValue::const_px(5),
892
            PixelValue::const_px(10),
893
        );
894
        for input in [
895
            "5px 10px",
896
            "  5px   10px  ",
897
            "\t5px\n10px\r\n",
898
            "5px\x0c10px",
899
        ] {
900
            assert_eq!(parse_border_spacing(input).unwrap(), expected, "{input:?}");
901
        }
902
    }
903

            
904
    #[test]
905
    fn border_spacing_splits_on_non_css_unicode_whitespace() {
906
        // Characterized leniency (same root cause as the keyword trim):
907
        // `split_whitespace` uses the Unicode White_Space property, so a
908
        // non-breaking space separates the two components even though CSS would
909
        // tokenize `5px\u{a0}10px` as a single (invalid) dimension token.
910
        let s = parse_border_spacing("5px\u{a0}10px").unwrap();
911
        assert_eq!(s.horizontal, PixelValue::const_px(5));
912
        assert_eq!(s.vertical, PixelValue::const_px(10));
913
    }
914

            
915
    #[test]
916
    fn border_spacing_propagates_pixel_value_errors() {
917
        // Unit with no number.
918
        assert!(matches!(
919
            parse_border_spacing("px"),
920
            Err(LayoutBorderSpacingParseError::PixelValue(
921
                CssPixelValueParseError::NoValueGiven("px", SizeMetric::Px)
922
            ))
923
        ));
924
        // Garbage, unknown unit, trailing junk.
925
        for input in ["abc", "5foo", "5px;", "#5px", "5 .. px"] {
926
            assert!(
927
                parse_border_spacing(input).is_err(),
928
                "expected {input:?} to be rejected"
929
            );
930
        }
931
        // A separated unit makes the *second* component the failing one.
932
        assert!(matches!(
933
            parse_border_spacing("5 px"),
934
            Err(LayoutBorderSpacingParseError::PixelValue(
935
                CssPixelValueParseError::NoValueGiven("px", SizeMetric::Px)
936
            ))
937
        ));
938
        // The error payload borrows the offending slice, not the whole input.
939
        assert!(matches!(
940
            parse_border_spacing("5px zzz"),
941
            Err(LayoutBorderSpacingParseError::PixelValue(
942
                CssPixelValueParseError::InvalidPixelValue("zzz")
943
            ))
944
        ));
945
    }
946

            
947
    #[test]
948
    fn border_spacing_accepts_unitless_numbers() {
949
        // Characterized gap: `parse_pixel_value` falls back to a bare f32 and
950
        // assumes px, so `border-spacing: 5` is accepted as 5px. Per CSS, only a
951
        // unitless *zero* is legal for a <length>.
952
        assert_eq!(
953
            parse_border_spacing("0").unwrap(),
954
            LayoutBorderSpacing::new(PixelValue::const_px(0))
955
        );
956
        assert_eq!(
957
            parse_border_spacing("5").unwrap(),
958
            LayoutBorderSpacing::new(PixelValue::const_px(5))
959
        );
960
        // -0 must land on the same stored value as +0 (no signed-zero surprises).
961
        assert_eq!(raw(parse_border_spacing("-0").unwrap().horizontal), 0);
962
        assert_eq!(
963
            parse_border_spacing("-0").unwrap(),
964
            parse_border_spacing("0").unwrap()
965
        );
966
    }
967

            
968
    #[test]
969
    fn border_spacing_accepts_negative_values() {
970
        // Characterized gap: `border-spacing` is a non-negative <length> in CSS,
971
        // but nothing here clamps -- the negative value is stored as-is.
972
        let s = parse_border_spacing("-5px -10px").unwrap();
973
        assert_eq!(raw(s.horizontal), -5000);
974
        assert_eq!(raw(s.vertical), -10_000);
975
    }
976

            
977
    #[test]
978
    fn border_spacing_flattens_nan_to_zero() {
979
        // "NaN" is a valid f32 literal, so this reaches `FloatValue::new(NaN)`,
980
        // where `NaN * 1000.0 as isize` yields 0. The parse succeeds with 0px --
981
        // it does not panic, and it does not store a NaN.
982
        let s = parse_border_spacing("NaNpx").unwrap();
983
        assert_eq!(raw(s.horizontal), 0);
984
        assert_eq!(raw(s.vertical), 0);
985
        assert!(s.horizontal.number.get().is_finite());
986
        assert_eq!(s, LayoutBorderSpacing::default());
987

            
988
        let mixed = parse_border_spacing("NaNpx 4px").unwrap();
989
        assert_eq!(raw(mixed.horizontal), 0);
990
        assert_eq!(raw(mixed.vertical), 4000);
991
    }
992

            
993
    #[test]
994
    fn border_spacing_saturates_infinite_and_overflowing_values() {
995
        // Both an explicit infinity and a finite-but-overflowing literal (whose
996
        // *1000 scaling overflows isize) must saturate rather than wrap.
997
        for input in ["infpx", "infinitypx", "1e40px", "3.5e38px"] {
998
            let s = parse_border_spacing(input).unwrap();
999
            assert_eq!(raw(s.horizontal), isize::MAX, "{input} must saturate high");
            assert!(s.horizontal.number.get().is_finite(), "{input} must be finite");
        }
        for input in ["-infpx", "-1e40px", "-3.5e38px"] {
            let s = parse_border_spacing(input).unwrap();
            assert_eq!(raw(s.horizontal), isize::MIN, "{input} must saturate low");
            assert!(s.horizontal.number.get().is_finite(), "{input} must be finite");
        }
        // Saturation is per-axis, and the high/low bounds do not collapse together.
        let s = parse_border_spacing("infpx -infpx").unwrap();
        assert_eq!(raw(s.horizontal), isize::MAX);
        assert_eq!(raw(s.vertical), isize::MIN);
        assert_ne!(s.horizontal, s.vertical);
    }
    #[test]
    fn border_spacing_truncates_below_milli_precision() {
        // FloatValue is fixed-point with 3 decimals and truncates (does not round)
        // toward zero, so sub-millipixel input silently becomes 0.
        assert_eq!(raw(parse_border_spacing("0.0005px").unwrap().horizontal), 0);
        assert_eq!(raw(parse_border_spacing("0.9999px").unwrap().horizontal), 999);
        assert_eq!(raw(parse_border_spacing("1.9999px").unwrap().horizontal), 1999);
        // Denormal input must not panic or produce a non-finite stored value.
        let denormal = parse_border_spacing("1.17549435e-38px").unwrap();
        assert_eq!(raw(denormal.horizontal), 0);
    }
    #[test]
    fn border_spacing_survives_extremely_long_input() {
        // One 1M-char token: rejected by the pixel parser, and the error borrows
        // the whole slice back out.
        let long_token = "z".repeat(1_000_000);
        assert!(parse_border_spacing(&long_token).is_err());
        // 200k components: hits the `_ => InvalidFormat` arm without hanging.
        let many = "5px ".repeat(200_000);
        assert_eq!(
            parse_border_spacing(&many),
            Err(LayoutBorderSpacingParseError::InvalidFormat)
        );
        // A megabyte of padding around a valid single value still parses.
        let padded = format!("{}5px{}", " ".repeat(500_000), " ".repeat(500_000));
        assert_eq!(
            parse_border_spacing(&padded).unwrap(),
            LayoutBorderSpacing::new(PixelValue::const_px(5))
        );
    }
    #[test]
    fn border_spacing_survives_deeply_nested_input() {
        let nested = format!("{}5px{}", "(".repeat(10_000), ")".repeat(10_000));
        assert!(
            parse_border_spacing(&nested).is_err(),
            "10k nested brackets must be rejected, not stack-overflow"
        );
    }
    #[test]
    fn border_spacing_unicode_input_does_not_panic() {
        for input in [
            "\u{1F600}",
            "5\u{1F600}px",
            "5px \u{1F600}",
            "5px",       // fullwidth digit
            "5\u{0301}px", // combining mark between number and unit
            "\u{FEFF}5px", // BOM prefix (not whitespace -> part of the token)
        ] {
            assert!(
                parse_border_spacing(input).is_err(),
                "expected {input:?} to be rejected"
            );
        }
    }
    // ------------------------------------------------------------------------
    // round-trips: print_as_css_value -> parse_* must be the identity
    // ------------------------------------------------------------------------
    #[test]
    fn keyword_enums_roundtrip_through_css() {
        for v in [LayoutTableLayout::Auto, LayoutTableLayout::Fixed] {
            assert_eq!(parse_table_layout(&v.print_as_css_value()).unwrap(), v);
        }
        for v in [StyleBorderCollapse::Separate, StyleBorderCollapse::Collapse] {
            assert_eq!(parse_border_collapse(&v.print_as_css_value()).unwrap(), v);
        }
        for v in [StyleCaptionSide::Top, StyleCaptionSide::Bottom] {
            assert_eq!(parse_caption_side(&v.print_as_css_value()).unwrap(), v);
        }
        for v in [StyleEmptyCells::Show, StyleEmptyCells::Hide] {
            assert_eq!(parse_empty_cells(&v.print_as_css_value()).unwrap(), v);
        }
        // Defaults round-trip too.
        assert_eq!(
            parse_table_layout(&LayoutTableLayout::default().print_as_css_value()).unwrap(),
            LayoutTableLayout::default()
        );
    }
    #[test]
    fn keyword_enum_css_spellings_are_distinct() {
        // A copy-paste in `print_as_css_value` would make two variants print the
        // same string, which the round-trip above would silently accept for one of
        // them.
        assert_ne!(
            LayoutTableLayout::Auto.print_as_css_value(),
            LayoutTableLayout::Fixed.print_as_css_value()
        );
        assert_ne!(
            StyleBorderCollapse::Separate.print_as_css_value(),
            StyleBorderCollapse::Collapse.print_as_css_value()
        );
        assert_ne!(
            StyleCaptionSide::Top.print_as_css_value(),
            StyleCaptionSide::Bottom.print_as_css_value()
        );
        assert_ne!(
            StyleEmptyCells::Show.print_as_css_value(),
            StyleEmptyCells::Hide.print_as_css_value()
        );
    }
    #[test]
    fn border_spacing_roundtrips_through_css() {
        let values = [
            PixelValue::const_px(0),
            PixelValue::const_px(5),
            PixelValue::const_px(-5),
            PixelValue::px(1.5),
            PixelValue::px(-0.125),
            PixelValue::em(2.0),
            PixelValue::rem(0.5),
            PixelValue::percent(50.0),
            PixelValue::from_metric(SizeMetric::Pt, 12.0),
            PixelValue::from_metric(SizeMetric::Vmin, 3.25),
        ];
        for h in values {
            // Single-value form (horizontal == vertical).
            let same = LayoutBorderSpacing::new(h);
            assert_eq!(
                parse_border_spacing(&same.print_as_css_value()).unwrap(),
                same,
                "single-value round-trip failed for {h:?}"
            );
            // Two-value form.
            for v in values {
                let sep = LayoutBorderSpacing::new_separate(h, v);
                assert_eq!(
                    parse_border_spacing(&sep.print_as_css_value()).unwrap(),
                    sep,
                    "two-value round-trip failed for {h:?} / {v:?}"
                );
            }
        }
    }
    #[test]
    fn border_spacing_prints_one_value_only_when_both_axes_match() {
        assert_eq!(
            LayoutBorderSpacing::new(PixelValue::const_px(5)).print_as_css_value(),
            "5px"
        );
        assert_eq!(
            LayoutBorderSpacing::new_separate(
                PixelValue::const_px(5),
                PixelValue::const_px(10)
            )
            .print_as_css_value(),
            "5px 10px"
        );
        // Same number, different unit -> still two values (the metric is part of Eq).
        assert_eq!(
            LayoutBorderSpacing::new_separate(
                PixelValue::const_px(0),
                PixelValue::percent(0.0)
            )
            .print_as_css_value(),
            "0px 0%"
        );
        assert_eq!(
            LayoutBorderSpacing::default().print_as_css_value(),
            "0px",
            "the default must not print as a two-value form"
        );
    }
    #[test]
    fn border_spacing_saturated_value_roundtrips_stably() {
        // The saturated bound prints as a lossy f32 (9223372000000000px), but
        // re-parsing it saturates right back to the same stored bound, so the
        // round-trip is still a fixed point.
        let saturated = parse_border_spacing("infpx").unwrap();
        let printed = saturated.print_as_css_value();
        assert_eq!(parse_border_spacing(&printed).unwrap(), saturated);
        let low = parse_border_spacing("-infpx").unwrap();
        assert_eq!(
            parse_border_spacing(&low.print_as_css_value()).unwrap(),
            low
        );
    }
    #[test]
    fn border_spacing_parse_print_is_idempotent_after_quantization() {
        // Inputs whose precision the fixed-point representation cannot hold must
        // reach a fixed point after a single round-trip, not drift on every pass.
        for input in ["0.0005px", "0.9999px", "0.1px", "1e40px", "NaNpx", "-0"] {
            let once = parse_border_spacing(input).unwrap();
            let printed = once.print_as_css_value();
            let twice = parse_border_spacing(&printed).unwrap();
            assert_eq!(once, twice, "not idempotent for {input:?}");
            assert_eq!(printed, twice.print_as_css_value(), "not stable for {input:?}");
        }
    }
    // ------------------------------------------------------------------------
    // FormatAsRustCode
    // ------------------------------------------------------------------------
    #[test]
    fn format_as_rust_code_emits_the_variant_paths() {
        assert_eq!(
            LayoutTableLayout::Fixed.format_as_rust_code(0),
            "LayoutTableLayout::Fixed"
        );
        assert_eq!(
            StyleBorderCollapse::Collapse.format_as_rust_code(0),
            "StyleBorderCollapse::Collapse"
        );
        assert_eq!(
            StyleCaptionSide::Bottom.format_as_rust_code(0),
            "StyleCaptionSide::Bottom"
        );
        assert_eq!(StyleEmptyCells::Hide.format_as_rust_code(0), "StyleEmptyCells::Hide");
        // The indent argument is ignored -- it must not change the output.
        assert_eq!(
            LayoutTableLayout::Auto.format_as_rust_code(0),
            LayoutTableLayout::Auto.format_as_rust_code(usize::MAX)
        );
    }
    #[test]
    fn format_as_rust_code_for_border_spacing_is_total() {
        assert_eq!(
            LayoutBorderSpacing::new(PixelValue::const_px(5)).format_as_rust_code(0),
            "LayoutBorderSpacing { horizontal: \
             PixelValue::const_from_metric_fractional(SizeMetric::Px, 5, 0), vertical: \
             PixelValue::const_from_metric_fractional(SizeMetric::Px, 5, 0) }"
        );
        // Extreme / degenerate values must still format without panicking.
        for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -2.5] {
            let code = LayoutBorderSpacing::new(PixelValue::px(v)).format_as_rust_code(0);
            assert!(code.starts_with("LayoutBorderSpacing {"), "{v}: {code}");
            assert!(code.ends_with('}'), "{v}: {code}");
        }
    }
}