Files
figcraft/crates/engine/src/frames_behaviour_tests.rs
T
bflatastic e917d3c2e6 M1: frames, groups, sections and masks behaviour suite and fixes
**A frames, groups, sections and masks behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (frames vs. groups when resized, constraints, Cmd/Ctrl-resize, clip content, Frame selection, grouping and ungrouping, group bounds and opacity, X/Y in groups, click targets, drawing a frame or section around layers, dropping into and out of frames, where sections may go, resizing sections, top-level frames as screens and exports, Use as mask, what a mask covers, alpha / vector / luminance masks, groups as masks, clicks on masked-out areas, a mask group's box and exports) is a table-driven case with the result the rule implies and a one-line derivation; where the documentation is silent the case says so and pins today's behaviour.

**Fixes (the suite failed on 7 tests before them)**
- **Masked-out areas don't take clicks**: hit testing and Select layer skip what a visible mask hides (the nearest mask below a layer; for a group or frame used as a mask, what its layers draw), so a click there reaches what's behind. Checked only once something is hit, and skipped in files without a mask (`Document::has_masks`, worked out once after each change), so hit testing costs what it did.
- **A mask group's box and exports are the mask's**: a group's bounds and export area count a mask for the layers it masks (layers under the mask still count), so the selection box, W/H, snapping and PNG/SVG/PDF exports crop to the mask instead of the whole masked layer (`Document::box_children`).
- **Sections stay on the page or in sections**: moving a section into a frame or group (`node.reparent`, refused at the root in `Document::reparent`), dragging one over a frame (it lands in the section around the frame, or on the page), grouping or framing a section, creating a section in a frame and pasting one into a selected frame (it goes on the page) no longer put sections where they can't be.
- **Drawing a frame or section around layers puts them in it**: the layers fully inside the drawn box (its siblings; visible and unlocked; a frame never takes a section; not in auto layout) move into it, in their order, without moving on the page; one undo step.
- **Resizing a section leaves its contents** where they are on the page (no constraints in sections), from a field, a handle (also the left/top edges) or `transform.resize` with any anchor.
- **Cmd/Ctrl while resizing a frame leaves its layers as they are**: #133 (merged in) keeps their places; this branch also keeps their sizes (the frame is resized without running constraints, so a left & right or scale child no longer stretches and is only moved back).
- **X/Y are measured in the frame, not the group**: groups and boolean groups have no coordinate space of their own, so a layer in a group shows (Design panel, `node.get`, `inspect_document`) and takes (`node.set`, `node.create` with a group parent) X/Y in the frame the group is in, or on the page (`Document::frame_relative`). Rotated groups too.

Already there and now covered by the suite: constraints when a frame resizes, groups scaling their layers, clip content (drawing, hits, export size), Frame selection's size and place, grouping/ungrouping keeping world transforms (rotated groups too), nested groups, empty groups going away, group opacity on the composite, click / Cmd-click / double-click targets (frames in sections count as top-level frames), dropping by the pointer and pulling out, group layers staying in their group when dragged, top-level frames as prototype screens and export boards, Use as mask (bottom layer, "Mask group", at the topmost layer's place, single-layer toggle), masks covering every layer above them, hidden masks, alpha / vector / luminance masks, a group as a mask, masks in SVG and PDF exports.

No new commands or UI strings (no i18n changes); each edit stays one undo step.
2026-10-12 00:57:43 +07:00

