1
//! CSS Shape data structures for shape-inside, shape-outside, and clip-path
2
//!
3
//! These types are C-compatible (repr(C)) for use across FFI boundaries.
4

            
5
use alloc::string::String;
6
use crate::corety::{AzString, OptionF32};
7

            
8
/// Compares two f32 values for ordering, treating NaN as equal.
9
413
fn cmp_f32(a: f32, b: f32) -> core::cmp::Ordering {
10
413
    a.partial_cmp(&b).unwrap_or(core::cmp::Ordering::Equal)
11
413
}
12

            
13
/// A 2D point for shape coordinates (using f32 for precision)
14
#[derive(Debug, Copy, Clone, PartialEq)]
15
#[repr(C)]
16
pub struct ShapePoint {
17
    pub x: f32,
18
    pub y: f32,
19
}
20

            
21
impl_option!(
22
    ShapePoint,
23
    OptionShapePoint,
24
    [Debug, Copy, Clone, PartialEq, Eq, PartialOrd]
25
);
26

            
27
impl ShapePoint {
28
111094
    #[must_use] pub const fn new(x: f32, y: f32) -> Self {
29
111094
        Self { x, y }
30
111094
    }
31

            
32
252
    #[must_use] pub const fn zero() -> Self {
33
252
        Self { x: 0.0, y: 0.0 }
34
252
    }
35
}
36

            
37
impl Eq for ShapePoint {}
38

            
39
// PartialOrd delegates to Ord (NaN-as-equal) so the two stay consistent.
40
impl PartialOrd for ShapePoint {
41
1337
    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
42
1337
        Some(self.cmp(other))
43
1337
    }
44
}
45

            
46
impl Ord for ShapePoint {
47
27522
    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
48
27522
        match self.x.partial_cmp(&other.x) {
49
22703
            Some(core::cmp::Ordering::Equal) => self
50
22703
                .y
51
22703
                .partial_cmp(&other.y)
52
22703
                .unwrap_or(core::cmp::Ordering::Equal),
53
4819
            other => other.unwrap_or(core::cmp::Ordering::Equal),
54
        }
55
27522
    }
56
}
57

            
58
impl core::hash::Hash for ShapePoint {
59
24
    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
60
24
        self.x.to_bits().hash(state);
61
24
        self.y.to_bits().hash(state);
62
24
    }
63
}
64

            
65
impl_vec!(ShapePoint, ShapePointVec, ShapePointVecDestructor, ShapePointVecDestructorType, ShapePointVecSlice, OptionShapePoint);
66
impl_vec_debug!(ShapePoint, ShapePointVec);
67
impl_vec_partialord!(ShapePoint, ShapePointVec);
68
impl_vec_ord!(ShapePoint, ShapePointVec);
69
impl_vec_clone!(ShapePoint, ShapePointVec, ShapePointVecDestructor);
70
impl_vec_partialeq!(ShapePoint, ShapePointVec);
71
impl_vec_eq!(ShapePoint, ShapePointVec);
72
impl_vec_hash!(ShapePoint, ShapePointVec);
73

            
74
/// A circle shape defined by center point and radius
75
#[derive(Debug, Copy, Clone)]
76
#[repr(C)]
77
pub struct ShapeCircle {
78
    pub center: ShapePoint,
79
    pub radius: f32,
80
}
81

            
82
// PartialEq is hand-written to match the to_bits Hash and the NaN-Equal Ord, so
83
// Eq stays reflexive even when a radius is NaN (a preserved, if unusual, length).
84
impl PartialEq for ShapeCircle {
85
    fn eq(&self, other: &Self) -> bool {
86
        self.cmp(other) == core::cmp::Ordering::Equal
87
    }
88
}
89
impl Eq for ShapeCircle {}
90
impl core::hash::Hash for ShapeCircle {
91
11
    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
92
11
        self.center.hash(state);
93
11
        self.radius.to_bits().hash(state);
94
11
    }
95
}
96
impl PartialOrd for ShapeCircle {
97
    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
98
        Some(self.cmp(other))
99
    }
100
}
101
impl Ord for ShapeCircle {
102
93
    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
103
93
        match self.center.cmp(&other.center) {
104
93
            core::cmp::Ordering::Equal => self
105
93
                .radius
106
93
                .partial_cmp(&other.radius)
107
93
                .unwrap_or(core::cmp::Ordering::Equal),
108
            other => other,
109
        }
110
93
    }
111
}
112

            
113
/// An ellipse shape defined by center point and two radii
114
#[derive(Debug, Copy, Clone)]
115
#[repr(C)]
116
pub struct ShapeEllipse {
117
    pub center: ShapePoint,
118
    pub radius_x: f32,
119
    pub radius_y: f32,
120
}
121

            
122
// PartialEq hand-written to match the to_bits Hash / NaN-Equal Ord (reflexive Eq).
123
impl PartialEq for ShapeEllipse {
124
    fn eq(&self, other: &Self) -> bool {
125
        self.cmp(other) == core::cmp::Ordering::Equal
126
    }
127
}
128
impl Eq for ShapeEllipse {}
129
impl core::hash::Hash for ShapeEllipse {
130
1
    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
131
1
        self.center.hash(state);
132
1
        self.radius_x.to_bits().hash(state);
133
1
        self.radius_y.to_bits().hash(state);
134
1
    }
135
}
136
impl PartialOrd for ShapeEllipse {
137
    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
138
        Some(self.cmp(other))
139
    }
140
}
141
impl Ord for ShapeEllipse {
142
22
    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
143
22
        match self.center.cmp(&other.center) {
144
22
            core::cmp::Ordering::Equal => match self.radius_x.partial_cmp(&other.radius_x) {
145
22
                Some(core::cmp::Ordering::Equal) | None => self
146
22
                    .radius_y
147
22
                    .partial_cmp(&other.radius_y)
148
22
                    .unwrap_or(core::cmp::Ordering::Equal),
149
                Some(other) => other,
150
            },
151
            other => other,
152
        }
153
22
    }
