diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 9eebb57f2..8f0e1b1d5 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -2162,445 +2162,4 @@ mod kind_de { } #[cfg(test)] -mod tests { - use super::*; - - /// A node writes its rarely set parts among the others, in the file's field order, and reads - /// them back into its extras; a node without any keeps none. - #[test] - fn extras_are_written_flat_in_order() { - let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); - n.name = Some("n".into()); - n.knockout_shape = true; - n.bbox_angle = 30.0; - { - let mut e = n.extras_mut(); - e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true))); - e.trace = Some(Box::new(serde_json::json!({"preset": "p"}))); - e.series_index = Some(2); - e.type_outlines = true; - e.graphic_style = Some(3); - e.attrs = Some(Box::default()); - e.slice = Some(Box::default()); - e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0))); - e.live_paint_gaps = Some(Box::default()); - } - /// The top-level keys of a JSON object, in the order written. - struct Keys(Vec); - impl<'de> Deserialize<'de> for Keys { - fn deserialize>(d: D) -> Result { - struct V; - impl<'de> serde::de::Visitor<'de> for V { - type Value = Keys; - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("an object") - } - fn visit_map>(self, mut a: A) -> Result { - let mut out = vec![]; - while let Some(k) = a.next_key::()? { - a.next_value::()?; - out.push(k); - } - Ok(Keys(out)) - } - } - d.deserialize_map(V) - } - } - let text = serde_json::to_string(&n).unwrap(); - let keys = serde_json::from_str::(&text).unwrap().0; - let v: serde_json::Value = serde_json::from_str(&text).unwrap(); - let order = [ - "id", - "name", - "knockout_shape", - "appearance", - "mask", - "trace", - "series_index", - "type_outlines", - "kind", - "graphic_style", - "attrs", - "slice", - "bbox_angle", - "perspective", - "live_paint_gaps", - ]; - assert_eq!(keys, order); - assert_eq!(serde_json::from_value::(v).unwrap(), n); - let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); - let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap(); - assert!(back.extras_box().is_none()); - // Clearing the last extra lets them go; clearing one a node doesn't have allocates none. - n.extras_mut().mask = None; - assert!(n.extras_box().is_some()); - *n.extras_mut() = NodeExtras::default(); - assert!(n.extras_box().is_none()); - n.extras_mut().mask = None; - assert!(n.extras_box().is_none()); - assert_eq!(n.extras(), &NodeExtras::default()); - } - - #[test] - fn rect_live_shape_roundtrip() { - let l = LiveShape::Rectangle { - w: 10.0, - h: 20.0, - radii: [0.0; 4], - kinds: Default::default(), - radii_relative: false, - xf: Affine::translate((5.0, 5.0)), - }; - assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0))); - assert_eq!(l.label(), "Rectangle"); - } - - #[test] - fn corner_kinds_save_only_when_some_corner_isnt_round() { - let mut l = LiveShape::Rectangle { - w: 100.0, - h: 50.0, - radii: [0.0, 10.0, 0.0, 0.0], - kinds: Default::default(), - radii_relative: false, - xf: Affine::IDENTITY, - }; - // Older files read as round. - let json = serde_json::to_value(&l).unwrap(); - assert!(json.get("kinds").is_none(), "{json}"); - assert_eq!(serde_json::from_value::(json).unwrap(), l); - if let LiveShape::Rectangle { kinds, .. } = &mut l { - kinds[1] = CornerKind::Chamfer; - } - let json = serde_json::to_value(&l).unwrap(); - assert_eq!(json["kinds"], serde_json::json!(["round", "chamfer", "round", "round"])); - assert_eq!(serde_json::from_value::(json).unwrap(), l); - // Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips. - let p: LiveShape = - serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap(); - assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![], radii_relative: false }); - let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0); - let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![], radii_relative: false }; - let json = serde_json::to_value(&path).unwrap(); - assert_eq!((json["shape"].as_str(), json.get("kinds")), (Some("path"), None)); - assert_eq!(serde_json::from_value::(json).unwrap(), path); - assert_eq!(path.label(), "Path"); - } - - /// A polygon read from a file has 3 to 1000 sides, as the commands give it: selecting one - /// with four billion made its outline and corner lists allocate tens of gigabytes. - #[test] - fn a_polygon_from_a_file_has_3_to_1000_sides() { - let read = |sides: u32| { - let json = serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": sides, "xf": [1, 0, 0, 1, 0, 0]}); - serde_json::from_value::(json).unwrap() - }; - let sides_of = |l: &LiveShape| match l { - LiveShape::Polygon { sides, .. } => *sides, - _ => panic!("not a polygon: {l:?}"), - }; - let huge = read(4_000_000_000); - assert_eq!([0, 5, 1000].map(|n| sides_of(&read(n))), [3, 5, 1000]); - assert_eq!(sides_of(&huge), 1000); - assert_eq!(huge.polygon_outline().anchor_count(), 1000); - } - - #[test] - fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() { - let close = |a: f64, b: f64| (a - b).abs() < 1e-9; - // A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page. - let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0); - let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![], radii_relative: false }; - assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal()); - p.set_polygon_angle(45.0); - assert!(close(p.polygon_angle().unwrap(), 45.0)); - let centre = p.to_path().bounds().unwrap().center(); - assert!(close(centre.x, 50.0) && close(centre.y, 50.0), "turned about its centre: {centre:?}"); - p.set_polygon_radius(30.0); - assert!(close(p.polygon_radius().unwrap(), 30.0)); - // A hexagon's side is as long as its radius. - assert!(close(p.polygon_radius_for_side(12.0).unwrap(), 12.0)); - // Stretched, its sides differ; Make Sides Equal keeps its centre, angle and mean radius. - p.transform(Affine::scale_non_uniform(2.0, 1.0)); - assert!(!p.polygon_sides_equal()); - let (r, a) = (p.polygon_radius().unwrap(), p.polygon_angle().unwrap()); - p.make_sides_equal(); - assert!(p.polygon_sides_equal() && close(p.polygon_radius().unwrap(), r) && close(p.polygon_angle().unwrap(), a)); - let LiveShape::Polygon { xf, .. } = &p else { panic!("polygon") }; - assert_eq!(*xf * Point::ZERO, Point::new(100.0, 50.0)); - // Other shapes have none of it. - let e = LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }; - assert_eq!((e.polygon_radius(), e.polygon_angle()), (None, None)); - } - - /// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path. - fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node { - let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf }; - let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art()); - if let NodeKind::Path { live: slot, .. } = &mut n.kind { - *slot = Some(Box::new(live)); - } - n - } - - fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) { - let NodeKind::Path { live: Some(l), .. } = &n.kind else { panic!("not a live shape") }; - let LiveShape::Rectangle { w, h, radii, xf, .. } = &**l else { panic!("not a live rectangle") }; - (*w, *h, *radii, *xf) - } - - fn near(a: f64, b: f64) -> bool { - (a - b).abs() < 1e-9 - } - - fn near_xf(a: Affine, b: Affine) -> bool { - a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y)) - } - - /// Size and radius of a live rectangle, within rounding. - fn assert_live(n: &Node, w: f64, h: f64, r: f64) { - let (lw, lh, radii, _) = live_of(n); - assert!(near(lw, w) && near(lh, h) && radii.iter().all(|x| near(*x, r)), "{lw} × {lh} r {radii:?}, want {w} × {h} r {r}"); - } - - /// Each corner arc of a rounded rectangle's path, mapped by `back` (to undo a rotation), - /// spans `r` along x and y: the corner is a circular arc of radius `r`. - fn assert_round_corners(n: &Node, back: Affine, r: f64) { - let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") }; - let a: Vec = path.subpaths[0].anchors.iter().map(|a| back * a.p).collect(); - assert_eq!(a.len(), 8, "a rounded rectangle"); - // Anchors 1→2, 3→4, 5→6 and 7→0 are the arcs. - for (i, j) in [(1, 2), (3, 4), (5, 6), (7, 0)] { - let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs()); - assert!(near(x, r) && near(y, r), "corner spans {x} × {y}, want {r} × {r}"); - } - } - - const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false }; - - #[test] - fn uneven_scales_keep_live_corners_circular() { - // A 100 pt rounded square at (10, 10) stretched to 300 × 100 (#291). - let mut n = live_rect(100.0, 100.0, 20.0, Affine::translate((10.0, 10.0))); - let stretch = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(3.0, 1.0) * Affine::translate((-10.0, -10.0)); - n.transform(stretch, KEEP_CORNERS); - assert_live(&n, 300.0, 100.0, 20.0); - assert!(near_xf(live_of(&n).3, Affine::translate((10.0, 10.0))), "the scale went into the size"); - let b = n.geometric_bounds().unwrap(); - assert!(near(b.x0, 10.0) && near(b.y0, 10.0) && near(b.x1, 310.0) && near(b.y1, 110.0), "{b:?}"); - assert_round_corners(&n, Affine::IDENTITY, 20.0); - // Scale Corners on: the radius scales by the mean scale, still circular. - let mut n = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY); - n.transform(Affine::scale_non_uniform(4.0, 1.0), Scaling::default()); - assert_live(&n, 400.0, 100.0, 40.0); - assert_round_corners(&n, Affine::IDENTITY, 40.0); - // Squashed below the radius, the short sides are fully round; stretched back, it returns. - n.transform(Affine::scale_non_uniform(1.0, 0.5), KEEP_CORNERS); - assert_round_corners(&n, Affine::IDENTITY, 25.0); - n.transform(Affine::scale_non_uniform(1.0, 2.0), KEEP_CORNERS); - assert_round_corners(&n, Affine::IDENTITY, 40.0); - } - - #[test] - fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() { - let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians()); - let mut n = live_rect(100.0, 50.0, 10.0, place); - // Stretched along its own width, as a bounding-box drag of a rotated shape does. - n.transform(place * Affine::scale_non_uniform(2.0, 1.0) * place.inverse(), KEEP_CORNERS); - assert_live(&n, 200.0, 50.0, 10.0); - assert!(near_xf(live_of(&n).3, place)); - assert_round_corners(&n, place.inverse(), 10.0); - // A reflection stays in `xf`; the corners stay as they were. - n.transform(Affine::scale_non_uniform(-1.0, 1.0), KEEP_CORNERS); - assert_live(&n, 200.0, 50.0, 10.0); - let xf = live_of(&n).3; - assert!(near(xf.determinant(), -1.0)); - assert_round_corners(&n, xf.inverse(), 10.0); - } - - #[test] - fn shears_and_moves_keep_the_old_transform() { - // Scaling a rotated rectangle along the page axes shears it: `xf` takes it all, as before. - let rot = Affine::rotate(30f64.to_radians()); - let mut n = live_rect(100.0, 50.0, 10.0, rot); - let before = n.clone(); - let a = Affine::scale_non_uniform(2.0, 1.0); - n.transform(a, Scaling::default()); - assert_eq!(live_of(&n), (100.0, 50.0, [10.0; 4], a * rot)); - let (NodeKind::Path { path, .. }, NodeKind::Path { path: old, .. }) = (&n.kind, &before.kind) else { panic!() }; - assert_eq!(*path, old.transformed(a)); - // A file saved with an uneven scale in `xf` keeps its exact geometry when moved... - let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(2.0, 1.0); - let mut n = live_rect(100.0, 100.0, 20.0, legacy); - n.transform(Affine::translate((10.0, 0.0)), KEEP_CORNERS); - assert_eq!(live_of(&n), (100.0, 100.0, [20.0; 4], Affine::translate((10.0, 0.0)) * legacy)); - // ...and once scaled, has document units and circular corners of its mean radius. - n.transform(Affine::scale(2.0), Scaling::default()); - assert_live(&n, 400.0, 200.0, 20.0 * 2f64.sqrt() * 2.0); - assert!(near(live_of(&n).3.determinant(), 1.0)); - assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0); - } - - /// A rectangle saved with an uneven scale in `xf` (before #291) has elliptical corners; Live - /// Corners see and edit it in document units, with circular corners (#442). - #[test] - fn a_scale_left_in_the_transform_folds_into_the_size() { - let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(4.0, 1.0); - let n = live_rect(50.0, 80.0, 20.0, legacy); - let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("not live") }; - let folded = live.folded(); - assert_eq!(live_of(&n).3, legacy, "folded() leaves the shape alone"); - let LiveShape::Rectangle { w, h, radii, xf, .. } = &folded else { panic!("not a rectangle") }; - // Drawn 200 × 80 with 80 × 20 corners; folded, the corners are circles of the mean radius. - assert!(near(*w, 200.0) && near(*h, 80.0) && radii.iter().all(|r| near(*r, 40.0)), "{w} × {h} r {radii:?}"); - assert!(near_xf(*xf, Affine::translate((5.0, 5.0)))); - let (a, b) = (folded.to_path().bounds().unwrap(), live.to_path().bounds().unwrap()); - assert!(near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1), "same place and size: {a:?} {b:?}"); - let corners = crate::LiveCorners::of(&n).unwrap(); - assert_eq!(corners.style(&corners.all()).0, Some(40.0), "the radius in document units"); - // Nothing to fold: a moved, rotated or reflected rectangle and a sheared one stay as they are. - let place = Affine::translate((5.0, 5.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(-1.0, 1.0); - let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 0.0, 0.0]); - for xf in [place, shear] { - let mut l = live_rect(100.0, 160.0, 20.0, xf); - let NodeKind::Path { live: Some(live), .. } = &mut l.kind else { panic!("not live") }; - assert!(!live.fold_scale()); - assert_eq!(live_of(&l), (100.0, 160.0, [20.0; 4], xf)); - } - } - - #[test] - fn default_transparency() { - let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - assert!(p().has_default_transparency()); - let tweaks: [fn(&mut Node); 7] = [ - |n| n.opacity = 0.5, - |n| n.blend = BlendMode::Multiply, - |n| n.isolate = true, - |n| n.knockout = Knockout::On, - |n| n.knockout = Knockout::Off, - |n| n.knockout_shape = true, - |n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(n.clone(), true))), - ]; - for tweak in tweaks { - let mut n = p(); - tweak(&mut n); - assert!(!n.has_default_transparency(), "{n:?}"); - } - } - - #[test] - fn transform_group_recurses() { - let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - let mut g = Node::group(NodeId(1), vec![Arc::new(p)]); - g.transform(Affine::scale(2.0), true); - assert_eq!(g.geometric_bounds(), Some(Rect::new(0.0, 0.0, 20.0, 20.0))); - let child = &g.children().unwrap()[0]; - assert_eq!(child.appearance.stroke_width(), 2.0); - } - - #[test] - fn unplaced_gradients_pin_only_when_a_refit_would_differ() { - use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; - let grad = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); - let fresh = || Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(grad(), grad(), 2.0)); - let geoms = |n: &Node| match (n.appearance.fill_paint(), n.appearance.stroke_paint()) { - (Paint::Gradient(f), Paint::Gradient(s)) => (f.geom, s.geom), - _ => panic!("gradient fill and stroke"), - }; - let mut n = fresh(); - n.transform(Affine::translate((10.0, 5.0)) * Affine::scale(2.0), true); - assert_eq!(geoms(&n), (None, None), "moves and uniform scales keep refitting"); - let mut n = fresh(); - n.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false); - let (f, s) = geoms(&n); - // Fitted on the old bounds (strokes on the stroke-inflated box), then rotated: vertical. - let mut want = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0); - want.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear); - let f = f.unwrap(); - assert!(f.start.distance(want.start) < 1e-9 && f.end.distance(want.end) < 1e-9, "{f:?}"); - assert!((s.unwrap().length() - 102.0).abs() < 1e-9); - } - - #[test] - fn display_names() { - let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - assert_eq!(p.display_name(), ""); - let l = Node::layer(NodeId(1), "Layer 1", LayerColor::Preset(0)); - assert_eq!(l.display_name(), "Layer 1"); - } - - #[test] - fn visual_bounds_include_stroke() { - let mut p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - p.appearance.stroke_mut().unwrap().join = crate::appearance::LineJoin::Round; - assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5))); - } - - /// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the - /// weight centred, the whole weight outside, nothing inside; scaled with the type. - #[test] - fn type_visual_bounds_grow_with_the_character_stroke() { - use crate::appearance::StrokeAlign; - use crate::text::{CharStyle, TextObject}; - let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() }; - let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style); - t.xf = Affine::scale(2.0) * t.xf; - let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))); - let g = node(&t).geometric_bounds().unwrap(); - for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] { - t.runs[0].style.stroke_align = align; - assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}"); - } - t.runs[0].style.stroke = vectorcraft_color::Paint::None; - assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth"); - } - - /// A group holding a path, a compound, an image and a nested group, as a file writes it. - fn nested_group() -> Node { - let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - let image = - ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; - let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true }); - let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd }); - let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)]; - Node::new(NodeId(1), NodeKind::Group { children: kids, clip: false }) - } - - #[test] - fn kinds_read_back_with_type_first_or_anywhere() { - let n = nested_group(); - let json = serde_json::to_string(&n).unwrap(); - assert!(json.contains(r#""kind":{"type":"group","children":["#), "written with `type` first: {json}"); - assert_eq!(serde_json::from_str::(&json).unwrap(), n); - // Through a JSON value, whose keys are sorted: `type` comes after `children`. - let v = serde_json::to_value(&n).unwrap(); - assert_eq!(serde_json::from_value::(v.clone()).unwrap(), n); - assert_eq!(serde_json::from_str::(&v.to_string()).unwrap(), n); - // `type` last, in the middle, and keys a newer version added (ignored, as before). - let kind = r#"{"children":[],"x":{"deep":[1,2]},"clip":true,"type":"group"}"#; - assert_eq!(serde_json::from_str::(kind).unwrap(), NodeKind::Group { children: vec![], clip: true }); - let kind = r#"{"symbol":"S","type":"symbolinstance","later":1,"xf":[1,0,0,1,5,6]}"#; - assert_eq!( - serde_json::from_str::(kind).unwrap(), - NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((5.0, 6.0)) } - ); - let kind = r#"{"width":2,"type":"image","key":"k","height":3,"xf":[1,0,0,1,0,0]}"#; - assert!(matches!(serde_json::from_str::(kind).unwrap(), NodeKind::Image(i) if i.key == "k" && i.height == 3)); - // Defaults as before. - let NodeKind::Layer { printable, preview, template, .. } = - serde_json::from_str(r#"{"type":"layer","color":{"Preset":0},"children":[]}"#).unwrap() - else { - panic!("a layer") - }; - assert!(printable && preview && !template); - } - - #[test] - fn bad_kinds_are_errors() { - for bad in [r#"{"children":[]}"#, r#"{"type":"nothing"}"#, r#"{"type":"group"}"#, r#"{"type":3,"children":[]}"#, r#"[]"#, r#""group""#] { - assert!(serde_json::from_str::(bad).is_err(), "{bad}"); - } - } -} +mod tests; diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs new file mode 100644 index 000000000..32ff35e6a --- /dev/null +++ b/crates/doc/src/node/tests.rs @@ -0,0 +1,440 @@ +use super::*; + +/// A node writes its rarely set parts among the others, in the file's field order, and reads +/// them back into its extras; a node without any keeps none. +#[test] +fn extras_are_written_flat_in_order() { + let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); + n.name = Some("n".into()); + n.knockout_shape = true; + n.bbox_angle = 30.0; + { + let mut e = n.extras_mut(); + e.mask = Some(Box::new(OpacityMask::new(Node::group(NodeId(8), vec![]), true))); + e.trace = Some(Box::new(serde_json::json!({"preset": "p"}))); + e.series_index = Some(2); + e.type_outlines = true; + e.graphic_style = Some(3); + e.attrs = Some(Box::default()); + e.slice = Some(Box::default()); + e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0))); + e.live_paint_gaps = Some(Box::default()); + } + /// The top-level keys of a JSON object, in the order written. + struct Keys(Vec); + impl<'de> Deserialize<'de> for Keys { + fn deserialize>(d: D) -> Result { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Keys; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("an object") + } + fn visit_map>(self, mut a: A) -> Result { + let mut out = vec![]; + while let Some(k) = a.next_key::()? { + a.next_value::()?; + out.push(k); + } + Ok(Keys(out)) + } + } + d.deserialize_map(V) + } + } + let text = serde_json::to_string(&n).unwrap(); + let keys = serde_json::from_str::(&text).unwrap().0; + let v: serde_json::Value = serde_json::from_str(&text).unwrap(); + let order = [ + "id", + "name", + "knockout_shape", + "appearance", + "mask", + "trace", + "series_index", + "type_outlines", + "kind", + "graphic_style", + "attrs", + "slice", + "bbox_angle", + "perspective", + "live_paint_gaps", + ]; + assert_eq!(keys, order); + assert_eq!(serde_json::from_value::(v).unwrap(), n); + let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); + let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap(); + assert!(back.extras_box().is_none()); + // Clearing the last extra lets them go; clearing one a node doesn't have allocates none. + n.extras_mut().mask = None; + assert!(n.extras_box().is_some()); + *n.extras_mut() = NodeExtras::default(); + assert!(n.extras_box().is_none()); + n.extras_mut().mask = None; + assert!(n.extras_box().is_none()); + assert_eq!(n.extras(), &NodeExtras::default()); +} + +#[test] +fn rect_live_shape_roundtrip() { + let l = LiveShape::Rectangle { + w: 10.0, + h: 20.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::translate((5.0, 5.0)), + }; + assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0))); + assert_eq!(l.label(), "Rectangle"); +} + +#[test] +fn corner_kinds_save_only_when_some_corner_isnt_round() { + let mut l = LiveShape::Rectangle { + w: 100.0, + h: 50.0, + radii: [0.0, 10.0, 0.0, 0.0], + kinds: Default::default(), + radii_relative: false, + xf: Affine::IDENTITY, + }; + // Older files read as round. + let json = serde_json::to_value(&l).unwrap(); + assert!(json.get("kinds").is_none(), "{json}"); + assert_eq!(serde_json::from_value::(json).unwrap(), l); + if let LiveShape::Rectangle { kinds, .. } = &mut l { + kinds[1] = CornerKind::Chamfer; + } + let json = serde_json::to_value(&l).unwrap(); + assert_eq!(json["kinds"], serde_json::json!(["round", "chamfer", "round", "round"])); + assert_eq!(serde_json::from_value::(json).unwrap(), l); + // Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips. + let p: LiveShape = + serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap(); + assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![], radii_relative: false }); + let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0); + let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![], radii_relative: false }; + let json = serde_json::to_value(&path).unwrap(); + assert_eq!((json["shape"].as_str(), json.get("kinds")), (Some("path"), None)); + assert_eq!(serde_json::from_value::(json).unwrap(), path); + assert_eq!(path.label(), "Path"); +} + +/// A polygon read from a file has 3 to 1000 sides, as the commands give it: selecting one +/// with four billion made its outline and corner lists allocate tens of gigabytes. +#[test] +fn a_polygon_from_a_file_has_3_to_1000_sides() { + let read = |sides: u32| { + let json = serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": sides, "xf": [1, 0, 0, 1, 0, 0]}); + serde_json::from_value::(json).unwrap() + }; + let sides_of = |l: &LiveShape| match l { + LiveShape::Polygon { sides, .. } => *sides, + _ => panic!("not a polygon: {l:?}"), + }; + let huge = read(4_000_000_000); + assert_eq!([0, 5, 1000].map(|n| sides_of(&read(n))), [3, 5, 1000]); + assert_eq!(sides_of(&huge), 1000); + assert_eq!(huge.polygon_outline().anchor_count(), 1000); +} + +#[test] +fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() { + let close = |a: f64, b: f64| (a - b).abs() < 1e-9; + // A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page. + let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0); + let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![], radii_relative: false }; + assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal()); + p.set_polygon_angle(45.0); + assert!(close(p.polygon_angle().unwrap(), 45.0)); + let centre = p.to_path().bounds().unwrap().center(); + assert!(close(centre.x, 50.0) && close(centre.y, 50.0), "turned about its centre: {centre:?}"); + p.set_polygon_radius(30.0); + assert!(close(p.polygon_radius().unwrap(), 30.0)); + // A hexagon's side is as long as its radius. + assert!(close(p.polygon_radius_for_side(12.0).unwrap(), 12.0)); + // Stretched, its sides differ; Make Sides Equal keeps its centre, angle and mean radius. + p.transform(Affine::scale_non_uniform(2.0, 1.0)); + assert!(!p.polygon_sides_equal()); + let (r, a) = (p.polygon_radius().unwrap(), p.polygon_angle().unwrap()); + p.make_sides_equal(); + assert!(p.polygon_sides_equal() && close(p.polygon_radius().unwrap(), r) && close(p.polygon_angle().unwrap(), a)); + let LiveShape::Polygon { xf, .. } = &p else { panic!("polygon") }; + assert_eq!(*xf * Point::ZERO, Point::new(100.0, 50.0)); + // Other shapes have none of it. + let e = LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }; + assert_eq!((e.polygon_radius(), e.polygon_angle()), (None, None)); +} + +/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path. +fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node { + let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf }; + let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art()); + if let NodeKind::Path { live: slot, .. } = &mut n.kind { + *slot = Some(Box::new(live)); + } + n +} + +fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) { + let NodeKind::Path { live: Some(l), .. } = &n.kind else { panic!("not a live shape") }; + let LiveShape::Rectangle { w, h, radii, xf, .. } = &**l else { panic!("not a live rectangle") }; + (*w, *h, *radii, *xf) +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +fn near_xf(a: Affine, b: Affine) -> bool { + a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y)) +} + +/// Size and radius of a live rectangle, within rounding. +fn assert_live(n: &Node, w: f64, h: f64, r: f64) { + let (lw, lh, radii, _) = live_of(n); + assert!(near(lw, w) && near(lh, h) && radii.iter().all(|x| near(*x, r)), "{lw} × {lh} r {radii:?}, want {w} × {h} r {r}"); +} + +/// Each corner arc of a rounded rectangle's path, mapped by `back` (to undo a rotation), +/// spans `r` along x and y: the corner is a circular arc of radius `r`. +fn assert_round_corners(n: &Node, back: Affine, r: f64) { + let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") }; + let a: Vec = path.subpaths[0].anchors.iter().map(|a| back * a.p).collect(); + assert_eq!(a.len(), 8, "a rounded rectangle"); + // Anchors 1→2, 3→4, 5→6 and 7→0 are the arcs. + for (i, j) in [(1, 2), (3, 4), (5, 6), (7, 0)] { + let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs()); + assert!(near(x, r) && near(y, r), "corner spans {x} × {y}, want {r} × {r}"); + } +} + +const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false }; + +#[test] +fn uneven_scales_keep_live_corners_circular() { + // A 100 pt rounded square at (10, 10) stretched to 300 × 100 (#291). + let mut n = live_rect(100.0, 100.0, 20.0, Affine::translate((10.0, 10.0))); + let stretch = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(3.0, 1.0) * Affine::translate((-10.0, -10.0)); + n.transform(stretch, KEEP_CORNERS); + assert_live(&n, 300.0, 100.0, 20.0); + assert!(near_xf(live_of(&n).3, Affine::translate((10.0, 10.0))), "the scale went into the size"); + let b = n.geometric_bounds().unwrap(); + assert!(near(b.x0, 10.0) && near(b.y0, 10.0) && near(b.x1, 310.0) && near(b.y1, 110.0), "{b:?}"); + assert_round_corners(&n, Affine::IDENTITY, 20.0); + // Scale Corners on: the radius scales by the mean scale, still circular. + let mut n = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY); + n.transform(Affine::scale_non_uniform(4.0, 1.0), Scaling::default()); + assert_live(&n, 400.0, 100.0, 40.0); + assert_round_corners(&n, Affine::IDENTITY, 40.0); + // Squashed below the radius, the short sides are fully round; stretched back, it returns. + n.transform(Affine::scale_non_uniform(1.0, 0.5), KEEP_CORNERS); + assert_round_corners(&n, Affine::IDENTITY, 25.0); + n.transform(Affine::scale_non_uniform(1.0, 2.0), KEEP_CORNERS); + assert_round_corners(&n, Affine::IDENTITY, 40.0); +} + +#[test] +fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() { + let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians()); + let mut n = live_rect(100.0, 50.0, 10.0, place); + // Stretched along its own width, as a bounding-box drag of a rotated shape does. + n.transform(place * Affine::scale_non_uniform(2.0, 1.0) * place.inverse(), KEEP_CORNERS); + assert_live(&n, 200.0, 50.0, 10.0); + assert!(near_xf(live_of(&n).3, place)); + assert_round_corners(&n, place.inverse(), 10.0); + // A reflection stays in `xf`; the corners stay as they were. + n.transform(Affine::scale_non_uniform(-1.0, 1.0), KEEP_CORNERS); + assert_live(&n, 200.0, 50.0, 10.0); + let xf = live_of(&n).3; + assert!(near(xf.determinant(), -1.0)); + assert_round_corners(&n, xf.inverse(), 10.0); +} + +#[test] +fn shears_and_moves_keep_the_old_transform() { + // Scaling a rotated rectangle along the page axes shears it: `xf` takes it all, as before. + let rot = Affine::rotate(30f64.to_radians()); + let mut n = live_rect(100.0, 50.0, 10.0, rot); + let before = n.clone(); + let a = Affine::scale_non_uniform(2.0, 1.0); + n.transform(a, Scaling::default()); + assert_eq!(live_of(&n), (100.0, 50.0, [10.0; 4], a * rot)); + let (NodeKind::Path { path, .. }, NodeKind::Path { path: old, .. }) = (&n.kind, &before.kind) else { panic!() }; + assert_eq!(*path, old.transformed(a)); + // A file saved with an uneven scale in `xf` keeps its exact geometry when moved... + let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(2.0, 1.0); + let mut n = live_rect(100.0, 100.0, 20.0, legacy); + n.transform(Affine::translate((10.0, 0.0)), KEEP_CORNERS); + assert_eq!(live_of(&n), (100.0, 100.0, [20.0; 4], Affine::translate((10.0, 0.0)) * legacy)); + // ...and once scaled, has document units and circular corners of its mean radius. + n.transform(Affine::scale(2.0), Scaling::default()); + assert_live(&n, 400.0, 200.0, 20.0 * 2f64.sqrt() * 2.0); + assert!(near(live_of(&n).3.determinant(), 1.0)); + assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0); +} + +/// A rectangle saved with an uneven scale in `xf` (before #291) has elliptical corners; Live +/// Corners see and edit it in document units, with circular corners (#442). +#[test] +fn a_scale_left_in_the_transform_folds_into_the_size() { + let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(4.0, 1.0); + let n = live_rect(50.0, 80.0, 20.0, legacy); + let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("not live") }; + let folded = live.folded(); + assert_eq!(live_of(&n).3, legacy, "folded() leaves the shape alone"); + let LiveShape::Rectangle { w, h, radii, xf, .. } = &folded else { panic!("not a rectangle") }; + // Drawn 200 × 80 with 80 × 20 corners; folded, the corners are circles of the mean radius. + assert!(near(*w, 200.0) && near(*h, 80.0) && radii.iter().all(|r| near(*r, 40.0)), "{w} × {h} r {radii:?}"); + assert!(near_xf(*xf, Affine::translate((5.0, 5.0)))); + let (a, b) = (folded.to_path().bounds().unwrap(), live.to_path().bounds().unwrap()); + assert!(near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1), "same place and size: {a:?} {b:?}"); + let corners = crate::LiveCorners::of(&n).unwrap(); + assert_eq!(corners.style(&corners.all()).0, Some(40.0), "the radius in document units"); + // Nothing to fold: a moved, rotated or reflected rectangle and a sheared one stay as they are. + let place = Affine::translate((5.0, 5.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(-1.0, 1.0); + let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 0.0, 0.0]); + for xf in [place, shear] { + let mut l = live_rect(100.0, 160.0, 20.0, xf); + let NodeKind::Path { live: Some(live), .. } = &mut l.kind else { panic!("not live") }; + assert!(!live.fold_scale()); + assert_eq!(live_of(&l), (100.0, 160.0, [20.0; 4], xf)); + } +} + +#[test] +fn default_transparency() { + let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + assert!(p().has_default_transparency()); + let tweaks: [fn(&mut Node); 7] = [ + |n| n.opacity = 0.5, + |n| n.blend = BlendMode::Multiply, + |n| n.isolate = true, + |n| n.knockout = Knockout::On, + |n| n.knockout = Knockout::Off, + |n| n.knockout_shape = true, + |n| n.extras_mut().mask = Some(Box::new(OpacityMask::new(n.clone(), true))), + ]; + for tweak in tweaks { + let mut n = p(); + tweak(&mut n); + assert!(!n.has_default_transparency(), "{n:?}"); + } +} + +#[test] +fn transform_group_recurses() { + let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let mut g = Node::group(NodeId(1), vec![Arc::new(p)]); + g.transform(Affine::scale(2.0), true); + assert_eq!(g.geometric_bounds(), Some(Rect::new(0.0, 0.0, 20.0, 20.0))); + let child = &g.children().unwrap()[0]; + assert_eq!(child.appearance.stroke_width(), 2.0); +} + +#[test] +fn unplaced_gradients_pin_only_when_a_refit_would_differ() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; + let grad = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let fresh = || Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(grad(), grad(), 2.0)); + let geoms = |n: &Node| match (n.appearance.fill_paint(), n.appearance.stroke_paint()) { + (Paint::Gradient(f), Paint::Gradient(s)) => (f.geom, s.geom), + _ => panic!("gradient fill and stroke"), + }; + let mut n = fresh(); + n.transform(Affine::translate((10.0, 5.0)) * Affine::scale(2.0), true); + assert_eq!(geoms(&n), (None, None), "moves and uniform scales keep refitting"); + let mut n = fresh(); + n.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false); + let (f, s) = geoms(&n); + // Fitted on the old bounds (strokes on the stroke-inflated box), then rotated: vertical. + let mut want = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0); + want.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear); + let f = f.unwrap(); + assert!(f.start.distance(want.start) < 1e-9 && f.end.distance(want.end) < 1e-9, "{f:?}"); + assert!((s.unwrap().length() - 102.0).abs() < 1e-9); +} + +#[test] +fn display_names() { + let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + assert_eq!(p.display_name(), ""); + let l = Node::layer(NodeId(1), "Layer 1", LayerColor::Preset(0)); + assert_eq!(l.display_name(), "Layer 1"); +} + +#[test] +fn visual_bounds_include_stroke() { + let mut p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + p.appearance.stroke_mut().unwrap().join = crate::appearance::LineJoin::Round; + assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5))); +} + +/// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the +/// weight centred, the whole weight outside, nothing inside; scaled with the type. +#[test] +fn type_visual_bounds_grow_with_the_character_stroke() { + use crate::appearance::StrokeAlign; + use crate::text::{CharStyle, TextObject}; + let style = CharStyle { stroke: vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), stroke_width: 4.0, ..Default::default() }; + let mut t = TextObject::point(vectorcraft_geom::Point::new(0.0, 0.0), "Ab", style); + t.xf = Affine::scale(2.0) * t.xf; + let node = |t: &TextObject| Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))); + let g = node(&t).geometric_bounds().unwrap(); + for (align, grow) in [(StrokeAlign::Center, 4.0), (StrokeAlign::Outside, 8.0), (StrokeAlign::Inside, 0.0)] { + t.runs[0].style.stroke_align = align; + assert_eq!(node(&t).visual_bounds(), Some(g.inflate(grow, grow)), "{align:?}"); + } + t.runs[0].style.stroke = vectorcraft_color::Paint::None; + assert_eq!(node(&t).visual_bounds(), Some(g), "no stroke, no growth"); +} + +/// A group holding a path, a compound, an image and a nested group, as a file writes it. +fn nested_group() -> Node { + let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let image = + ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true }); + let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd }); + let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)]; + Node::new(NodeId(1), NodeKind::Group { children: kids, clip: false }) +} + +#[test] +fn kinds_read_back_with_type_first_or_anywhere() { + let n = nested_group(); + let json = serde_json::to_string(&n).unwrap(); + assert!(json.contains(r#""kind":{"type":"group","children":["#), "written with `type` first: {json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), n); + // Through a JSON value, whose keys are sorted: `type` comes after `children`. + let v = serde_json::to_value(&n).unwrap(); + assert_eq!(serde_json::from_value::(v.clone()).unwrap(), n); + assert_eq!(serde_json::from_str::(&v.to_string()).unwrap(), n); + // `type` last, in the middle, and keys a newer version added (ignored, as before). + let kind = r#"{"children":[],"x":{"deep":[1,2]},"clip":true,"type":"group"}"#; + assert_eq!(serde_json::from_str::(kind).unwrap(), NodeKind::Group { children: vec![], clip: true }); + let kind = r#"{"symbol":"S","type":"symbolinstance","later":1,"xf":[1,0,0,1,5,6]}"#; + assert_eq!( + serde_json::from_str::(kind).unwrap(), + NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((5.0, 6.0)) } + ); + let kind = r#"{"width":2,"type":"image","key":"k","height":3,"xf":[1,0,0,1,0,0]}"#; + assert!(matches!(serde_json::from_str::(kind).unwrap(), NodeKind::Image(i) if i.key == "k" && i.height == 3)); + // Defaults as before. + let NodeKind::Layer { printable, preview, template, .. } = + serde_json::from_str(r#"{"type":"layer","color":{"Preset":0},"children":[]}"#).unwrap() + else { + panic!("a layer") + }; + assert!(printable && preview && !template); +} + +#[test] +fn bad_kinds_are_errors() { + for bad in [r#"{"children":[]}"#, r#"{"type":"nothing"}"#, r#"{"type":"group"}"#, r#"{"type":3,"children":[]}"#, r#"[]"#, r#""group""#] { + assert!(serde_json::from_str::(bad).is_err(), "{bad}"); + } +}