1
//! CSS properties for flowing content around shapes (CSS Shapes Module).
2
//!
3
//! Defines [`ShapeOutside`], [`ShapeInside`], [`ClipPath`], [`ShapeMargin`],
4
//! and [`ShapeImageThreshold`]. Note: `ClipPath` belongs to CSS Masking but
5
//! is co-located here for convenience.
6

            
7
use alloc::string::{String, ToString};
8

            
9
use crate::{
10
    props::{
11
        basic::{
12
            length::{parse_float_value, FloatValue},
13
            pixel::{
14
                parse_pixel_value, CssPixelValueParseError,
15
                PixelValue,
16
            },
17
        },
18
        formatter::PrintAsCssValue,
19
    },
20
    shape::CssShape,
21
};
22
#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
23
/// CSS shape-outside property for wrapping text around shapes
24
#[derive(Debug, Clone, PartialEq)]
25
#[repr(C, u8)]
26
#[derive(Default)]
27
pub enum ShapeOutside {
28
    #[default]
29
    None,
30
    Shape(CssShape),
31
}
32

            
33
impl Eq for ShapeOutside {}
34
impl core::hash::Hash for ShapeOutside {
35
2
    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
36
2
        core::mem::discriminant(self).hash(state);
37
2
        if let Self::Shape(s) = self {
38
1
            s.hash(state);
39
1
        }
40
2
    }
41
}
42
impl PartialOrd for ShapeOutside {
43
1
    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
44
1
        Some(self.cmp(other))
45
1
    }
46
}
47
impl Ord for ShapeOutside {
48
2
    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
49
2
        match (self, other) {
50
            (Self::None, Self::None) => core::cmp::Ordering::Equal,
51
2
            (Self::None, Self::Shape(_)) => core::cmp::Ordering::Less,
52
            (Self::Shape(_), Self::None) => core::cmp::Ordering::Greater,
53
            (Self::Shape(a), Self::Shape(b)) => a.cmp(b),
54
        }
55
2
    }
56
}
57

            
58

            
59
impl PrintAsCssValue for ShapeOutside {
60
23
    fn print_as_css_value(&self) -> String {
61
23
        match self {
62
3
            Self::None => "none".to_string(),
63
20
            Self::Shape(shape) => shape.print_as_css_value(),
64
        }
65
23
    }
66
}
67
#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
68
/// CSS shape-inside property for flowing text within shapes
69
#[derive(Debug, Clone, PartialEq)]
70
#[repr(C, u8)]
71
#[derive(Default)]
72
pub enum ShapeInside {
73
    #[default]
74
    None,
75
    Shape(CssShape),
76
}
77

            
78
impl Eq for ShapeInside {}
79
impl core::hash::Hash for ShapeInside {
80
    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
81
        core::mem::discriminant(self).hash(state);
82
        if let Self::Shape(s) = self {
83
            s.hash(state);
84
        }
85
    }
86
}
87
impl PartialOrd for ShapeInside {
88
1
    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
89
1
        Some(self.cmp(other))
90
1
    }
91
}
92
impl Ord for ShapeInside {
93
2
    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
94
2
        match (self, other) {
95
            (Self::None, Self::None) => core::cmp::Ordering::Equal,
96
2
            (Self::None, Self::Shape(_)) => core::cmp::Ordering::Less,
97
            (Self::Shape(_), Self::None) => core::cmp::Ordering::Greater,
98
            (Self::Shape(a), Self::Shape(b)) => a.cmp(b),
99
        }
100
2
    }
101
}
102

            
103

            
104
impl PrintAsCssValue for ShapeInside {
105
12
    fn print_as_css_value(&self) -> String {
106
12
        match self {
107
2
            Self::None => "none".to_string(),
108
10
            Self::Shape(shape) => shape.print_as_css_value(),
109
        }
110
12
    }
111
}
112
#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
113
/// CSS clip-path property for clipping element rendering
114
#[derive(Debug, Clone, PartialEq)]
115
#[repr(C, u8)]
116
#[derive(Default)]
117
pub enum ClipPath {
118
    #[default]
119
    None,
120
    Shape(CssShape),
121
}
122

            
123
impl Eq for ClipPath {}
124
impl core::hash::Hash for ClipPath {
125
10
    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
126
10
        core::mem::discriminant(self).hash(state);
127
10
        if let Self::Shape(s) = self {
128
7
            s.hash(state);
129
7
        }
130
10
    }
131
}
132
impl PartialOrd for ClipPath {
133
2
    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
134
2
        Some(self.cmp(other))
135
2
    }
136
}
137
impl Ord for ClipPath {
138
62
    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
139
62
        match (self, other) {
140
1
            (Self::None, Self::None) => core::cmp::Ordering::Equal,
141
3
            (Self::None, Self::Shape(_)) => core::cmp::Ordering::Less,
142
1
            (Self::Shape(_), Self::None) => core::cmp::Ordering::Greater,
143
57
            (Self::Shape(a), Self::Shape(b)) => a.cmp(b),
144
        }
145
62
    }
146
}
147

            
148

            
149
impl PrintAsCssValue for ClipPath {
150
12
    fn print_as_css_value(&self) -> String {
151
12
        match self {
152
2
            Self::None => "none".to_string(),
153
10
            Self::Shape(shape) => shape.print_as_css_value(),
154
        }
155
12
    }
156
}
157

            
158
/// CSS `shape-margin` property — adds margin to the shape-outside exclusion area.
159
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
160
#[repr(C)]
161
pub struct ShapeMargin {
162
    pub inner: PixelValue,
163
}
164

            
165
impl Default for ShapeMargin {
166
3
    fn default() -> Self {
167
3
        Self {
168
3
            inner: PixelValue::zero(),
169
3
        }
170
3
    }
171
}
172

            
173
impl PrintAsCssValue for ShapeMargin {
174
8
    fn print_as_css_value(&self) -> String {
175
8
        self.inner.print_as_css_value()
176
8
    }
177
}
178

            
179
/// CSS `shape-image-threshold` property — alpha threshold for image-based shapes.
180
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
181
#[repr(C)]
182
pub struct ShapeImageThreshold {
183
    pub inner: FloatValue,
184
}
185

            
186
impl Default for ShapeImageThreshold {
187
2
    fn default() -> Self {
188
2
        Self {
189
2
            inner: FloatValue::const_new(0),
190
2
        }
191
2
    }
192
}
193

            
194
impl PrintAsCssValue for ShapeImageThreshold {
195
5
    fn print_as_css_value(&self) -> String {
196
5
        self.inner.to_string()
197
5
    }
198
}
199

            
200
// Formatting to Rust code
201
impl crate::codegen::format::FormatAsRustCode for ShapeOutside {
202
    fn format_as_rust_code(&self, _tabs: usize) -> String {
203
        match self {
204
            Self::None => String::from("ShapeOutside::None"),
205
            Self::Shape(s) => {
206
                let mut r = String::from("ShapeOutside::Shape(");
207
                r.push_str(&s.format_as_rust_code());
208
                r.push(')');
209
                r
210
            }
211
        }
212
    }
213
}
214

            
215
impl crate::codegen::format::FormatAsRustCode for ShapeInside {
216
    fn format_as_rust_code(&self, _tabs: usize) -> String {
217
        match self {
218
            Self::None => String::from("ShapeInside::None"),
219
            Self::Shape(s) => {
220
                let mut r = String::from("ShapeInside::Shape(");
221
                r.push_str(&s.format_as_rust_code());
222
                r.push(')');
223
                r
224
            }
225
        }
226
    }
227
}
228

            
229
impl crate::codegen::format::FormatAsRustCode for ClipPath {
230
    fn format_as_rust_code(&self, _tabs: usize) -> String {
231
        match self {
232
            Self::None => String::from("ClipPath::None"),
233
            Self::Shape(s) => {
234
                let mut r = String::from("ClipPath::Shape(");
235
                r.push_str(&s.format_as_rust_code());
236
                r.push(')');
237
                r
238
            }
239
        }
240
    }
241
}
242

            
243
impl crate::codegen::format::FormatAsRustCode for ShapeMargin {
244
    fn format_as_rust_code(&self, _tabs: usize) -> String {
245
        format!(
246
            "ShapeMargin {{ inner: {} }}",
247
            crate::codegen::format::format_pixel_value(&self.inner)
248
        )
249
    }
250
}
251

            
252
impl crate::codegen::format::FormatAsRustCode for ShapeImageThreshold {
253
    fn format_as_rust_code(&self, _tabs: usize) -> String {
254
        format!(
255
            "ShapeImageThreshold {{ inner: {} }}",
256
            crate::codegen::format::format_float_value(&self.inner)
257
        )
258
    }
259
}
260

            
261
// --- PARSERS ---
262
#[cfg(feature = "parser")]
263
pub mod parser {
264
    use core::num::ParseFloatError;
265

            
266
    #[allow(clippy::wildcard_imports)] // parser submodule reuses the parent module's value types
267
    use super::*;
268
    use crate::shape_parser::{parse_shape, ShapeParseError};
269

            
270
    /// Parser for shape-outside property
271
    /// # Errors
272
    ///
273
    /// Returns an error if `input` is not a valid CSS `shape-outside` value.
274
297
    pub fn parse_shape_outside(input: &str) -> Result<ShapeOutside, ShapeParseError> {
275
297
        let trimmed = input.trim();
276
297
        if trimmed == "none" {
277
12
            Ok(ShapeOutside::None)
278
        } else {
279
285
            let shape = parse_shape(trimmed)?;
280
113
            Ok(ShapeOutside::Shape(shape))
281
        }
282
297
    }
283

            
284
    /// Parser for shape-inside property
285
    /// # Errors
286
    ///
287
    /// Returns an error if `input` is not a valid CSS `shape-inside` value.
288
285
    pub fn parse_shape_inside(input: &str) -> Result<ShapeInside, ShapeParseError> {
289
285
        let trimmed = input.trim();
290
285
        if trimmed == "none" {
291
11
            Ok(ShapeInside::None)
292
        } else {
293
274
            let shape = parse_shape(trimmed)?;
294
102
            Ok(ShapeInside::Shape(shape))
295
        }
296
285
    }
297

            
298
    /// Parser for clip-path property
299
    /// # Errors
300
    ///
301
    /// Returns an error if `input` is not a valid CSS `clip-path` value.
302
387
    pub fn parse_clip_path(input: &str) -> Result<ClipPath, ShapeParseError> {
303
387
        let trimmed = input.trim();
304
387
        if trimmed == "none" {
305
12
            Ok(ClipPath::None)
306
        } else {
307
375
            let shape = parse_shape(trimmed)?;
308
148
            Ok(ClipPath::Shape(shape))
309
        }
310
387
    }
311

            
312
    /// Parser for shape-margin property
313
    /// # Errors
314
    ///
315
    /// Returns an error if `input` is not a valid CSS `shape-margin` value.
316
211
    pub fn parse_shape_margin(input: &str) -> Result<ShapeMargin, CssPixelValueParseError<'_>> {
317
        Ok(ShapeMargin {
318
211
            inner: parse_pixel_value(input)?,
319
        })
320
211
    }