154
}
155

            
156
/// A polygon shape defined by a list of points (in clockwise order)
157
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
158
#[repr(C)]
159
pub struct ShapePolygon {
160
    pub points: ShapePointVec,
161
}
162

            
163
/// An inset rectangle with optional border radius
164
/// Defined by insets from the reference box edges
165
#[derive(Debug, Copy, Clone)]
166
#[repr(C)]
167
pub struct ShapeInset {
168
    pub inset_top: f32,
169
    pub inset_right: f32,
170
    pub inset_bottom: f32,
171
    pub inset_left: f32,
172
    pub border_radius: OptionF32,
173
}
174

            
175
// PartialEq hand-written to match the to_bits Hash / NaN-Equal Ord (reflexive Eq).
176
impl PartialEq for ShapeInset {
177
    fn eq(&self, other: &Self) -> bool {
178
        self.cmp(other) == core::cmp::Ordering::Equal
179
    }
180
}
181
impl Eq for ShapeInset {}
182
impl core::hash::Hash for ShapeInset {
183
    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
184
        self.inset_top.to_bits().hash(state);
185
        self.inset_right.to_bits().hash(state);
186
        self.inset_bottom.to_bits().hash(state);
187
        self.inset_left.to_bits().hash(state);
188
        self.border_radius.hash(state);
189
    }
190
}
191
impl PartialOrd for ShapeInset {
192
    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
193
        Some(self.cmp(other))
194
    }
195
}
196
impl Ord for ShapeInset {
197
37
    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
198
37
        cmp_f32(self.inset_top, other.inset_top)
199
37
            .then_with(|| cmp_f32(self.inset_right, other.inset_right))
200
37
            .then_with(|| cmp_f32(self.inset_bottom, other.inset_bottom))
201
37
            .then_with(|| cmp_f32(self.inset_left, other.inset_left))
202
37
            .then_with(|| self.border_radius.cmp(&other.border_radius))
203
37
    }
204
}
205

            
206
/// An SVG-like path for shape definitions.
207
/// TODO: path parsing is not yet implemented — `data` is stored but not interpreted.
208
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
209
#[repr(C)]
210
pub struct ShapePath {
211
    pub data: AzString,
212
}
213

            
214
/// Represents a CSS shape for shape-inside, shape-outside, and clip-path.
215
/// Used for both text layout (shape-inside/outside) and rendering clipping (clip-path).
216
#[derive(Debug, Clone)]
217
#[repr(C, u8)]
218
pub enum CssShape {
219
    Circle(ShapeCircle),
220
    Ellipse(ShapeEllipse),
221
    Polygon(ShapePolygon),
222
    Inset(ShapeInset),
223
    Path(ShapePath),
224
}
225

            
226
// PartialEq hand-written to match the to_bits Hash / NaN-Equal Ord (reflexive Eq
227
// through the float-holding variants).
228
impl PartialEq for CssShape {
229
71
    fn eq(&self, other: &Self) -> bool {
230
71
        self.cmp(other) == core::cmp::Ordering::Equal
231
71
    }
232
}
233
impl Eq for CssShape {}
234

            
235
impl core::hash::Hash for CssShape {
236
14
    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
237
14
        core::mem::discriminant(self).hash(state);
238
14
        match self {
239
11
            Self::Circle(c) => c.hash(state),
240
1
            Self::Ellipse(e) => e.hash(state),
241
2
            Self::Polygon(p) => p.hash(state),
242
            Self::Inset(i) => i.hash(state),
243
            Self::Path(p) => p.hash(state),
244
        }
245
14
    }
246
}
247

            
248
impl PartialOrd for CssShape {
249
91
    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
250
91
        Some(self.cmp(other))
251
91
    }
252
}
253

            
254
impl Ord for CssShape {
255
    // The tie-break arms `(Self::X(_), _) => Less` / `(_, Self::X(_)) => Greater`
256
    // share bodies but are ORDER-DEPENDENT: they encode the variant ordering, so
257
    // merging them (clippy::match_same_arms) would change the comparison result.
258
    #[allow(clippy::match_same_arms)]
259
542
    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
260
542
        match (self, other) {
261
93
            (Self::Circle(a), Self::Circle(b)) => a.cmp(b),
262
21
            (Self::Ellipse(a), Self::Ellipse(b)) => a.cmp(b),
263
39
            (Self::Polygon(a), Self::Polygon(b)) => a.cmp(b),
264
37
            (Self::Inset(a), Self::Inset(b)) => a.cmp(b),
265
46
            (Self::Path(a), Self::Path(b)) => a.cmp(b),
266
            // Different variants: use discriminant ordering
267
68
            (Self::Circle(_), _) => core::cmp::Ordering::Less,
268
64
            (_, Self::Circle(_)) => core::cmp::Ordering::Greater,
269
33
            (Self::Ellipse(_), _) => core::cmp::Ordering::Less,
270
30
            (_, Self::Ellipse(_)) => core::cmp::Ordering::Greater,
271
38
            (Self::Polygon(_), _) => core::cmp::Ordering::Less,
272
36
            (_, Self::Polygon(_)) => core::cmp::Ordering::Greater,
273
19
            (Self::Inset(_), Self::Path(_)) => core::cmp::Ordering::Less,
274
18
            (Self::Path(_), Self::Inset(_)) => core::cmp::Ordering::Greater,
275
        }
276
542
    }
277
}
278

            
279
impl CssShape {
280
    /// Creates a circle shape at the given position with the given radius
281
408
    #[must_use] pub const fn circle(center: ShapePoint, radius: f32) -> Self {
282
408
        Self::Circle(ShapeCircle { center, radius })
283
408
    }
284

            
285
    /// Creates an ellipse shape
286
51
    #[must_use] pub const fn ellipse(center: ShapePoint, radius_x: f32, radius_y: f32) -> Self {
287
51
        Self::Ellipse(ShapeEllipse {
288
51
            center,
289
51
            radius_x,
290
51
            radius_y,
291
51
        })
292
51
    }
293

            
294
    /// Creates a polygon from a list of points
295
82
    #[must_use] pub const fn polygon(points: ShapePointVec) -> Self {
296
82
        Self::Polygon(ShapePolygon { points })
297
82
    }
298

            
299
    /// Creates an inset rectangle
300
46
    #[must_use] pub const fn inset(top: f32, right: f32, bottom: f32, left: f32) -> Self {
301
46
        Self::Inset(ShapeInset {
302
46
            inset_top: top,
303
46
            inset_right: right,
304
46
            inset_bottom: bottom,
305
46
            inset_left: left,
306
46
            border_radius: OptionF32::None,
307
46
        })
308
46
    }
309

            
310
    /// Creates an inset rectangle with rounded corners
311
40
    #[must_use] pub const fn inset_rounded(top: f32, right: f32, bottom: f32, left: f32, radius: f32) -> Self {
