Files
figcraft/crates/doc/src/tests.rs
T
BFlat ee82cdd138 M3: extended variable collections, variables import/export, gradient stop and variant bindings (#114)
Library collections can be extended in a consuming file (inherited modes, per-mode overrides with reset, extra variables; library updates keep overrides). Variables export to the REST JSON shape or CSS custom properties and import back (merged by name, validated by the REST reader, one undo step). Gradient stops and variant properties bind to variables. Also fixes clicking a gradient stop adding a new stop.
2026-10-11 10:49:16 +07:00

346 lines
15 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,
});
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), Some(b));
assert_eq!(d.hit_test(p, Point::new(15.0, 15.0), 0.0), Some(a));
assert_eq!(d.hit_test(p, Point::new(95.0, 95.0), 0.0), Some(outside));
// Clipped part of `outside` isn't hit.
assert_eq!(d.hit_test(p, Point::new(120.0, 120.0), 0.0), None);
assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0), Some(f));
}
#[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).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, "");
}
#[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 }],
};
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(),
});
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(),
});
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(),
});
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 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 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);
}