Files
figcraft/crates/engine/src/panels_behaviour_tests.rs
T
bflatastic 626a9eac08 M1: layers and assets panels behaviour suite and fixes
A behaviour suite for the layers panel and the Assets panel written from the public documentation
(table rows with one-line derivations; rows the documentation is silent on say so and pin today's
behaviour), and the fixes it found.

- Engine suite `crates/engine/src/panels_behaviour_tests.rs` (6 tests, about 50 rows): reordering
  and moving layers into frames, groups and auto layout, refused drops, renaming several layers,
  show/hide and lock rules, pages and dividers, Assets folders, hidden-from-publishing names, drops.
- Panel suite `crates/ui-egui/src/layers_behaviour_tests.rs` (5 tests, driven through egui
  headless): click / Shift range / Cmd-Ctrl toggle, Alt-click expand, Collapse layers, sweeping
  across eyes and padlocks, the Rename layers dialog, dragging pages, dividers.

Fixes:
- Several layers dropped together in the layers panel land together at the drop point (layers
  from apart split around it before).
- Collapse layers (Alt+L) keeps the rows leading to the selection open (it closed everything).
- The eye and padlock no longer vanish when the pointer reaches them (the row lost its hover to
  them, so clicks could miss); dragging across eyes or padlocks sets every row passed in one undo
  step. Layers inside a locked frame show a faint padlock.
- Several layers selected + Cmd/Ctrl+R / F2 / Rename open a Rename layers dialog (Match, Rename to,
  Current name / Number up / Number down buttons, start value, preview) instead of renaming the top
  row only.
- `object.batchRename`: `$n` counts up and `$N` down (they were swapped), `$nnn` pads, `start`
  sets where numbers start/stop, `regex: true` matches a regular expression with `$1`..`$9`,
  `` $` `` and `$'`, and codes work in the replacement too. Returns the new names.
- Pages: dragged up and down the list to reorder; a blank page named with a leading hyphen is a
  divider (drawn as a line, never opened, holds no layers); duplicates are named "Copy of ...";
  the last page that isn't a divider can't be deleted. `page.list` reports `divider`.
- Assets panel: components filed in folders by page, the frames holding them and the slash parts
  of their names (folders fold away; open while searching), tiles show the last name part;
  `component.list` gives each component's `folder` and `label` for agents.
- Publishing: a leading `.` or `_` keeps a component (style, collection) out of a library even
  after it was published, and removing the prefix brings it back (the previous publish's choice
  won before).

The engine gains the `regex` crate (already in the lock file through env_logger) for the
documented regular-expression matching.
2026-10-12 03:47:34 +07:00