493 lines
24 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 behaviour suite: each case is a rule from the public documentation
//! of frames, groups and sections (frames vs. groups when resized, constraints, clip content,
//! frame selection, grouping and ungrouping, X/Y relative to the frame, click targets, drawing a
//! frame or section around layers, dropping into and out of frames, where sections may go,
//! resizing sections) with the result the rule implies. The comment on each case is the one-line
//! derivation. Where the documentation is silent the case says so and pins today's behaviour.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind};
use figcraft_doc::{NodeId, NodeKind};
use figcraft_geom::Point;
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}: {e}"))
}
fn frame(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
let mut p = json!({"x": x, "y": y, "width": w, "height": h});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id(ex(s, "frame.create", p))
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
let mut p = json!({"x": x, "y": y, "width": w, "height": h, "props": {"fills": ["#FF0000"]}});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id(ex(s, "shape.rectangle", p))
}
fn section(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id(ex(s, "node.create", json!({"type": "section", "x": x, "y": y, "width": w, "height": h})))
}
/// Page bounds as (x, y, w, h).
fn wb(s: &Session, n: NodeId) -> (f64, f64, f64, f64) {
let b = s.doc().unwrap().world_bounds(n).unwrap();
(b.x0, b.y0, b.width(), b.height())
}
fn parent(s: &Session, n: NodeId) -> Option<NodeId> {
s.doc().unwrap().node(n).unwrap().parent
}
fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent {
let mut e = PointerEvent::at(kind, x, y, 1.0);
e.cmd = cmd;
e
}
/// Press at `a`, drag to `b` in a few steps and release.
fn drag(s: &mut Session, a: (f64, f64), b: (f64, f64), cmd: bool) {
s.pointer(&ev(PointerKind::Down, a.0, a.1, cmd)).unwrap();
for k in 1..=4 {
let t = f64::from(k) / 4.0;
s.pointer(&ev(PointerKind::Move, a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t, cmd)).unwrap();
}
s.pointer(&ev(PointerKind::Up, b.0, b.1, cmd)).unwrap();
}
/// The RGBA of `node` rendered at 1x, at (`x`, `y`) of its export 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 resizing_a_frame_applies_constraints_and_a_group_scales() {
// A 100-wide frame with a 20-wide child at x 10 is resized to 200 (one edit each).
let cases = [
// Left keeps the distance to the left edge.
("left", (10.0, 20.0)),
// Right keeps the distance to the right edge: 100 - 30 = 70, so 200 - 70 - 20 = 110.
("right", (110.0, 20.0)),
// Left & right keep both distances: 10 on the left, 70 on the right, 200 - 80 wide.
("leftRight", (10.0, 120.0)),
// Center keeps the offset from the centre: moves by (200 - 100) / 2.
("center", (60.0, 20.0)),
// Scale keeps the proportions: x and width × 2.
("scale", (20.0, 40.0)),
];
for (h, want) in cases {
let mut s = Session::new();
let f = frame(&mut s, 0.0, 0.0, 100.0, 100.0, None);
let c = rect(&mut s, 10.0, 10.0, 20.0, 20.0, Some(f));
ex(&mut s, "node.set", json!({"id": c, "props": {"constraints": {"horizontal": h}}}));
ex(&mut s, "node.set", json!({"id": f, "props": {"width": 200}}));
let (x, _, w, _) = wb(&s, c);
assert_eq!((x, w), want, "{h}");
s.undo().unwrap();
assert_eq!(wb(&s, c).0, 10.0, "{h}: one undo step");
}
// Groups have no constraints for their layers: resizing one scales what's in it (0..20 and
// 30..50 in a 50-wide group resized to 100: both × 2).
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0, None);
let b = rect(&mut s, 30.0, 0.0, 20.0, 20.0, None);
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
ex(&mut s, "node.set", json!({"id": g, "props": {"width": 100}}));
assert_eq!((wb(&s, a), wb(&s, b)), ((0.0, 0.0, 40.0, 20.0), (60.0, 0.0, 40.0, 20.0)));
}
#[test]
fn cmd_resize_of_a_frame_ignores_constraints() {
// Documented: holding Cmd (Ctrl) while resizing a frame leaves its layers as they are.
for (cmd, want_x, want_w) in [(false, 170.0, 120.0), (true, 70.0, 20.0)] {
let mut s = Session::new();
let f = frame(&mut s, 0.0, 0.0, 100.0, 100.0, None);
let c = rect(&mut s, 70.0, 40.0, 20.0, 20.0, Some(f));
let wide = rect(&mut s, 10.0, 70.0, 20.0, 20.0, Some(f));
ex(&mut s, "node.set", json!({"id": c, "props": {"constraints": {"horizontal": "right"}}}));
ex(&mut s, "node.set", json!({"id": wide, "props": {"constraints": {"horizontal": "leftRight"}}}));
ex(&mut s, "select.set", json!({"ids": [f]}));
// The right edge's handle, dragged 100 to the right.
drag(&mut s, (100.0, 50.0), (200.0, 50.0), cmd);
assert_eq!(wb(&s, f).2, 200.0, "cmd {cmd}");
// Right: 70 + 100, left & right: 20 + 100 wide; with Cmd both stay as they were.
assert_eq!((wb(&s, c).0, wb(&s, wide).2), (want_x, want_w), "cmd {cmd}");
}
}
#[test]
fn resizing_a_section_leaves_its_contents() {
// Documented: resizing a section doesn't move or resize what's in it (no constraints there).
let mut s = Session::new();
let sec = section(&mut s, 0.0, 0.0, 400.0, 300.0);
let f = frame(&mut s, 300.0, 200.0, 50.0, 50.0, Some(sec));
ex(&mut s, "node.set", json!({"id": f, "props": {"constraints": {"horizontal": "scale", "vertical": "bottom"}}}));
let before = wb(&s, f);
ex(&mut s, "node.set", json!({"id": sec, "props": {"width": 800, "height": 600}}));
assert_eq!(wb(&s, f), before, "a field edit");
// Dragging the left edge: the section's origin moves, the frame doesn't.
ex(&mut s, "select.set", json!({"ids": [sec]}));
drag(&mut s, (0.0, 300.0), (-100.0, 300.0), false);
assert_eq!(wb(&s, sec), (-100.0, 0.0, 900.0, 600.0));
assert_eq!(wb(&s, f), before, "a handle drag");
// Anchored resizes too.
ex(&mut s, "transform.resize", json!({"ids": [sec], "width": 500, "anchor": "r"}));
assert_eq!(wb(&s, f), before, "transform.resize");
}
#[test]
fn clip_content_hides_and_unhides_what_overflows() {
// Documented: with Clip content on, what lies outside the frame isn't shown (or clickable);
// off, it is. A 100×100 frame with a child from 80 to 140.
for (clip, hit_outside, export_w) in [(true, false, 100), (false, true, 140)] {
let mut s = Session::new();
let f = frame(&mut s, 0.0, 0.0, 100.0, 100.0, None);
let c = rect(&mut s, 80.0, 10.0, 60.0, 20.0, Some(f));
ex(&mut s, "node.set", json!({"id": f, "props": {"clip": clip}}));
let page = s.page().unwrap();
let hit = s.doc().unwrap().hit_test(page, Point::new(120.0, 20.0), 0.0, None);
assert_eq!(hit == Some(c), hit_outside, "clip {clip}");
assert_eq!(figcraft_render::render_node(s.doc().unwrap(), f, 1.0).unwrap().width(), export_w, "clip {clip}");
}
// New frames clip (silent on the default for Frame selection: pinned to the frame default).
let mut s = Session::new();
let f = frame(&mut s, 0.0, 0.0, 10.0, 10.0, None);
assert!(s.doc().unwrap().node(f).unwrap().clip);
}
#[test]
fn frame_selection_wraps_the_layers_where_the_topmost_was() {
// Documented: Frame selection puts the selected layers in a new frame the size of their
// bounds, in the place of the topmost selected layer; the layers don't move on the page.
let mut s = Session::new();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0, None);
let other = rect(&mut s, 200.0, 0.0, 10.0, 10.0, None);
let b = rect(&mut s, 50.0, 40.0, 30.0, 30.0, None);
let top = rect(&mut s, 300.0, 0.0, 10.0, 10.0, None);
let f = id(ex(&mut s, "object.frameSelection", json!({"ids": [b, a]})));
let doc = s.doc().unwrap();
// Bounds: 10..80 by 10..70.
assert_eq!(wb(&s, f), (10.0, 10.0, 70.0, 60.0));
assert_eq!(doc.node(f).unwrap().children, vec![a, b], "the layers keep their order");
let page = s.page().unwrap();
assert_eq!(doc.node(page).unwrap().children, vec![other, f, top], "where b (the topmost selected) was");
assert_eq!((wb(&s, a), wb(&s, b)), ((10.0, 10.0, 20.0, 20.0), (50.0, 40.0, 30.0, 30.0)));
assert_eq!(s.selection(), vec![f]);
// Inside a frame, the new frame nests there.
let outer = frame(&mut s, 500.0, 0.0, 200.0, 200.0, None);
let c = rect(&mut s, 20.0, 20.0, 10.0, 10.0, Some(outer));
let inner = id(ex(&mut s, "object.frameSelection", json!({"ids": [c]})));
assert_eq!(parent(&s, inner), Some(outer));
// One undo step.
s.undo().unwrap();
assert_eq!(parent(&s, c), Some(outer));
}
#[test]
fn grouping_and_ungrouping_keep_layers_in_place() {
// Documented: a group's bounds hug its layers; grouping and ungrouping don't move anything,
// also once the group is rotated; ungrouping selects the layers.
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 40.0, 20.0, None);
let b = rect(&mut s, 60.0, 30.0, 20.0, 20.0, None);
ex(&mut s, "node.set", json!({"id": b, "props": {"rotation": 45}}));
let before = (wb(&s, a), wb(&s, b));
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
assert_eq!((wb(&s, a), wb(&s, b)), before);
let hug = s.doc().unwrap().bounds_of(&[a, b]).unwrap();
assert_eq!(wb(&s, g), (hug.x0, hug.y0, hug.width(), hug.height()), "the group hugs its layers");
// Rotating the group turns its layers with it; ungrouping leaves them where they ended up.
ex(&mut s, "transform.rotate", json!({"ids": [g], "degrees": 30}));
let worlds = |s: &Session| [a, b].map(|i| s.doc().unwrap().world_transform(i));
let turned = worlds(&s);
ex(&mut s, "object.ungroup", json!({"ids": [g]}));
for (x, y) in worlds(&s).iter().zip(turned.iter()) {
let (p, q) = (x.as_coeffs(), y.as_coeffs());
assert!(p.iter().zip(q.iter()).all(|(u, v)| (u - v).abs() < 1e-9), "{p:?} {q:?}");
}
assert!(s.doc().unwrap().node(g).is_none());
assert_eq!(s.selection(), vec![a, b]);
// Nested groups: ungrouping the outer one keeps the inner one.
let c = rect(&mut s, 200.0, 0.0, 10.0, 10.0, None);
let inner = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
let outer = id(ex(&mut s, "object.group", json!({"ids": [inner, c]})));
ex(&mut s, "object.ungroup", json!({"ids": [outer]}));
assert_eq!(s.doc().unwrap().node(inner).unwrap().children, vec![a, b]);
// Silent on what ungrouping does with the group's opacity: pinned (multiplied into the layers).
let g = id(ex(&mut s, "object.group", json!({"ids": [c]})));
ex(&mut s, "node.set", json!({"id": g, "props": {"opacity": 0.5}}));
ex(&mut s, "object.ungroup", json!({"ids": [g]}));
assert_eq!(s.doc().unwrap().node(c).unwrap().opacity, 0.5);
}
#[test]
fn group_bounds_follow_their_layers_and_empty_groups_go() {
// Documented: a group is as big as its layers; it changes when they do, and deleting its last
// layer deletes it.
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0, None);
let b = rect(&mut s, 30.0, 0.0, 20.0, 20.0, None);
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
ex(&mut s, "transform.move", json!({"ids": [b], "dx": 50, "dy": 10}));
assert_eq!(wb(&s, g), (0.0, 0.0, 100.0, 30.0));
ex(&mut s, "edit.delete", json!({"ids": [a, b]}));
assert!(s.doc().unwrap().node(g).is_none());
}
#[test]
fn group_opacity_applies_to_the_whole_group() {
// Documented: a group's opacity applies to the group as one, so overlapping layers in a 50%
// group don't show through each other; two 50% layers do (alpha 0.5 + 0.5 × 0.5 = 0.75).
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 30.0, 10.0, None);
let b = rect(&mut s, 20.0, 0.0, 30.0, 10.0, None);
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
ex(&mut s, "node.set", json!({"id": g, "props": {"opacity": 0.5}}));
let (alone, overlap) = (pixel(&s, g, 5, 5)[3], pixel(&s, g, 25, 5)[3]);
assert!(alone.abs_diff(128) <= 1 && overlap == alone, "{alone} {overlap}");
ex(&mut s, "node.set", json!({"ids": [a, b], "props": {"opacity": 0.5}}));
ex(&mut s, "node.set", json!({"id": g, "props": {"opacity": 1}}));
assert!(pixel(&s, g, 25, 5)[3].abs_diff(191) <= 1);
}
#[test]
fn x_and_y_are_relative_to_the_frame_not_the_group() {
// Documented: X and Y are measured from the top-left of the frame the layer is in (the canvas
// if none); groups don't start a coordinate space of their own.
let mut s = Session::new();
let f = frame(&mut s, 100.0, 100.0, 300.0, 300.0, None);
let a = rect(&mut s, 30.0, 40.0, 20.0, 20.0, Some(f));
let b = rect(&mut s, 80.0, 60.0, 20.0, 20.0, Some(f));
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
let xy = |s: &mut Session, n: NodeId| {
let v = ex(s, "node.get", json!({"id": n}));
(v["x"].as_f64().unwrap(), v["y"].as_f64().unwrap())
};
let cases = [
// The group sits at the top-left of its layers, in the frame.
(g, (30.0, 40.0)),
// Its first layer is where the group starts: 30, 40 in the frame, not 0, 0 in the group.
(a, (30.0, 40.0)),
(b, (80.0, 60.0)),
];
for (n, want) in cases {
assert_eq!(xy(&mut s, n), want, "{n}");
}
// Setting X places the layer in the frame; the group follows (b now from 50 to 70).
ex(&mut s, "node.set", json!({"id": b, "props": {"x": 50}}));
assert_eq!(xy(&mut s, b), (50.0, 60.0));
assert_eq!(wb(&s, b).0, 150.0);
assert_eq!(wb(&s, g), (130.0, 140.0, 40.0, 40.0));
// A group on the canvas: page coordinates.
let c = rect(&mut s, 500.0, 20.0, 10.0, 10.0, None);
let d = rect(&mut s, 520.0, 50.0, 10.0, 10.0, None);
ex(&mut s, "object.group", json!({"ids": [c, d]}));
assert_eq!(xy(&mut s, d), (520.0, 50.0));
// A rotated group: its layers' X/Y are still in the frame's axes.
ex(&mut s, "transform.rotate", json!({"ids": [g], "degrees": 90}));
let t = s.doc().unwrap().world_transform(a).as_coeffs();
let (x, y) = xy(&mut s, a);
assert!((x - (t[4] - 100.0)).abs() < 1e-9 && (y - (t[5] - 100.0)).abs() < 1e-9, "{x} {y}");
}
#[test]
fn clicks_pick_children_of_top_level_frames_and_whole_groups() {
// Documented: top-level frames (also those in sections) act as boards, so a click picks
// their direct child; groups and nested frames are picked whole; a double click goes one
// level in; Cmd/Ctrl-click picks the layer itself.
let mut s = Session::new();
let f = frame(&mut s, 0.0, 0.0, 300.0, 200.0, None);
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0, Some(f));
let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0, Some(f));
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
let nested = frame(&mut s, 100.0, 100.0, 80.0, 80.0, Some(f));
let deep = rect(&mut s, 10.0, 10.0, 20.0, 20.0, Some(nested));
let loose = rect(&mut s, 200.0, 20.0, 20.0, 20.0, Some(f));
let sec = section(&mut s, 0.0, 400.0, 400.0, 300.0);
let sf = frame(&mut s, 20.0, 20.0, 100.0, 100.0, Some(sec));
let sr = rect(&mut s, 10.0, 10.0, 20.0, 20.0, Some(sf));
let doc = s.doc().unwrap().clone();
let pick = |leaf, ctx: &[NodeId], deep| figcraft_doc::geometry::select_target(&doc, leaf, ctx, deep);
let enter = |leaf, sel: &[NodeId]| figcraft_doc::geometry::enter_target(&doc, leaf, sel);
let cases: [(&str, NodeId, NodeId); 10] = [
// A layer in a group in a top-level frame: the group.
("group", pick(a, &[], false), g),
// A plain child of a top-level frame: itself.
("child", pick(loose, &[], false), loose),
// A layer in a nested frame: the nested frame (a child of the board).
("nested frame", pick(deep, &[], false), nested),
// The frame's own background: the frame.
("background", pick(f, &[], false), f),
// A frame in a section is a top-level frame: its child.
("frame in a section", pick(sr, &[], false), sr),
// Cmd/Ctrl-click: the layer itself.
("deep click", pick(a, &[], true), a),
// With the group selected, a click on its layer picks the layer (siblings resolve there).
("in the selected group", pick(b, &[a], false), b),
// A double click on the selected group goes into it.
("double click", enter(a, &[g]), a),
// A double click on the selected nested frame goes into it.
("double click a frame", enter(deep, &[nested]), deep),
// A double click with nothing selected first picks what a click would.
("double click, nothing selected", enter(a, &[]), g),
];
for (rule, got, want) in cases {
assert_eq!(got, want, "{rule}");
}
}
#[test]
fn drawing_a_frame_or_section_around_layers_puts_them_in_it() {
// Documented: drawing a frame (or a section) around layers puts those fully inside it in the
// new frame (section); layers only partly inside stay where they are.
let mut s = Session::new();
let inside = rect(&mut s, 20.0, 20.0, 30.0, 30.0, None);
let partly = rect(&mut s, 180.0, 20.0, 40.0, 40.0, None);
let out = rect(&mut s, 400.0, 400.0, 10.0, 10.0, None);
let page = s.page().unwrap();
ex(&mut s, "tool.frame", json!({}));
drag(&mut s, (0.0, 0.0), (200.0, 200.0), false);
let f = *s.selection().first().unwrap();
assert!(matches!(s.doc().unwrap().node(f).unwrap().kind, NodeKind::Frame));
assert_eq!((parent(&s, inside), parent(&s, partly), parent(&s, out)), (Some(f), Some(page), Some(page)));
assert_eq!(wb(&s, inside), (20.0, 20.0, 30.0, 30.0), "it doesn't move on the page");
// One undo step takes the frame away and puts the layer back.
s.undo().unwrap();
assert_eq!(parent(&s, inside), Some(page));
assert!(s.doc().unwrap().node(f).is_none());
// Inside a frame: the frame's own layers that end up inside the new one.
let board = frame(&mut s, 1000.0, 0.0, 400.0, 400.0, None);
let k = rect(&mut s, 50.0, 50.0, 20.0, 20.0, Some(board));
ex(&mut s, "tool.frame", json!({}));
drag(&mut s, (1020.0, 20.0), (1120.0, 120.0), false);
let nf = *s.selection().first().unwrap();
assert_eq!((parent(&s, nf), parent(&s, k)), (Some(board), Some(nf)));
// A section drawn around frames takes them (and leaves what's only partly inside).
ex(&mut s, "tool.section", json!({}));
drag(&mut s, (-50.0, -50.0), (1500.0, 500.0), false);
let sec = *s.selection().first().unwrap();
assert!(matches!(s.doc().unwrap().node(sec).unwrap().kind, NodeKind::Section { .. }));
assert_eq!((parent(&s, board), parent(&s, inside), parent(&s, out)), (Some(sec), Some(sec), Some(sec)));
assert_eq!(parent(&s, partly), Some(sec), "fully inside this larger one");
// A frame drawn around a section leaves the section out (sections don't go in frames).
let lone = section(&mut s, 3000.0, 0.0, 100.0, 100.0);
ex(&mut s, "tool.frame", json!({}));
drag(&mut s, (2900.0, -100.0), (3200.0, 200.0), false);
assert_eq!(parent(&s, lone), Some(page));
}
#[test]
fn dropping_into_and_pulling_out_of_frames_follows_the_pointer() {
// Documented: a layer dragged over a frame goes into it, decided by where the pointer is
// (silent on layers only partly inside: pinned, the pointer decides); dragged out of the frame,
// it leaves it.
let mut s = Session::new();
let page = s.page().unwrap();
let f = frame(&mut s, 0.0, 0.0, 200.0, 200.0, None);
let r = rect(&mut s, 300.0, 0.0, 40.0, 40.0, None);
// Pointer at 190 inside the frame; the rectangle spans 170..210, partly outside.
drag(&mut s, (320.0, 20.0), (190.0, 20.0), false);
assert_eq!(parent(&s, r), Some(f));
assert_eq!(wb(&s, r).0, 170.0);
drag(&mut s, (190.0, 20.0), (320.0, 20.0), false);
assert_eq!(parent(&s, r), Some(page));
// Layers of a group stay in it when dragged (the group follows).
let a = rect(&mut s, 400.0, 0.0, 20.0, 20.0, None);
let b = rect(&mut s, 430.0, 0.0, 20.0, 20.0, None);
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
ex(&mut s, "select.set", json!({"ids": [a]}));
drag(&mut s, (410.0, 10.0), (100.0, 100.0), false);
assert_eq!(parent(&s, a), Some(g));
}
#[test]
fn sections_go_on_the_page_or_in_sections() {
// Documented: sections can be nested in sections but can't go in frames or groups.
let mut s = Session::new();
let page = s.page().unwrap();
let f = frame(&mut s, 0.0, 0.0, 300.0, 300.0, None);
let sec = section(&mut s, 500.0, 0.0, 200.0, 200.0);
let outer = section(&mut s, 1000.0, 0.0, 600.0, 600.0);
let r = rect(&mut s, 900.0, 0.0, 10.0, 10.0, None);
let refused = [
("into a frame", json!({"ids": [sec], "parent": f})),
("into a group", {
let g = id(ex(&mut s, "object.group", json!({"ids": [r]})));
json!({"ids": [sec], "parent": g})
}),
];
for (rule, p) in refused {
assert!(s.execute("node.reparent", &p).is_err(), "{rule}");
assert_eq!(parent(&s, sec), Some(page), "{rule}");
}
assert!(s.execute("object.group", &json!({"ids": [sec]})).is_err(), "grouping a section");
assert!(s.execute("object.frameSelection", &json!({"ids": [sec]})).is_err(), "framing a section");
assert!(s.execute("node.create", &json!({"type": "section", "parent": f})).is_err(), "a new section in a frame");
// Into a section: fine.
ex(&mut s, "node.reparent", json!({"ids": [sec], "parent": outer}));
assert_eq!(parent(&s, sec), Some(outer));
ex(&mut s, "node.reparent", json!({"ids": [sec], "parent": page}));
// Dragged over a frame, a section stays on the page.
ex(&mut s, "select.set", json!({"ids": [sec]}));
drag(&mut s, (600.0, 100.0), (150.0, 150.0), false);
assert_eq!(parent(&s, sec), Some(page));
assert_eq!(wb(&s, sec).0, 50.0, "but it moves");
// Pasted with a frame selected: on the page, not in the frame.
ex(&mut s, "edit.copy", json!({"ids": [sec]}));
ex(&mut s, "select.set", json!({"ids": [f]}));
let pasted = ex(&mut s, "edit.paste", json!({}));
let p = id_of(&pasted["ids"][0]).unwrap();
assert_eq!(parent(&s, p), Some(page));
}
#[test]
fn top_level_frames_are_screens_and_export_whole() {
// Documented: top-level frames (on the page or in sections) are the screens of prototypes and
// the boards that export; a nested frame is neither on its own.
let mut s = Session::new();
let page = s.page().unwrap();
let a = frame(&mut s, 0.0, 0.0, 100.0, 200.0, None);
let nested = frame(&mut s, 10.0, 10.0, 30.0, 30.0, Some(a));
let sec = section(&mut s, 300.0, 0.0, 400.0, 400.0);
let b = frame(&mut s, 20.0, 20.0, 50.0, 60.0, Some(sec));
let doc = s.doc().unwrap();
assert_eq!(doc.top_frames(page), vec![a, b]);
assert_eq!(doc.top_level_frame(nested), Some(a));
let px = figcraft_render::render_node(doc, a, 2.0).unwrap();
assert_eq!((px.width(), px.height()), (200, 400), "the frame's own size at 2x");
}
#[test]
fn frames_and_groups_convert_through_frame_selection_and_remove_frame() {
// Documented: Frame selection wraps a group in a frame, Ungroup / Remove frame take a container
// away; layers stay where they are either way. (Silent on a one-step convert: not offered.)
let mut s = Session::new();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0, None);
let b = rect(&mut s, 50.0, 10.0, 20.0, 20.0, None);
let g = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
let f = id(ex(&mut s, "object.frameSelection", json!({"ids": [g]})));
ex(&mut s, "object.ungroup", json!({"ids": [g]}));
let doc = s.doc().unwrap();
assert_eq!(doc.node(f).unwrap().children, vec![a, b]);
assert_eq!((wb(&s, a), wb(&s, f)), ((10.0, 10.0, 20.0, 20.0), (10.0, 10.0, 60.0, 20.0)));
let g2 = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
ex(&mut s, "object.removeFrame", json!({"ids": [f]}));
assert_eq!(parent(&s, g2), s.page().ok());
assert_eq!(wb(&s, g2), (10.0, 10.0, 60.0, 20.0));
}