312
40
        Self::Inset(ShapeInset {
313
40
            inset_top: top,
314
40
            inset_right: right,
315
40
            inset_bottom: bottom,
316
40
            inset_left: left,
317
40
            border_radius: OptionF32::Some(radius),
318
40
        })
319
40
    }
320

            
321
118
    #[must_use] pub fn print_as_css_value(&self) -> String {
322
        use alloc::format;
323
118
        match self {
324
32
            Self::Circle(ShapeCircle { center, radius }) => {
325
32
                format!("circle({}px at {}px {}px)", radius, center.x, center.y)
326
            }
327
15
            Self::Ellipse(ShapeEllipse { center, radius_x, radius_y }) => {
328
15
                format!("ellipse({}px {}px at {}px {}px)", radius_x, radius_y, center.x, center.y)
329
            }
330
21
            Self::Polygon(ShapePolygon { points }) => {
331
21
                let pts: Vec<String> = points.as_ref().iter()
332
7054
                    .map(|p| format!("{}px {}px", p.x, p.y))
333
21
                    .collect();
334
21
                format!("polygon({})", pts.join(", "))
335
            }
336
23
            Self::Inset(ShapeInset { inset_top, inset_right, inset_bottom, inset_left, border_radius }) => {
337
23
                let base = format!("inset({inset_top}px {inset_right}px {inset_bottom}px {inset_left}px");
338
23
                match border_radius {
339
9
                    OptionF32::Some(r) => format!("{base} round {r}px)"),
340
14
                    OptionF32::None => format!("{base})"),
341
                }
342
            }
343
27
            Self::Path(ShapePath { data }) => {
344
27
                format!("path(\"{}\")", data.as_str())
345
            }
346
        }
347
118
    }
348

            
349
25
    #[must_use] pub fn format_as_rust_code(&self) -> String {
350
        use alloc::format;
351
25
        match self {
352
3
            Self::Circle(ShapeCircle { center, radius }) => {
353
3
                format!(
354
3
                    "CssShape::Circle(ShapeCircle {{ center: ShapePoint::new({}_f32, {}_f32), radius: {}_f32 }})",
355
                    center.x, center.y, radius
356
                )
357
            }
358
2
            Self::Ellipse(ShapeEllipse { center, radius_x, radius_y }) => {
359
2
                format!(
360
2
                    "CssShape::Ellipse(ShapeEllipse {{ center: ShapePoint::new({}_f32, {}_f32), radius_x: {}_f32, radius_y: {}_f32 }})",
361
                    center.x, center.y, radius_x, radius_y
362
                )
363
            }
364
3
            Self::Polygon(ShapePolygon { points }) => {
365
3
                let pts: Vec<String> = points.as_ref().iter()
366
3
                    .map(|p| format!("ShapePoint::new({}_f32, {}_f32)", p.x, p.y))
367
3
                    .collect();
368
3
                format!("CssShape::Polygon(ShapePolygon {{ points: vec![{}].into() }})", pts.join(", "))
369
            }
370
14
            Self::Inset(ShapeInset { inset_top, inset_right, inset_bottom, inset_left, border_radius }) => {
371
14
                let br = match border_radius {
372
12
                    OptionF32::Some(r) => format!("OptionF32::Some({r}_f32)"),
373
2
                    OptionF32::None => String::from("OptionF32::None"),
374
                };
375
14
                format!(
376
14
                    "CssShape::Inset(ShapeInset {{ inset_top: {inset_top}_f32, inset_right: {inset_right}_f32, inset_bottom: {inset_bottom}_f32, inset_left: {inset_left}_f32, border_radius: {br} }})"
377
                )
378
            }
379
3
            Self::Path(ShapePath { data }) => {
380
3
                format!("CssShape::Path(ShapePath {{ data: AzString::from_const_str(\"{}\") }})", data.as_str())
381
            }
382
        }
383
25
    }
384
}
385

            
386
impl_option!(
387
    CssShape,
388
    OptionCssShape,
389
    copy = false,
390
    [Debug, Clone, PartialEq, Eq]
391
);
392

            
393
#[cfg(test)]
394
mod autotest_generated {
395
    // Float values are compared for exact bit/value identity on purpose: these
396
    // tests check that constructors and (de)serialization are lossless, not that
397
    // the values are approximately right.
398
    #![allow(
399
        clippy::float_cmp,
400
        clippy::unreadable_literal,
401
        clippy::cast_precision_loss
402
    )]
403

            
404
    use core::{
405
        cmp::Ordering,
406
        hash::{Hash, Hasher},
407
    };
408
    use std::collections::hash_map::DefaultHasher;
409

            
410
    use super::*;
411
    use crate::shape_parser::{parse_shape, ShapeParseError};
412

            
413
    /// Every f32 edge value the shape types have to survive.
414
    const EDGE_F32: &[f32] = &[
415
        0.0,
416
        -0.0,
417
        1.0,
418
        -1.0,
419
        f32::MIN,
420
        f32::MAX,
421
        f32::MIN_POSITIVE,
422
        f32::EPSILON,
423
        f32::INFINITY,
424
        f32::NEG_INFINITY,
425
        f32::NAN,
426
    ];
427

            
428
    fn hash_of<T: Hash>(t: &T) -> u64 {
429
        let mut h = DefaultHasher::new();
430
        t.hash(&mut h);
431
        h.finish()
432
    }
433

            
434
    /// Encode a shape to CSS, decode it back. Panics if the shape does not
435
    /// survive its own printer -> the crate's own parser.
436
    fn roundtrip(shape: &CssShape) -> CssShape {
437
        let css = shape.print_as_css_value();
438
        parse_shape(&css).unwrap_or_else(|e| panic!("round-trip failed for {css:?}: {e:?}"))
439
    }
440

            
441
    fn poly(coords: &[(f32, f32)]) -> CssShape {
442
        let pts: Vec<ShapePoint> = coords.iter().map(|(x, y)| ShapePoint::new(*x, *y)).collect();
443
        CssShape::polygon(ShapePointVec::from_vec(pts))
444
    }
445

            
446
    fn path(data: &str) -> CssShape {
447
        CssShape::Path(ShapePath {
448
            data: AzString::from(data),
449
        })
450
    }
451

            
452
    // ---------------------------------------------------------------------
453
    // cmp_f32 (private, numeric)
454
    // ---------------------------------------------------------------------
455

            
456
    #[test]