321

            
322
    /// Parser for shape-image-threshold property
323
    /// # Errors
324
    ///
325
    /// Returns an error if `input` is not a valid CSS `shape-image-threshold` value.
326
191
    pub fn parse_shape_image_threshold(
327
191
        input: &str,
328
191
    ) -> Result<ShapeImageThreshold, ParseFloatError> {
329
191
        let val = parse_float_value(input)?;
330
        // value should be clamped between 0.0 and 1.0
331
55
        let clamped = val.get().clamp(0.0, 1.0);
332
55
        Ok(ShapeImageThreshold {
333
55
            inner: FloatValue::new(clamped),
334
55
        })
335
191
    }
336
}
337

            
338
#[cfg(feature = "parser")]
339
pub use parser::*;
340

            
341
#[cfg(all(test, feature = "parser"))]
342
mod tests {
343
    // Tests assert that parsed values equal the exact source literals.
344
    #![allow(clippy::float_cmp)]
345
    use super::*;
346

            
347
    #[test]
348
1
    fn test_parse_shape_properties() {
349
        // Test shape-outside
350
1
        assert!(matches!(
351
1
            parse_shape_outside("none").unwrap(),
352
            ShapeOutside::None
353
        ));
354
1
        assert!(matches!(
355
1
            parse_shape_outside("circle(50px)").unwrap(),
356
            ShapeOutside::Shape(_)
357
        ));
358

            
359
        // Test shape-inside
360
1
        assert!(matches!(
361
1
            parse_shape_inside("none").unwrap(),
362
            ShapeInside::None
363
        ));
364
1
        assert!(matches!(
365
1
            parse_shape_inside("circle(100px at 50px 50px)").unwrap(),
366
            ShapeInside::Shape(_)
367
        ));
368

            
369
        // Test clip-path
370
1
        assert!(matches!(parse_clip_path("none").unwrap(), ClipPath::None));
371
1
        assert!(matches!(
372
1
            parse_clip_path("polygon(0 0, 100px 0, 100px 100px, 0 100px)").unwrap(),
373
            ClipPath::Shape(_)
374
        ));
375

            
376
        // Test existing properties
377
1
        assert_eq!(
378
1
            parse_shape_margin("10px").unwrap().inner,
379
1
            PixelValue::px(10.0)
380
        );
381
1
        assert_eq!(parse_shape_image_threshold("0.5").unwrap().inner.get(), 0.5);
382
1
    }
383
}
384

            
385
#[cfg(all(test, feature = "parser"))]
386
mod autotest_generated {
387
    //! Adversarial tests for the five `shape.rs` parsers.
388
    //!
389
    //! Three of these tests are *characterization* tests: they pin down current
390
    //! behaviour that is a genuine defect in code this module calls into
391
    //! (`shape_parser` / `props::basic::pixel`). Each is marked `KNOWN BUG`, and
392
    //! each is written so that it FAILS THE DAY THE BUG IS FIXED, with a message
393
    //! saying what to replace it with. They are tripwires, not endorsements.
394

            
395
    // float_cmp: parsed values are compared against the exact literals they were
396
    // built from. eq_op: several tests compare a value with itself on purpose —
397
    // reflexivity of PartialEq is precisely what is under test.
398
    #![allow(clippy::float_cmp, clippy::eq_op)]
399

            
400
    use core::{cmp::Ordering, hash::Hash};
401

            
402
    use super::*;
403
    use crate::{
404
        corety::OptionF32,
405
        props::basic::length::SizeMetric,
406
        shape::{ShapeCircle, ShapeEllipse, ShapeInset, ShapePath, ShapePolygon},
407
        shape_parser::ShapeParseError,
408
    };
409

            
410
    /// Inputs that USED to make `shape_parser::parse_path` panic (a lone `"` argument
411
    /// satisfies both `starts_with('"')` and `ends_with('"')`, so the parser sliced
412
    /// `[1..0]`). Now fixed — these return `Err`. Kept as a corpus of formerly-panicking
413
    /// inputs; `path_lone_quote_returns_err_not_panic` asserts the graceful rejection,
414
    /// and the fuzz guards below tolerate them for free.
415
    const KNOWN_PANIC_INPUTS: &[&str] = &["path(\")", "path( \" )"];
416

            
417
    fn is_known_panic(input: &str) -> bool {
418
        KNOWN_PANIC_INPUTS.contains(&input)
419
    }
420

            
421
    /// Every input the shape-function parsers must survive: malformed, huge,
422
    /// boundary-numeric and non-ASCII. Includes the known-panic family above so
423
    /// the corpus stays honest; callers filter it explicitly.
424
    fn nasty_corpus() -> Vec<String> {
425
        let mut corpus: Vec<String> = [
426
            // empty / whitespace (incl. U+00A0, which `str::trim` also strips)
427
            "",
428
            " ",
429
            "      ",
430
            "\t",
431
            "\n",
432
            "\r\n",
433
            "\t \n ",
434
            "\u{a0}",
435
            // keyword handling
436
            "none",
437
            " none ",
438
            "NONE",
439
            "None",
440
            "nonee",
441
            "none none",
442
            "none;",
443
            // bare punctuation / unbalanced parens
444
            "(",
445
            ")",
446
            "()",
447
            ")(",
448
            "((",
449
            "))",
450
            "(()",
451
            "())",
452
            "!@#$%^&*",
453
            ";;;;",
454
            ",,,,",
455
            "\0",
456
            "\0\0(\0)\0",
457
            "\u{7f}",
458
            // structurally broken function calls
459
            "circle",
460
            "circle(",
461
            "circle)",
462
            "circle()",
463
            "circle( )",
464
            "circle(50px",
465
            "circle 50px)",
466
            "circle(50px))",
467
            "((circle(50px)))",
468
            "unknown(50px)",
469
            "square(1px)",
470
            "(50px)",
471
            " (50px) ",
472
            "circle(;)",
473
            "circle(,)",
474
            // leading / trailing junk
475
            "circle(50px);garbage",
476
            "circle(50px)garbage",
477
            "junk circle(50px)",
478
            "circle(50px) circle(50px)",
479
            // boundary numbers
480
            "circle(0)",
481
            "circle(-0)",
482
            "circle(0px)",
483
            "circle(-0px)",
484
            "circle(-50px)",
485
            "circle(50)",
486
            "circle(50%)",
487
            "circle(NaN)",
488
            "circle(nan)",
489
            "circle(inf)",
490
            "circle(-inf)",
491
            "circle(infpx)",
492
            "circle(NaNpx)",
493
            "circle(1e400px)",
494
            "circle(-1e400)",
495
            "circle(9223372036854775807px)",
496
            "circle(-9223372036854775808)",
497
            "circle(0x10px)",
498
            "circle(1_000px)",
499
            "circle(+5px)",
500
            "circle(.5px)",
501
            "circle(5.px)",
502
            // arity edges
503
            "circle(50px at)",
504
            "circle(50px at 1px)",
505
            "circle(50px at 1px 2px 3px)",
506
            "circle(50px AT 1px 2px)",
507
            "ellipse()",
508
            "ellipse(1px)",
509
            "ellipse(1px 2px)",
510
            "ellipse(1px 2px at 3px 4px)",
511
            "ellipse(a b)",
512
            "polygon()",
513
            "polygon(,)",
514
            "polygon(0 0)",
515
            "polygon(0 0, 1 1)",
516
            "polygon(0 0, 1 1, 2 2)",
517
            "polygon(0 0, 1 1, 2 2,)",
518
            "polygon(nonzero,)",
519
            "polygon(nonzero, 0 0, 1 1, 2 2)",
520
            "polygon(evenodd,0 0,1 1,2 2)",
521
            "polygon(nonzero 0 0, 1 1, 2 2)",
522
            "polygon(0, 1, 2)",
523
            "polygon(x y, x y, x y)",
524
            "inset()",
525
            "inset( )",
526
            "inset(10px)",
527
            "inset(1px 2px 3px 4px 5px)",
528
            "inset(round)",
529
            "inset(round 5px)",
530
            "inset(10px round)",
531
            "inset(10px round 5px)",
532
            "inset(10px round 5px 6px)",
533
            "inset(roundround)",
534
            "path()",
535
            "path(abc)",
536
            "path(\"\")",
537
            "path(\"M 0 0 Z\")",
538
            "path(\"\"\")",
539
            "path(\"🙂\")",
540
            // non-ASCII: `parse_function` slices on the byte offsets of '(' and
541
            // ')', so every multibyte input here is a char-boundary probe.
542
            "🙂",
543
            "🙂(1px)",
544
            "circle(🙂)",
545
            "circle(🙂px)",
546
            "circle(1px at 🙂 🙂)",
547
            "cïrcle(1px)",
548
            "e\u{0301}(1px)",
549
            "\u{202e}circle(1px)",
550
            "circle(١٢٣px)",
551
            "\u{1f600}\u{1f600}(\u{1f600})",
552
        ]
553
        .iter()
554
        .map(|s| (*s).to_string())
555
        .collect();
556

            
557
        for panicking in KNOWN_PANIC_INPUTS {
558
            corpus.push((*panicking).to_string());
559
        }
560

            
561
        // Pathological sizes: must terminate, must not overflow the stack.
562
        corpus.push("a".repeat(1_000_000));
563
        corpus.push("(".repeat(100_000));
564
        corpus.push(format!("circle({}px)", "9".repeat(10_000)));
565
        corpus.push("circle(".repeat(10_000) + &")".repeat(10_000));
566
        corpus.push(format!(
567
            "polygon({}1px 1px)",
568
            "1px 1px, ".repeat(10_000)
569
        ));
570
        corpus.push(format!("path(\"{}\")", "M 0 0 ".repeat(10_000)));
571

            
572
        corpus
573
    }
574

            
575
    fn hash_of<T: Hash>(value: &T) -> u64 {
576
        use core::hash::Hasher;
577
        use std::collections::hash_map::DefaultHasher;
578

            
579
        let mut hasher = DefaultHasher::new();
580
        value.hash(&mut hasher);
581
        hasher.finish()
582
    }
583

            
584
    fn clip_shape(input: &str) -> CssShape {
585
        match parse_clip_path(input) {
586
            Ok(ClipPath::Shape(shape)) => shape,
587
            other => panic!("expected a shape for {input:?}, got {other:?}"),
588
        }
589
    }
590

            
591
    // ---------------------------------------------------------------------
592
    // KNOWN BUGS — characterization tests (see module docs)
593
    // ---------------------------------------------------------------------
594

            
595
    /// KNOWN BUG (`shape_parser::parse_path`): a lone `"` as the argument passes
596
    /// *both* the `starts_with('"')` and `ends_with('"')` guards, so
597
    /// `&args[1..args.len() - 1]` slices `[1..0]` and panics with
598
    /// "slice index starts at 1 but ends at 0".
599
    ///
600
    /// It is reachable from all three public shape parsers, from untrusted CSS:
601
    /// `clip-path: path(")`. The correct result is
602
    /// `Err(ShapeParseError::InvalidSyntax(_))`.
603
    ///
604
    /// FIXED: `parse_path` now requires `args.len() >= 2` before slicing, so a lone
605
    /// `"` argument returns `Err(InvalidSyntax)` instead of panicking on the reversed
606
    /// `[1..0]` slice. These inputs are reachable from untrusted CSS (`clip-path:
607
    /// path(")`), so the graceful-rejection guarantee matters.
608
    #[test]
609
    fn path_lone_quote_returns_err_not_panic() {
610
        for input in KNOWN_PANIC_INPUTS {
611
            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
612
                parse_clip_path(input)
613
            }));