389 lines
21 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.
//! Layers panel and Assets panel behaviour suite, written from the public documentation of design
//! tools: what the panels' gestures do to the document (reordering and moving layers, renaming
//! several at once, showing, hiding and locking, pages and dividers, the Assets panel's listing,
//! hidden names and drops). Each rule is a table row with a one-line derivation; where the
//! documentation is silent the row says so and pins today's behaviour. The gestures themselves
//! (clicks, Shift ranges, drags in the panel) are covered in the UI crate's
//! `layers_behaviour_tests`.
// The tables read best as tuples.
#![allow(clippy::type_complexity)]
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind};
use figcraft_doc::NodeId;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn rect(s: &mut Session, name: &str, x: f64, y: f64, parent: Option<NodeId>) -> NodeId {
let mut p = json!({"x": x, "y": y, "width": 40, "height": 40, "props": {"name": name}});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id(&ex(s, "shape.rectangle", p))
}
fn frame(s: &mut Session, name: &str, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id(&ex(s, "frame.create", json!({"x": x, "y": y, "width": w, "height": h, "name": name})))
}
fn kids(s: &Session, id: NodeId) -> Vec<NodeId> {
s.doc().unwrap().node(id).unwrap().children.clone()
}
fn name(s: &Session, id: NodeId) -> String {
s.doc().unwrap().node(id).unwrap().name.clone()
}
fn world(s: &Session, id: NodeId) -> [f64; 6] {
s.doc().unwrap().world_transform(id).as_coeffs()
}
fn same(a: [f64; 6], b: [f64; 6]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
}
fn click(s: &mut Session, x: f64, y: f64) {
s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap();
}
/// Frame `f` (0,0 400×300) holding `a`, `b`, `c`, `d` bottom to top; the group `g` of `e` on the
/// page; the auto layout frame `al` (500,0) of `x` and `y`; an instance `inst` (0,400) of a
/// component; a section `sec` (900,0).
#[allow(dead_code)]
struct Scene {
f: NodeId,
a: NodeId,
b: NodeId,
c: NodeId,
d: NodeId,
g: NodeId,
e: NodeId,
al: NodeId,
x: NodeId,
y: NodeId,
inst: NodeId,
sec: NodeId,
}
fn scene(s: &mut Session) -> Scene {
let f = frame(s, "F", 0.0, 0.0, 400.0, 300.0);
let a = rect(s, "a", 10.0, 10.0, Some(f));
let b = rect(s, "b", 60.0, 10.0, Some(f));
let c = rect(s, "c", 110.0, 10.0, Some(f));
let d = rect(s, "d", 160.0, 10.0, Some(f));
let e = rect(s, "e", 0.0, 600.0, None);
let g = id(&ex(s, "object.group", json!({"ids": [e]})));
let al = frame(s, "AL", 500.0, 0.0, 300.0, 100.0);
let x = rect(s, "x", 0.0, 0.0, Some(al));
let y = rect(s, "y", 50.0, 0.0, Some(al));
ex(s, "layout.add", json!({"ids": [al], "direction": "horizontal"}));
let main = frame(s, "Main", 0.0, 800.0, 100.0, 40.0);
rect(s, "Bg", 0.0, 0.0, Some(main));
let comp = id(&ex(s, "component.create", json!({"ids": [main]})));
let inst = id(&ex(s, "instance.create", json!({"component": comp, "x": 0, "y": 400})));
let sec = id(&ex(s, "node.create", json!({"type": "section", "x": 900, "y": 0, "width": 300, "height": 300})));
ex(s, "select.none", json!({}));
Scene { f, a, b, c, d, g, e, al, x, y, inst, sec }
}
#[test]
fn dragging_in_the_layers_panel_reorders_by_the_documented_rules() {
// The panel drops layers with `node.reparent {ids, parent, index}`: `index` counts the
// parent's children bottom first, as they are before the move.
// (moved, index in F, F's children after (bottom first), derivation)
let rows: [(&[&str], usize, [&str; 4], &str); 6] = [
(&["a"], 4, ["b", "c", "d", "a"], "a layer dragged to the top of its parent's list comes to the front"),
(&["d"], 0, ["d", "a", "b", "c"], "a layer dragged to the bottom of the list goes to the back"),
(&["a"], 2, ["b", "a", "c", "d"], "a layer dropped between two layers lands between them"),
(&["a", "b"], 4, ["c", "d", "a", "b"], "several selected layers move together, keeping their order"),
(&["a", "d"], 2, ["b", "a", "d", "c"], "several layers from apart land together where they are dropped"),
(&["b", "c"], 1, ["a", "b", "c", "d"], "silent: dropping a block of layers onto itself changes nothing (pinned)"),
];
for (moved, index, after, why) in rows {
let mut s = Session::new();
let k = scene(&mut s);
let by = |n: &str| match n {
"a" => k.a,
"b" => k.b,
"c" => k.c,
_ => k.d,
};
let ids: Vec<NodeId> = moved.iter().map(|n| by(n)).collect();
let before: Vec<[f64; 6]> = ids.iter().map(|i| world(&s, *i)).collect();
ex(&mut s, "node.reparent", json!({"ids": ids, "parent": k.f, "index": index}));
let names: Vec<String> = kids(&s, k.f).iter().map(|i| name(&s, *i)).collect();
assert_eq!(names, after, "{why}");
for (i, w) in ids.iter().zip(before) {
assert!(same(world(&s, *i), w), "{why}: reordering leaves the layers in place on the canvas");
}
}
}
#[test]
fn dragging_into_frames_groups_and_auto_layout() {
let mut s = Session::new();
let k = scene(&mut s);
// Into a group: the layer keeps its place on the canvas (silent, pinned).
let w = world(&s, k.a);
ex(&mut s, "node.reparent", json!({"ids": [k.a], "parent": k.g}));
assert_eq!(kids(&s, k.g).last(), Some(&k.a), "a layer dropped on a group goes in it, on top");
assert!(same(world(&s, k.a), w), "silent: a layer moved into a group keeps its place on the canvas (pinned)");
// Out to the page.
let page = s.page().unwrap();
ex(&mut s, "node.reparent", json!({"ids": [k.b], "parent": page}));
assert_eq!(s.doc().unwrap().node(k.b).unwrap().parent, Some(page), "a layer dragged out of a frame goes to the page");
// Into auto layout: the layer joins the flow where it is dropped.
ex(&mut s, "node.reparent", json!({"ids": [k.c], "parent": k.al, "index": 0}));
assert_eq!(kids(&s, k.al).first(), Some(&k.c), "dropped first in an auto layout frame");
let doc = s.doc().unwrap();
let lx = |i: NodeId| doc.node(i).unwrap().transform.as_coeffs()[4];
assert!(lx(k.c) < lx(k.x) && lx(k.x) < lx(k.y), "the auto layout frame places it first in its flow");
// Refused drops.
let refused: [(NodeId, NodeId, &str); 3] = [
(k.f, k.d, "a layer can't go into its own child"),
(k.d, k.inst, "an instance's layers come from its main: nothing can be dropped into it"),
(k.sec, k.f, "sections go on the page or in other sections, not in frames"),
];
for (what, into, why) in refused {
let before = s.doc().unwrap().clone();
assert!(s.execute("node.reparent", &json!({"ids": [what], "parent": into})).is_err(), "{why}");
assert_eq!(*s.doc().unwrap(), before, "{why}: nothing changed");
}
// Silent (pinned): hidden and locked layers move like any other; a locked frame takes drops.
ex(&mut s, "object.toggleVisible", json!({"ids": [k.d], "visible": false}));
ex(&mut s, "object.toggleLock", json!({"ids": [k.f], "locked": true}));
ex(&mut s, "node.reparent", json!({"ids": [k.b, k.d], "parent": k.f}));
// `b` was above the frame on the page, so it stays above `d` (stacking order kept).
assert_eq!(
kids(&s, k.f).iter().rev().take(2).copied().collect::<Vec<_>>(),
[k.b, k.d],
"silent: hidden layers move and locked frames take drops (pinned)"
);
}
#[test]
fn renaming_several_layers_by_the_documented_codes() {
// Three layers named `names`, bottom to top; (names, params, new names, derivation).
let rows: Vec<([&str; 3], Value, [&str; 3], &str)> = vec![
(["A", "B", "C"], json!({"name": "Icon"}), ["Icon", "Icon", "Icon"], "a plain new name goes to every layer"),
(["A", "B", "C"], json!({"name": "Icon_$n"}), ["Icon_1", "Icon_2", "Icon_3"], "the up number counts up from layer to layer"),
(["A", "B", "C"], json!({"name": "Icon_$N"}), ["Icon_3", "Icon_2", "Icon_1"], "the down number counts down"),
(["A", "B", "C"], json!({"name": "Icon_$nnn"}), ["Icon_001", "Icon_002", "Icon_003"], "repeating the n pads the number (three digits)"),
(["A", "B", "C"], json!({"name": "$NNN"}), ["003", "002", "001"], "padding works counting down too"),
(["A", "B", "C"], json!({"name": "$n $&"}), ["1 A", "2 B", "3 C"], "the current name code keeps each name; the number can go first"),
(["A", "B", "C"], json!({"name": "$&-$n"}), ["A-1", "B-2", "C-3"], "or the number goes at the end"),
(["A", "B", "C"], json!({"name": "$n", "start": 5}), ["5", "6", "7"], "up numbers start from the value given"),
(["A", "B", "C"], json!({"name": "$N", "start": 0}), ["2", "1", "0"], "down numbers stop at the value given"),
(
["Old A", "Old B", "Old C"],
json!({"find": "Old", "replace": "New"}),
["New A", "New B", "New C"],
"a match replaces only the matched part",
),
(["A", "B", "C"], json!({"find": "zz", "replace": "y"}), ["A", "B", "C"], "layers without the match keep their names"),
(
["a_a", "b", "c"],
json!({"find": "a", "replace": "<$&>"}),
["<a>_<a>", "b", "c"],
"every occurrence is replaced; the current name code is the matched text",
),
(
["Icon_003", "Logo_7", "x"],
json!({"find": "([a-zA-Z]+)_(\\d+)", "regex": true, "replace": "$2_$1"}),
["003_Icon", "7_Logo", "x"],
"a regular expression's groups swap parts of the name",
),
(
["ab", "b", "c"],
json!({"find": "b", "regex": true, "replace": "[$`|$']"}),
["a[a|]", "[|]", "c"],
"the before and after codes are the text around the match",
),
(["A", "B", "C"], json!({"find": "B", "replace": "$n"}), ["A", "2", "C"], "numbers count every layer, matched or not"),
(["A", "B", "C"], json!({"name": "$$$n"}), ["$1", "$2", "$3"], "two dollar signs make one"),
(
["A", "B", "C"],
json!({"name": "$x $1"}),
["$x $1", "$x $1", "$x $1"],
"unknown codes stay as typed (a group without a regular expression too)",
),
];
for (names, mut params, after, why) in rows {
let mut s = Session::new();
let ids: Vec<NodeId> = names.iter().enumerate().map(|(k, n)| rect(&mut s, n, k as f64 * 50.0, 0.0, None)).collect();
// Given top first, as a range picked in the panel: the numbering follows the stacking
// order, bottom first (silent, pinned).
params["ids"] = json!(ids.iter().rev().collect::<Vec<_>>());
ex(&mut s, "object.batchRename", params);
let got: Vec<String> = ids.iter().map(|i| name(&s, *i)).collect();
assert_eq!(got, after, "{why}");
}
// One undo step; a bad regular expression changes nothing.
let mut s = Session::new();
let a = rect(&mut s, "A", 0.0, 0.0, None);
let steps = s.undo_depth().0;
ex(&mut s, "object.batchRename", json!({"ids": [a], "name": "$n"}));
assert_eq!(s.undo_depth().0, steps + 1, "a batch rename is one undo step");
assert!(
s.execute("object.batchRename", &json!({"ids": [a], "find": "(", "regex": true, "replace": "x"})).is_err(),
"an invalid pattern is refused"
);
assert_eq!(name(&s, a), "1");
// Odd input stays bounded: a lone dollar sign, a very wide pad, a huge start, other scripts.
let r = |find: Option<&str>, to: &str, start: Option<f64>, name: &str| {
crate::rename::BatchRename::new(find, false, to, find.is_none().then_some(to), start).unwrap().apply(name, 0, 1)
};
assert_eq!(r(None, "x$", None, "a"), "x$");
assert_eq!(r(None, &format!("${}", "n".repeat(40)), None, "a"), format!("{:012}", 1));
assert_eq!(r(None, "$n", Some(1e30), "a"), "1000000000000");
assert_eq!(r(None, "$n", Some(f64::NAN), "a"), "1");
assert_eq!(r(Some("é"), "e", None, "café é"), "cafe e");
}
#[test]
fn showing_hiding_and_locking_by_the_documented_rules() {
let mut s = Session::new();
let k = scene(&mut s);
// Hidden layers can still be moved and changed.
ex(&mut s, "object.toggleVisible", json!({"ids": [k.a]}));
assert!(!s.doc().unwrap().node(k.a).unwrap().visible, "the eye hides a layer");
ex(&mut s, "node.set", json!({"id": k.a, "props": {"opacity": 0.5}}));
ex(&mut s, "transform.move", json!({"ids": [k.a], "dx": 5, "dy": 0}));
let n = s.doc().unwrap().node(k.a).unwrap().clone();
assert!((n.opacity - 0.5).abs() < 1e-6 && (n.transform.as_coeffs()[4] - 15.0).abs() < 1e-9, "a hidden layer can still be moved and changed");
// Mixed visibility (silent, pinned): one toggle shows them all.
ex(&mut s, "object.toggleVisible", json!({"ids": [k.a, k.b]}));
let doc = s.doc().unwrap();
assert!(
doc.node(k.a).unwrap().visible && doc.node(k.b).unwrap().visible,
"silent: toggling hidden and shown layers together shows them all (pinned)"
);
// Locking a frame locks its layers on the canvas; a child can't be unlocked on its own.
ex(&mut s, "object.toggleLock", json!({"ids": [k.f], "locked": true}));
click(&mut s, 180.0, 30.0);
assert!(s.selection().is_empty(), "a locked frame's layers can't be picked on the canvas");
ex(&mut s, "object.toggleLock", json!({"ids": [k.d], "locked": false}));
click(&mut s, 180.0, 30.0);
assert!(s.selection().is_empty(), "a layer stays locked while its parent is");
ex(&mut s, "object.toggleLock", json!({"ids": [k.f], "locked": false}));
click(&mut s, 180.0, 30.0);
assert_eq!(s.selection(), [k.d], "unlocking the parent frees its layers");
// Silent (pinned): locking deselects; the panel can still select a locked layer.
ex(&mut s, "object.toggleLock", json!({"ids": [k.d]}));
assert!(s.selection().is_empty(), "silent: locking a selected layer deselects it (pinned)");
ex(&mut s, "select.set", json!({"ids": [k.d]}));
assert_eq!(s.selection(), [k.d], "silent: a locked layer can still be selected from the panel (pinned)");
}
fn pages(s: &mut Session) -> Vec<Value> {
ex(s, "page.list", json!({})).as_array().unwrap().clone()
}
#[test]
fn pages_by_the_documented_rules() {
let mut s = Session::new();
let p1 = s.page().unwrap();
rect(&mut s, "r", 30.0, 40.0, None);
let p2 = id(&ex(&mut s, "page.add", json!({})));
assert_eq!(s.page().unwrap(), p2, "silent: a new page opens (pinned)");
assert_eq!(name(&s, p2), "Page 2", "silent: new pages are numbered (pinned)");
ex(&mut s, "page.rename", json!({"id": p2, "name": "Flows"}));
assert_eq!(name(&s, p2), "Flows", "a page is renamed in place");
ex(&mut s, "page.move", json!({"id": p2, "index": 0}));
assert_eq!(pages(&mut s)[0]["id"], json!(p2), "dragging a page up the list reorders the pages");
// Duplicating: the copy is named "Copy of …", next to the source, with copies of its layers.
let dup = id(&ex(&mut s, "page.duplicate", json!({"id": p1})));
assert_eq!(name(&s, dup), format!("Copy of {}", name(&s, p1)), "a duplicated page's name starts with Copy of");
let ids: Vec<Value> = pages(&mut s).iter().map(|p| p["id"].clone()).collect();
assert_eq!(ids, [json!(p2), json!(p1), json!(dup)], "silent: the copy comes right after its source (pinned)");
assert_eq!(kids(&s, dup).len(), 1, "the copy has the page's layers");
// Moving layers to another page keeps their coordinates.
let r = kids(&s, p1)[0];
let w = world(&s, r);
ex(&mut s, "object.moveToPage", json!({"ids": [r], "page": p2}));
assert_eq!(s.doc().unwrap().page_of(r), Some(p2), "Move to page moves the layers");
assert!(same(world(&s, r), w), "layers moved to a page land at the same coordinates");
// Dividers: a blank page named with a leading hyphen.
ex(&mut s, "page.select", json!({"id": p2}));
let div = id(&ex(&mut s, "page.add", json!({"name": "-"})));
let list = pages(&mut s);
let row = list.iter().find(|p| p["id"] == json!(div)).unwrap();
assert_eq!(row["divider"], json!(true), "a blank page named with a hyphen first is a divider");
assert_eq!(s.page().unwrap(), p2, "adding a divider leaves the canvas where it was");
assert!(s.execute("page.select", &json!({"id": div})).is_err(), "a divider isn't a page to open");
assert!(s.execute("object.moveToPage", &json!({"ids": [r], "page": div})).is_err(), "a divider holds no layers");
ex(&mut s, "page.rename", json!({"id": p2, "name": "-Flows"}));
assert!(!s.doc().unwrap().is_divider(p2), "a page with content stays a page whatever its name");
ex(&mut s, "page.rename", json!({"id": p2, "name": "Flows"}));
// Deleting.
let mut t = Session::new();
let only = t.page().unwrap();
assert!(t.execute("page.delete", &json!({"id": only})).is_err(), "silent: the only page can't be deleted (pinned)");
let d = id(&ex(&mut t, "page.add", json!({"name": "- - -"})));
assert!(t.execute("page.delete", &json!({"id": only})).is_err(), "the last page that isn't a divider can't be deleted");
ex(&mut t, "page.delete", json!({"id": d}));
ex(&mut s, "page.delete", json!({"id": p2}));
assert_ne!(s.page().unwrap(), div, "deleting the open page opens a page, never a divider");
}
#[test]
fn assets_panel_lists_files_and_drops_by_the_documented_rules() {
let mut s = Session::new();
let page = s.page().unwrap();
ex(&mut s, "page.rename", json!({"name": "Kit"}));
let holder = frame(&mut s, "Controls", 0.0, 0.0, 600.0, 200.0);
let b = id(&ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 80, "height": 30, "name": "Buttons / Primary", "parent": holder})));
let comp = id(&ex(&mut s, "component.create", json!({"ids": [b]})));
let loose = frame(&mut s, "Badge", 800.0, 0.0, 40.0, 20.0);
let badge = id(&ex(&mut s, "component.create", json!({"ids": [loose]})));
// (asset, folders, tile name, derivation)
let rows: [(NodeId, &[&str], &str, &str); 2] = [
(comp, &["Kit", "Controls", "Buttons"], "Primary", "assets file under their page and frame, then a folder per slash in the name"),
(badge, &["Kit"], "Badge", "an asset on the page files under the page alone"),
];
let listed = ex(&mut s, "component.list", json!({}));
for (asset, folders, label, why) in rows {
let want = (folders.iter().map(|f| f.to_string()).collect::<Vec<_>>(), label.to_string());
assert_eq!(crate::library::asset_folder(s.doc().unwrap(), asset), want, "{why}");
let row = listed.as_array().unwrap().iter().find(|r| r["id"] == json!(asset)).unwrap();
assert_eq!((row["folder"].clone(), row["label"].clone()), (json!(folders), json!(label)), "{why} (as listed for agents)");
}
assert!(crate::library::local_components(s.doc().unwrap()).contains(&comp), "local components are listed");
// Hidden from publishing: a leading period (or underscore) in the name, both ways.
let rows: [(&str, bool, &str); 4] = [
("Badge", true, "a component is offered for publishing"),
(".Badge", false, "a leading period keeps a component out of the library, even one published before"),
("_Badge", false, "silent beyond variable collections: a leading underscore does the same (pinned)"),
("Badge", true, "removing the prefix brings it back"),
];
let first = crate::library::publish(s.doc().unwrap(), None, &crate::library::default_selection(s.doc().unwrap(), None), None, 1).unwrap();
for (n, offered, why) in rows {
ex(&mut s, "object.rename", json!({"ids": [badge], "name": n}));
let sel = crate::library::default_selection(s.doc().unwrap(), Some(&first.manifest));
assert_eq!(sel.components.contains(&badge), offered, "{why}");
}
// Dropping an asset: into the frame under the pointer, into an auto layout flow by position.
let target = frame(&mut s, "Target", 0.0, 400.0, 300.0, 200.0);
let placed = id(&ex(&mut s, "instance.place", json!({"component": comp, "x": 100, "y": 500})));
assert_eq!(s.doc().unwrap().node(placed).unwrap().parent, Some(target), "an asset dropped on a frame goes into it");
let row = frame(&mut s, "Row", 400.0, 400.0, 400.0, 60.0);
rect(&mut s, "one", 0.0, 0.0, Some(row));
rect(&mut s, "two", 50.0, 0.0, Some(row));
ex(&mut s, "layout.add", json!({"ids": [row], "direction": "horizontal"}));
let first_x = s.doc().unwrap().world_transform(kids(&s, row)[0]).as_coeffs()[4];
let into = id(&ex(&mut s, "instance.place", json!({"component": comp, "x": first_x + 1.0, "y": 420})));
assert_eq!(kids(&s, row).first(), Some(&into), "an asset dropped in an auto layout frame joins the flow where it is dropped");
assert_eq!(s.doc().unwrap().page_of(into), Some(page));
}