Files
figcraft/crates/doc/src/tests.rs
T
Skyfuller1303 a4076caaf4 Merge main and preserve vector region paints through path operations
Retain regional paint stacks and coordinate frames through simplify, offset, flatten, bucket normalization and Shape Builder. Preserve legacy fill semantics and synchronize importer warning catalogs. Verified with cargo xtask ci on Windows and Ubuntu/WSL, native UI screenshots, and CLI round-trip/export checks.
2026-10-26 19:50:00 +01:00

675 lines
29 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 = Variables::default();
v.collections.push(Collection {
id: "c1".into(),
name: "Theme".into(),
modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }],
extends: None,
});
let mut vals = std::collections::BTreeMap::new();
vals.insert("light".to_string(), VariableValue::Color(Color::WHITE));
vals.insert("dark".to_string(), VariableValue::Color(Color::BLACK));
v.variables.push(Variable {
id: "bg".into(),
name: "bg".into(),
collection: "c1".into(),
kind: VariableType::Color,
values: vals,
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
});
let mut alias = std::collections::BTreeMap::new();
alias.insert("light".to_string(), VariableValue::Alias("bg".into()));
v.variables.push(Variable {
id: "surface".into(),
name: "surface".into(),
collection: "c1".into(),
kind: VariableType::Color,
values: alias,
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
});
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.
let mut cyc = std::collections::BTreeMap::new();
cyc.insert("light".to_string(), VariableValue::Alias("loop".into()));
v.variables.push(Variable {
id: "loop".into(),
name: "loop".into(),
collection: "c1".into(),
kind: VariableType::Color,
values: cyc,
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
});
assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None);
}
#[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 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(&copy));
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());
}