614
            match outcome {
615
                Ok(res) => assert!(res.is_err(), "{input:?} must be rejected, got {res:?}"),
616
                Err(_) => panic!("parse_clip_path({input:?}) still panics — the slice bug regressed"),
617
            }
618
        }
619
    }
620

            
621
    /// KNOWN BUG (`props::basic::pixel::parse_pixel_value`): the metric table is
622
    /// scanned in order and tries `("in", In)` *before* `("vmin", Vmin)`. Since
623
    /// "vmin" ends with "in", `10vmin` strips to `10vm`, which fails to parse as
624
    /// an f32 — so the valid CSS unit `vmin` is rejected outright. `vmax`, `vw`
625
    /// and `vh` are unaffected (no earlier metric is a suffix of them).
626
    ///
627
    /// This is not shape-specific: every property that routes through
628
    /// `parse_pixel_value` (width, margin, padding, …) rejects `vmin` too.
629
    ///
630
    /// WHEN pixel.rs IS FIXED (match longest metric first, or move vmax/vmin
631
    /// ahead of "in"), this test fails — replace it with:
632
    ///     `assert_eq!(parse_shape_margin("10vmin").unwrap().inner.metric`, `SizeMetric::Vmin`);
633
    #[test]
634
    fn known_bug_vmin_unit_is_rejected_by_metric_table_order() {
635
        // FIXED (as this pin's own message instructed): "10vmin" now parses to Vmin.
636
        assert_eq!(
637
            parse_shape_margin("10vmin").unwrap().inner.metric,
638
            SizeMetric::Vmin
639
        );
640

            
641
        // The sibling viewport units do work, which is what makes the bug easy
642
        // to miss: only the unit that *ends in an earlier metric* is broken.
643
        assert_eq!(
644
            parse_shape_margin("10vmax").unwrap().inner.metric,
645
            SizeMetric::Vmax
646
        );
647
        assert_eq!(
648
            parse_shape_margin("10vw").unwrap().inner.metric,
649
            SizeMetric::Vw
650
        );
651
        assert_eq!(
652
            parse_shape_margin("10vh").unwrap().inner.metric,
653
            SizeMetric::Vh
654
        );
655
    }
656

            
657
    /// A NaN f32 in a shape used to break the `Eq`/`Ord` contracts: the property
658
    /// enums derived `PartialEq` (raw compare, NaN != NaN) while hand-writing
659
    /// `Ord`/`Hash` as NaN-Equal (`to_bits`), so `a == a` was false yet `cmp` said