457
    fn cmp_f32_orders_ordinary_values() {
458
        assert_eq!(cmp_f32(1.0, 2.0), Ordering::Less);
459
        assert_eq!(cmp_f32(2.0, 1.0), Ordering::Greater);
460
        assert_eq!(cmp_f32(2.0, 2.0), Ordering::Equal);
461
        assert_eq!(cmp_f32(-1.0, 1.0), Ordering::Less);
462
    }
463

            
464
    #[test]
465
    fn cmp_f32_treats_both_zeroes_as_equal() {
466
        assert_eq!(cmp_f32(0.0, -0.0), Ordering::Equal);
467
        assert_eq!(cmp_f32(-0.0, 0.0), Ordering::Equal);
468
    }
469

            
470
    #[test]
471
    fn cmp_f32_nan_is_equal_to_everything_and_never_panics() {
472
        // Documented contract: "treating NaN as equal".
473
        assert_eq!(cmp_f32(f32::NAN, f32::NAN), Ordering::Equal);
474
        for &v in EDGE_F32 {
475
            assert_eq!(cmp_f32(f32::NAN, v), Ordering::Equal);
476
            assert_eq!(cmp_f32(v, f32::NAN), Ordering::Equal);
477
        }
478
    }
479

            
480
    #[test]
481
    fn cmp_f32_handles_infinities_and_limits() {
482
        assert_eq!(cmp_f32(f32::INFINITY, f32::MAX), Ordering::Greater);
483
        assert_eq!(cmp_f32(f32::NEG_INFINITY, f32::MIN), Ordering::Less);
484
        assert_eq!(cmp_f32(f32::INFINITY, f32::INFINITY), Ordering::Equal);
485
        assert_eq!(
486
            cmp_f32(f32::NEG_INFINITY, f32::INFINITY),
487
            Ordering::Less
488
        );
489
        assert_eq!(cmp_f32(f32::MIN_POSITIVE, 0.0), Ordering::Greater);
490
        // Smallest subnormal still sorts above zero.
491
        assert_eq!(cmp_f32(f32::from_bits(1), 0.0), Ordering::Greater);
492
        assert_eq!(cmp_f32(f32::MIN, f32::MAX), Ordering::Less);
493
    }
494

            
495
    #[test]
496
    fn cmp_f32_is_antisymmetric_over_all_edge_values() {
497
        for &a in EDGE_F32 {
498
            for &b in EDGE_F32 {
499
                assert_eq!(
500
                    cmp_f32(a, b),
501
                    cmp_f32(b, a).reverse(),
502
                    "antisymmetry broken for ({a}, {b})"
503
                );
504
            }
505
        }
506
    }
507

            
508
    #[test]
509
    fn cmp_f32_nan_equality_is_not_transitive() {
510
        // Characterization of the known cost of "NaN as equal": NaN == 1.0 and
511
        // NaN == 2.0, yet 1.0 < 2.0. Ord's transitivity therefore does NOT hold
512
        // once a NaN is in the set, so slices holding NaN-bearing shapes must
513
        // not be `sort()`ed (std may panic on a non-total order).
514
        assert_eq!(cmp_f32(f32::NAN, 1.0), Ordering::Equal);
515
        assert_eq!(cmp_f32(f32::NAN, 2.0), Ordering::Equal);
516
        assert_eq!(cmp_f32(1.0, 2.0), Ordering::Less);
517
    }
518

            
519
    // ---------------------------------------------------------------------
520
    // ShapePoint::new / ShapePoint::zero (constructors)
521
    // ---------------------------------------------------------------------
522

            
523
    #[test]
524
    fn shapepoint_new_stores_fields_verbatim() {
525
        let p = ShapePoint::new(3.5, -7.25);
526
        assert_eq!(p.x, 3.5);
527
        assert_eq!(p.y, -7.25);
528
    }
529

            
530
    #[test]
531
    fn shapepoint_new_does_not_normalize_edge_values() {
532
        for &x in EDGE_F32 {
533
            for &y in EDGE_F32 {
534
                let p = ShapePoint::new(x, y);
535
                // Bit-exact: no clamping, no NaN canonicalization, no -0 flush.
536
                assert_eq!(p.x.to_bits(), x.to_bits());
537
                assert_eq!(p.y.to_bits(), y.to_bits());
538
            }
539
        }
540
    }
541

            
542
    #[test]
543
    fn shapepoint_new_preserves_negative_zero_sign() {
544
        let p = ShapePoint::new(-0.0, 0.0);
545
        assert_eq!(p.x.to_bits(), (-0.0f32).to_bits());
546
        assert_eq!(p.y.to_bits(), (0.0f32).to_bits());
547
        assert!(p.x.is_sign_negative());
548
    }
549

            
550
    #[test]
551
    fn shapepoint_zero_is_the_neutral_positive_zero() {
552
        let z = ShapePoint::zero();
553
        assert_eq!(z.x.to_bits(), 0);
554
        assert_eq!(z.y.to_bits(), 0);
555
        assert_eq!(z, ShapePoint::new(0.0, 0.0));
556
        assert_eq!(z.cmp(&ShapePoint::new(0.0, 0.0)), Ordering::Equal);
557
    }
558

            
559
    #[test]
560
    fn shapepoint_constructors_are_usable_in_const_context() {
561
        const P: ShapePoint = ShapePoint::new(1.0, 2.0);
562
        const Z: ShapePoint = ShapePoint::zero();
563
        assert_eq!(P.x, 1.0);
564
        assert_eq!(Z, ShapePoint::zero());
565
        // repr(C) layout guarantee relied on across the FFI boundary.
566
        assert_eq!(size_of::<ShapePoint>(), 8);
567
    }
568

            
569
    // ---------------------------------------------------------------------
570
    // ShapePoint Ord / Eq / Hash invariants
571
    // ---------------------------------------------------------------------
572

            
573
    #[test]
574
    fn shapepoint_ord_sorts_by_x_then_y() {
575
        let mut v = vec![
576
            ShapePoint::new(1.0, 5.0),
577
            ShapePoint::new(-3.0, 0.0),
578
            ShapePoint::new(1.0, -5.0),
579
            ShapePoint::new(0.0, 0.0),
580
        ];
581
        v.sort(); // no NaN involved -> total order holds, sort must not panic
582
        assert_eq!(
583
            v,
584
            vec![
585
                ShapePoint::new(-3.0, 0.0),
586
                ShapePoint::new(0.0, 0.0),
587
                ShapePoint::new(1.0, -5.0),
588
                ShapePoint::new(1.0, 5.0),
589
            ]
590
        );
591
    }
