Files
figcraft/crates/engine/src/convert_container_tests.rs
T

369 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Frames, groups and sections converted into each other in place: the layers stay where they are on
//! the page, what only a frame has goes with it, what can't convert is refused, one undo step.
use serde_json::{Value, json};
use crate::params::id_of;
use crate::{EngineError, Session};
use figcraft_doc::{NodeId, NodeKind, Sizing};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn rect(s: &mut Session, parent: NodeId, x: f64, y: f64) -> NodeId {
id(ex(s, "shape.rectangle", json!({"parent": parent, "x": x, "y": y, "width": 40, "height": 20})))
}
/// A layer's box on the page: `[x0, y0, x1, y1]`, rounded.
fn page_box(s: &Session, n: NodeId) -> [f64; 4] {
let b = s.doc().unwrap().world_bounds(n).unwrap();
[b.x0, b.y0, b.x1, b.y1].map(|v| (v * 1000.0).round() / 1000.0)
}
fn kind(s: &Session, n: NodeId) -> NodeKind {
s.doc().unwrap().node(n).unwrap().kind.clone()
}
#[test]
fn adding_auto_layout_converts_a_single_group_and_arranges_its_children_directly() {
let mut s = Session::new();
let page = s.page().unwrap();
let (a, b) = (rect(&mut s, page, 10.0, 20.0), rect(&mut s, page, 90.0, 20.0));
let group = id(ex(&mut s, "object.group", json!({"ids": [a, b], "name": "Actions"})));
let siblings = s.doc().unwrap().node(page).unwrap().children.clone();
let before = [page_box(&s, group), page_box(&s, a), page_box(&s, b)];
let result = ex(&mut s, "layout.add", json!({"ids": [group], "direction": "horizontal"}));
assert_eq!(result["ids"], json!([group]), "the group's identity is retained");
let doc = s.doc().unwrap();
let frame = doc.node(group).unwrap();
assert!(matches!(frame.kind, NodeKind::Frame));
assert_eq!(frame.name, "Actions");
assert_eq!(frame.parent, Some(page));
assert_eq!(frame.children, vec![a, b], "no intermediate group or wrapper frame");
assert!(frame.layout.is_some() && frame.fills.is_empty() && !frame.clip);
assert_eq!(doc.node(page).unwrap().children, siblings);
assert_eq!([page_box(&s, group), page_box(&s, a), page_box(&s, b)], before);
// The second child now follows changes to the first through auto layout.
ex(&mut s, "node.set", json!({"id": a, "props": {"width": 60}}));
assert_eq!(page_box(&s, b), [110.0, 20.0, 150.0, 40.0]);
s.undo().unwrap();
s.undo().unwrap();
let restored = s.doc().unwrap().node(group).unwrap();
assert!(matches!(restored.kind, NodeKind::Group) && restored.layout.is_none());
assert_eq!(restored.children, vec![a, b]);
assert_eq!([page_box(&s, group), page_box(&s, a), page_box(&s, b)], before);
s.redo().unwrap();
assert!(matches!(kind(&s, group), NodeKind::Frame));
assert!(s.doc().unwrap().node(group).unwrap().layout.is_some());
}
#[test]
fn adding_auto_layout_to_multiple_groups_still_wraps_the_selection() {
let mut s = Session::new();
let page = s.page().unwrap();
let (a, b) = (rect(&mut s, page, 10.0, 20.0), rect(&mut s, page, 90.0, 20.0));
let g1 = id(ex(&mut s, "object.group", json!({"ids": [a]})));
let g2 = id(ex(&mut s, "object.group", json!({"ids": [b]})));
let result = ex(&mut s, "layout.add", json!({"ids": [g1, g2]}));
let frame = crate::params::id_of(&result["ids"][0]).unwrap();
let doc = s.doc().unwrap();
assert!(matches!(doc.node(frame).unwrap().kind, NodeKind::Frame));
assert_eq!(doc.node(frame).unwrap().children, vec![g1, g2]);
assert!(matches!(kind(&s, g1), NodeKind::Group) && matches!(kind(&s, g2), NodeKind::Group));
}
#[test]
fn adding_auto_layout_preserves_mask_runs_and_their_rendering() {
let mut s = Session::new();
let page = s.page().unwrap();
let (right, left) = (rect(&mut s, page, 300.0, 0.0), rect(&mut s, page, 0.0, 0.0));
let (mask, content) = (rect(&mut s, page, 100.0, 0.0), rect(&mut s, page, 80.0, 0.0));
let (mask2, content2) = (rect(&mut s, page, 200.0, 0.0), rect(&mut s, page, 180.0, 0.0));
let group = id(ex(&mut s, "object.group", json!({"ids": [right, left, mask, content, mask2, content2]})));
ex(&mut s, "node.set", json!({"ids": [mask, mask2], "props": {"isMask": true}}));
// Absolute layers keep the overlaps, isolating paint order from normal flow placement.
ex(&mut s, "node.set", json!({"ids": [mask, content, mask2, content2], "props": {"absolute": true}}));
let before = figcraft_render::render_node(s.doc().unwrap(), group, 1.0).unwrap();
ex(&mut s, "layout.add", json!({"ids": [group]}));
let doc = s.doc().unwrap();
let frame = doc.node(group).unwrap();
assert_eq!(frame.children, vec![left, right, mask, content, mask2, content2], "sort the unmasked prefix, retain mask runs");
let runs: Vec<Vec<NodeId>> = doc.paint_runs(frame).iter().map(|r| frame.children[r.clone()].to_vec()).collect();
assert_eq!(runs, vec![vec![left], vec![right], vec![mask, content], vec![mask2, content2]]);
let after = figcraft_render::render_node(doc, group, 1.0).unwrap();
assert_eq!((after.width(), after.height(), after.data()), (before.width(), before.height(), before.data()));
s.undo().unwrap();
assert_eq!(s.doc().unwrap().node(group).unwrap().children, vec![right, left, mask, content, mask2, content2]);
}
#[test]
fn a_hidden_mask_keeps_its_boundary_but_unmasked_children_still_sort() {
let mut s = Session::new();
let page = s.page().unwrap();
let mask = rect(&mut s, page, 100.0, 0.0);
let content = rect(&mut s, page, 80.0, 0.0);
let hidden_mask = rect(&mut s, page, 200.0, 0.0);
let (right, left) = (rect(&mut s, page, 300.0, 0.0), rect(&mut s, page, 250.0, 0.0));
let group = id(ex(&mut s, "object.group", json!({"ids": [mask, content, hidden_mask, right, left]})));
ex(&mut s, "node.set", json!({"ids": [mask, hidden_mask], "props": {"isMask": true}}));
ex(&mut s, "node.set", json!({"id": hidden_mask, "props": {"visible": false}}));
ex(&mut s, "layout.add", json!({"ids": [group], "direction": "horizontal"}));
let doc = s.doc().unwrap();
let frame = doc.node(group).unwrap();
assert_eq!(frame.children, vec![mask, content, hidden_mask, left, right]);
let runs: Vec<Vec<NodeId>> = doc.paint_runs(frame).iter().map(|r| frame.children[r.clone()].to_vec()).collect();
assert_eq!(runs, vec![vec![mask, content], vec![hidden_mask], vec![left], vec![right]]);
}
#[test]
fn adding_auto_layout_infers_gaps_from_flow_children_only() {
for props in [json!({"visible": false}), json!({"absolute": true})] {
let mut s = Session::new();
let page = s.page().unwrap();
let a = rect(&mut s, page, 0.0, 0.0);
let excluded = rect(&mut s, page, 80.0, 0.0);
let b = rect(&mut s, page, 160.0, 0.0);
let group = id(ex(&mut s, "object.group", json!({"ids": [a, excluded, b]})));
ex(&mut s, "node.set", json!({"id": excluded, "props": props}));
let before = [page_box(&s, a), page_box(&s, b)];
ex(&mut s, "layout.add", json!({"ids": [group]}));
let layout = s.doc().unwrap().node(group).unwrap().layout.as_ref().unwrap();
assert_eq!(layout.gap, 120.0);
assert_eq!([page_box(&s, a), page_box(&s, b)], before);
}
}
#[test]
fn hidden_children_do_not_influence_inferred_direction_or_padding() {
let mut s = Session::new();
let frame = id(ex(&mut s, "frame.create", json!({"width": 300, "height": 200})));
let (a, b) = (rect(&mut s, frame, 10.0, 20.0), rect(&mut s, frame, 90.0, 20.0));
let hidden = rect(&mut s, frame, 0.0, 1000.0);
ex(&mut s, "node.set", json!({"id": hidden, "props": {"visible": false}}));
let before = [page_box(&s, a), page_box(&s, b)];
ex(&mut s, "layout.add", json!({"ids": [frame]}));
let layout = s.doc().unwrap().node(frame).unwrap().layout.as_ref().unwrap();
assert_eq!(layout.mode, figcraft_doc::LayoutMode::Horizontal);
assert_eq!(layout.padding, [20.0, 170.0, 160.0, 10.0]);
assert_eq!(layout.gap, 40.0);
assert_eq!([page_box(&s, a), page_box(&s, b)], before);
}
#[test]
fn converting_a_group_preserves_fill_on_each_axis_in_its_parent() {
for (h, v) in [("fill", "fixed"), ("fixed", "fill"), ("fill", "fill")] {
let mut s = Session::new();
let parent = id(ex(&mut s, "frame.create", json!({"width": 500, "height": 300})));
let (a, b) = (rect(&mut s, parent, 0.0, 0.0), rect(&mut s, parent, 80.0, 0.0));
let group = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
let sibling = rect(&mut s, parent, 200.0, 0.0);
ex(&mut s, "node.set", json!({"id": parent, "props": {"layout": "horizontal", "padding": 0, "gap": 10}}));
ex(&mut s, "node.set", json!({"id": group, "props": {"sizingH": h, "sizingV": v}}));
let before = [page_box(&s, group), page_box(&s, sibling)];
ex(&mut s, "layout.add", json!({"ids": [group]}));
let cl = &s.doc().unwrap().node(group).unwrap().child_layout;
assert_eq!(cl.sizing_h, if h == "fill" { Sizing::Fill } else { Sizing::Hug });
assert_eq!(cl.sizing_v, if v == "fill" { Sizing::Fill } else { Sizing::Hug });
assert_eq!([page_box(&s, group), page_box(&s, sibling)], before);
s.undo().unwrap();
assert!(matches!(kind(&s, group), NodeKind::Group));
assert_eq!([page_box(&s, group), page_box(&s, sibling)], before);
}
}
#[test]
fn a_frame_becomes_a_group_around_its_layers() {
let mut s = Session::new();
let f = id(ex(
&mut s,
"frame.create",
json!({"x": 100, "y": 50, "width": 300, "height": 200, "name": "Card", "props": {"fill": "#FF0000", "cornerRadius": 8}}),
));
let (a, b) = (rect(&mut s, f, 20.0, 30.0), rect(&mut s, f, 120.0, 90.0));
ex(&mut s, "node.set", json!({"id": f, "props": {"opacity": 0.5}}));
let before = [page_box(&s, a), page_box(&s, b)];
let r = ex(&mut s, "object.convertToGroup", json!({"ids": [f]}));
assert_eq!(r["ids"], json!([f]));
let n = s.doc().unwrap().node(f).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Group));
assert_eq!((n.name.as_str(), n.opacity, n.children.clone()), ("Card", 0.5, vec![a, b]), "the same layer: name, opacity, layers");
assert!(n.fills.is_empty() && n.strokes.is_empty() && !n.clip && n.corners.uniform() == Some(0.0), "what only a frame has is gone");
assert_eq!([page_box(&s, a), page_box(&s, b)], before, "the layers stay where they are");
// A group's box is its layers': (120, 80) to (260, 160).
assert_eq!(page_box(&s, f), [120.0, 80.0, 260.0, 160.0]);
ex(&mut s, "edit.undo", json!({}));
let n = s.doc().unwrap().node(f).unwrap();
assert!(matches!(n.kind, NodeKind::Frame) && n.fills.len() == 1 && n.clip, "one undo step brings the frame back");
assert_eq!(page_box(&s, f), [100.0, 50.0, 400.0, 250.0]);
}
#[test]
fn auto_layout_is_dropped_and_positions_kept() {
let mut s = Session::new();
let props = json!({"layout": "horizontal", "padding": 10, "gap": 10});
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "props": props})));
let (a, b) = (rect(&mut s, f, 0.0, 0.0), rect(&mut s, f, 0.0, 0.0));
ex(&mut s, "node.set", json!({"id": b, "props": {"sizingH": "fill", "sizingV": "fill"}}));
// Laid out: a at (10, 10) 40 × 20, b fills (60, 10) to (190, 90).
let before = [page_box(&s, a), page_box(&s, b)];
assert_eq!(before, [[10.0, 10.0, 50.0, 30.0], [60.0, 10.0, 190.0, 90.0]]);
ex(&mut s, "object.convertToGroup", json!({"ids": [f]}));
let doc = s.doc().unwrap();
assert!(doc.node(f).unwrap().layout.is_none());
assert_eq!([page_box(&s, a), page_box(&s, b)], before, "the layout's positions and sizes are kept");
let cl = &doc.node(b).unwrap().child_layout;
assert_eq!((cl.sizing_h, cl.sizing_v), (Sizing::Fixed, Sizing::Fixed), "nothing to fill in a group");
// And back: a plain frame of the group's box, the layers still in place.
ex(&mut s, "object.convertToFrame", json!({"ids": [f]}));
let n = s.doc().unwrap().node(f).unwrap();
assert!(matches!(n.kind, NodeKind::Frame) && n.layout.is_none() && !n.clip && n.fills.is_empty());
assert_eq!((page_box(&s, f), [page_box(&s, a), page_box(&s, b)]), ([10.0, 10.0, 190.0, 90.0], before));
}
#[test]
fn a_rotated_group_becomes_a_frame_in_place() {
let mut s = Session::new();
let page = s.page().unwrap();
let (a, b) = (rect(&mut s, page, 10.0, 10.0), rect(&mut s, page, 80.0, 60.0));
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
ex(&mut s, "node.set", json!({"id": g, "props": {"rotation": 30}}));
let before = [page_box(&s, a), page_box(&s, b), page_box(&s, g)];
ex(&mut s, "object.convertToFrame", json!({"ids": [g]}));
assert!(matches!(kind(&s, g), NodeKind::Frame));
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, g)], before);
// The layers now sit in the frame's space: a at its origin.
let c = s.doc().unwrap().node(a).unwrap().transform.as_coeffs();
assert!(c[4].abs() < 1e-6 && c[5].abs() < 1e-6, "{c:?}");
}
#[test]
fn what_cannot_convert_is_refused_and_nothing_changes() {
let mut s = Session::new();
let page = s.page().unwrap();
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
let inner = id(ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 50, "height": 50, "parent": f})));
rect(&mut s, inner, 0.0, 0.0);
let comp = id(ex(&mut s, "component.create", json!({"ids": [f]})));
let inst = id(ex(&mut s, "instance.create", json!({"component": comp, "x": 300, "y": 0})));
let inst_inner = s.doc().unwrap().node(inst).unwrap().children[0];
let empty = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 50, "height": 50})));
let loose = rect(&mut s, page, 500.0, 0.0);
let ok_frame = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 50, "height": 50})));
rect(&mut s, ok_frame, 5.0, 5.0);
// (layer, what the message says)
for (layer, says) in [
(comp, "main component"),
(inst, "is an instance"),
(inst_inner, "inside an instance"),
(empty, "is empty"),
(loose, "neither a frame nor a group"),
] {
for cmd in ["object.convertToGroup", "object.convertToFrame"] {
if layer == empty && cmd == "object.convertToFrame" {
continue;
}
// Together with a frame that could convert: all or nothing.
let e = s.execute(cmd, &json!({"ids": [ok_frame, layer]})).unwrap_err().to_string();
assert!(e.contains(says), "{cmd} on {layer}: {e}");
assert!(matches!(kind(&s, ok_frame), NodeKind::Frame), "{cmd}: nothing converts when one can't");
}
}
// Already what was asked for: left alone, not an error.
assert_eq!(ex(&mut s, "object.convertToFrame", json!({"ids": [ok_frame]}))["ids"], json!([]));
// A frame inside a main component converts (its instances follow their main).
ex(&mut s, "object.convertToGroup", json!({"ids": [inner]}));
assert!(matches!(kind(&s, inner), NodeKind::Group));
}
#[test]
fn junk_parameters_are_refused_not_fatal() {
let junk = [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([[1]]), json!({"a": 1}), json!([0, 999_999, "7"])];
for cmd in ["object.convertToGroup", "object.convertToFrame", "object.convertToSection"] {
for j in &junk {
let mut s = Session::new();
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50})));
rect(&mut s, f, 0.0, 0.0);
for p in [json!({"ids": j}), json!({"id": j}), j.clone()] {
let r = s.execute(cmd, &p);
assert!(!matches!(r, Err(EngineError::Internal { .. })), "{cmd} {p}: {r:?}");
}
}
}
}
#[test]
fn a_frame_on_the_page_becomes_a_section_and_back() {
let mut s = Session::new();
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 50, "width": 300, "height": 200, "name": "Flow", "props": {"fill": "#FFEEDD"}})));
let (a, b) = (rect(&mut s, f, 20.0, 30.0), rect(&mut s, f, 400.0, 90.0));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"}));
ex(&mut s, "node.set", json!({"id": f, "props": {"width": 300, "height": 200}}));
let before = [page_box(&s, a), page_box(&s, b), page_box(&s, f)];
assert_eq!(ex(&mut s, "object.convertToSection", json!({"ids": [f]}))["ids"], json!([f]));
let n = s.doc().unwrap().node(f).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Section { contents_hidden: false }));
assert_eq!((n.name.as_str(), n.children.clone(), n.fills.len()), ("Flow", vec![a, b], 1), "same name, layers and fill");
assert!(n.layout.is_none() && !n.clip, "no auto layout or clipping on a section");
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before, "nothing moves");
// And back: a frame that keeps the fill and doesn't clip, so the look stays.
assert_eq!(ex(&mut s, "object.convertToFrame", json!({"ids": [f]}))["ids"], json!([f]));
let n = s.doc().unwrap().node(f).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Frame) && n.fills.len() == 1 && !n.clip);
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before);
// Each conversion is one undo step.
ex(&mut s, "edit.undo", json!({}));
assert!(matches!(kind(&s, f), NodeKind::Section { .. }));
ex(&mut s, "edit.undo", json!({}));
assert!(matches!(kind(&s, f), NodeKind::Frame));
assert!(s.doc().unwrap().node(f).unwrap().layout.is_some(), "the frame's auto layout is back");
}
#[test]
fn only_frames_on_the_page_or_in_a_section_become_sections() {
let mut s = Session::new();
let outer = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 300})));
let inner = id(ex(&mut s, "frame.create", json!({"parent": outer, "x": 10, "y": 10, "width": 100, "height": 100})));
let err = s.execute("object.convertToSection", &json!({"ids": [inner]})).unwrap_err().to_string();
assert!(err.contains("inside a frame"), "{err}");
let r = rect(&mut s, outer, 150.0, 150.0);
assert!(s.execute("object.convertToSection", &json!({"ids": [r]})).is_err(), "a rectangle isn't a frame");
// A frame inside a section converts; a section holding a section can't become a frame.
let sec = id(ex(&mut s, "node.create", json!({"type": "section", "x": 500, "y": 0, "width": 400, "height": 400})));
let in_sec = id(ex(&mut s, "frame.create", json!({"parent": sec, "x": 20, "y": 20, "width": 100, "height": 100})));
ex(&mut s, "object.convertToSection", json!({"ids": [in_sec]}));
assert!(matches!(kind(&s, in_sec), NodeKind::Section { .. }));
let err = s.execute("object.convertToFrame", &json!({"ids": [sec]})).unwrap_err().to_string();
assert!(err.contains("holds sections"), "{err}");
assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed");
}
#[test]
fn wrapping_errors_name_the_command_that_was_run() {
// Grouping, framing, auto layout, masks and booleans share one wrapping step; a refusal names
// the command that asked, not `object.group`.
let mut s = Session::new();
let page = s.page().unwrap();
for cmd in ["object.group", "object.frameSelection", "layout.add", "bool.union"] {
let err = s.execute(cmd, &json!({"ids": [page]})).unwrap_err().to_string();
assert!(err.contains(&format!("`{cmd}`")), "{cmd}: {err}");
}
let r = rect(&mut s, page, 0.0, 0.0);
let err = s.execute("object.mask", &json!({"ids": [page, r]})).unwrap_err().to_string();
assert!(err.contains("`object.mask`"), "{err}");
let section = id(ex(&mut s, "node.create", json!({"type": "section", "x": 300, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"ids": [section], "props": {"fills": ["#FFFFFF"], "strokes": ["#000000"], "strokeWeight": 1}}));
let err = s.execute("vector.outlineStroke", &json!({"ids": [section]})).unwrap_err().to_string();
assert!(err.contains("`vector.outlineStroke`"), "{err}");
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100})));
let inner = rect(&mut s, f, 10.0, 10.0);
let err = s.execute("object.wrapInSection", &json!({"ids": [inner, r]})).unwrap_err().to_string();
assert!(err.contains("`object.wrapInSection`"), "{err}");
}