660
    /// `Equal`. Fixed: `PartialEq` is now hand-written to match `Ord`, so a
661
    /// preserved NaN length stays reflexive and consistent with Hash/Ord.
662
    #[test]
663
    fn nan_shape_is_reflexive_and_consistent_across_eq_ord_hash() {
664
        let a = parse_clip_path("circle(NaN)").expect("NaN is a preserved length");
665
        let b = parse_clip_path("circle(NaN)").expect("NaN is a preserved length");
666
        assert_eq!(a, a, "Eq must be reflexive for a NaN shape");
667
        assert_eq!(a, b);
668
        assert_eq!(a.cmp(&b), Ordering::Equal);
669
        assert_eq!(a.partial_cmp(&b), Some(Ordering::Equal));
670
        assert_eq!(hash_of(&a), hash_of(&b));
671
    }
672

            
673
    /// Contrast with the bug above: `ShapeMargin` / `ShapeImageThreshold` store
674
    /// their numbers as `FloatValue` (an isize-encoded fixed-point), and the
675
    /// f32 -> isize cast maps NaN to 0. So no NaN can survive into these two
676
    /// types and their derived `Eq` really is reflexive.
677
    #[test]
678
    fn floatvalue_encoding_makes_margin_and_threshold_nan_free() {
679
        let threshold = parse_shape_image_threshold("NaN").expect("f32 parses NaN");
680
        assert!(threshold.inner.get().is_finite());
681
        assert_eq!(threshold.inner.get(), 0.0);
682
        assert_eq!(threshold, threshold);
683
        assert_eq!(hash_of(&threshold), hash_of(&threshold));
684

            
685
        // "NaNpx" is *accepted* (leniency worth tightening) but cannot produce a
686
        // NaN PixelValue. Written to also pass once the parser rejects it.
687
        if let Ok(margin) = parse_shape_margin("NaNpx") {
688
            assert!(
689
                margin.inner.number.get().is_finite(),
690
                "NaN leaked into a PixelValue"
691
            );
692
            assert_eq!(margin.inner.number.get(), 0.0);
693
            assert_eq!(margin, margin);
694
        } else { /* rejecting "NaNpx" outright would be more correct */ }
695
    }
696

            
697
    // ---------------------------------------------------------------------
698
    // Panic / hang safety across the whole corpus
699
    // ---------------------------------------------------------------------
700

            
701
    /// The headline invariant: no input may panic any shape-function parser.
702
    ///
703
    /// Written as "the set of panicking inputs is a subset of the known-bug set",
704
    /// so it keeps passing (and keeps guarding) after `parse_path` is fixed.
705
    #[test]
706
    fn shape_parsers_never_panic_on_hostile_input() {
707
        let mut unexpected: Vec<String> = Vec::new();
708

            
709
        for input in nasty_corpus() {
710
            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
711
                let _ = parse_shape_outside(&input);
712
                let _ = parse_shape_inside(&input);
713
                let _ = parse_clip_path(&input);
714
            }));
715

            
716
            if outcome.is_err() && !is_known_panic(&input) {
717
                let preview: String = input.chars().take(48).collect();
718
                unexpected.push(preview);
719
            }
720
        }
721

            
722
        assert!(
723
            unexpected.is_empty(),
724
            "shape parsers panicked on input(s) outside the known-bug set: \
725
             {unexpected:?}"
726
        );
727
    }
728

            
729
    /// Same invariant for the two numeric parsers — these have no known panics,
730
    /// so the bar is absolute.
731
    #[test]
732
    fn numeric_parsers_never_panic_on_hostile_input() {
733
        for input in nasty_corpus() {
734
            let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
735
                let _ = parse_shape_margin(&input);
736
                let _ = parse_shape_image_threshold(&input);
737
            }));
738
            assert!(
739
                outcome.is_ok(),
740
                "numeric parser panicked on {:?}",
741
                input.chars().take(48).collect::<String>()
742
            );
743
        }
744
    }
745

            
746
    /// All three properties delegate to the same `parse_shape`, so they must
747
    /// accept exactly the same language and produce the same shape. Compared via
748
    /// `Ord` rather than `==` because NaN shapes are not self-equal (see
749
    /// `known_bug_nan_shape_breaks_eq_ord_consistency`).
750
    #[test]
751
    fn the_three_shape_properties_agree_on_every_input() {
752
        for input in nasty_corpus() {
753
            if is_known_panic(&input) {
754
                continue;
755
            }
756

            
757
            let outside = parse_shape_outside(&input);
758
            let inside = parse_shape_inside(&input);
759
            let clip = parse_clip_path(&input);
760

            
761
            assert_eq!(
762
                outside.is_ok(),
763
                inside.is_ok(),
764
                "shape-outside and shape-inside disagree on {input:?}"
765
            );
766
            assert_eq!(
767
                outside.is_ok(),
768
                clip.is_ok(),
769
                "shape-outside and clip-path disagree on {input:?}"
770
            );
771

            
772
            if let (Ok(ShapeOutside::Shape(a)), Ok(ShapeInside::Shape(b)), Ok(ClipPath::Shape(c))) =
773
                (&outside, &inside, &clip)
774
            {
775
                assert_eq!(a.cmp(b), Ordering::Equal, "different shape for {input:?}");
776
                assert_eq!(a.cmp(c), Ordering::Equal, "different shape for {input:?}");
777
            }
778
        }
779
    }
780

            
781
    /// A million-character input, a 10 000-deep paren nest and a 10 000-point
782
    /// polygon must all terminate. `parse_shape` is iterative, so nesting must
783
    /// not grow the stack; if this ever hangs or overflows, the test never
784
    /// returns and the suite times out rather than passing silently.
785
    #[test]
786
    fn pathological_sizes_terminate_without_stack_overflow() {
787
        let million = "a".repeat(1_000_000);
788
        assert!(matches!(
789
            parse_clip_path(&million),
790
            Err(ShapeParseError::InvalidSyntax(_))
791
        ));
792

            
793
        let nested = "circle(".repeat(10_000) + &")".repeat(10_000);
794
        assert!(parse_clip_path(&nested).is_err());
795

            
796
        let unclosed = "(".repeat(100_000);
797
        assert!(matches!(
798
            parse_clip_path(&unclosed),
799
            Err(ShapeParseError::InvalidSyntax(_))
800
        ));
801

            
802
        let big_polygon = format!("polygon({}1px 1px)", "1px 1px, ".repeat(10_000));
803
        match clip_shape(&big_polygon) {
804
            CssShape::Polygon(ShapePolygon { points }) => {
805
                assert_eq!(points.as_ref().len(), 10_001);
806
            }
807
            other => panic!("expected Polygon, got {other:?}"),
808
        }
809

            
810
        // 10 000 digits overflow f32 to infinity rather than erroring — the
811
        // shape keeps a raw f32, so this is where a non-finite radius gets in.
812
        let huge_radius = format!("circle({}px)", "9".repeat(10_000));
813
        match clip_shape(&huge_radius) {
814
            CssShape::Circle(ShapeCircle { radius, .. }) => {
815
                assert!(radius.is_infinite() && radius.is_sign_positive());
816
            }
817
            other => panic!("expected Circle, got {other:?}"),
818
        }
819
    }
820

            
821
    // ---------------------------------------------------------------------
822
    // parse_shape_outside / parse_shape_inside / parse_clip_path
823
    // ---------------------------------------------------------------------
824

            
825
    #[test]
826
    fn empty_and_whitespace_only_input_is_empty_input_error() {
827
        for input in ["", " ", "      ", "\t", "\n", "\r\n", "\t \n ", "\u{a0}"] {
828
            assert_eq!(
829
                parse_shape_outside(input),
830
                Err(ShapeParseError::EmptyInput),
831
                "shape-outside {input:?}"
832
            );
833
            assert_eq!(
834
                parse_shape_inside(input),
835
                Err(ShapeParseError::EmptyInput),
836
                "shape-inside {input:?}"
837
            );
838
            assert_eq!(
839
                parse_clip_path(input),
840
                Err(ShapeParseError::EmptyInput),
841
                "clip-path {input:?}"
842
            );
843
        }
844
    }
845

            
846
    /// Positive control, plus the one piece of trimming the parsers do promise.
847
    #[test]