592

            
593
    #[test]
594
    fn shapepoint_cmp_is_antisymmetric_even_with_nan() {
595
        for &ax in EDGE_F32 {
596
            for &ay in EDGE_F32 {
597
                let a = ShapePoint::new(ax, ay);
598
                for &bx in EDGE_F32 {
599
                    let b = ShapePoint::new(bx, 1.0);
600
                    assert_eq!(
601
                        a.cmp(&b),
602
                        b.cmp(&a).reverse(),
603
                        "antisymmetry broken for {a:?} vs {b:?}"
604
                    );
605
                    // PartialOrd must agree with Ord (it delegates).
606
                    assert_eq!(a.partial_cmp(&b), Some(a.cmp(&b)));
607
                }
608
            }
609
        }
610
    }
611

            
612
    #[test]
613
    fn shapepoint_cmp_with_nan_x_ignores_y_entirely() {
614
        // Characterization: when the x comparison is indeterminate, cmp() returns
615
        // Equal WITHOUT looking at y -- unlike ShapeEllipse::cmp, which falls
616
        // through to the next field on None. The two NaN policies in this file
617
        // disagree; see the report.
618
        let a = ShapePoint::new(f32::NAN, 1.0);
619
        let b = ShapePoint::new(f32::NAN, 2.0);
620
        assert_eq!(a.cmp(&b), Ordering::Equal);
621

            
622
        let e1 = ShapeEllipse {
623
            center: ShapePoint::zero(),
624
            radius_x: f32::NAN,
625
            radius_y: 1.0,
626
        };
627
        let e2 = ShapeEllipse {
628
            center: ShapePoint::zero(),
629
            radius_x: f32::NAN,
630
            radius_y: 2.0,
631
        };
632
        assert_eq!(e1.cmp(&e2), Ordering::Less);
633
    }
634

            
635
    #[test]
636
    fn shapepoint_nan_is_ord_equal_but_partialeq_unequal() {
637
        // Eq is implemented for ShapePoint, but PartialEq is derived on f32, so
638
        // reflexivity does not actually hold for NaN while Ord reports Equal.
639
        let a = ShapePoint::new(f32::NAN, 0.0);
640
        let b = ShapePoint::new(f32::NAN, 0.0);
641
        assert_ne!(a, b, "derived PartialEq: NaN != NaN");
642
        assert_eq!(a.cmp(&b), Ordering::Equal, "Ord: NaN treated as equal");
643
    }
644

            
645
    #[test]
646
    fn shapepoint_hash_is_deterministic_and_bitwise() {
647
        let a = ShapePoint::new(1.5, -2.5);
648
        let b = ShapePoint::new(1.5, -2.5);
649
        assert_eq!(hash_of(&a), hash_of(&b));
650
        assert_ne!(hash_of(&a), hash_of(&ShapePoint::new(-2.5, 1.5)));
651
        // NaN hashes by bits, so an identical NaN hashes identically even though
652
        // it does not compare PartialEq-equal to itself.
653
        let n1 = ShapePoint::new(f32::NAN, 0.0);
654
        let n2 = ShapePoint::new(f32::NAN, 0.0);
655
        assert_eq!(hash_of(&n1), hash_of(&n2));
656
    }
657

            
658
    // ---------------------------------------------------------------------
659
    // CssShape constructors (numeric)
660
    // ---------------------------------------------------------------------
661

            
662
    #[test]
663
    fn circle_stores_center_and_radius_including_zero_and_negative() {
664
        match CssShape::circle(ShapePoint::zero(), 0.0) {
665
            CssShape::Circle(c) => {
666
                assert_eq!(c.radius, 0.0);
667
                assert_eq!(c.center, ShapePoint::zero());
668
            }
669
            other => panic!("expected Circle, got {other:?}"),
670
        }
671
        // A negative radius is CSS-invalid but accepted verbatim here (no clamp).
672
        match CssShape::circle(ShapePoint::new(-1.0, -2.0), -50.0) {
673
            CssShape::Circle(c) => assert_eq!(c.radius, -50.0),
674
            other => panic!("expected Circle, got {other:?}"),
675
        }
676
    }
677

            
678
    #[test]
679
    fn circle_accepts_every_f32_edge_value_without_panicking() {
680
        for &r in EDGE_F32 {
681
            for &c in EDGE_F32 {
682
                let shape = CssShape::circle(ShapePoint::new(c, c), r);
683
                match shape {
684
                    CssShape::Circle(circle) => {
685
                        assert_eq!(circle.radius.to_bits(), r.to_bits());
686
                    }
687
                    other => panic!("expected Circle, got {other:?}"),
688
                }
689
            }
690
        }
691
    }
692

            
693
    #[test]
694
    fn ellipse_stores_both_radii_verbatim() {
695
        match CssShape::ellipse(ShapePoint::new(1.0, 2.0), f32::INFINITY, f32::NEG_INFINITY) {
696
            CssShape::Ellipse(e) => {
697
                assert!(e.radius_x.is_infinite() && e.radius_x.is_sign_positive());
698
                assert!(e.radius_y.is_infinite() && e.radius_y.is_sign_negative());
699
                assert_eq!(e.center, ShapePoint::new(1.0, 2.0));
700
            }
701
            other => panic!("expected Ellipse, got {other:?}"),
702
        }
703
        match CssShape::ellipse(ShapePoint::zero(), f32::NAN, f32::MAX) {
704
            CssShape::Ellipse(e) => {
705
                assert!(e.radius_x.is_nan());
706
                assert_eq!(e.radius_y, f32::MAX);
707
            }
708
            other => panic!("expected Ellipse, got {other:?}"),
709
        }
710
    }
711

            
712
    #[test]
713
    fn polygon_accepts_empty_and_huge_point_lists() {
714
        match CssShape::polygon(ShapePointVec::from_vec(Vec::new())) {
715
            CssShape::Polygon(p) => {
716
                assert!(p.points.is_empty());
717
                assert_eq!(p.points.len(), 0);
718
            }
719
            other => panic!("expected Polygon, got {other:?}"),
720
        }
721

            
722
        let big: Vec<ShapePoint> = (0..10_000)
723
            .map(|i| ShapePoint::new(i as f32, -(i as f32)))
724
            .collect();
725
        match CssShape::polygon(ShapePointVec::from_vec(big)) {
726
            CssShape::Polygon(p) => {
727
                assert_eq!(p.points.len(), 10_000);
728
                assert_eq!(p.points.as_ref()[9_999], ShapePoint::new(9999.0, -9999.0));
729
            }
730
            other => panic!("expected Polygon, got {other:?}"),
731
        }
732
    }
