M3: strengthen inherited slot property regression coverage

Cover inherited Boolean bindings and independent same-component slot content. Strengthen text, HTML and stored-property assertions, split default and own-content cases, and document ownership-based binding resolution.
This commit is contained in:
impulsado committed 2026-11-10 09:34:17 +01:00
1 parent a6d9b74df9
commit a66946d13e
2 files changed
+172 -30

No files matched your search

+4 -2
View File
@@ -165,8 +165,8 @@ fn copy_layers(
// No longer a slot in the main: it mirrors the main again.
n.overrides.remove(SLOT);
}
// Component properties: the main layer's bindings use this instance's values; layers of
// nested instances use the nested instance's values (one level per source hop).
// Bindings use the enclosing instance of their defining component, even when slot
// content has fewer source hops than enclosing instances.
let enc = enclosing.get(&parent).cloned().unwrap_or_default();
apply_enclosing_props(doc, &mut n, orig, &enc, &inst_props);
if let NodeKind::Instance { component: c, props } = &n.kind {
@@ -254,6 +254,8 @@ fn rebuild_nested(doc: &mut Document, nested: Vec<NodeId>, old: &BTreeMap<NodeId
}
}
/// Apply bindings from their defining components, preserving explicit overrides and letting
/// nearer source layers take precedence. Slot content need not have one source hop per instance.
fn apply_enclosing_props(
doc: &Document,
n: &mut Node,
+168 -28
View File
@@ -405,44 +405,67 @@ fn slot_text_composition(s: &mut Session, own: bool) -> (NodeId, NodeId, NodeId,
}
fn assert_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
let label = kids(s, instance)[0];
let characters = node(s, label).kind.text().unwrap().characters.clone();
let children = kids(s, instance);
assert_eq!(children.len(), 1, "Content {instance} must have exactly one Label");
let label = children[0];
assert_eq!(node(s, label).name, "Label");
let characters = node(s, label).kind.text().expect("Content's only child must be text").characters.clone();
let html = ex(s, "inspect.code", json!({"id": instance, "language": "html"}))["code"].as_str().unwrap().to_string();
assert_eq!(characters, expected, "slot Label characters; HTML: {html}");
assert_eq!(html.matches("<p ").count(), 1, "slot Label HTML must contain exactly one paragraph: {html}");
assert_eq!(html.matches("</p>").count(), 1, "slot Label HTML must contain exactly one paragraph: {html}");
assert!(html.contains(&format!(">{expected}</p>")), "slot Label HTML: {html}");
}
fn assert_local_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
assert!(matches!(&node(s, instance).kind, NodeKind::Instance { props, .. } if props.get("Text") == Some(&json!(expected))));
assert!(
matches!(&node(s, instance).kind, NodeKind::Instance { props, .. } if props.get("Text") == Some(&json!(expected))),
"instance {instance} must store its own Text value {expected:?}: {:?}",
node(s, instance).kind
);
let properties = props_of(s, instance);
assert_eq!(properties.iter().find(|p| p["name"] == "Text").unwrap()["value"], expected);
let text = properties.iter().find(|p| p["name"] == "Text").unwrap_or_else(|| panic!("instance {instance} has no Text property: {properties:?}"));
assert_eq!(text["value"], expected, "instance {instance} Text property");
}
/// Exercise the same ownership contract for default slot content and content supplied by the
/// Composition main; separate tests identify which route regresses.
fn check_slot_text_properties_stay_local(own: bool) {
let mut s = Session::new();
let (content, label, _base, composition, main_content) = slot_text_composition(&mut s, own);
let a = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 400})));
let b = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 800})));
let ac = copy_of(&s, a, main_content);
let bc = copy_of(&s, b, main_content);
ex(&mut s, "instance.setProperties", json!({"ids": [ac], "properties": {"Text": "Custom"}}));
assert_local_slot_text(&mut s, ac, "Custom");
assert_slot_text(&mut s, ac, "Custom");
assert_local_slot_text(&mut s, bc, "Default");
assert_slot_text(&mut s, bc, "Default");
assert_local_slot_text(&mut s, main_content, "Default");
assert_slot_text(&mut s, main_content, "Default");
assert_eq!(node(&s, label).kind.text().unwrap().characters, "Default");
// Main property edits propagate without replacing another instance's local value.
ex(&mut s, "instance.setProperties", json!({"ids": [main_content], "properties": {"Text": "Main value"}}));
assert_slot_text(&mut s, ac, "Custom");
assert_slot_text(&mut s, bc, "Main value");
assert_slot_text(&mut s, main_content, "Main value");
ex(&mut s, "component.editProperty", json!({"id": content, "name": "Text", "default": "New default"}));
assert_slot_text(&mut s, ac, "Custom");
assert_slot_text(&mut s, bc, "Main value");
assert_local_slot_text(&mut s, main_content, "Main value");
assert_slot_text(&mut s, main_content, "Main value");
healthy(&s);
}
#[test]
fn slot_text_properties_stay_local_in_default_and_own_content() {
for own in [false, true] {
let mut s = Session::new();
let (content, label, _base, composition, main_content) = slot_text_composition(&mut s, own);
let a = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 400})));
let b = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 800})));
let ac = copy_of(&s, a, main_content);
let bc = copy_of(&s, b, main_content);
ex(&mut s, "instance.setProperties", json!({"ids": [ac], "properties": {"Text": "Custom"}}));
assert_local_slot_text(&mut s, ac, "Custom");
assert_slot_text(&mut s, ac, "Custom");
assert_slot_text(&mut s, bc, "Default");
assert_slot_text(&mut s, main_content, "Default");
assert_eq!(node(&s, label).kind.text().unwrap().characters, "Default");
// Main property edits propagate without replacing another instance's local value.
ex(&mut s, "instance.setProperties", json!({"ids": [main_content], "properties": {"Text": "Main value"}}));
assert_slot_text(&mut s, ac, "Custom");
assert_slot_text(&mut s, bc, "Main value");
assert_slot_text(&mut s, main_content, "Main value");
ex(&mut s, "component.editProperty", json!({"id": content, "name": "Text", "default": "New default"}));
assert_slot_text(&mut s, ac, "Custom");
assert_slot_text(&mut s, bc, "Main value");
healthy(&s);
}
fn slot_text_properties_stay_local_in_default_content() {
check_slot_text_properties_stay_local(false);
}
#[test]
fn slot_text_properties_stay_local_in_own_content() {
check_slot_text_properties_stay_local(true);
}
#[test]
@@ -451,11 +474,15 @@ fn slot_text_properties_do_not_read_an_unbound_base_property() {
let (_content, _label, base, composition, main_content) = slot_text_composition(&mut s, true);
ex(&mut s, "component.addProperty", json!({"id": base, "name": "Text", "type": "text", "default": "Wrong owner"}));
let i = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 400})));
let nested_base = kids(&s, i)[0];
assert_local_slot_text(&mut s, nested_base, "Wrong owner");
let nested = copy_of(&s, i, main_content);
assert_local_slot_text(&mut s, nested, "Default");
assert_slot_text(&mut s, nested, "Default");
ex(&mut s, "instance.setProperties", json!({"ids": [nested], "properties": {"Text": "Custom"}}));
assert_local_slot_text(&mut s, nested, "Custom");
assert_slot_text(&mut s, nested, "Custom");
assert_local_slot_text(&mut s, nested_base, "Wrong owner");
assert_slot_text(&mut s, main_content, "Default");
healthy(&s);
}
@@ -472,6 +499,9 @@ fn slot_text_properties_survive_inherited_own_content_and_persistence() {
let bc = copy_of(&s, b, inherited_content);
ex(&mut s, "instance.setProperties", json!({"ids": [ac], "properties": {"Text": "Custom"}}));
assert_local_slot_text(&mut s, ac, "Custom");
assert_local_slot_text(&mut s, bc, "Default");
assert_local_slot_text(&mut s, inherited_content, "Default");
assert_local_slot_text(&mut s, main_content, "Default");
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
let mut s = Session::with_document(back);
@@ -495,6 +525,116 @@ fn slot_text_properties_survive_inherited_own_content_and_persistence() {
healthy(&s);
}
/// Check both the stored property and the Label it drives, not just one representation.
fn assert_slot_visibility(s: &mut Session, instance: NodeId, expected: bool) {
assert!(
matches!(&node(s, instance).kind, NodeKind::Instance { props, .. } if props.get("Visible") == Some(&json!(expected))),
"instance {instance} must store Visible={expected}: {:?}",
node(s, instance).kind
);
let properties = props_of(s, instance);
let visible =
properties.iter().find(|p| p["name"] == "Visible").unwrap_or_else(|| panic!("instance {instance} has no Visible property: {properties:?}"));
assert_eq!(visible["value"], expected, "instance {instance} Visible property");
let children = kids(s, instance);
assert_eq!(children.len(), 1, "Content {instance} must have exactly one Label");
assert_eq!(node(s, children[0]).name, "Label");
assert_eq!(node(s, children[0]).visible, expected, "instance {instance} Label visibility");
}
#[test]
fn slot_boolean_properties_stay_local_in_inherited_own_content() {
let mut s = Session::new();
let (content, label, base, composition, main_content) = slot_text_composition(&mut s, true);
ex(&mut s, "component.addProperty", json!({"id": content, "name": "Visible", "type": "boolean", "default": true, "layer": label}));
ex(&mut s, "component.addProperty", json!({"id": base, "name": "Visible", "type": "boolean", "default": false}));
let (outer, inherited) = slot_text_wrapper(&mut s, composition, "Outer composition");
let inherited_base = kids(&s, inherited)[0];
let inherited_content = copy_of(&s, inherited, main_content);
let a = idv(&ex(&mut s, "instance.create", json!({"component": outer, "x": 400})));
let b = idv(&ex(&mut s, "instance.create", json!({"component": outer, "x": 800})));
let ac = copy_of(&s, a, inherited_content);
let bc = copy_of(&s, b, inherited_content);
let abase = copy_of(&s, a, inherited_base);
assert!(matches!(&node(&s, abase).kind, NodeKind::Instance { props, .. } if props.get("Visible") == Some(&json!(false))));
assert_slot_visibility(&mut s, ac, true);
assert_slot_visibility(&mut s, bc, true);
ex(&mut s, "instance.setProperties", json!({"ids": [ac], "properties": {"Visible": false}}));
assert_slot_visibility(&mut s, ac, false);
assert_slot_visibility(&mut s, bc, true);
assert_slot_visibility(&mut s, inherited_content, true);
assert_slot_visibility(&mut s, main_content, true);
// A main update must not replace either instance's local value with the Base's homonym.
ex(&mut s, "instance.setProperties", json!({"ids": [main_content], "properties": {"Visible": false}}));
assert_slot_visibility(&mut s, ac, false);
assert_slot_visibility(&mut s, bc, false);
ex(&mut s, "instance.setProperties", json!({"ids": [bc], "properties": {"Visible": true}}));
ex(&mut s, "node.set", json!({"id": content, "props": {"fill": "#abcdef"}}));
assert_slot_visibility(&mut s, ac, false);
assert_slot_visibility(&mut s, bc, true);
assert_slot_visibility(&mut s, main_content, false);
healthy(&s);
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
let mut s = Session::with_document(back);
assert_slot_visibility(&mut s, ac, false);
assert_slot_visibility(&mut s, bc, true);
assert_slot_visibility(&mut s, inherited_content, false);
assert_slot_visibility(&mut s, main_content, false);
ex(&mut s, "instance.setProperties", json!({"ids": [main_content], "properties": {"Visible": true}}));
assert_slot_visibility(&mut s, ac, false);
assert_slot_visibility(&mut s, bc, true);
assert_slot_visibility(&mut s, main_content, true);
healthy(&s);
}
#[test]
fn same_component_in_own_slot_keeps_independent_text_properties() {
let mut s = Session::new();
let frame = idv(&ex(&mut s, "frame.create", json!({"width": 200, "height": 100, "name": "Card"})));
let label = idv(&ex(&mut s, "text.create", json!({"text": "Default", "parent": frame, "props": {"name": "Label"}})));
let body = idv(&ex(&mut s, "frame.create", json!({"width": 180, "height": 60, "y": 40, "name": "Body", "parent": frame})));
let card = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
ex(&mut s, "component.addProperty", json!({"id": card, "name": "Text", "type": "text", "default": "Default", "layer": label}));
ex(&mut s, "component.makeSlot", json!({"id": body}));
// Only page instance content holds another Card: the main never contains itself.
let outer = idv(&ex(&mut s, "instance.create", json!({"component": card, "x": 400})));
let inner = idv(&ex(&mut s, "instance.slotInsert", json!({"id": outer, "slot": "Body", "component": card})));
let check = |s: &mut Session, outer_text: &str, inner_text: &str| {
assert_local_slot_text(s, outer, outer_text);
assert_local_slot_text(s, inner, inner_text);
assert_eq!(node(s, copy_of(s, outer, label)).kind.text().unwrap().characters, outer_text);
assert_eq!(node(s, copy_of(s, inner, label)).kind.text().unwrap().characters, inner_text);
let outer_body = copy_of(s, outer, body);
assert!(slot_edited(s.doc().unwrap(), outer_body));
assert_eq!(kids(s, outer_body), vec![inner]);
assert!(kids(s, copy_of(s, inner, body)).is_empty());
assert!(kids(s, body).is_empty(), "the main's slot must remain empty");
healthy(s);
};
ex(&mut s, "instance.setProperties", json!({"ids": [outer], "properties": {"Text": "Outer"}}));
check(&mut s, "Outer", "Default");
ex(&mut s, "instance.setProperties", json!({"ids": [inner], "properties": {"Text": "Inner"}}));
check(&mut s, "Outer", "Inner");
ex(&mut s, "text.set", json!({"id": label, "characters": "New default"}));
ex(&mut s, "node.set", json!({"id": card, "props": {"fill": "#abcdef"}}));
check(&mut s, "Outer", "Inner");
assert_eq!(fill(&s, outer), "ABCDEF");
assert_eq!(fill(&s, inner), "ABCDEF");
ex(&mut s, "instance.setProperties", json!({"ids": [outer], "properties": {"Text": "Outer updated"}}));
check(&mut s, "Outer updated", "Inner");
ex(&mut s, "instance.setProperties", json!({"ids": [inner], "properties": {"Text": "Inner updated"}}));
check(&mut s, "Outer updated", "Inner updated");
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
let mut s = Session::with_document(back);
check(&mut s, "Outer updated", "Inner updated");
ex(&mut s, "node.set", json!({"id": card, "props": {"fill": "#123456"}}));
check(&mut s, "Outer updated", "Inner updated");
assert_eq!(fill(&s, outer), "123456");
assert_eq!(fill(&s, inner), "123456");
}
/// Random slot edits, main edits and resets keep every instance mirroring its main.
#[test]
fn random_slot_edits_keep_instances_consistent() {