848
    fn none_keyword_parses_and_is_trimmed() {
849
        for input in ["none", " none ", "\tnone\n", "  none\r\n"] {
850
            assert_eq!(parse_shape_outside(input), Ok(ShapeOutside::None));
851
            assert_eq!(parse_shape_inside(input), Ok(ShapeInside::None));
852
            assert_eq!(parse_clip_path(input), Ok(ClipPath::None));
853
        }
854
    }
855

            
856
    /// `none` is matched case-sensitively, which is not CSS-conformant (CSS
857
    /// keywords are ASCII case-insensitive). Asserted as an invariant that holds
858
    /// either way — uppercase must never silently become a *shape*.
859
    #[test]
860
    fn uppercase_none_never_yields_a_shape() {
861
        for input in ["NONE", "None", "nOnE"] {
862
            match parse_clip_path(input) {
863
                Ok(ClipPath::None) | Err(_) => {}
864
                Ok(other) => panic!("{input:?} parsed as a shape: {other:?}"),
865
            }
866
        }
867
        // Current behaviour: rejected as an unparseable function.
868
        assert!(parse_clip_path("NONE").is_err());
869
    }
870

            
871
    #[test]
872
    fn garbage_and_broken_parens_are_rejected() {
873
        // No '(' at all.
874
        for input in ["!@#$%^&*", ";;;;", ",,,,", "circle", "\u{7f}", "🙂"] {
875
            assert!(
876
                matches!(
877
                    parse_clip_path(input),
878
                    Err(ShapeParseError::InvalidSyntax(_))
879
                ),
880
                "expected InvalidSyntax for {input:?}"
881
            );
882
        }
883

            
884
        // '(' but no ')'.
885
        assert!(matches!(
886
            parse_clip_path("circle(50px"),
887
            Err(ShapeParseError::InvalidSyntax(_))
888
        ));
889
        // ')' before '('.
890
        assert!(matches!(
891
            parse_clip_path(")("),
892
            Err(ShapeParseError::InvalidSyntax(_))
893
        ));
894
        // Empty function name.
895
        assert!(matches!(
896
            parse_clip_path("()"),
897
            Err(ShapeParseError::UnknownFunction(_))
898
        ));
899
        // Unknown function names carry the offending name.
900
        assert_eq!(
901
            parse_clip_path("square(1px)"),
902
            Err(ShapeParseError::UnknownFunction("square".to_string()))
903
        );
904
        assert_eq!(
905
            parse_clip_path("junk circle(50px)"),
906
            Err(ShapeParseError::UnknownFunction("junk circle".to_string()))
907
        );
908
    }
909

            
910
    /// Trailing junk after the closing paren is currently *ignored*:
911
    /// `parse_function` takes `rfind(')')` and never checks that the input ends
912
    /// there, so `circle(50px);garbage` parses as a plain circle. That is a
913
    /// leniency bug (a declaration with trailing garbage should be dropped), but
914
    /// it is not a memory-safety issue.
915
    ///
916
    /// Asserted as the invariant that must hold either way: the parser may
917
    /// reject the input, but it must never return a *different* shape than the
918
    /// prefix describes.
919
    #[test]
920
    fn trailing_junk_is_ignored_but_never_changes_the_shape() {
921
        for input in [
922
            "circle(50px);garbage",
923
            "circle(50px)garbage",
924
            "circle(50px)🙂",
925
        ] {
926
            match parse_clip_path(input) {
927
                Ok(ClipPath::Shape(CssShape::Circle(ShapeCircle { center, radius }))) => {
928
                    assert_eq!(radius, 50.0, "{input:?}");
929
                    assert_eq!(center.x, 0.0);
930
                    assert_eq!(center.y, 0.0);
931
                }
932
                Err(_) => { /* rejecting trailing junk would be more correct */ }
933
                other => panic!("{input:?} produced an unexpected value: {other:?}"),
934
            }
935
        }
936
    }
937

            
938
    /// `parse_function` slices `input` at the *byte* offsets of '(' and ')'.
939
    /// Those are ASCII, so they can never land inside a multibyte sequence — but
940
    /// only if nothing else slices. These probe that.
941
    #[test]
942
    fn multibyte_input_does_not_panic_and_is_rejected() {
943
        for input in [
944
            "🙂(1px)",
945
            "circle(🙂)",
946
            "circle(🙂px)",
947
            "circle(1px at 🙂 🙂)",
948
            "cïrcle(1px)",
949
            "e\u{0301}(1px)",
950
            "\u{202e}circle(1px)",
951
            "circle(١٢٣px)",
952
            "\u{1f600}\u{1f600}(\u{1f600})",
953
        ] {
954
            assert!(
955
                parse_clip_path(input).is_err(),
956
                "expected Err for {input:?}"
957
            );
958
        }
959

            
960
        // Multibyte *inside* a quoted path is data, and is preserved verbatim.
961
        match clip_shape("path(\"🙂\")") {
962
            CssShape::Path(ShapePath { data }) => assert_eq!(data.as_str(), "🙂"),
963
            other => panic!("expected Path, got {other:?}"),
964
        }
965
    }
966

            
967
    #[test]
