mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
* M2: components can't contain their own instances (#1084) Edits that would make a component hold an instance of itself, directly, through other components or through another variant of its set, are refused with the loop named. Files holding such a loop open with one instance per loop detached and a note; a rebuild never expands an instance inside a copy of itself, so a loop merged in a live session stops growing. * M2: a variant may hold a sibling variant's instance; only true loops are refused (#1084) Each variant counts as its own component: refusals and open-time repairs apply only to a component leading back to itself through instances (an instance-swap value included), so files with nested sibling variants open unchanged. sync_instance_depth is split into match_layers and copy_layers.
235 lines
11 KiB
Rust
235 lines
11 KiB
Rust
//! Components can't contain their own instances, directly, through other components or through
|
||
//! the variants of a set (#1084); files holding such a loop open with it broken.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use base64::Engine;
|
||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::Session;
|
||
use crate::components::component_cycle;
|
||
use crate::components::tests::{copy_of, ex, idv, kids, node};
|
||
|
||
/// A 100×50 component named `name` at (x, 0), holding a rectangle; returns its id.
|
||
fn component(s: &mut Session, name: &str, x: f64) -> NodeId {
|
||
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 50, "name": name})));
|
||
ex(s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f}));
|
||
idv(&ex(s, "component.create", json!({"ids": [f]})))
|
||
}
|
||
|
||
fn instance(s: &mut Session, component: NodeId, parent: NodeId) -> NodeId {
|
||
idv(&ex(s, "instance.create", json!({"component": component, "x": 20, "y": 20, "parent": parent})))
|
||
}
|
||
|
||
/// `cmd` is refused with the loop explained, and the document is left as it was.
|
||
fn refused(s: &mut Session, cmd: &str, p: Value) {
|
||
let before = s.doc().unwrap().clone();
|
||
let undo = s.ds().unwrap().history.undo.len();
|
||
let err = s.execute(cmd, &p).expect_err(&format!("{cmd} {p} should be refused")).to_string();
|
||
assert!(err.contains("can't contain"), "{cmd}: {err}");
|
||
assert_eq!(*s.doc().unwrap(), before, "{cmd} rolled back");
|
||
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "{cmd} left no undo step");
|
||
}
|
||
|
||
/// Card holds a Badge instance; Badge holds an Icon instance. Returns (card, badge, icon, the
|
||
/// Icon instance in Badge).
|
||
fn chain(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||
let icon = component(s, "Icon", 0.0);
|
||
let badge = component(s, "Badge", 200.0);
|
||
let card = component(s, "Card", 400.0);
|
||
let in_badge = instance(s, icon, badge);
|
||
instance(s, badge, card);
|
||
(card, badge, icon, in_badge)
|
||
}
|
||
|
||
#[test]
|
||
fn direct_and_indirect_loops_are_refused() {
|
||
let mut s = Session::new();
|
||
let (card, badge, _, in_badge) = chain(&mut s);
|
||
// Directly, and through Badge: Card already shows Badge.
|
||
refused(&mut s, "instance.create", json!({"component": card, "parent": card}));
|
||
refused(&mut s, "instance.create", json!({"component": card, "parent": badge}));
|
||
refused(&mut s, "instance.swap", json!({"ids": [in_badge], "component": card}));
|
||
// A Card instance moved or pasted into Badge.
|
||
let page = s.page().unwrap();
|
||
let loose = instance(&mut s, card, page);
|
||
refused(&mut s, "node.reparent", json!({"ids": [loose], "parent": badge}));
|
||
ex(&mut s, "edit.copy", json!({"ids": [loose]}));
|
||
refused(&mut s, "edit.paste", json!({"parent": badge}));
|
||
// A copied main pastes as an instance of it.
|
||
ex(&mut s, "edit.copy", json!({"ids": [card]}));
|
||
refused(&mut s, "edit.paste", json!({"parent": badge}));
|
||
assert!(component_cycle(s.doc().unwrap()).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn dropping_an_instance_where_it_would_contain_itself_places_it_on_the_page() {
|
||
let mut s = Session::new();
|
||
let (card, badge, _, _) = chain(&mut s);
|
||
let r = ex(&mut s, "instance.place", json!({"component": card, "x": 250, "y": 25}));
|
||
assert_eq!(r["parent"], json!(s.page().unwrap()), "not into Badge: {r}");
|
||
assert!(!s.doc().unwrap().is_ancestor(badge, idv(&r)));
|
||
}
|
||
|
||
#[test]
|
||
fn an_instance_swap_property_can_not_close_a_loop() {
|
||
let mut s = Session::new();
|
||
let (card, badge, icon, in_badge) = chain(&mut s);
|
||
ex(&mut s, "component.addProperty", json!({"id": badge, "name": "Icon", "type": "instanceSwap", "default": icon, "layer": in_badge}));
|
||
let in_card = kids(&s, card).into_iter().find(|k| matches!(node(&s, *k).kind, NodeKind::Instance { .. })).unwrap();
|
||
refused(&mut s, "instance.setProperties", json!({"ids": [in_card], "properties": {"Icon": card}}));
|
||
// Another component is fine.
|
||
let star = component(&mut s, "Star", 600.0);
|
||
ex(&mut s, "instance.setProperties", json!({"ids": [in_card], "properties": {"Icon": star}}));
|
||
}
|
||
|
||
/// A set of two variants, "State=On" holding an instance of "State=Off". Returns (on, off, the Off
|
||
/// instance in On).
|
||
fn nested_variants(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||
let on = component(s, "State=On", 0.0);
|
||
let off = component(s, "State=Off", 200.0);
|
||
ex(s, "component.combineVariants", json!({"ids": [on, off]}));
|
||
let held = instance(s, off, on);
|
||
(on, off, held)
|
||
}
|
||
|
||
#[test]
|
||
fn a_variant_holds_a_sibling_but_no_loop_back_through_it() {
|
||
let mut s = Session::new();
|
||
let (on, off, held) = nested_variants(&mut s);
|
||
assert!(component_cycle(s.doc().unwrap()).is_none(), "a sibling variant's instance is no loop");
|
||
// Off leading back to On, directly or through another component, is one.
|
||
refused(&mut s, "instance.create", json!({"component": on, "parent": off}));
|
||
let row = component(&mut s, "Row", 600.0);
|
||
instance(&mut s, on, row);
|
||
refused(&mut s, "instance.create", json!({"component": row, "parent": off}));
|
||
// So is switching the held instance to the variant holding it.
|
||
refused(&mut s, "instance.setProperties", json!({"ids": [held], "properties": {"State": "On"}}));
|
||
// Combining a component with one holding its instance is fine too.
|
||
let a = component(&mut s, "A", 0.0);
|
||
let b = component(&mut s, "B", 200.0);
|
||
instance(&mut s, a, b);
|
||
ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}));
|
||
assert!(component_cycle(s.doc().unwrap()).is_none());
|
||
}
|
||
|
||
/// `doc` saved and opened again: the session and what opening it reported.
|
||
fn reopened(doc: &Document) -> (Session, Value) {
|
||
let data = base64::engine::general_purpose::STANDARD.encode(figcraft_format::save_bytes(doc, None).unwrap());
|
||
let mut s = Session::new();
|
||
let r = ex(&mut s, "file.open", json!({"data": data, "name": "variants.figcraft"}));
|
||
(s, r)
|
||
}
|
||
|
||
#[test]
|
||
fn a_file_with_nested_sibling_variants_opens_unchanged_and_a_loop_through_them_is_repaired() {
|
||
let mut s = Session::new();
|
||
let (on, off, held) = nested_variants(&mut s);
|
||
let doc = s.doc().unwrap().clone();
|
||
let (t, r) = reopened(&doc);
|
||
assert_eq!(r["repairs"], json!([]), "{r}");
|
||
assert_eq!(t.doc().unwrap().nodes().count(), doc.nodes().count());
|
||
assert!(matches!(node(&t, held).kind, NodeKind::Instance { component, .. } if component == off));
|
||
// On → Off → On: an On instance in Off, written directly as an older file could have it.
|
||
let spare = component(&mut s, "Spare", 600.0);
|
||
let back = instance(&mut s, spare, off);
|
||
let mut doc = s.doc().unwrap().clone();
|
||
if let NodeKind::Instance { component, .. } = &mut doc.get_mut(back).unwrap().kind {
|
||
*component = on;
|
||
}
|
||
assert!(component_cycle(&doc).is_some());
|
||
let (t, r) = reopened(&doc);
|
||
assert!(r["repairs"].as_array().is_some_and(|n| n.iter().any(|n| n.as_str().is_some_and(|n| n.contains("was detached")))), "{r}");
|
||
assert!(component_cycle(t.doc().unwrap()).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn components_around_instances_and_instances_in_slots_stay_allowed() {
|
||
let mut s = Session::new();
|
||
let (card, badge, ..) = chain(&mut s);
|
||
// A new component around a Card instance.
|
||
let page = s.page().unwrap();
|
||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100, "parent": page})));
|
||
instance(&mut s, card, f);
|
||
let holder = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||
instance(&mut s, badge, holder);
|
||
// An instance in a slot of an instance of its own component: the slot holds that instance's
|
||
// own content, outside its main.
|
||
let slot = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": card, "name": "Slot"})));
|
||
ex(&mut s, "component.makeSlot", json!({"id": slot}));
|
||
let outer = instance(&mut s, card, page);
|
||
let outer_slot = copy_of(&s, outer, slot);
|
||
instance(&mut s, card, outer_slot);
|
||
assert!(component_cycle(s.doc().unwrap()).is_none());
|
||
}
|
||
|
||
/// A document where Card holds a Badge instance and Badge a Card instance, made by editing the
|
||
/// layers directly (as an older file or a merge could have it). Returns it with (card, badge).
|
||
fn looped() -> (Document, NodeId, NodeId) {
|
||
let mut s = Session::new();
|
||
let (card, badge, _, in_badge) = chain(&mut s);
|
||
let mut doc = s.doc().unwrap().clone();
|
||
if let NodeKind::Instance { component, .. } = &mut doc.get_mut(in_badge).unwrap().kind {
|
||
*component = card;
|
||
}
|
||
crate::components::sync_instance(&mut doc, in_badge);
|
||
assert!(component_cycle(&doc).is_some());
|
||
(doc, card, badge)
|
||
}
|
||
|
||
/// Edit mains `a` and `b` `n` times, every instance rebuilt in full each time; the layer count
|
||
/// after each round.
|
||
fn edit_mains(s: &mut Session, a: NodeId, b: NodeId, n: usize) -> Vec<usize> {
|
||
crate::components::FULL_REBUILD.with(|f| f.set(true));
|
||
let sizes = (0..n)
|
||
.map(|i| {
|
||
ex(s, "node.set", json!({"id": a, "props": {"fill": format!("#{:02x}0000", i * 20)}}));
|
||
ex(s, "node.set", json!({"id": b, "props": {"fill": format!("#00{:02x}00", i * 20)}}));
|
||
s.doc().unwrap().nodes().count()
|
||
})
|
||
.collect();
|
||
crate::components::FULL_REBUILD.with(|f| f.set(false));
|
||
sizes
|
||
}
|
||
|
||
#[test]
|
||
fn a_file_with_a_loop_opens_with_it_broken_and_a_note() {
|
||
let (doc, card, badge) = looped();
|
||
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
|
||
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||
let mut s = Session::new();
|
||
let r = ex(&mut s, "file.open", json!({"data": data, "name": "looped.figcraft"}));
|
||
let notes = r["repairs"].as_array().cloned().unwrap_or_default();
|
||
assert!(notes.iter().any(|n| n.as_str().is_some_and(|n| n.contains("was detached"))), "{r}");
|
||
let doc = s.doc().unwrap();
|
||
assert!(component_cycle(doc).is_none());
|
||
// The detached instance kept its layers; the other side is still an instance.
|
||
assert!(doc.contains(card) && doc.contains(badge));
|
||
let count = doc.nodes().count();
|
||
// Editing the mains no longer grows the file.
|
||
assert_eq!(edit_mains(&mut s, card, badge, 10), vec![count; 10]);
|
||
// Importing it into another file breaks it the same way.
|
||
let mut t = Session::new();
|
||
let r = ex(&mut t, "file.import", json!({"data": data, "name": "looped.figcraft"}));
|
||
assert!(r["warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
|
||
assert!(component_cycle(t.doc().unwrap()).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_loop_merged_in_stops_expanding_and_the_document_stays_editable() {
|
||
let (doc, card, badge) = looped();
|
||
let mut s = Session::new();
|
||
let uid = s.ds().unwrap().uid;
|
||
s.apply_remote(uid, Arc::new(doc));
|
||
assert!(s.message.as_deref().is_some_and(|m| m.contains("contain each other")), "{:?}", s.message);
|
||
let sizes = edit_mains(&mut s, card, badge, 12);
|
||
// A couple of rebuilds settle the copies; after that nothing grows.
|
||
assert!(sizes.windows(2).skip(2).all(|w| w[0] == w[1]), "{sizes:?}");
|
||
// Editing goes on: layers added to a main reach its instances.
|
||
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4, "parent": card}));
|
||
let page = s.page().unwrap();
|
||
let loose = instance(&mut s, badge, page);
|
||
assert!(!kids(&s, loose).is_empty());
|
||
}
|