use super::*; use figcraft_geom::Point; fn page(doc: &Document) -> NodeId { doc.first_page().unwrap() } #[test] fn new_document_has_one_page() { let d = Document::new("x"); assert_eq!(d.pages.len(), 1); assert_eq!(d.node(page(&d)).unwrap().name, "Page 1"); assert!(d.validate().is_empty()); } #[test] fn create_and_world_transform() { let mut d = Document::new("x"); let p = page(&d); let f = d.create(p, NodeKind::Frame, "Frame 1", 100.0, 50.0, 400.0, 300.0).unwrap(); let r = d.create(f, NodeKind::Rectangle, "Rect", 10.0, 20.0, 50.0, 40.0).unwrap(); let b = d.world_bounds(r).unwrap(); assert_eq!((b.x0, b.y0, b.x1, b.y1), (110.0, 70.0, 160.0, 110.0)); assert!(d.validate().is_empty()); assert_eq!(d.top_level_frame(r), Some(f)); } #[test] fn reparent_keeps_world_position_and_rejects_cycles() { let mut d = Document::new("x"); let p = page(&d); let f = d.create(p, NodeKind::Frame, "F", 100.0, 100.0, 400.0, 300.0).unwrap(); let r = d.create(p, NodeKind::Rectangle, "R", 150.0, 150.0, 10.0, 10.0).unwrap(); d.reparent(r, f, None).unwrap(); assert_eq!(d.node(r).unwrap().parent, Some(f)); let b = d.world_bounds(r).unwrap(); assert_eq!((b.x0, b.y0), (150.0, 150.0)); assert!(matches!(d.reparent(f, f, None), Err(DocError::Cycle(..)))); let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 1.0, 1.0).unwrap(); assert!(matches!(d.reparent(f, g, None), Err(DocError::Cycle(..)))); let r2 = d.create(p, NodeKind::Rectangle, "R2", 0.0, 0.0, 1.0, 1.0).unwrap(); assert!(matches!(d.reparent(f, r2, None), Err(DocError::NotContainer(..)))); assert!(d.validate().is_empty()); } #[test] fn reorder_within_parent() { let mut d = Document::new("x"); let p = page(&d); let a = d.create(p, NodeKind::Rectangle, "A", 0.0, 0.0, 1.0, 1.0).unwrap(); let b = d.create(p, NodeKind::Rectangle, "B", 0.0, 0.0, 1.0, 1.0).unwrap(); let c = d.create(p, NodeKind::Rectangle, "C", 0.0, 0.0, 1.0, 1.0).unwrap(); d.reparent(a, p, Some(3)).unwrap(); assert_eq!(d.node(p).unwrap().children, vec![b, c, a]); d.reorder(a, 0).unwrap(); assert_eq!(d.node(p).unwrap().children, vec![a, b, c]); } #[test] fn remove_subtree_and_last_page_guard() { let mut d = Document::new("x"); let p = page(&d); let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 10.0, 10.0).unwrap(); let r = d.create(f, NodeKind::Rectangle, "R", 0.0, 0.0, 1.0, 1.0).unwrap(); d.remove(f).unwrap(); assert!(!d.contains(r)); assert!(d.remove(p).is_err()); let p2 = d.add_page("Page 2"); d.remove(p).unwrap(); assert_eq!(d.pages, vec![p2]); } #[test] fn group_fits_children() { let mut d = Document::new("x"); let p = page(&d); let g = d.create(p, NodeKind::Group, "Group 1", 0.0, 0.0, 0.0, 0.0).unwrap(); d.create(g, NodeKind::Rectangle, "A", 10.0, 10.0, 20.0, 20.0).unwrap(); let b = d.create(g, NodeKind::Rectangle, "B", 50.0, 40.0, 10.0, 10.0).unwrap(); d.fit_to_children(g).unwrap(); let gb = d.world_bounds(g).unwrap(); assert_eq!((gb.x0, gb.y0, gb.x1, gb.y1), (10.0, 10.0, 60.0, 50.0)); let bb = d.world_bounds(b).unwrap(); assert_eq!((bb.x0, bb.y0), (50.0, 40.0)); } #[test] fn duplicate_remaps_ids_and_interactions() { let mut d = Document::new("x"); let p = page(&d); let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); let a = d.create(f, NodeKind::Rectangle, "A", 0.0, 0.0, 10.0, 10.0).unwrap(); let b = d.create(f, NodeKind::Rectangle, "B", 20.0, 0.0, 10.0, 10.0).unwrap(); d.get_mut(a).unwrap().interactions.push(proto::Interaction { trigger: proto::Trigger::OnClick, actions: vec![proto::Action::Navigate { dest: b, preserve_scroll: false }], transition: proto::Transition::Instant, duration_ms: 300, easing: proto::Easing::EaseOut, delay_ms: 0, }); let f2 = d.duplicate_into(f, p, None).unwrap(); let kids = d.node(f2).unwrap().children.clone(); assert_eq!(kids.len(), 2); assert!(!kids.contains(&a)); let a2 = d.node(kids[0]).unwrap(); let proto::Action::Navigate { dest, .. } = a2.interactions[0].actions[0] else { panic!() }; assert_eq!(dest, kids[1]); assert!(d.validate().is_empty()); } #[test] fn hit_test_respects_z_and_clip() { let mut d = Document::new("x"); let p = page(&d); let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); let a = d.create(f, NodeKind::Rectangle, "A", 10.0, 10.0, 50.0, 50.0).unwrap(); let b = d.create(f, NodeKind::Rectangle, "B", 30.0, 30.0, 50.0, 50.0).unwrap(); let outside = d.create(f, NodeKind::Rectangle, "Out", 90.0, 90.0, 50.0, 50.0).unwrap(); assert_eq!(d.hit_test(p, Point::new(40.0, 40.0), 0.0, None), Some(b)); assert_eq!(d.hit_test(p, Point::new(15.0, 15.0), 0.0, None), Some(a)); assert_eq!(d.hit_test(p, Point::new(95.0, 95.0), 0.0, None), Some(outside)); // Clipped part of `outside` isn't hit. assert_eq!(d.hit_test(p, Point::new(120.0, 120.0), 0.0, None), None); assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0, None), Some(f)); } #[test] fn region_paints_participate_in_colors_images_and_pattern_remapping() { let mut d = Document::new("region resources"); let p = page(&d); let group = d.create(p, NodeKind::Group, "Group", 0.0, 0.0, 40.0, 20.0).unwrap(); let source = d.create(group, NodeKind::Rectangle, "Source", 0.0, 0.0, 4.0, 4.0).unwrap(); let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 20.0), Point::new(0.0, 20.0)], true); network.regions[0].fills = Some(vec![ figcraft_color::Paint::solid(figcraft_color::Color::from_hex("FF0000").unwrap()), figcraft_color::Paint::image("region-image", figcraft_color::ImageScaleMode::Fill), figcraft_color::Paint::pattern(source.0), ]); let vector = d.create(group, NodeKind::Vector { network }, "Vector", 0.0, 0.0, 20.0, 20.0).unwrap(); assert!(used_images(&d).contains("region-image")); let dup = d.duplicate_into(group, p, None).unwrap(); let copied_children = d.node(dup).unwrap().children.clone(); let copied_source = copied_children[0]; let copied_vector = d.node(copied_children[1]).unwrap(); let NodeKind::Vector { network } = &copied_vector.kind else { panic!() }; let figcraft_color::PaintKind::Pattern(pattern) = &network.regions[0].fills.as_ref().unwrap()[2].kind else { panic!() }; assert_eq!(pattern.source, copied_source.0); let red = figcraft_color::Color::from_hex("FF0000").unwrap(); d.map_colors(|c| if c == red { figcraft_color::Color::BLACK } else { c }); let NodeKind::Vector { network } = &d.node(vector).unwrap().kind else { panic!() }; assert_eq!(network.regions[0].fills.as_ref().unwrap()[0].solid_color(), Some(figcraft_color::Color::BLACK)); } #[test] fn click_target_rules() { let mut d = Document::new("x"); let p = page(&d); let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 200.0, 200.0).unwrap(); let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 0.0, 0.0).unwrap(); let r = d.create(g, NodeKind::Rectangle, "R", 10.0, 10.0, 20.0, 20.0).unwrap(); d.fit_to_children(g).unwrap(); let leaf = d.hit_test(p, Point::new(15.0, 15.0), 0.0, None).unwrap(); assert_eq!(leaf, r); // Child of a top-level frame: the group. assert_eq!(geometry::select_target(&d, leaf, &[], false), g); // With the group selected, a click enters it. assert_eq!(geometry::select_target(&d, leaf, &[r], false), r); // Deep select. assert_eq!(geometry::select_target(&d, leaf, &[], true), r); // Top-level group selects as a whole. let g2 = d.create(p, NodeKind::Group, "G2", 0.0, 0.0, 0.0, 0.0).unwrap(); let r2 = d.create(g2, NodeKind::Rectangle, "R2", 300.0, 0.0, 20.0, 20.0).unwrap(); d.fit_to_children(g2).unwrap(); assert_eq!(geometry::select_target(&d, r2, &[], false), g2); } #[test] fn text_runs_restyle_and_replace() { let mut t = TextData::new("Hello world"); t.restyle(0, 5, |s| s.font_style = "Bold".into()); assert_eq!(t.runs.len(), 1); assert_eq!(t.style_at(0).font_style, "Bold"); assert_eq!(t.style_at(6).font_style, "Regular"); t.replace_range(5, 5, "!!"); assert_eq!(t.characters, "Hello!! world"); // Typed text at the end of a run takes its style. assert_eq!(t.style_at(6).font_style, "Bold"); assert_eq!(t.spans().len(), 2); t.restyle(0, 100, |s| s.font_size = 20.0); assert!(t.spans().iter().all(|(_, _, s)| s.font_size == 20.0)); t.restyle(0, 13, |s| s.font_style = "Regular".into()); assert!(t.is_uniform()); t.replace_range(0, 99, ""); assert_eq!(t.characters, ""); } /// A run that reaches past the text (a file can carry one: nothing clamps `runs` on load) /// must not overflow the arithmetic that shifts the runs along, and must not leave the /// text unwritable. #[test] fn text_runs_past_the_text_never_overflow() { let style = || TextStyle { font_style: "Bold".into(), ..Default::default() }; for end in [usize::MAX, usize::MAX - 1, 1_000_000] { let mut t = TextData::new("hi"); t.runs = vec![StyleRun { start: 0, end, style: style() }]; // Typing at the caret, and replacing a selection: both shift `end`. t.replace_range(0, 0, "x"); assert_eq!(t.characters, "xhi", "end {end}"); t.replace_range(0, 3, "yo"); assert_eq!(t.characters, "yo", "end {end}"); // The text is still writable and the runs are back inside it. t.replace_range(0, 0, "!"); assert_eq!(t.characters, "!yo", "end {end}"); assert!(t.runs.iter().all(|r| r.start < r.end && r.end <= t.char_count()), "end {end}: {:?}", t.runs); } } #[test] fn text_case() { assert_eq!(TextCase::Title.apply("hello big world"), "Hello Big World"); assert_eq!(style_weight("Semi Bold Italic"), 600); assert_eq!(style_weight("Black"), 900); assert_eq!(style_weight("Regular"), 400); assert_eq!(style_weight("Hairline"), 100); } #[test] fn vector_network_paths() { let net = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true); use figcraft_geom::Shape; assert!((net.fill_path().area().abs() - 50.0).abs() < 1e-9); let open = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)], false); assert!(open.fill_path().elements().is_empty()); assert_eq!(open.stroke_path().segments().count(), 2); // Round-trip a bezier path. let rr = figcraft_geom::rounded_rect(40.0, 20.0, [5.0; 4], 0.0); let n2 = VectorNetwork::from_path(&rr); assert!((n2.fill_path().area() - rr.area()).abs() < 1e-6); let mut bad = VectorNetwork { vertices: vec![Vertex::new(0.0, 0.0)], segments: vec![Segment::line(0, 5)], regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd, fills: None, ..Default::default() }], }; bad.sanitize(); assert!(bad.segments.is_empty()); let _ = bad.fill_path(); } #[test] fn variables_resolve_modes_and_aliases() { let mut v = vars_with_colours(&[("bg", VariableValue::Color(Color::WHITE), VariableValue::Color(Color::BLACK))]); add(&mut v, "surface", VariableValue::Alias("bg".into())); let mut modes = std::collections::BTreeMap::new(); assert_eq!(v.resolve("surface", &modes), Some(VariableValue::Color(Color::WHITE))); modes.insert("c1".to_string(), "dark".to_string()); assert_eq!(v.resolve("bg", &modes), Some(VariableValue::Color(Color::BLACK))); // Cycle. add(&mut v, "loop", VariableValue::Alias("loop".into())); assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None); } #[test] fn variables_resolve_stored_ids_never_by_name() { let mut v = Variables::default(); v.collections.push(Collection { id: "c1".into(), name: "Theme".into(), modes: vec![Mode { id: "m".into(), name: "Mode 1".into() }], extends: None, }); let var = |id: &str, name: &str, value: VariableValue| colour_var(id, name, &[("m", value)]); let red = Color::from_hex("#FF0000").unwrap(); let blue = Color::from_hex("#0D99FF").unwrap(); // Named like the next variable's id, and like an id that no longer exists. v.variables.push(var("VariableID:1", "VariableID:2", VariableValue::Color(red))); v.variables.push(var("VariableID:2", "surface", VariableValue::Color(blue))); v.variables.push(var("VariableID:3", "VariableID:9", VariableValue::Color(red))); v.variables.push(var("VariableID:4", "alias", VariableValue::Alias("VariableID:9".into()))); let modes = std::collections::BTreeMap::new(); assert_eq!(v.resolve("VariableID:2", &modes), Some(VariableValue::Color(blue))); // A deleted id stays unresolved instead of picking up the variable named like it. assert_eq!(v.resolve("VariableID:9", &modes), None); assert_eq!(v.resolve("VariableID:4", &modes), None); // What a person types: an id wins over a name, and a name still finds its variable (#1613). assert_eq!(v.get("VariableID:2").map(|x| x.name.as_str()), Some("surface")); assert_eq!(v.get("surface").map(|x| x.id.as_str()), Some("VariableID:2")); assert!(v.get_by_id("surface").is_none()); } #[test] fn collections_and_modes_resolve_stored_ids_never_by_name() { let mut v = Variables::default(); let coll = |id: &str, name: &str, modes: Vec| Collection { id: id.into(), name: name.into(), modes, extends: None }; let mode = |id: &str, name: &str| Mode { id: id.into(), name: name.into() }; v.collections.push(coll("c1", "c2", vec![mode("m1", "m2")])); v.collections.push(coll("c2", "Theme", vec![mode("m2", "Dark"), mode("m3", "m9")])); assert_eq!(v.collection("c2").map(|c| c.name.as_str()), Some("Theme"), "an id wins over a name"); assert_eq!(v.collection("Theme").map(|c| c.id.as_str()), Some("c2")); assert!(v.collection_by_id("Theme").is_none() && v.collection_by_id("c9").is_none()); let theme = v.collection_by_id("c2").unwrap(); assert_eq!(theme.mode_by_id("m2").map(|m| m.name.as_str()), Some("Dark")); assert!(theme.mode_by_id("m9").is_none(), "a deleted mode id isn't the mode named like it"); assert_eq!(theme.find_mode("Dark").map(|m| m.id.as_str()), Some("m2")); } #[test] fn serde_roundtrip_and_repair() { let mut d = Document::new("x"); let p = page(&d); let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); d.get_mut(f).unwrap().layout = Some(AutoLayout::default()); d.create(f, NodeKind::Text(TextData::new("Hi")), "Hi", 0.0, 0.0, 10.0, 10.0).unwrap(); d.create(f, NodeKind::Star { count: 5, inner_ratio: 0.4 }, "S", 0.0, 0.0, 10.0, 10.0).unwrap(); let j = serde_json::to_string(&d).unwrap(); let back: Document = serde_json::from_str(&j).unwrap(); assert_eq!(back, d); // Corrupt: dangling child, orphan. let mut v: serde_json::Value = serde_json::from_str(&j).unwrap(); v["nodes"][f.to_string()]["children"].as_array_mut().unwrap().push(serde_json::json!(999)); let mut bad: Document = serde_json::from_value(v).unwrap(); assert!(!bad.validate().is_empty()); bad.repair(); assert!(bad.validate().is_empty(), "{:?}", bad.validate()); } #[test] fn repair_drops_cycles_and_repeated_children() { let mut d = Document::new("x"); let p = page(&d); let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap(); let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); let mut v = serde_json::to_value(&d).unwrap(); // F and G each other's parent and child (G listing F twice), and R listed twice by the page. v["nodes"][f.to_string()]["parent"] = serde_json::json!(g); v["nodes"][g.to_string()]["children"] = serde_json::json!([f, f]); v["nodes"][p.to_string()]["children"].as_array_mut().unwrap().push(serde_json::json!(r)); let mut bad: Document = serde_json::from_value(v).unwrap(); let issues = bad.validate(); for want in ["listed twice", "not under a page"] { assert!(issues.iter().any(|i| i.contains(want)), "{want}: {issues:?}"); } bad.repair(); assert!(bad.validate().is_empty(), "{:?}", bad.validate()); assert!(bad.node(f).is_none() && bad.node(g).is_none()); assert_eq!(bad.node(p).unwrap().children, vec![r]); assert_eq!(bad.subtree(p), vec![p, r]); } #[test] fn repair_lists_a_page_once_however_often_the_file_lists_it() { let mut d = Document::new("x"); let p = page(&d); let q = d.add_page("Two"); let mut v = serde_json::to_value(&d).unwrap(); v["pages"] = serde_json::json!([p, q, p, q, p]); let mut bad: Document = serde_json::from_value(v).unwrap(); assert!(bad.validate().iter().any(|i| i.contains("listed twice")), "{:?}", bad.validate()); bad.repair(); assert!(bad.validate().is_empty(), "{:?}", bad.validate()); assert_eq!(bad.pages, vec![p, q]); } #[test] fn validate_caps_the_issue_list() { let mut d = Document::new("x"); let p = page(&d); let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap(); let mut v = serde_json::to_value(&d).unwrap(); v["nodes"][f.to_string()]["children"] = serde_json::json!(vec![g; 5000]); let bad: Document = serde_json::from_value(v).unwrap(); let issues = bad.validate(); assert!(!issues.is_empty()); assert!(issues.len() <= 100, "{}", issues.len()); } #[test] fn auto_layout_loads_files_without_grid_tracks() { // Saved before grid tracks, cells and spans existed. let al: AutoLayout = serde_json::from_str(r#"{"mode":"grid","gap":4,"grid_columns":3,"grid_rows":2}"#).unwrap(); assert_eq!((al.grid_columns, al.column_tracks.len(), al.row_tracks.len()), (3, 0, 0)); let cl: ChildLayout = serde_json::from_str(r#"{"sizing_h":"fill","absolute":true,"min_w":10}"#).unwrap(); assert_eq!((cl.grid_column, cl.grid_row, cl.column_span(), cl.row_span()), (None, None, 1, 1)); // Tracks round-trip; a track missing its value reads as the default kind's value. let t: GridTrack = serde_json::from_str(r#"{"sizing":"fixed","value":120}"#).unwrap(); assert_eq!(t, GridTrack::fixed(120.0)); assert_eq!(serde_json::from_str::("{}").unwrap(), GridTrack::fill(1.0)); } #[test] fn node_id_parse() { assert_eq!("12".parse::().unwrap(), NodeId(12)); assert_eq!("0:12".parse::().unwrap(), NodeId(12)); assert!("x".parse::().is_err()); } #[test] fn next_name_increments() { let mut d = Document::new("x"); let p = page(&d); d.create(p, NodeKind::Frame, "Frame 1", 0.0, 0.0, 1.0, 1.0).unwrap(); d.create(p, NodeKind::Frame, "Frame 7", 0.0, 0.0, 1.0, 1.0).unwrap(); assert_eq!(d.next_name("Frame"), "Frame 8"); assert_eq!(d.next_name("Ellipse"), "Ellipse 1"); } #[test] fn next_name_never_overflows() { let mut d = Document::new("x"); let p = page(&d); d.create(p, NodeKind::Frame, "Frame 18446744073709551615", 0.0, 0.0, 1.0, 1.0).unwrap(); assert_eq!(d.next_name("Frame"), "Frame 1", "the counter can't step past u64::MAX"); d.create(p, NodeKind::Frame, "Frame 1", 0.0, 0.0, 1.0, 1.0).unwrap(); assert_eq!(d.next_name("Frame"), "Frame 2"); } #[test] fn custom_easings() { use crate::proto::Easing; // cubic-bezier(0.25, 0.1, 0.25, 1) is CSS `ease`: 0.8024 at half time. let ease = Easing::CustomBezier { x1: 0.25, y1: 0.1, x2: 0.25, y2: 1.0 }; assert!((ease.eval(0.5) - 0.8024).abs() < 1e-3, "{}", ease.eval(0.5)); let linear = Easing::CustomBezier { x1: 0.0, y1: 0.0, x2: 1.0, y2: 1.0 }; for t in [0.0, 0.1, 0.37, 0.9, 1.0] { assert!((linear.eval(t) - t).abs() < 1e-6); } // Springs start at 0, end at 1; an under-damped one overshoots. let bouncy = Easing::CustomSpring { mass: 1.0, stiffness: 300.0, damping: 6.0 }; assert!(bouncy.eval(0.0).abs() < 1e-9); assert_eq!(bouncy.eval(1.0), 1.0); assert!((0..100).map(|i| bouncy.eval(f64::from(i) / 100.0)).any(|v| v > 1.01), "overshoots"); let stiff = Easing::CustomSpring { mass: 1.0, stiffness: 100.0, damping: 40.0 }; assert!((0..100).map(|i| stiff.eval(f64::from(i) / 100.0)).all(|v| (-1e-9..=1.0 + 1e-9).contains(&v)), "over-damped never overshoots"); assert!(Easing::CUSTOM_SPRING.is_spring()); // Junk values stay finite. for e in [ Easing::CustomBezier { x1: f64::NAN, y1: f64::INFINITY, x2: -5.0, y2: 1e300 }, Easing::CustomSpring { mass: 0.0, stiffness: -1.0, damping: f64::NAN }, Easing::CustomSpring { mass: 1e300, stiffness: 1e-300, damping: 0.0 }, ] { for i in 0..=20 { assert!(e.eval(f64::from(i) / 20.0).is_finite()); } } // Serde: presets stay strings, custom curves are objects. assert_eq!(serde_json::to_value(Easing::Gentle).unwrap(), serde_json::json!("gentle")); let v = serde_json::to_value(Easing::CUSTOM_BEZIER).unwrap(); assert_eq!(v["customBezier"]["x1"], serde_json::json!(0.42)); let back: Easing = serde_json::from_value(serde_json::json!({"customSpring": {"mass": 2, "stiffness": 50, "damping": 5}})).unwrap(); assert_eq!(back, Easing::CustomSpring { mass: 2.0, stiffness: 50.0, damping: 5.0 }); assert_eq!(serde_json::from_value::(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut); } #[test] fn text_style_updates_merge_equal_runs() { let def = TextStyle { font_size: 14.0, ..Default::default() }; let mut t = TextData::new("abcd"); t.link_text_style(0, 2, &def, "S"); t.link_text_style(2, 4, &def, "S"); t.restyle(2, 4, |s| s.font_size = 24.0); assert_eq!(t.runs.len(), 2); t.update_text_style("S", &TextStyle { font_size: 24.0, ..def.clone() }); let spans = t.spans(); assert_eq!(spans.len(), 1); assert_eq!((spans[0].0, spans[0].1), (0, 4)); assert_eq!(spans[0].2.font_size, 24.0); assert_eq!(spans[0].2.style_id.as_deref(), Some("S")); // Remapping two links to one id merges the runs. let mut t = TextData::new("ab"); t.link_text_style(0, 1, &def, "A"); t.link_text_style(1, 2, &def, "B"); t.map_text_styles(|_| Some("C".into())); assert_eq!(t.spans().len(), 1); assert_eq!(t.style_at(1).style_id.as_deref(), Some("C")); // Different values stay separate. let mut t = TextData::new("ab"); t.link_text_style(0, 1, &def, "A"); t.link_text_style(1, 2, &TextStyle { font_size: 30.0, ..Default::default() }, "B"); t.map_text_styles(|_| Some("C".into())); let spans = t.spans(); assert_eq!(spans.len(), 2); assert_eq!((spans[0].2.font_size, spans[1].2.font_size), (14.0, 30.0)); // Unlinking a run equal to the base drops it. let mut t = TextData::new("ab"); let base = t.style.clone(); t.link_text_style(0, 1, &base, "S"); t.map_text_styles(|_| None); assert!(t.runs.is_empty()); } #[test] fn text_style_empty_range_links_and_unlinks_all() { let def = TextStyle { font_style: "Bold".into(), ..Default::default() }; let mut t = TextData::new("abcd"); t.link_text_style(2, 2, &def, "S"); assert!((0..4).all(|i| t.style_at(i).style_id.as_deref() == Some("S") && t.style_at(i).font_style == "Bold")); t.unlink_text_style(2, 2); assert!((0..4).all(|i| t.style_at(i).style_id.is_none() && t.style_at(i).font_style == "Bold")); // Partial ranges stay partial. t.link_text_style(0, 4, &def, "S"); t.unlink_text_style(1, 3); assert_eq!(t.style_at(0).style_id.as_deref(), Some("S")); assert!(t.style_at(1).style_id.is_none() && t.style_at(2).style_id.is_none()); assert_eq!(t.style_at(3).style_id.as_deref(), Some("S")); // Empty text changes the base style. let mut e = TextData::new(""); e.link_text_style(0, 0, &def, "S"); assert_eq!(e.style.style_id.as_deref(), Some("S")); e.unlink_text_style(0, 0); assert!(e.style.style_id.is_none()); } #[test] fn stale_next_id_never_overwrites_existing_nodes() { let mut d = Document::new("x"); let p = page(&d); let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); let a = d.create(f, NodeKind::Rectangle, "A", 0.0, 0.0, 10.0, 10.0).unwrap(); let mut v = serde_json::to_value(&d).unwrap(); v["next_id"] = serde_json::json!(1); let mut bad: Document = serde_json::from_value(v).unwrap(); let before: Vec<(NodeId, String, Option)> = [p, f, a].iter().map(|i| (*i, bad.node(*i).unwrap().name.clone(), bad.node(*i).unwrap().parent)).collect(); let n = bad.node_count(); let copy = bad.duplicate_into(f, p, None).unwrap(); assert!(![p, f, a].contains(©)); assert_eq!(bad.node_count(), n + 2); let copies = bad.duplicate_all_into(&[f], p).unwrap(); assert_eq!(bad.node_count(), n + 4); assert!(copies.iter().all(|c| ![p, f, a, copy].contains(c))); for (i, name, parent) in &before { let node = bad.node(*i).unwrap(); assert_eq!((&node.name, node.parent), (name, *parent)); } assert!(bad.validate().is_empty(), "{:?}", bad.validate()); // A paste whose id map points at an existing node fails and changes nothing. let nodes: Vec = bad.subtree(f).into_iter().filter_map(|i| bad.node(i).cloned()).collect(); let mut map: BTreeMap = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect(); map.insert(a, p); let snapshot = bad.clone(); assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err()); assert_eq!(bad, snapshot); assert_eq!(bad.node(p).unwrap().name, snapshot.node(p).unwrap().name); assert!(bad.validate().is_empty(), "{:?}", bad.validate()); // So does one that maps two nodes to the same new id. let mut map: BTreeMap = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect(); map.insert(a, map[&f]); assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err()); assert_eq!(bad, snapshot); } #[test] fn hit_test_even_odd_vector_hole_misses() { let ring = |rule| { let sq = |o: f64, s: f64, base: usize| { ( [(o, o), (o + s, o), (o + s, o + s), (o, o + s)].map(|(x, y)| Vertex::new(x, y)), [Segment::line(base, base + 1), Segment::line(base + 1, base + 2), Segment::line(base + 2, base + 3), Segment::line(base + 3, base)], ) }; let (v1, s1) = sq(0.0, 200.0, 0); let (v2, s2) = sq(60.0, 80.0, 4); VectorNetwork { vertices: v1.into_iter().chain(v2).collect(), segments: s1.into_iter().chain(s2).collect(), regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule, ..Default::default() }], } }; let mut d = Document::new("x"); let p = page(&d); let eo = d.create(p, NodeKind::Vector { network: ring(WindingRule::EvenOdd) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap(); assert_eq!(d.hit_test(p, Point::new(20.0, 100.0), 0.0, None), Some(eo)); assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), None, "the hole is not filled"); let mut d = Document::new("x"); let p = page(&d); let nz = d.create(p, NodeKind::Vector { network: ring(WindingRule::NonZero) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap(); assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), Some(nz)); } #[test] fn is_ancestor_holds_for_very_deep_chains() { let mut d = Document::new("x"); let root = d.create(page(&d), NodeKind::Frame, "root", 0.0, 0.0, 1.0, 1.0).unwrap(); let mut leaf = root; for _ in 0..10_005 { leaf = d.create(leaf, NodeKind::Frame, "n", 0.0, 0.0, 1.0, 1.0).unwrap(); } assert!(d.is_ancestor(root, leaf)); assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..)))); } #[test] fn render_bounds_of_a_doubled_child_list_end_at_once() { // A file can list a child twice at every level: 2^40 visits if each is walked. let mut d = Document::new("x"); let mut parent = page(&d); let mut groups = Vec::new(); for _ in 0..40 { parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap(); groups.push(parent); } for w in groups.windows(2) { d.node_mut(w[0]).unwrap().children.push(w[1]); } assert!(d.world_render_bounds(groups[0]).is_some()); } #[test] fn render_bounds_stop_where_drawing_stops_on_very_deep_chains() { // A crafted file nests groups far deeper than anything draws (#1062). The leaf counts while // it's within the renderer's depth and is left out past it, as the renderer leaves it out. let chain = |depth: usize| { let mut d = Document::new("x"); let mut parent = page(&d); for _ in 0..depth { parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap(); } d.create(parent, NodeKind::Rectangle, "R", 100.0, 100.0, 10.0, 10.0).unwrap(); let top = d.node(page(&d)).unwrap().children[0]; d.world_render_bounds(top).unwrap() }; assert_eq!(chain(MAX_RENDER_DEPTH).x1, 110.0); assert_eq!(chain(20_000).x1, 10.0); } #[test] fn text_insert_at_run_boundary_keeps_runs_disjoint() { let mut t = TextData::new("0123456789"); t.restyle(0, 5, |s| s.font_size = 20.0); t.restyle(5, 10, |s| s.font_size = 30.0); t.replace_range(5, 5, "xx"); let runs: Vec<(usize, usize)> = t.runs.iter().map(|r| (r.start, r.end)).collect(); assert_eq!(runs, vec![(0, 7), (7, 12)]); } #[test] fn many_small_vector_subpaths_build_and_stroke_without_rescans() { // Many closed subpaths whose last point repeats the start (#F-332), a long open chain (#F-334) // and a big rounded polygon (#F-333): each used to scan the whole network per step. let mut p = figcraft_geom::kurbo::BezPath::new(); for i in 0..3_000 { let x = f64::from(i) * 10.0; p.move_to((x, 0.0)); p.line_to((x + 5.0, 0.0)); p.line_to((x + 5.0, 5.0)); p.line_to((x, 0.0)); p.close_path(); } let net = VectorNetwork::from_path(&p); assert_eq!(net.vertices.len(), 3 * 3_000); assert_eq!(net.regions[0].loops.len(), 3_000); assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len())); assert_eq!(net.stroke_runs().len(), 3_000); let pts: Vec = (0..20_000).map(|i| Point::new(f64::from(i), f64::from(i % 7))).collect(); let chain = VectorNetwork::from_points(&pts, false); let runs = chain.stroke_runs(); assert_eq!(runs.len(), 1); assert!(!runs[0].closed); let ring: Vec = (0..4_000) .map(|i| { let t = f64::from(i) / 4_000.0 * std::f64::consts::TAU; Point::new(1000.0 * t.cos(), 1000.0 * t.sin() * if i % 2 == 0 { 1.0 } else { 0.9 }) }) .collect(); let mut poly = VectorNetwork::from_points(&ring, true); for v in &mut poly.vertices { v.radius = 0.5; } let rounded = poly.rounded().unwrap(); assert!(rounded.regions[0].loops[0].len() > poly.segments.len(), "arcs are walked between the sides"); assert_eq!(rounded.regions[0].loops[0].len(), rounded.segments.len()); } #[test] fn two_segment_closed_loop_rounded_corners() { let mut v0 = Vertex::new(0.0, 0.0); v0.radius = 2.0; let mut v1 = Vertex::new(10.0, 0.0); v1.radius = 2.0; let net = VectorNetwork { vertices: vec![v0, v1], segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }], regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero, ..Default::default() }], }; let r = net.rounded().unwrap(); assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop"); let path = net.fill_path(); assert!(!path.elements().is_empty()); // Both points sit next to the curved segment, so smoothing keeps their plain arcs. assert_eq!(net.rounded_smoothed(0.6), net.rounded(), "a corner next to a curve keeps the plain arc"); } /// A closed 100 × 100 square whose corners (top left, top right, bottom right, bottom left) have /// radii `radii`. fn rounded_square(radii: [f64; 4]) -> VectorNetwork { let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)]; let mut net = VectorNetwork::from_points(&pts, true); for (v, r) in net.vertices.iter_mut().zip(radii) { v.radius = r; } net } /// The area a closed path encloses, whichever way it runs. fn area_of(p: &figcraft_geom::BezPath) -> f64 { use figcraft_geom::Shape; p.area().abs() } #[test] fn closed_vectors_smooth_each_rounded_point_with_its_own_radius() { use figcraft_geom::Shape; use figcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; // (point radii, smoothing, derivation): a closed square draws the smoothed rectangle's corners, // each point keeping its own radius and a point without one staying sharp. let rows: Vec<([f64; 4], f64, &str)> = vec![ ([20.0; 4], 0.6, "one radius on every point"), ([30.0, 0.0, 10.0, 0.0], 0.6, "each point keeps its own radius; points without one stay sharp"), ([12.0; 4], 1.0, "full smoothing"), ([40.0; 4], 0.6, "a corner too large for its edges loses smoothing first"), ]; for (radii, s, why) in rows { let net = rounded_square(radii); let smoothed = net.rounded_smoothed(s).unwrap().fill_path(); // The rectangle's corners take [top left, top right, bottom right, bottom left] radii. let want = area_of(&figcraft_geom::rounded_rect(100.0, 100.0, radii, s)); let got = area_of(&smoothed); assert!((got - want).abs() < 0.1, "{why}: {got} vs the smoothed rectangle's {want}"); assert!((got - area_of(&net.fill_path())).abs() > 1.0, "{why}: smoothing changes the outline"); let b = smoothed.bounding_box(); assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 100.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "{why}: {b:?}"); } // The sharp points stay on the outline, and the outline has no kinks at the rounded points. let smoothed = rounded_square([30.0, 0.0, 10.0, 0.0]).rounded_smoothed(0.6).unwrap(); let path = smoothed.fill_path(); for p in [Point::new(100.0, 0.0), Point::new(0.0, 100.0)] { let d = path.segments().map(|seg| seg.nearest(p, 1e-9).distance_sq).fold(f64::INFINITY, f64::min); assert!(d < 1e-12, "the sharp point {p:?} stays a corner"); } let segs: Vec<_> = path.segments().filter(|seg| seg.arclen(1e-6) > 1e-6).collect(); let mut kinks = 0; for (i, seg) in segs.iter().enumerate() { let next = segs[(i + 1) % segs.len()]; let out = (seg.eval(1.0) - seg.eval(1.0 - 1e-6)).normalize(); let into = (next.eval(1e-6) - next.eval(0.0)).normalize(); if out.dot(into) < 0.999 { kinks += 1; } } assert_eq!(kinks, 2, "only the two sharp points turn without a curve"); assert_eq!(smoothed.regions[0].loops[0].len(), smoothed.segments.len(), "the loop walks every curve once"); } #[test] fn corner_smoothing_leaves_open_paths_and_zero_alone() { let square = rounded_square([20.0; 4]); assert_eq!(square.rounded_smoothed(0.0), square.rounded(), "no smoothing draws the plain rounded points"); assert_eq!(square.rounded_smoothed(f64::NAN), square.rounded(), "an unusable smoothing draws no smoothing"); // An open path (no closed region) keeps its plain arcs. let pts = [Point::new(200.0, 0.0), Point::new(250.0, 0.0), Point::new(250.0, 50.0)]; let mut open = VectorNetwork::from_points(&pts, false); for v in &mut open.vertices { v.radius = 20.0; } assert!(open.regions.is_empty()); assert_eq!(open.rounded_smoothed(0.6), open.rounded(), "an open path takes no smoothing"); // In a network with a closed square and a separate open branch, only the square smooths. let mut mixed = square.clone(); let base = mixed.vertices.len(); mixed.vertices.extend(open.vertices.iter().copied()); mixed.segments.extend(open.segments.iter().map(|s| Segment { a: s.a + base, b: s.b + base, ..*s })); let runs = mixed.rounded_smoothed(0.6).unwrap().stroke_runs(); let open_run = runs.iter().find(|r| !r.closed).expect("the open branch"); assert_eq!(open_run.path, open.rounded().unwrap().stroke_runs()[0].path, "the open branch keeps its plain arc"); let closed_run = runs.iter().find(|r| r.closed).expect("the square"); assert_eq!(closed_run.path, square.rounded_smoothed(0.6).unwrap().stroke_runs()[0].path, "the square smooths"); assert_ne!(closed_run.path, square.rounded().unwrap().stroke_runs()[0].path, "the square's smoothed corners differ from its arcs"); } #[test] fn smoothed_vector_corners_stay_finite_on_awkward_networks() { use figcraft_geom::Shape; // A concave L, a square with a square hole, points with unusable radii and a loop that names a // missing segment. let l = [(0.0, 0.0), (100.0, 0.0), (100.0, 40.0), (40.0, 40.0), (40.0, 100.0), (0.0, 100.0)]; let mut concave = VectorNetwork::from_points(&l.map(|(x, y)| Point::new(x, y)), true); for v in &mut concave.vertices { v.radius = 1000.0; } let mut holed = rounded_square([10.0; 4]); let hole = rounded_square([5.0; 4]); let (base, seg_base) = (holed.vertices.len(), holed.segments.len()); holed.vertices.extend(hole.vertices.iter().map(|v| Vertex { x: 25.0 + v.x / 2.0, y: 25.0 + v.y / 2.0, ..*v })); holed.segments.extend(hole.segments.iter().map(|s| Segment { a: s.a + base, b: s.b + base, ..*s })); holed.regions[0].loops.push((seg_base..seg_base + hole.segments.len()).collect()); holed.regions[0].winding = WindingRule::EvenOdd; let mut odd = rounded_square([f64::INFINITY, 10.0, f64::NAN, 10.0]); odd.regions[0].loops[0].push(99); for (net, s, why) in [(&concave, 1.0, "concave"), (&holed, 0.6, "holed"), (&odd, 0.6, "odd radii")] { let drawn = net.rounded_smoothed(s).unwrap(); let b = drawn.fill_path().bounding_box(); assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()), "{why}: {b:?}"); assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 100.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "{why}: {b:?}"); assert!(!drawn.stroke_path().elements().is_empty(), "{why}"); } let concave_path = concave.rounded_smoothed(1.0).unwrap().fill_path(); assert_ne!(concave_path, concave.fill_path(), "the concave L smooths"); assert!(concave_path.winding(Point::new(20.0, 60.0)) != 0, "the L stays filled"); let holed_net = holed.rounded_smoothed(0.6).unwrap(); assert!(!holed_net.fill_contains(Point::new(50.0, 50.0)) && holed_net.fill_contains(Point::new(12.0, 50.0)), "the hole stays a hole"); } #[test] fn clicks_on_a_smoothed_vector_corner_follow_the_drawn_fill() { // A closed square with rounded points and smoothing: a click between the plain arc and the // smoothed corner selects the layer only where the smoothed fill is drawn. let pts = [Point::new(0.0, 0.0), Point::new(1000.0, 0.0), Point::new(1000.0, 1000.0), Point::new(0.0, 1000.0)]; let mut net = VectorNetwork::from_points(&pts, true); for v in &mut net.vertices { v.radius = 200.0; } let smoothed = net.rounded_smoothed(1.0).unwrap(); let mut d = Document::new("x"); let p = page(&d); let v = d.create(p, NodeKind::Vector { network: net.clone() }, "V", 0.0, 0.0, 1000.0, 1000.0).unwrap(); let n = d.node_mut(v).unwrap(); n.corners.smoothing = 1.0; n.strokes.clear(); // Walk the row 20 units below the top edge inward to where each outline starts covering it. let first_inside = |inside: &dyn Fn(Point) -> bool| (0..5_000).map(|i| f64::from(i) * 0.1).find(|&x| inside(Point::new(x, 20.0))); let plain = first_inside(&|pt| net.fill_contains(pt)).unwrap(); let drawn = first_inside(&|pt| smoothed.fill_contains(pt)).unwrap(); assert!((plain - drawn).abs() > 5.0, "smoothing moves the corner: plain {plain}, smoothed {drawn}"); let pt = Point::new((plain + drawn) / 2.0, 20.0); assert_ne!(net.fill_contains(pt), smoothed.fill_contains(pt)); assert_eq!(d.hit_test(p, pt, 0.0, None).is_some(), smoothed.fill_contains(pt), "the click follows the smoothed corner at {pt:?}"); assert_eq!(d.hit_test(p, Point::new(500.0, 500.0), 0.0, None), Some(v)); } /// A rectangle whose top-right corner was cut off in the vector editor (a diagonal between a point /// on the top edge and one on the right edge), then rounded: the fill, which the fill and the /// selection outline draw, is cut by an arc at every point just as the stroke is, however the loop /// lists its segments and whichever way each one runs (#1951, #1506). #[test] fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() { use figcraft_geom::BezPath; use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest}; let corners = [Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 50.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)]; let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min); let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::(); for first in 0..corners.len() { for backwards in [false, true] { for flipped in 0..(1u32 << corners.len()) { let mut net = VectorNetwork::from_points(&corners, true); for v in &mut net.vertices { v.radius = 43.0; } for (i, s) in net.segments.iter_mut().enumerate() { if flipped & (1 << i) != 0 { std::mem::swap(&mut s.a, &mut s.b); std::mem::swap(&mut s.ta, &mut s.tb); } } let lp = &mut net.regions[0].loops[0]; lp.rotate_left(first); if backwards { lp.reverse(); } let why = format!("loop from segment {first}{}, segments flipped {flipped:05b}", if backwards { " backwards" } else { "" }); let stroke = net.stroke_path(); let fill = net.fill_path(); let region = net.rounded().unwrap().region_path(0).unwrap(); for (i, p) in corners.iter().enumerate() { assert!(keeps_off(&stroke, *p) > 2.0, "{why}: the stroke is cut at point {i} ({:.2} from it)", keeps_off(&stroke, *p)); assert!(keeps_off(&fill, *p) > 2.0, "{why}: the fill runs into point {i} ({:.2} from it)", keeps_off(&fill, *p)); assert!(keeps_off(®ion, *p) > 2.0, "{why}: the region runs into point {i} ({:.2} from it)", keeps_off(®ion, *p)); } assert!( (length(&fill) - length(&stroke)).abs() < 1e-6, "{why}: fill {} and stroke {} trace the same outline", length(&fill), length(&stroke) ); assert!( (length(®ion) - length(&stroke)).abs() < 1e-6, "{why}: region {} and stroke {} trace the same outline", length(®ion), length(&stroke) ); } } } } #[test] fn stored_variable_ids_resolve_only_their_exact_collection() { let mut vars = crate::proto_expr_tests::vars(); // The wrong collection's modes force the old resolver's first-value fallback (dark). let mut shadow = vars.collections[0].clone(); shadow.id = "shadow".into(); shadow.name = "c".into(); shadow.modes = vec![Mode { id: "other".into(), name: "Other".into() }]; vars.collections.insert(0, shadow); assert_eq!(vars.resolve("n", &Default::default()), Some(VariableValue::Number(3.0))); vars.collections.retain(|c| c.id != "c"); assert_eq!(vars.resolve("n", &Default::default()), None); } // ——— colour calculations ——— /// A colour variable of collection `c1` with `values` by mode id. fn colour_var(id: &str, name: &str, values: &[(&str, VariableValue)]) -> Variable { Variable { id: id.into(), name: name.into(), collection: "c1".into(), kind: VariableType::Color, values: values.iter().map(|(m, v)| (m.to_string(), v.clone())).collect(), description: String::new(), scopes: None, hidden_from_publishing: false, extends: None, overrides: Default::default(), code_syntax: Default::default(), } } /// `c1` with Light/Dark (Light is the default mode) and colour variables `("id", light, dark)`. fn vars_with_colours(cols: &[(&str, VariableValue, VariableValue)]) -> Variables { let mut v = Variables::default(); v.collections.push(Collection { id: "c1".into(), name: "C".into(), modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }], extends: None, }); for (id, light, dark) in cols { v.variables.push(colour_var(id, id, &[("light", light.clone()), ("dark", dark.clone())])); } v } /// Adds `id` with a value in the Light mode only. fn add(v: &mut Variables, id: &str, light: VariableValue) { v.variables.push(colour_var(id, id, &[("light", light)])); } fn fade(source: &str, opacity: f32) -> VariableValue { VariableValue::ColorAlpha { variable: source.into(), opacity } } fn mix(from: ColorOperand, to: ColorOperand, t: f32) -> VariableValue { VariableValue::ColorMix { from, to, t } } fn lit(hex: &str) -> ColorOperand { ColorOperand::Literal { literal: Color::from_hex_or_name(hex).unwrap() } } fn src(id: &str) -> ColorOperand { ColorOperand::Variable { variable: id.into() } } fn red() -> VariableValue { VariableValue::Color(Color::from_rgb8_const(255, 0, 0)) } fn resolved(v: &Variables, id: &str, mode: Option<&str>) -> Color { let modes: std::collections::BTreeMap = mode.map(|m| std::collections::BTreeMap::from([("c1".to_string(), m.to_string())])).unwrap_or_default(); match v.resolve(id, &modes) { Some(VariableValue::Color(c)) => c, other => panic!("{id} did not resolve to a colour: {other:?}"), } } #[test] fn an_alpha_calculation_replaces_alpha_and_follows_its_source() { let mut v = vars_with_colours(&[("base", red(), VariableValue::Color(Color::from_rgb8_const(0, 0, 255)))]); add(&mut v, "fade", fade("base", 0.5)); assert_eq!(resolved(&v, "fade", None).to_rgba8(), [255, 0, 0, 128], "default mode: red at half alpha (replace)"); assert_eq!(resolved(&v, "fade", Some("dark")).to_rgba8(), [0, 0, 255, 128], "the source's value in the chosen mode is what fades"); } #[test] fn an_alpha_calculation_replaces_even_a_faded_sources_alpha() { // Replace (not scale): a fade of a source that already has alpha lands on exactly the requested // opacity, so re-editing the source's alpha cannot compound with the fade's. let quarter = VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.25)); let mut v = vars_with_colours(&[("base", quarter.clone(), quarter)]); add(&mut v, "fade", fade("base", 0.5)); assert_eq!(resolved(&v, "fade", None).a, 0.5, "not 0.25 * 0.5"); } #[test] fn an_alpha_calculation_reads_through_an_alias() { let mut v = vars_with_colours(&[("base", red(), red())]); add(&mut v, "mirror", VariableValue::Alias("base".into())); add(&mut v, "faded", fade("mirror", 0.5)); assert_eq!(resolved(&v, "faded", None).to_rgba8(), [255, 0, 0, 128]); } #[test] fn a_mix_blends_stored_components_with_exact_edges() { let mut v = vars_with_colours(&[("base", red(), VariableValue::Color(Color::from_rgb8_const(0, 0, 255)))]); add(&mut v, "t0", mix(src("base"), lit("#FFFFFF"), 0.0)); add(&mut v, "t1", mix(src("base"), lit("#FFFFFF"), 1.0)); add(&mut v, "grey", mix(lit("#000000"), lit("#FFFFFF"), 0.4)); add(&mut v, "afade", mix(lit("#FF0000"), lit("#FF000000"), 0.25)); assert_eq!(resolved(&v, "t0", None).to_rgba8(), [255, 0, 0, 255], "t=0 is exactly the first operand"); assert_eq!(resolved(&v, "t1", None).to_rgba8(), [255, 255, 255, 255], "t=1 is exactly the second"); assert_eq!(resolved(&v, "grey", None).to_rgba8(), [102, 102, 102, 255], "0.4 of white rounds to 102 (0x66)"); assert_eq!(resolved(&v, "afade", None).to_rgba8(), [255, 0, 0, 191], "alpha blends like the channels (0.75 -> 0xBF)"); assert_eq!(resolved(&v, "t0", Some("dark")).to_rgba8(), [0, 0, 255, 255], "the variable operand follows the mode"); // Exact in the stored components too, not only after 8-bit rounding. let faint = ColorOperand::Literal { literal: Color::new(1e-8, 0.0, 0.0, 1.0) }; add(&mut v, "to_faint", mix(lit("#FF0000"), faint.clone(), 1.0)); add(&mut v, "from_faint", mix(faint, lit("#FF0000"), 0.0)); assert_eq!(resolved(&v, "to_faint", None).r, 1e-8, "t=1 is the second operand to the last bit"); assert_eq!(resolved(&v, "from_faint", None).r, 1e-8, "t=0 is the first operand to the last bit"); } #[test] fn calculations_compose_and_a_crafted_cycle_terminates() { let mut v = vars_with_colours(&[("base", red(), red())]); add(&mut v, "fade", fade("base", 0.5)); add(&mut v, "toast", mix(src("fade"), lit("#FFFFFF"), 0.0)); assert_eq!(resolved(&v, "toast", None).to_rgba8(), [255, 0, 0, 128], "a calculation may read a calculation"); // A hand-crafted cycle (commands refuse these) must unwind, never spin. add(&mut v, "loop", mix(src("loop2"), lit("#FFFFFF"), 0.5)); add(&mut v, "loop2", fade("loop", 0.5)); assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None); } #[test] fn calculation_resolution_is_memoised_and_bounded() { // A doubling tree: each level reads the one below twice. Memoised per resolve, that is // one pass over the levels; unmemoised it is 2^23 walks (the time bound catches a regression). let mut v = vars_with_colours(&[("leaf", red(), red())]); let mut prev = "leaf".to_string(); for i in 0..23 { let id = format!("f{i}"); add(&mut v, &id, mix(src(&prev), src(&prev), 0.5)); prev = id; } let started = std::time::Instant::now(); assert_eq!(resolved(&v, &prev, None).to_rgba8(), [255, 0, 0, 255], "the tree reads the leaf"); assert!(started.elapsed().as_millis() < 200, "memoised: {:?}", started.elapsed()); // A diamond: two fades of one base under one mix (shared subtrees each resolved once). let quarter = VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.25)); let mut d = vars_with_colours(&[("base", quarter.clone(), quarter)]); add(&mut d, "fa", fade("base", 0.25)); add(&mut d, "fb", fade("base", 0.75)); add(&mut d, "dia", mix(src("fa"), src("fb"), 0.5)); assert_eq!(resolved(&d, "dia", None).a, 0.5, "0.25 + (0.75 - 0.25) * 0.5"); // One resolve reads at most 32 variables, however they are chained. let mut long = vars_with_colours(&[("v0", red(), red())]); for i in 1..=40 { add(&mut long, &format!("v{i}"), fade(&format!("v{}", i - 1), 0.99)); } assert_eq!(resolved(&long, "v31", None).to_rgba8(), [255, 0, 0, 252], "32 variables still resolve"); assert_eq!(long.resolve("v32", &std::collections::BTreeMap::new()), None, "a 33rd reads nothing"); } #[test] fn the_colour_walker_visits_calculation_literals() { // Document::map_colors (profile conversion) must reach the colours stored inside a mix: // leaving them behind would blend converted operands with unconverted ones. let mut v = vars_with_colours(&[("base", red(), red())]); add(&mut v, "mx", mix(lit("#804020"), src("base"), 0.5)); let mut doc = Document::new("x"); doc.variables = v; doc.map_colors(|c| c.with_alpha(0.5)); let mx = doc.variables.variables.iter().find(|x| x.id == "mx").unwrap(); let VariableValue::ColorMix { from, .. } = &mx.values["light"] else { panic!("still the mix") }; let ColorOperand::Literal { literal } = from else { panic!("literal operand") }; assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour"); } #[test] fn frame_relative_ends_on_a_looping_parent_chain() { let mut d = Document::new("x"); let p = page(&d); let f = d.create(p, NodeKind::Group, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap(); let mut v = serde_json::to_value(&d).unwrap(); // F's parent is its own child G (as a restored snapshot can leave things). v["nodes"][f.to_string()]["parent"] = serde_json::json!(g); let bad: Document = serde_json::from_value(v).unwrap(); let _ = bad.frame_relative(g); } #[test] fn remove_children_removes_every_subtree_and_keeps_the_parent() { let mut doc = Document::new("T"); let page = doc.first_page().unwrap(); let frame = doc.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); let inner = doc.create(frame, NodeKind::Frame, "Inner", 0.0, 0.0, 10.0, 10.0).unwrap(); let leaf = doc.create(inner, NodeKind::Rectangle, "Leaf", 0.0, 0.0, 5.0, 5.0).unwrap(); let other = doc.create(page, NodeKind::Rectangle, "Other", 0.0, 0.0, 5.0, 5.0).unwrap(); doc.remove_children(frame).unwrap(); assert!(doc.contains(frame) && doc.contains(other)); assert!(!doc.contains(inner) && !doc.contains(leaf)); assert!(doc.get(frame).unwrap().children.is_empty()); }