968
    fn circle_boundary_numbers() {
969
        // Unitless and `%` are both accepted; `%` is silently treated as a raw
970
        // number (parse_length has a TODO — it needs the container size).
971
        for input in ["circle(50px)", "circle(50)", "circle(50%)"] {
972
            match clip_shape(input) {
973
                CssShape::Circle(ShapeCircle { radius, .. }) => assert_eq!(radius, 50.0),
974
                other => panic!("expected Circle, got {other:?}"),
975
            }
976
        }
977

            
978
        // Zero, signed zero, and negative radii are all accepted. A negative
979
        // radius is invalid per CSS Shapes; it is stored as-is.
980
        for (input, expected) in [
981
            ("circle(0px)", 0.0_f32),
982
            ("circle(-0px)", -0.0_f32),
983
            ("circle(-50px)", -50.0_f32),
984
            ("circle(+5px)", 5.0_f32),
985
            ("circle(.5px)", 0.5_f32),
986
            ("circle(5.px)", 5.0_f32),
987
        ] {
988
            match clip_shape(input) {
989
                CssShape::Circle(ShapeCircle { radius, .. }) => {
990
                    assert_eq!(radius, expected, "{input:?}");
991
                }
992
                other => panic!("expected Circle, got {other:?}"),
993
            }
994
        }
995

            
996
        // f32 overflow saturates to infinity instead of erroring.
997
        for input in ["circle(1e400px)", "circle(inf)", "circle(infpx)"] {
998
            match clip_shape(input) {
999
                CssShape::Circle(ShapeCircle { radius, .. }) => {
                    assert!(radius.is_infinite(), "{input:?} -> {radius}");
                }
                other => panic!("expected Circle, got {other:?}"),
            }
        }
        // i64::MAX survives as an f32 approximation, no overflow panic.
        match clip_shape("circle(9223372036854775807px)") {
            CssShape::Circle(ShapeCircle { radius, .. }) => {
                assert!(radius.is_finite() && radius > 9.0e18);
            }
            other => panic!("expected Circle, got {other:?}"),
        }
        // Rust-only / C-only numeric literals are NOT valid CSS numbers.
        for input in ["circle(0x10px)", "circle(1_000px)"] {
            assert!(
                matches!(
                    parse_clip_path(input),
                    Err(ShapeParseError::InvalidNumber(_))
                ),
                "expected InvalidNumber for {input:?}"
            );
        }
    }
    /// `circle()` needs 4 parts *and* `parts[1] == "at"` before it reads a
    /// center; anything else silently falls back to the origin rather than
    /// erroring. Pin that down — a partial `at` clause is not a parse error.
    #[test]
    fn circle_at_clause_arity_falls_back_to_origin() {
        for input in [
            "circle(50px at)",
            "circle(50px at 1px)",
            "circle(50px AT 1px 2px)",
        ] {
            match clip_shape(input) {
                CssShape::Circle(ShapeCircle { center, radius }) => {
                    assert_eq!(radius, 50.0);
                    assert_eq!((center.x, center.y), (0.0, 0.0), "{input:?}");
                }
                other => panic!("expected Circle, got {other:?}"),
            }
        }
        // A complete `at` clause is honoured; extra trailing parts are ignored.
        for input in ["circle(50px at 1px 2px)", "circle(50px at 1px 2px 3px)"] {
            match clip_shape(input) {
                CssShape::Circle(ShapeCircle { center, radius }) => {
                    assert_eq!(radius, 50.0);
                    assert_eq!((center.x, center.y), (1.0, 2.0), "{input:?}");
                }
                other => panic!("expected Circle, got {other:?}"),
            }
        }
        assert!(matches!(
            parse_clip_path("circle()"),
            Err(ShapeParseError::MissingParameter(_))
        ));
    }
    #[test]
    fn ellipse_requires_two_radii() {
        for input in ["ellipse()", "ellipse(1px)"] {
            assert!(
                matches!(
                    parse_clip_path(input),
                    Err(ShapeParseError::MissingParameter(_))
                ),
                "expected MissingParameter for {input:?}"
            );
        }
        match clip_shape("ellipse(1px 2px)") {
            CssShape::Ellipse(ShapeEllipse {
                center,
                radius_x,
                radius_y,
            }) => {
                assert_eq!((radius_x, radius_y), (1.0, 2.0));
                assert_eq!((center.x, center.y), (0.0, 0.0));
            }
            other => panic!("expected Ellipse, got {other:?}"),
        }
        match clip_shape("ellipse(1px 2px at 3px 4px)") {
            CssShape::Ellipse(ShapeEllipse {
                center,
                radius_x,
                radius_y,
            }) => {
                assert_eq!((radius_x, radius_y), (1.0, 2.0));
                assert_eq!((center.x, center.y), (3.0, 4.0));
            }
            other => panic!("expected Ellipse, got {other:?}"),
        }
        assert!(matches!(
            parse_clip_path("ellipse(a b)"),
            Err(ShapeParseError::InvalidNumber(_))
        ));
    }
    #[test]
    fn polygon_needs_three_complete_points() {
        // Fewer than 3 points, empty args, and a trailing comma all fail.
        for input in [
            "polygon()",
            "polygon(,)",
            "polygon(0 0)",
            "polygon(0 0, 1 1)",
            "polygon(0 0, 1 1, 2 2,)",
            "polygon(0, 1, 2)",
            "polygon(nonzero,)",
        ] {
            assert!(
                parse_clip_path(input).is_err(),
                "expected Err for {input:?}"
            );
        }
        match clip_shape("polygon(0 0, 1 1, 2 2)") {
            CssShape::Polygon(ShapePolygon { points }) => {
                assert_eq!(points.as_ref().len(), 3);
                assert_eq!((points.as_ref()[2].x, points.as_ref()[2].y), (2.0, 2.0));
            }
            other => panic!("expected Polygon, got {other:?}"),
        }
        // The optional fill-rule prefix is accepted (and ignored) only when it
        // is immediately followed by a comma.
        for input in [
            "polygon(nonzero, 0 0, 1 1, 2 2)",
            "polygon(evenodd,0 0,1 1,2 2)",
        ] {
            match clip_shape(input) {
                CssShape::Polygon(ShapePolygon { points }) => {
                    assert_eq!(points.as_ref().len(), 3, "{input:?}");
                }
                other => panic!("expected Polygon, got {other:?}"),
            }
        }
        assert!(matches!(
            parse_clip_path("polygon(nonzero 0 0, 1 1, 2 2)"),
            Err(ShapeParseError::InvalidNumber(_))
        ));
        assert!(matches!(
            parse_clip_path("polygon(x y, x y, x y)"),
            Err(ShapeParseError::InvalidNumber(_))
        ));
    }
    #[test]
    fn inset_shorthand_and_round_keyword() {
        // 1/2/3/4-value shorthand, same rules as margin/padding.
        let cases: [(&str, [f32; 4]); 4] = [
            ("inset(10px)", [10.0, 10.0, 10.0, 10.0]),
            ("inset(1px 2px)", [1.0, 2.0, 1.0, 2.0]),
            ("inset(1px 2px 3px)", [1.0, 2.0, 3.0, 2.0]),
            ("inset(1px 2px 3px 4px)", [1.0, 2.0, 3.0, 4.0]),
        ];
        for (input, [top, right, bottom, left]) in cases {
            match clip_shape(input) {
                CssShape::Inset(ShapeInset {
                    inset_top,
                    inset_right,
                    inset_bottom,
                    inset_left,
                    border_radius,
                }) => {
                    assert_eq!(
                        [inset_top, inset_right, inset_bottom, inset_left],
                        [top, right, bottom, left],
                        "{input:?}"
                    );
                    assert!(matches!(border_radius, OptionF32::None));
                }
                other => panic!("expected Inset, got {other:?}"),
            }
        }
        // 5 values is rejected; no values is rejected.
        assert!(matches!(
            parse_clip_path("inset(1px 2px 3px 4px 5px)"),
            Err(ShapeParseError::InvalidSyntax(_))
        ));
        assert!(matches!(
            parse_clip_path("inset()"),
            Err(ShapeParseError::MissingParameter(_))
        ));
        assert!(matches!(
            parse_clip_path("inset( )"),
            Err(ShapeParseError::MissingParameter(_))
        ));
        // `round` with a missing / unparseable radius errors rather than
        // panicking on the `args[round_pos + 5..]` slice.
        for input in [
            "inset(round)",
            "inset(10px round)",
            "inset(roundround)",
            "inset(10px round 5px 6px)",
        ] {
            assert!(
                matches!(
                    parse_clip_path(input),
                    Err(ShapeParseError::InvalidNumber(_))
                ),
                "expected InvalidNumber for {input:?}"
            );
        }
        match clip_shape("inset(10px round 5px)") {
            CssShape::Inset(ShapeInset { border_radius, .. }) => {
                assert!(matches!(border_radius, OptionF32::Some(r) if r == 5.0));
            }
            other => panic!("expected Inset, got {other:?}"),
        }
    }
    #[test]
    fn path_data_must_be_quoted_and_is_stored_verbatim() {
        // Unquoted / half-quoted path data is rejected.
        for input in ["path()", "path(abc)", "path(\"abc)", "path(abc\")"] {
            assert!(
                matches!(
                    parse_clip_path(input),
                    Err(ShapeParseError::InvalidSyntax(_))
                ),
                "expected InvalidSyntax for {input:?}"
            );
        }
        // An empty quoted path is valid and yields empty data (this is the
        // len == 2 neighbour of the len == 1 panic in the known-bug test).
        match clip_shape("path(\"\")") {
            CssShape::Path(ShapePath { data }) => assert_eq!(data.as_str(), ""),
            other => panic!("expected Path, got {other:?}"),
        }
        // Path data is never interpreted, so it can contain anything — including
        // the parens that `parse_function` scans for, thanks to rfind(')').
        match clip_shape("path(\"M 0 0 (L) 1 1 Z\")") {
            CssShape::Path(ShapePath { data }) => {
                assert_eq!(data.as_str(), "M 0 0 (L) 1 1 Z");
            }
            other => panic!("expected Path, got {other:?}"),
        }
    }
    // ---------------------------------------------------------------------
    // Round-trip: print_as_css_value -> parse -> identical value
    // ---------------------------------------------------------------------
    #[test]
    fn shape_properties_round_trip_through_their_css_representation() {
        let inputs = [
            "none",
            "circle(50px)",
            "circle(50px at 10px 20px)",
            "circle(-1px at -2px -3px)",
            "ellipse(1px 2px)",
            "ellipse(1px 2px at 3px 4px)",
            "polygon(0px 0px, 100px 0px, 100px 100px)",
            "polygon(0px 0px, 1px 1px, 2px 2px, 3px 3px, 4px 4px)",
            "inset(1px 2px 3px 4px)",
            "inset(10px round 5px)",
            "path(\"M 0 0 L 1 1 Z\")",
        ];
        for input in inputs {
            let outside = parse_shape_outside(input).expect(input);
            let printed = outside.print_as_css_value();
            assert_eq!(
                parse_shape_outside(&printed).as_ref(),
                Ok(&outside),
                "shape-outside round-trip changed the value: {input:?} -> {printed:?}"
            );
            // Printing must also be idempotent, not just re-parseable.
            assert_eq!(
                parse_shape_outside(&printed).expect(input).print_as_css_value(),
                printed
            );
            let inside = parse_shape_inside(input).expect(input);
            let printed = inside.print_as_css_value();
            assert_eq!(parse_shape_inside(&printed).as_ref(), Ok(&inside), "{input:?}");
            let clip = parse_clip_path(input).expect(input);
            let printed = clip.print_as_css_value();
            assert_eq!(parse_clip_path(&printed).as_ref(), Ok(&clip), "{input:?}");
        }
    }
    #[test]
    fn none_prints_as_the_none_keyword() {
        assert_eq!(ShapeOutside::None.print_as_css_value(), "none");
        assert_eq!(ShapeInside::None.print_as_css_value(), "none");
        assert_eq!(ClipPath::None.print_as_css_value(), "none");
    }
    #[test]
    fn margin_and_threshold_round_trip() {
        for input in ["0px", "10px", "-5px", "1.5em", "50%", "2rem", "12pt", "1in"] {
            let margin = parse_shape_margin(input).expect(input);
            let printed = margin.print_as_css_value();
            assert_eq!(
                parse_shape_margin(&printed).expect(&printed),
                margin,
                "shape-margin round-trip changed the value: {input:?} -> {printed:?}"
            );
        }
        for input in ["0", "0.5", "1", "0.001", "0.999"] {
            let threshold = parse_shape_image_threshold(input).expect(input);
            let printed = threshold.print_as_css_value();
            assert_eq!(
                parse_shape_image_threshold(&printed).expect(&printed),
                threshold,
                "shape-image-threshold round-trip changed the value: {input:?} -> {printed:?}"
            );
        }
    }
    // ---------------------------------------------------------------------
    // parse_shape_margin
    // ---------------------------------------------------------------------
    #[test]
    fn margin_empty_and_whitespace_only_is_empty_string_error() {
        for input in ["", " ", "     ", "\t", "\n", "\r\n", "\u{a0}"] {
            assert!(
                matches!(
                    parse_shape_margin(input),
                    Err(CssPixelValueParseError::EmptyString)
                ),
                "expected EmptyString for {input:?}"
            );
        }
    }
    #[test]
    fn margin_valid_units_map_to_the_right_metric() {
        // NOTE: `vmin` is missing here on purpose — it is broken. See
        // `known_bug_vmin_unit_is_rejected_by_metric_table_order`.
        let cases = [
            ("10px", SizeMetric::Px),
            ("10em", SizeMetric::Em),
            ("10rem", SizeMetric::Rem),
            ("10pt", SizeMetric::Pt),
            ("10in", SizeMetric::In),
            ("10cm", SizeMetric::Cm),
            ("10mm", SizeMetric::Mm),
            ("10%", SizeMetric::Percent),
            ("10vw", SizeMetric::Vw),
            ("10vh", SizeMetric::Vh),
            ("10vmax", SizeMetric::Vmax),
            // Unitless numbers are accepted and default to px.
            ("10", SizeMetric::Px),
        ];
        for (input, metric) in cases {
            let margin = parse_shape_margin(input).expect(input);
            assert_eq!(margin.inner.metric, metric, "{input:?}");
            assert_eq!(margin.inner.number.get(), 10.0, "{input:?}");
        }
        // Whitespace around the value and between number and unit is tolerated.
        assert_eq!(
            parse_shape_margin("  10px  ").expect("padded").inner,
            PixelValue::px(10.0)
        );
        assert_eq!(
            parse_shape_margin("10 px").expect("inner space").inner,
            PixelValue::px(10.0)
        );
    }
    #[test]
    fn margin_rejects_malformed_and_junk_suffixed_values() {
        // A unit with no number.
        assert!(matches!(
            parse_shape_margin("px"),
            Err(CssPixelValueParseError::NoValueGiven(_, SizeMetric::Px))
        ));
        // A number with a bad number part in front of a known unit.
        assert!(matches!(
            parse_shape_margin("abcpx"),
            Err(CssPixelValueParseError::ValueParseErr(_, "abc"))
        ));
        // Neither a known unit nor a bare number.
        for input in [
            "10px;garbage",
            "garbage",
            "10 20px",
            "10px 20px",
            "10PX",
            "10Px",
            "10px🙂",
            "🙂px",
            "!@#$",
        ] {
            assert!(
                parse_shape_margin(input).is_err(),
                "expected Err for {input:?}"
            );
        }
    }
    /// Unit matching is ASCII-case-sensitive, which is not CSS-conformant.
    /// Asserted so it holds either way: uppercase must never yield a *wrong*
    /// value, only the right one or an error.
    #[test]
    fn margin_uppercase_units_never_yield_a_wrong_value() {
        for input in ["10PX", "10Px", "10EM", "10%"] {
            if let Ok(margin) = parse_shape_margin(input) { assert_eq!(margin.inner.number.get(), 10.0, "{input:?}") } else { /* current behaviour for the uppercase forms */ }
        }
    }
    /// The `FloatValue` isize encoding is the only thing standing between a
    /// hostile stylesheet and a non-finite length in layout. Nothing that parses
    /// may produce a NaN or infinite `PixelValue`.
    #[test]
    fn margin_never_produces_a_non_finite_value() {
        let extremes = [
            "0px",
            "-0px",
            "0",
            "-0",
            "NaNpx",
            "nanpx",
            "infpx",
            "-infpx",
            "inf",
            "-inf",
            "1e400px",
            "-1e400px",
            "1e38px",
            "-1e38px",
            "9223372036854775807px",
            "-9223372036854775808px",
            "340282350000000000000000000000000000000px",
            "0.0000000000000000001px",
        ];
        for input in extremes {
            if let Ok(margin) = parse_shape_margin(input) {
                let value = margin.inner.number.get();
                assert!(
                    value.is_finite(),
                    "{input:?} produced a non-finite PixelValue: {value}"
                );
            }
        }
        // Saturation, specifically: `1e400` parses to f32::INFINITY (Rust's f32
        // FromStr does not error on overflow), and the `f32 as isize` cast in
        // FloatValue::new then saturates at the isize bound — which is exactly
        // what keeps the infinity from reaching layout.
        let saturated = parse_shape_margin("1e400px").expect("f32 overflow parses as inf");
        assert_eq!(saturated.inner.number.number(), isize::MAX);
        assert!(saturated.inner.number.get().is_finite());
        let saturated_neg = parse_shape_margin("-1e400px").expect("parses as -inf");
        assert_eq!(saturated_neg.inner.number.number(), isize::MIN);
        assert!(saturated_neg.inner.number.get().is_finite());
        // NaN saturates to 0 rather than to a bound.
        assert_eq!(
            parse_shape_margin("NaNpx")
                .expect("NaN currently parses")
                .inner
                .number
                .number(),
            0
        );
    }
    /// `FloatValue` keeps 3 decimal places and *truncates* toward zero — sizes
    /// below 0.001 collapse to exactly 0. Worth pinning: it silently changes
    /// authored values.
    #[test]
    fn margin_quantizes_to_three_decimals_by_truncation() {
        assert_eq!(
            parse_shape_margin("0.001px").expect("0.001").inner.number.get(),
            0.001
        );
        // 0.0005 does NOT round up to 0.001 — it truncates to 0.
        assert_eq!(
            parse_shape_margin("0.0005px").expect("0.0005").inner.number.get(),
            0.0
        );
        assert_eq!(
            parse_shape_margin("0.0009px").expect("0.0009").inner.number.get(),
            0.0
        );
        let truncated = parse_shape_margin("1.9999px").expect("1.9999").inner.number.get();
        assert!(
            (truncated - 1.999).abs() < 1.0e-6,
            "expected truncation to 1.999, got {truncated}"
        );
    }
    // ---------------------------------------------------------------------
    // parse_shape_image_threshold
    // ---------------------------------------------------------------------
    #[test]
    fn threshold_empty_whitespace_and_garbage_are_errors() {
        for input in [
            "", " ", "   ", "\t\n", "abc", "0.5px", "50%", "1,0", "0.5.5", "🙂", "--1",
        ] {
            assert!(
                parse_shape_image_threshold(input).is_err(),
                "expected Err for {input:?}"
            );
        }
    }
    #[test]
    fn threshold_parses_and_trims_valid_values() {
        assert_eq!(parse_shape_image_threshold("0").expect("0").inner.get(), 0.0);
        assert_eq!(parse_shape_image_threshold("1").expect("1").inner.get(), 1.0);
        assert_eq!(
            parse_shape_image_threshold("0.5").expect("0.5").inner.get(),
            0.5
        );
        assert_eq!(
            parse_shape_image_threshold("  0.5  ")
                .expect("padded")
                .inner
                .get(),
            0.5
        );
        // f32 accepts these spellings; CSS numbers do too.
        assert_eq!(
            parse_shape_image_threshold("+0.5").expect("+0.5").inner.get(),
            0.5
        );
        assert_eq!(
            parse_shape_image_threshold("5e-1").expect("5e-1").inner.get(),
            0.5
        );
        assert_eq!(
            parse_shape_image_threshold(".5").expect(".5").inner.get(),
            0.5
        );
    }
    /// The documented contract: the result is clamped to `0.0 ..= 1.0`. Assert it
    /// as a hard invariant over every input that parses at all — including the
    /// ones that reach `clamp` as infinities.
    #[test]
    fn threshold_is_always_clamped_to_zero_one_and_finite() {
        let extremes = [
            "0", "-0", "1", "-1", "2", "1.0001", "-0.0001", "100", "1e10", "-1e10", "1e38",
            "1e400", "-1e400", "inf", "-inf", "infinity", "-infinity", "NaN", "nan", "-NaN",
            "9223372036854775807", "-9223372036854775808", "1e-45", "-1e-45", "0.0000001",
        ];
        for input in extremes {
            let Ok(threshold) = parse_shape_image_threshold(input) else {
                continue;
            };
            let value = threshold.inner.get();
            assert!(
                value.is_finite(),
                "{input:?} produced a non-finite threshold: {value}"
            );
            assert!(
                (0.0..=1.0).contains(&value),
                "{input:?} escaped the [0, 1] clamp: {value}"
            );
        }
        // Direction of the clamp, specifically.
        assert_eq!(parse_shape_image_threshold("2").expect("2").inner.get(), 1.0);
        assert_eq!(
            parse_shape_image_threshold("-1").expect("-1").inner.get(),
            0.0
        );
        assert_eq!(
            parse_shape_image_threshold("inf").expect("inf").inner.get(),
            1.0
        );
        assert_eq!(
            parse_shape_image_threshold("-inf").expect("-inf").inner.get(),
            0.0
        );
        // NaN is neutralised by the isize encoding *before* it reaches clamp
        // (`f32 as isize` maps NaN to 0), so it lands on 0.0 rather than
        // propagating or panicking.
        assert_eq!(
            parse_shape_image_threshold("NaN").expect("NaN").inner.get(),
            0.0
        );
    }
    /// Same 0.001 truncation as `ShapeMargin`: a threshold below 0.001 becomes a
    /// fully transparent 0.
    #[test]
    fn threshold_quantizes_to_three_decimals() {
        assert_eq!(
            parse_shape_image_threshold("0.001")
                .expect("0.001")
                .inner
                .get(),
            0.001
        );
        assert_eq!(
            parse_shape_image_threshold("0.0005")
                .expect("0.0005")
                .inner
                .get(),
            0.0
        );
        let truncated = parse_shape_image_threshold("0.9999")
            .expect("0.9999")
            .inner
            .get();
        assert!(
            (truncated - 0.999).abs() < 1.0e-6,
            "expected truncation to 0.999, got {truncated}"
        );
    }
    #[test]
    fn threshold_survives_a_ten_thousand_digit_number() {
        let huge = "9".repeat(10_000);
        assert_eq!(
            parse_shape_image_threshold(&huge)
                .expect("overflows to inf, clamps to 1")
                .inner
                .get(),
            1.0
        );
        let tiny = format!("0.{}1", "0".repeat(10_000));
        assert_eq!(
            parse_shape_image_threshold(&tiny)
                .expect("underflows to 0")
                .inner
                .get(),
            0.0
        );
    }
    // ---------------------------------------------------------------------
    // Type invariants: Default, Ord/PartialOrd agreement, Hash
    // ---------------------------------------------------------------------
    #[test]
    fn defaults_are_none_and_zero() {
        assert_eq!(ShapeOutside::default(), ShapeOutside::None);
        assert_eq!(ShapeInside::default(), ShapeInside::None);
        assert_eq!(ClipPath::default(), ClipPath::None);
        assert_eq!(ShapeMargin::default().inner, PixelValue::zero());
        assert_eq!(ShapeMargin::default().inner.number.get(), 0.0);
        assert_eq!(ShapeImageThreshold::default().inner.get(), 0.0);
        // The defaults are exactly what the minimal CSS text parses to.
        assert_eq!(parse_clip_path("none").expect("none"), ClipPath::default());
        assert_eq!(
            parse_shape_margin("0px").expect("0px"),
            ShapeMargin::default()
        );
        assert_eq!(
            parse_shape_image_threshold("0").expect("0"),
            ShapeImageThreshold::default()
        );
    }
    /// `None` sorts before any shape, and the hand-written `Ord` must agree with
    /// the `PartialOrd` that delegates to it.
    #[test]
    fn none_sorts_before_shape_and_ord_agrees_with_partial_ord() {
        let shape_clip = parse_clip_path("circle(1px)").expect("circle");
        let shape_out = parse_shape_outside("circle(1px)").expect("circle");
        let shape_in = parse_shape_inside("circle(1px)").expect("circle");
        assert_eq!(ClipPath::None.cmp(&shape_clip), Ordering::Less);
        assert_eq!(shape_clip.cmp(&ClipPath::None), Ordering::Greater);
        assert_eq!(ClipPath::None.cmp(&ClipPath::None), Ordering::Equal);
        assert_eq!(
            ClipPath::None.partial_cmp(&shape_clip),
            Some(ClipPath::None.cmp(&shape_clip))
        );
        assert_eq!(ShapeOutside::None.cmp(&shape_out), Ordering::Less);
        assert_eq!(
            ShapeOutside::None.partial_cmp(&shape_out),
            Some(Ordering::Less)
        );
        assert_eq!(ShapeInside::None.cmp(&shape_in), Ordering::Less);
        assert_eq!(
            ShapeInside::None.partial_cmp(&shape_in),
            Some(Ordering::Less)
        );
    }
    /// `CssShape`'s `Ord` is hand-written with explicit cross-variant arms whose
    /// ORDER encodes the variant ranking. Check it is a strict, antisymmetric
    /// total order across all five variants — a merged/reordered arm would show
    /// up here as two variants comparing Less in both directions.
    #[test]
    fn css_shape_variant_ordering_is_antisymmetric() {
        let shapes = [
            parse_clip_path("circle(1px)").expect("circle"),
            parse_clip_path("ellipse(1px 2px)").expect("ellipse"),
            parse_clip_path("polygon(0 0, 1 1, 2 2)").expect("polygon"),
            parse_clip_path("inset(1px)").expect("inset"),
            parse_clip_path("path(\"Z\")").expect("path"),
        ];
        for (i, a) in shapes.iter().enumerate() {
            assert_eq!(a.cmp(a), Ordering::Equal, "variant {i} is not self-equal");
            for (j, b) in shapes.iter().enumerate() {
                let forward = a.cmp(b);
                let backward = b.cmp(a);
                assert_eq!(
                    forward,
                    backward.reverse(),
                    "cmp is not antisymmetric for variants {i} and {j}"
                );
                if i < j {
                    assert_eq!(
                        forward,
                        Ordering::Less,
                        "variant {i} should sort before variant {j}"
                    );
                }
            }
        }
    }
    /// Hash must agree with equality for the values that *are* self-equal (i.e.
    /// everything except the NaN shapes covered by the known-bug test), and the
    /// discriminant must take part so `None` and a shape don't collide.
    #[test]
    fn hash_agrees_with_equality() {
        let a = parse_clip_path("circle(50px at 1px 2px)").expect("circle");
        let b = parse_clip_path("circle(50px at 1px 2px)").expect("circle");
        assert_eq!(a, b);
        assert_eq!(hash_of(&a), hash_of(&b));
        let different = parse_clip_path("circle(51px at 1px 2px)").expect("circle");
        assert_ne!(a, different);
        assert_ne!(hash_of(&a), hash_of(&different));
        // The discriminant takes part in the hash, so None and a shape do not
        // collide (the manual Hash impls would be easy to write without it).
        assert_ne!(hash_of(&ClipPath::None), hash_of(&a));
        assert_eq!(hash_of(&ClipPath::None), hash_of(&ClipPath::None));
        let outside = parse_shape_outside("circle(50px at 1px 2px)").expect("circle");
        assert_ne!(hash_of(&ShapeOutside::None), hash_of(&outside));
        let margin = parse_shape_margin("10px").expect("10px");
        assert_eq!(
            hash_of(&margin),
            hash_of(&ShapeMargin {
                inner: PixelValue::px(10.0)
            })
        );
        assert_ne!(
            hash_of(&margin),
            hash_of(&parse_shape_margin("10em").expect("10em"))
        );
    }
}