733

            
734
    #[test]
735
    fn inset_has_no_border_radius_and_keeps_side_order() {
736
        match CssShape::inset(1.0, 2.0, 3.0, 4.0) {
737
            CssShape::Inset(i) => {
738
                assert_eq!(i.inset_top, 1.0);
739
                assert_eq!(i.inset_right, 2.0);
740
                assert_eq!(i.inset_bottom, 3.0);
741
                assert_eq!(i.inset_left, 4.0);
742
                assert_eq!(i.border_radius, OptionF32::None);
743
                assert_eq!(i.border_radius.into_option(), None);
744
            }
745
            other => panic!("expected Inset, got {other:?}"),
746
        }
747
    }
748

            
749
    #[test]
750
    fn inset_accepts_min_max_and_nan_without_panicking() {
751
        match CssShape::inset(f32::MIN, f32::MAX, f32::NAN, f32::NEG_INFINITY) {
752
            CssShape::Inset(i) => {
753
                assert_eq!(i.inset_top, f32::MIN);
754
                assert_eq!(i.inset_right, f32::MAX);
755
                assert!(i.inset_bottom.is_nan());
756
                assert!(i.inset_left.is_infinite());
757
            }
758
            other => panic!("expected Inset, got {other:?}"),
759
        }
760
    }
761

            
762
    #[test]
763
    fn inset_rounded_keeps_even_a_css_invalid_negative_radius() {
764
        match CssShape::inset_rounded(0.0, 0.0, 0.0, 0.0, -5.0) {
765
            CssShape::Inset(i) => {
766
                assert_eq!(i.border_radius, OptionF32::Some(-5.0));
767
                assert_eq!(i.border_radius.into_option(), Some(-5.0));
768
            }
769
            other => panic!("expected Inset, got {other:?}"),
770
        }
771
        match CssShape::inset_rounded(0.0, 0.0, 0.0, 0.0, f32::NAN) {
772
            CssShape::Inset(i) => match i.border_radius {
773
                OptionF32::Some(r) => assert!(r.is_nan()),
774
                OptionF32::None => panic!("radius was dropped"),
775
            },
776
            other => panic!("expected Inset, got {other:?}"),
777
        }
778
    }
779

            
780
    #[test]
781
    fn css_shape_constructors_are_usable_in_const_context() {
782
        const CIRCLE: CssShape = CssShape::circle(ShapePoint::zero(), 5.0);
783
        const ELLIPSE: CssShape = CssShape::ellipse(ShapePoint::zero(), 1.0, 2.0);
784
        const INSET: CssShape = CssShape::inset(0.0, 0.0, 0.0, 0.0);
785
        const ROUNDED: CssShape = CssShape::inset_rounded(0.0, 0.0, 0.0, 0.0, 1.0);
786
        assert!(matches!(CIRCLE, CssShape::Circle(_)));
787
        assert!(matches!(ELLIPSE, CssShape::Ellipse(_)));
788
        assert!(matches!(INSET, CssShape::Inset(_)));
789
        assert!(matches!(ROUNDED, CssShape::Inset(_)));
790
    }
791

            
792
    // ---------------------------------------------------------------------
793
    // CssShape Ord / Hash invariants
794
    // ---------------------------------------------------------------------
795

            
796
    #[test]
797
    fn css_shape_variant_order_is_circle_ellipse_polygon_inset_path() {
798
        let shapes = vec![
799
            path("M 0 0"),
800
            CssShape::inset(1.0, 1.0, 1.0, 1.0),
801
            poly(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]),
802
            CssShape::ellipse(ShapePoint::zero(), 1.0, 2.0),
803
            CssShape::circle(ShapePoint::zero(), 1.0),
804
        ];
805
        let mut sorted = shapes;
806
        sorted.sort(); // finite values only -> total order, must not panic
807
        let discriminants: Vec<&str> = sorted
808
            .iter()
809
            .map(|s| match s {
810
                CssShape::Circle(_) => "circle",
811
                CssShape::Ellipse(_) => "ellipse",
812
                CssShape::Polygon(_) => "polygon",
813
                CssShape::Inset(_) => "inset",
814
                CssShape::Path(_) => "path",
815
            })
816
            .collect();
817
        assert_eq!(
818
            discriminants,
819
            vec!["circle", "ellipse", "polygon", "inset", "path"]
820
        );
821
    }
822

            
823
    #[test]
824
    fn css_shape_cmp_is_antisymmetric_across_variants_and_nan() {
825
        let shapes = vec![
826
            CssShape::circle(ShapePoint::zero(), f32::NAN),
827
            CssShape::circle(ShapePoint::new(f32::NAN, 0.0), 1.0),
828
            CssShape::ellipse(ShapePoint::zero(), f32::NAN, f32::INFINITY),
829
            poly(&[]),
830
            poly(&[(f32::NAN, f32::NEG_INFINITY)]),
831
            CssShape::inset(f32::NAN, f32::MAX, f32::MIN, -0.0),
832
            CssShape::inset_rounded(0.0, 0.0, 0.0, 0.0, f32::NAN),
833
            path(""),
834
            path("\u{1F600}"),
835
        ];
836
        for a in &shapes {
837
            for b in &shapes {
838
                assert_eq!(
839
                    a.cmp(b),
840
                    b.cmp(a).reverse(),
841
                    "antisymmetry broken for {a:?} vs {b:?}"
842
                );
843
                assert_eq!(a.partial_cmp(b), Some(a.cmp(b)));
844
            }
845
        }
846
    }
847

            
848
    #[test]
849
    fn css_shape_hash_separates_variants_with_identical_payloads() {
850
        let circle = CssShape::circle(ShapePoint::zero(), 1.0);
851
        let ellipse = CssShape::ellipse(ShapePoint::zero(), 1.0, 1.0);
852
        assert_ne!(hash_of(&circle), hash_of(&ellipse));
853
        assert_eq!(
854
            hash_of(&circle),
855
            hash_of(&CssShape::circle(ShapePoint::zero(), 1.0))
856
        );
857
        // Equal values must hash equally (holds for every non-NaN, non -0.0 case).
858
        let p1 = poly(&[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)]);
