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

224 lines
11 KiB
Rust

//! Auto layout behaviour through commands (the rules of `figcraft_layout`'s behaviour suite that
//! involve edits): what sizing a layer may take, how a hugging frame follows its children, the
//! settings' defaults, canvas stacking in renders and exports, and one undo step per edit.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{CellAlign, MainAlign, NodeId, Sizing};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn rect(s: &mut Session, parent: NodeId, w: f64, h: f64, fill: &str) -> NodeId {
id(s.execute("shape.rectangle", &json!({"parent": parent, "x": 0, "y": 0, "width": w, "height": h, "props": {"fill": fill}})).unwrap())
}
/// A frame with auto layout (no padding, no gap) and the given extra props.
fn al_frame(s: &mut Session, props: Value) -> NodeId {
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap());
let mut all = json!({"layout": "horizontal", "padding": 0, "gap": 0, "fills": []});
if let (Some(all), Value::Object(extra)) = (all.as_object_mut(), props) {
all.extend(extra);
}
set(s, f, all);
f
}
fn set(s: &mut Session, node: NodeId, props: Value) {
s.execute("node.set", &json!({"id": node, "props": props})).unwrap();
}
fn size(s: &Session, node: NodeId) -> (f64, f64) {
let n = s.doc().unwrap().node(node).unwrap();
(n.width, n.height)
}
fn pos(s: &Session, node: NodeId) -> (f64, f64) {
let c = s.doc().unwrap().node(node).unwrap().transform.as_coeffs();
(c[4], c[5])
}
fn sizing(s: &Session, node: NodeId) -> (Sizing, Sizing) {
let cl = &s.doc().unwrap().node(node).unwrap().child_layout;
(cl.sizing_h, cl.sizing_v)
}
/// The RGBA of `node` rendered at 1x, at (`x`, `y`) of its box.
fn pixel(s: &Session, node: NodeId, x: u32, y: u32) -> [u8; 4] {
let pix = figcraft_render::render_node(s.doc().unwrap(), node, 1.0).unwrap();
let i = ((y * pix.width() + x) * 4) as usize;
let d = pix.data();
[d[i], d[i + 1], d[i + 2], d[i + 3]]
}
#[test]
fn fill_on_a_child_turns_the_hugging_parent_fixed() {
// Documented: a child set to Fill container makes its parent stop hugging that axis.
let mut s = Session::new();
let f = al_frame(&mut s, json!({"sizingH": "hug", "sizingV": "hug"}));
let a = rect(&mut s, f, 50.0, 20.0, "#FF0000");
rect(&mut s, f, 30.0, 40.0, "#0000FF");
assert_eq!(size(&s, f), (80.0, 40.0));
set(&mut s, a, json!({"sizingH": "fill"}));
assert_eq!(sizing(&s, f), (Sizing::Fixed, Sizing::Hug));
assert_eq!(sizing(&s, a).0, Sizing::Fill);
// Fixed at the size it had: the fill child takes what the other child leaves.
assert_eq!(size(&s, f), (80.0, 40.0));
assert_eq!(size(&s, a), (50.0, 20.0));
s.undo().unwrap();
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Hug));
}
#[test]
fn hug_and_fill_only_where_they_apply() {
// Documented: Hug applies to auto layout frames (and text); Fill to children of auto layout,
// never to a top-level frame.
let mut s = Session::new();
let f = al_frame(&mut s, json!({}));
let r = rect(&mut s, f, 50.0, 20.0, "#FF0000");
assert!(s.execute("node.set", &json!({"id": r, "props": {"sizingH": "hug"}})).is_err());
assert!(s.execute("node.set", &json!({"id": f, "props": {"sizingH": "fill"}})).is_err());
assert_eq!(sizing(&s, f), (Sizing::Fixed, Sizing::Fixed));
}
#[test]
fn a_hugging_frame_follows_its_children_through_nested_levels() {
// Documented: hug resizes the frame from its children; nested frames keep their own padding
// and gap.
let mut s = Session::new();
let outer = al_frame(&mut s, json!({"layout": "vertical", "sizingH": "hug", "sizingV": "hug", "padding": 10, "gap": 5}));
let inner = id(s.execute("frame.create", &json!({"parent": outer, "x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
set(&mut s, inner, json!({"layout": "horizontal", "padding": 4, "gap": 2, "sizingH": "hug", "sizingV": "hug", "fills": []}));
let a = rect(&mut s, inner, 30.0, 20.0, "#FF0000");
rect(&mut s, inner, 30.0, 10.0, "#0000FF");
rect(&mut s, outer, 20.0, 20.0, "#00FF00");
// Inner: 4 + 30 + 2 + 30 + 4 = 70 by 4 + 20 + 4 = 28; outer: 10 + 70 + 10 by 10 + 28 + 5 + 20 + 10.
assert_eq!(size(&s, inner), (70.0, 28.0));
assert_eq!(size(&s, outer), (90.0, 73.0));
// A child grows: both levels follow in the same edit, and one undo puts it all back.
set(&mut s, a, json!({"width": 60, "height": 40}));
assert_eq!(size(&s, inner), (100.0, 48.0));
assert_eq!(size(&s, outer), (120.0, 93.0));
s.undo().unwrap();
assert_eq!(size(&s, outer), (90.0, 73.0));
}
#[test]
fn new_auto_layout_includes_inside_strokes() {
// Documented: by default inside strokes are included in auto layout sizes.
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
set(&mut s, f, json!({"stroke": "#000000", "strokeWeight": 3, "strokeAlign": "inside"}));
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
assert!(s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().strokes_included);
set(&mut s, f, json!({"padding": 0, "sizingH": "hug", "sizingV": "hug"}));
let r = rect(&mut s, f, 40.0, 20.0, "#FF0000");
assert_eq!(size(&s, f), (46.0, 26.0));
assert_eq!(pos(&s, r), (3.0, 3.0));
// Excluded: the stroke takes no space (one undo step brings it back).
set(&mut s, f, json!({"strokesIncluded": false}));
assert_eq!(size(&s, f), (40.0, 20.0));
s.undo().unwrap();
assert_eq!(size(&s, f), (46.0, 26.0));
}
#[test]
fn auto_gaps_and_cell_alignment_through_node_set() {
let mut s = Session::new();
let f = al_frame(&mut s, json!({}));
let k: Vec<NodeId> = (0..3).map(|_| rect(&mut s, f, 20.0, 20.0, "#FF0000")).collect();
// 200 - 60 = 140: evenly 35 everywhere; around 46.67 between, half at the edges.
set(&mut s, f, json!({"mainAlign": "spaceEvenly"}));
assert_eq!(s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().main_align, MainAlign::SpaceEvenly);
assert_eq!(k.iter().map(|c| pos(&s, *c).0).collect::<Vec<_>>(), vec![35.0, 90.0, 145.0]);
set(&mut s, f, json!({"primaryAxisAlignItems": "SPACE_AROUND"}));
assert!((pos(&s, k[0]).0 - 70.0 / 3.0).abs() < 1e-9);
assert!(s.execute("node.set", &json!({"id": f, "props": {"mainAlign": "spaceSideways"}})).is_err());
// A grid child aligns in its own cell; auto goes back to the frame's alignment.
set(&mut s, f, json!({"layout": "grid", "columnTracks": ["1fr", "1fr", "1fr"], "rowTracks": ["1fr"], "mainAlign": "min"}));
set(&mut s, k[1], json!({"gridAlignH": "max", "gridChildVerticalAlign": "CENTER"}));
let cl = s.doc().unwrap().node(k[1]).unwrap().child_layout.clone();
assert_eq!((cl.grid_align_h, cl.grid_align_v), (Some(CellAlign::Max), Some(CellAlign::Center)));
// Columns of 200 / 3 (the second ends at 133.33) in one 100 px row.
let (x, y) = pos(&s, k[1]);
assert!((x - (400.0 / 3.0 - 20.0)).abs() < 1e-9 && (y - 40.0).abs() < 1e-9, "{x} {y}");
set(&mut s, k[1], json!({"gridAlignH": "auto", "gridAlignV": null}));
assert!((pos(&s, k[1]).0 - 200.0 / 3.0).abs() < 1e-9);
assert!(s.execute("node.set", &json!({"id": k[1], "props": {"gridAlignH": "baseline"}})).is_err());
}
#[test]
fn vertical_wrap_through_node_set() {
// Documented: a vertical flow wraps into a new column when it reaches the frame's height.
let mut s = Session::new();
let f = al_frame(&mut s, json!({"layout": "vertical", "wrap": true, "gap": 10, "crossGap": 5}));
let k: Vec<NodeId> = (0..3).map(|_| rect(&mut s, f, 20.0, 40.0, "#FF0000")).collect();
assert_eq!(k.iter().map(|c| pos(&s, *c)).collect::<Vec<_>>(), vec![(0.0, 0.0), (0.0, 50.0), (25.0, 0.0)]);
}
#[test]
fn vertical_wrap_columns_take_arrows_and_drops() {
// 40 px tall items, gap 10, 160 px tall: three per column, columns 30 px apart.
let mut s = Session::new();
let f = al_frame(&mut s, json!({"layout": "vertical", "wrap": true, "gap": 10, "crossGap": 10, "height": 160}));
let k: Vec<NodeId> = (0..6).map(|_| rect(&mut s, f, 20.0, 40.0, "#FF0000")).collect();
let kids = |s: &Session| s.doc().unwrap().node(f).unwrap().children.clone();
assert_eq!(pos(&s, k[3]), (30.0, 0.0), "second column");
// Right: the second item moves into the second column, beside where it was.
s.execute("transform.nudge", &json!({"ids": [k[1]], "dx": 1, "dy": 0})).unwrap();
assert_eq!(kids(&s), vec![k[0], k[2], k[3], k[4], k[1], k[5]]);
assert_eq!(pos(&s, k[1]), (30.0, 50.0));
// Down still steps along the flow.
s.execute("transform.nudge", &json!({"ids": [k[1]], "dx": 0, "dy": 1})).unwrap();
assert_eq!(kids(&s), vec![k[0], k[2], k[3], k[4], k[5], k[1]]);
// A drop between two items of a column: before the lower one, a horizontal insertion line.
let (index, line) = crate::drop::auto_layout_slot(s.doc().unwrap(), f, figcraft_geom::Point::new(35.0, 45.0), &[]).unwrap();
assert_eq!(index, 4);
assert_eq!(line, Some([30.0, 48.0, 50.0, 48.0]));
}
#[test]
fn adding_auto_layout_to_a_frame_keeps_its_size() {
// An existing frame with content hugs it (padding inferred, so the size is preserved);
// an empty one keeps its size instead of collapsing to its padding.
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap());
rect(&mut s, f, 40.0, 20.0, "#FF0000");
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Hug));
assert_eq!(size(&s, f), (300.0, 200.0));
// An empty frame keeps its size too.
let e = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap());
s.execute("layout.add", &json!({"ids": [e]})).unwrap();
assert_eq!(sizing(&s, e), (Sizing::Fixed, Sizing::Fixed));
assert_eq!(size(&s, e), (300.0, 200.0));
}
#[test]
fn canvas_stacking_paints_and_exports_the_first_child_on_top() {
// Documented: the last child is on top by default; "first on top" flips which one is drawn
// over the other, and the layer order stays the same.
let mut s = Session::new();
let f = al_frame(&mut s, json!({"sizingH": "hug", "sizingV": "hug", "gap": -20}));
let a = rect(&mut s, f, 50.0, 20.0, "#FF0000");
let b = rect(&mut s, f, 50.0, 20.0, "#0000FF");
let order = s.doc().unwrap().node(f).unwrap().children.clone();
let svg = |s: &Session| figcraft_svg::export_svg(s.doc().unwrap(), &[f], &figcraft_svg::SvgOptions::default()).unwrap().to_lowercase();
let red_first = |svg: &str| svg.find("ff0000").unwrap() < svg.find("0000ff").unwrap();
// The overlap is x 30..50.
assert_eq!(pixel(&s, f, 40, 10), [0, 0, 255, 255]);
assert!(red_first(&svg(&s)));
set(&mut s, f, json!({"reverseZ": true}));
assert_eq!(pixel(&s, f, 40, 10), [255, 0, 0, 255]);
assert!(!red_first(&svg(&s)));
assert!(figcraft_pdf::export_nodes_pdf(s.doc().unwrap(), &[f]).is_ok());
// Places and layers stay; one undo step puts the last child back on top.
assert_eq!(s.doc().unwrap().node(f).unwrap().children, order);
assert_eq!((pos(&s, a), pos(&s, b)), ((0.0, 0.0), (30.0, 0.0)));
s.undo().unwrap();
assert_eq!(pixel(&s, f, 40, 10), [0, 0, 255, 255]);
}