mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 13:52:34 +00:00
1094 lines
52 KiB
Rust
1094 lines
52 KiB
Rust
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);
|
||
}
|
||
|
||
#[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<Mode>| 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::<GridTrack>("{}").unwrap(), GridTrack::fill(1.0));
|
||
}
|
||
|
||
#[test]
|
||
fn node_id_parse() {
|
||
assert_eq!("12".parse::<NodeId>().unwrap(), NodeId(12));
|
||
assert_eq!("0:12".parse::<NodeId>().unwrap(), NodeId(12));
|
||
assert!("x".parse::<NodeId>().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::<Easing>(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<NodeId>)> =
|
||
[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<Node> = bad.subtree(f).into_iter().filter_map(|i| bad.node(i).cloned()).collect();
|
||
let mut map: BTreeMap<NodeId, NodeId> = 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<NodeId, NodeId> = 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_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<Point> = (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<Point> = (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::<f64>();
|
||
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<String, String> =
|
||
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());
|
||
}
|