859
        let p2 = poly(&[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)]);
860
        assert_eq!(p1, p2);
861
        assert_eq!(hash_of(&p1), hash_of(&p2));
862
    }
863

            
864
    // ---------------------------------------------------------------------
865
    // print_as_css_value (getter)
866
    // ---------------------------------------------------------------------
867

            
868
    #[test]
869
    fn print_as_css_value_known_constructions() {
870
        assert_eq!(
871
            CssShape::circle(ShapePoint::new(100.0, 100.0), 50.0).print_as_css_value(),
872
            "circle(50px at 100px 100px)"
873
        );
874
        assert_eq!(
875
            CssShape::ellipse(ShapePoint::new(1.0, 2.0), 3.0, 4.5).print_as_css_value(),
876
            "ellipse(3px 4.5px at 1px 2px)"
877
        );
878
        assert_eq!(
879
            poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)]).print_as_css_value(),
880
            "polygon(0px 0px, 100px 0px, 100px 100px)"
881
        );
882
        assert_eq!(
883
            CssShape::inset(1.0, 2.0, 3.0, 4.0).print_as_css_value(),
884
            "inset(1px 2px 3px 4px)"
885
        );
886
        assert_eq!(
887
            CssShape::inset_rounded(1.0, 2.0, 3.0, 4.0, 5.0).print_as_css_value(),
888
            "inset(1px 2px 3px 4px round 5px)"
889
        );
890
        assert_eq!(
891
            path("M 0 0 L 1 1 Z").print_as_css_value(),
892
            "path(\"M 0 0 L 1 1 Z\")"
893
        );
894
    }
895

            
896
    #[test]
897
    fn print_as_css_value_empty_polygon_emits_empty_parens() {
898
        // Not valid CSS (a polygon needs >= 3 points) but must not panic.
899
        assert_eq!(poly(&[]).print_as_css_value(), "polygon()");
900
    }
901

            
902
    #[test]
903
    fn print_as_css_value_emits_non_css_tokens_for_nan_and_inf() {
904
        // Characterization: `NaNpx` / `infpx` are NOT valid CSS lengths. The
905
        // printer does not guard against non-finite inputs; see the report.
906
        let s = CssShape::circle(
907
            ShapePoint::new(f32::INFINITY, f32::NEG_INFINITY),
908
            f32::NAN,
909
        )
910
        .print_as_css_value();
911
        assert_eq!(s, "circle(NaNpx at infpx -infpx)");
912
    }
913

            
914
    #[test]
915
    fn print_as_css_value_handles_extreme_finite_values() {
916
        let s = CssShape::inset(f32::MIN, f32::MAX, f32::MIN_POSITIVE, 0.0).print_as_css_value();
917
        assert!(s.starts_with("inset(-"), "got {s}");
918
        assert!(s.ends_with("px)"), "got {s}");
919
        assert!(!s.contains("inf"), "MIN/MAX must not print as inf: {s}");
920
    }
921

            
922
    #[test]
923
    fn print_as_css_value_survives_a_huge_polygon() {
924
        let coords: Vec<(f32, f32)> = (0..5_000).map(|i| (i as f32, i as f32)).collect();
925
        let s = poly(&coords).print_as_css_value();
926
        assert!(s.starts_with("polygon(0px 0px, "));
927
        assert!(s.ends_with("4999px 4999px)"));
928
        assert_eq!(s.matches(", ").count(), 4_999);
929
    }
930

            
931
    #[test]
932
    fn print_as_css_value_does_not_escape_path_data() {
933
        // Characterization: a quote inside the path data is emitted raw, so the
934
        // printed value is no longer a well-formed CSS string. See the report.
935
        let s = path("a\"b").print_as_css_value();
936
        assert_eq!(s, "path(\"a\"b\")");
937
        // Unicode path data is passed through byte-for-byte.
938
        let uni = path("M 0 0 \u{2192} \u{1F600}").print_as_css_value();
939
        assert!(uni.contains('\u{1F600}'));
940
    }
941

            
942
    // ---------------------------------------------------------------------
943
    // format_as_rust_code (serializer)
944
    // ---------------------------------------------------------------------
945

            
946
    #[test]
947
    fn format_as_rust_code_known_constructions() {
948
        assert_eq!(
949
            CssShape::circle(ShapePoint::new(1.0, 2.0), 3.0).format_as_rust_code(),
950
            "CssShape::Circle(ShapeCircle { center: ShapePoint::new(1_f32, 2_f32), radius: 3_f32 \
951
             })"
952
        );
953
        assert_eq!(
954
            CssShape::ellipse(ShapePoint::new(1.0, 2.0), 3.0, 4.0).format_as_rust_code(),
955
            "CssShape::Ellipse(ShapeEllipse { center: ShapePoint::new(1_f32, 2_f32), radius_x: \
956
             3_f32, radius_y: 4_f32 })"
957
        );
958
        assert_eq!(
959
            CssShape::inset(1.0, 2.0, 3.0, 4.0).format_as_rust_code(),
960
            "CssShape::Inset(ShapeInset { inset_top: 1_f32, inset_right: 2_f32, inset_bottom: \
961
             3_f32, inset_left: 4_f32, border_radius: OptionF32::None })"
962
        );
963
        assert!(CssShape::inset_rounded(0.0, 0.0, 0.0, 0.0, 5.0)
964
            .format_as_rust_code()
965
            .contains("border_radius: OptionF32::Some(5_f32)"));
966
        assert_eq!(
967
            path("M 0 0").format_as_rust_code(),
968
            "CssShape::Path(ShapePath { data: AzString::from_const_str(\"M 0 0\") })"
969
        );
970
    }
971

            
972
    #[test]
973
    fn format_as_rust_code_is_non_empty_for_every_variant() {
974
        let shapes = vec![
975
            CssShape::circle(ShapePoint::zero(), 0.0),
976
            CssShape::ellipse(ShapePoint::zero(), 0.0, 0.0),
977
            poly(&[]),
978
            CssShape::inset(0.0, 0.0, 0.0, 0.0),
979
            path(""),
980
        ];
981
        for s in &shapes {
982
            let code = s.format_as_rust_code();
983
            assert!(code.starts_with("CssShape::"), "got {code}");
984
            assert!(code.ends_with(')'), "got {code}");
985
            assert!(!code.is_empty());
986
        }
987
    }
988

            
989
    #[test]
990
    fn format_as_rust_code_empty_polygon_emits_empty_vec() {
991
        assert_eq!(
992
            poly(&[]).format_as_rust_code(),
993
            "CssShape::Polygon(ShapePolygon { points: vec![].into() })"
994
        );
995
        assert_eq!(
996
            poly(&[(0.0, 0.0), (1.0, 1.0)]).format_as_rust_code(),
997
            "CssShape::Polygon(ShapePolygon { points: vec![ShapePoint::new(0_f32, 0_f32), \
998
             ShapePoint::new(1_f32, 1_f32)].into() })"
999
        );
    }
    #[test]
    fn format_as_rust_code_emits_uncompilable_tokens_for_nan_and_inf() {
        // Characterization of a real codegen defect: `NaN_f32` / `inf_f32` are
        // not valid Rust literals, so generated code containing a non-finite
        // shape does not compile. See the report.
        let code = CssShape::circle(ShapePoint::new(f32::INFINITY, f32::NEG_INFINITY), f32::NAN)
            .format_as_rust_code();
        assert!(code.contains("NaN_f32"), "got {code}");
        assert!(code.contains("inf_f32"), "got {code}");
        assert!(code.contains("-inf_f32"), "got {code}");
    }
    #[test]
    fn format_as_rust_code_does_not_escape_path_data() {
        // Characterization: quotes and backslashes in path data are emitted raw,
        // producing uncompilable Rust. See the report.
        let code = path("a\"b\\c").format_as_rust_code();
        assert!(code.contains("from_const_str(\"a\"b\\c\")"), "got {code}");
    }
    #[test]
    fn format_as_rust_code_survives_extreme_values() {
        for &v in EDGE_F32 {
            let code = CssShape::inset_rounded(v, v, v, v, v).format_as_rust_code();
            assert!(code.starts_with("CssShape::Inset("), "got {code}");
        }
    }
    // ---------------------------------------------------------------------
    // Round-trip: print_as_css_value -> shape_parser::parse_shape
    // ---------------------------------------------------------------------
    #[test]
    fn roundtrip_circle_ellipse_inset_and_path() {
        for shape in [
            CssShape::circle(ShapePoint::new(100.0, -25.5), 50.0),
            CssShape::circle(ShapePoint::zero(), 0.0),
            CssShape::ellipse(ShapePoint::new(-1.0, 2.0), 3.0, 4.5),
            CssShape::inset(1.0, 2.0, 3.0, 4.0),
            CssShape::inset(-1.0, -2.0, -3.0, -4.0),
            CssShape::inset_rounded(1.0, 2.0, 3.0, 4.0, 5.0),
            path("M 0 0 L 100 0 L 100 100 Z"),
            path(""),
        ] {
            assert_eq!(roundtrip(&shape), shape);
        }
    }
    #[test]
    fn roundtrip_polygon_needs_at_least_three_points() {
        let ok = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
        assert_eq!(roundtrip(&ok), ok);
        // Printer/parser asymmetry: these print fine but the crate's own parser
        // rejects them, so a shape can survive `print` and die on re-parse.
        let two = poly(&[(0.0, 0.0), (1.0, 1.0)]).print_as_css_value();
        assert!(matches!(
            parse_shape(&two),
            Err(ShapeParseError::InvalidSyntax(_))
        ));
        let empty = poly(&[]).print_as_css_value();
        assert!(matches!(
            parse_shape(&empty),
            Err(ShapeParseError::InvalidSyntax(_))
        ));
    }
    #[test]
    fn roundtrip_preserves_extreme_finite_floats_bit_for_bit() {
        for &v in &[
            f32::MIN,
            f32::MAX,
            f32::MIN_POSITIVE,
            f32::EPSILON,
            f32::from_bits(1), // smallest positive subnormal
            1e-7,
            123_456.79,
        ] {
            let shape = CssShape::circle(ShapePoint::new(v, -v), v);
            match roundtrip(&shape) {
                CssShape::Circle(c) => {
                    assert_eq!(c.radius.to_bits(), v.to_bits(), "radius lost for {v:e}");
                    assert_eq!(c.center.x.to_bits(), v.to_bits(), "x lost for {v:e}");
                    assert_eq!(c.center.y.to_bits(), (-v).to_bits(), "y lost for {v:e}");
                }
                other => panic!("expected Circle, got {other:?}"),
            }
        }
    }
    #[test]
    fn roundtrip_of_non_finite_values_survives_but_is_not_valid_css() {
        // Rust's f32 FromStr happens to accept "inf"/"NaN", so azul's own parser
        // reads back what its printer emitted -- but no browser would.
        match roundtrip(&CssShape::circle(ShapePoint::zero(), f32::INFINITY)) {
            CssShape::Circle(c) => assert!(c.radius.is_infinite() && c.radius > 0.0),
            other => panic!("expected Circle, got {other:?}"),
        }
        match roundtrip(&CssShape::circle(ShapePoint::zero(), f32::NAN)) {
            CssShape::Circle(c) => assert!(c.radius.is_nan()),
            other => panic!("expected Circle, got {other:?}"),
        }
        match roundtrip(&CssShape::inset(
            f32::NEG_INFINITY,
            f32::INFINITY,
            f32::NAN,
            0.0,
        )) {
            CssShape::Inset(i) => {
                assert!(i.inset_top.is_infinite() && i.inset_top < 0.0);
                assert!(i.inset_right.is_infinite() && i.inset_right > 0.0);
                assert!(i.inset_bottom.is_nan());
                assert_eq!(i.inset_left, 0.0);
            }
            other => panic!("expected Inset, got {other:?}"),
        }
    }
    #[test]
    fn roundtrip_path_with_hostile_and_unicode_data() {
        // A ')' inside the data is safe (the parser scans with rfind), and so is
        // a bare quote (the outer quotes are stripped positionally).
        for data in [
            "M 0 0)",
            "M 0 0 \u{2192} \u{1F600}",
            "M\n0\t0",
            "a\"b",
            "\u{0}",
        ] {
            let shape = path(data);
            assert_eq!(roundtrip(&shape), shape, "path data {data:?} did not survive");
        }
    }
    #[test]
    fn roundtrip_huge_polygon() {
        let coords: Vec<(f32, f32)> = (0..2_000).map(|i| (i as f32, -(i as f32))).collect();
        let shape = poly(&coords);
        assert_eq!(roundtrip(&shape), shape);
    }
}