From 4268bd859f95fc195063284bfcd8d6f9298223dc Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 14:00:44 +0000 Subject: [PATCH] refactor(fig): split mapping and lint test helpers --- crates/format/src/fig/assets_tests.rs | 43 ++++--- crates/format/src/fig/map/layer.rs | 109 ++++++++++++++---- .../format/src/fig/source_identity_tests.rs | 8 ++ crates/format/src/fig/tests.rs | 51 +++++--- .../format/src/rest/proto_variable_tests.rs | 28 +++-- 5 files changed, 178 insertions(+), 61 deletions(-) diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index a271a908..cf115c8f 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -274,19 +274,30 @@ fn variables_modes_and_bindings_round_trip() { #[test] fn variables_are_written_as_nodes_on_a_hidden_page() { let nodes = written(&themed()); - let hidden = named(&nodes, "Internal Only Canvas"); + assert_hidden_theme(&nodes); + assert_variable_values(&nodes); + assert_variable_bindings(&nodes); +} + +fn assert_hidden_theme(nodes: &[Value]) { + let hidden = named(nodes, "Internal Only Canvas"); assert_eq!((hidden["type"].as_str(), hidden["internalOnly"].as_bool(), hidden["visible"].as_bool()), (Some("CANVAS"), Some(true), Some(false))); - let set = named(&nodes, "Theme"); + let set = named(nodes, "Theme"); assert_eq!(set["type"], "VARIABLE_SET"); assert_eq!(set["parentIndex"]["guid"], hidden["guid"]); let modes = set["variableSetModes"].as_array().unwrap(); assert_eq!(modes.iter().map(|m| m["name"].as_str().unwrap()).collect::>(), ["Light", "Dark"]); - let bg = named(&nodes, "color/bg"); +} + +fn assert_variable_values(nodes: &[Value]) { + let set = named(nodes, "Theme"); + let modes = set["variableSetModes"].as_array().unwrap(); + let bg = named(nodes, "color/bg"); assert_eq!((bg["type"].as_str(), bg["variableResolvedType"].as_str()), (Some("VARIABLE"), Some("COLOR"))); assert_eq!(bg["variableSetID"]["guid"], set["guid"]); assert_eq!(bg["description"], "Behind everything"); assert_eq!(bg["variableScopes"], json!(["FRAME_FILL", "SHAPE_FILL"])); - assert_eq!(named(&nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"])); + assert_eq!(named(nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"])); let values = bg["variableDataValues"]["entries"].as_array().unwrap(); assert_eq!(values.len(), 2); assert_eq!(values[1]["modeID"], modes[1]["id"]); @@ -295,19 +306,25 @@ fn variables_are_written_as_nodes_on_a_hidden_page() { (Some("COLOR"), Some(0.25)) ); // An alias names the variable's node; every value says the type it resolves to. - let accent = &named(&nodes, "color/accent")["variableDataValues"]["entries"]; + let accent = &named(nodes, "color/accent")["variableDataValues"]["entries"]; assert_eq!(accent[0]["variableData"]["dataType"], "ALIAS"); assert_eq!(accent[0]["variableData"]["resolvedDataType"], "COLOR"); - assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(&nodes, "color/fg")["guid"]); - assert_eq!(named(&nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀"); - assert_eq!(named(&nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false); - assert_eq!(named(&nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0); + assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(nodes, "color/fg")["guid"]); + assert_eq!(named(nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀"); + assert_eq!(named(nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false); + assert_eq!(named(nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0); +} + +fn assert_variable_bindings(nodes: &[Value]) { + let bg = named(nodes, "color/bg"); + let set = named(nodes, "Theme"); + let modes = set["variableSetModes"].as_array().unwrap(); // Bindings: on the paint, the gradient stop, the effect and the grid, and in the layer's map. - let card = named(&nodes, "Card"); + let card = named(nodes, "Card"); assert_eq!(card["fillPaints"][0]["colorVar"]["value"]["alias"]["guid"], bg["guid"]); assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], set["guid"]); assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableModeID"], modes[1]["id"]); - assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(&nodes, "space/gap")["guid"]); + assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(nodes, "space/gap")["guid"]); let fields: Vec<&str> = card["variableConsumptionMap"]["entries"].as_array().unwrap().iter().map(|e| e["variableField"].as_str().unwrap()).collect(); for f in @@ -315,7 +332,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() { { assert!(fields.contains(&f), "{f} in {fields:?}"); } - let swatch = named(&nodes, "Swatch"); + let swatch = named(nodes, "Swatch"); assert!(swatch["fillPaints"][1]["stopsVar"][0].get("colorVar").is_none()); assert_eq!(swatch["fillPaints"][1]["stopsVar"][1]["colorVar"]["value"]["alias"]["guid"], bg["guid"]); assert_eq!(swatch["fillPaints"][1]["stopsVar"].as_array().unwrap().len(), 2); @@ -324,7 +341,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() { assert_eq!(swatch["effects"][0][key]["dataType"], "ALIAS", "{key}"); } // The page sets a mode too. - assert_eq!(named(&nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(&nodes, "Spacing")["guid"]); + assert_eq!(named(nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(nodes, "Spacing")["guid"]); } #[test] diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs index e4793d48..e750a5e4 100644 --- a/crates/format/src/fig/map/layer.rs +++ b/crates/format/src/fig/map/layer.rs @@ -2,7 +2,10 @@ use super::component::{Entry, Inst, OverrideRow, prop_ref}; use super::connector::connector_has_saved_label; use super::layout::{layout_grid, tracks}; use super::paint::{effect, export_setting, paint, paint_bound, stroke}; -use super::*; +use super::{ + Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, guid, json, matrix, network, num, rows, + size, +}; /// How the layers of a resized container follow it when the file gives no derived layout for /// them: a frame's by their constraints, from its main's size to its own; a group's scale with it. @@ -61,6 +64,22 @@ impl Fit { } } +struct LayerChildren<'s, 'a> { + id: &'s str, + inst: Option<&'s Inst<'a>>, + base: &'s Kv<'a>, + child_space: Affine, + view: &'s View<'a>, + object: &'s mut Obj, + ty: &'s str, + rest_id: &'s str, + connector_snapshot: bool, + connector_terminal: bool, + depth: usize, + included: Option<&'s BTreeSet>, + slot_content: Option, +} + impl<'a> Mapper<'a> { /// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the /// parent's coordinates to the enclosing frame's (groups and booleans place their children in @@ -136,16 +155,7 @@ impl<'a> Mapper<'a> { connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget()); // A supported connector snapshot has its own note below instead of the generic stand-in one. let stand = stand_in(own).filter(|_| !connector_snapshot); - let ty = match own { - "ROUNDED_RECTANGLE" => "RECTANGLE", - "SYMBOL" => "COMPONENT", - "FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET", - "FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP", - "VARIABLE" | "VARIABLE_SET" => return None, - "CONNECTOR" if connector_snapshot && connector_terminal => "VECTOR", - "CONNECTOR" if connector_snapshot => "FRAME", - t => stand.map_or(t, |s| s.0), - }; + let ty = Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)?; if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); } @@ -225,18 +235,59 @@ impl<'a> Mapper<'a> { source.insert("slotOverride".into(), json!(true)); } let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY }; - let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(&view, &rest_id) } else { Vec::new() }; + let mut kids = self.map_layer_children(LayerChildren { + id, + inst, + base, + child_space, + view: &view, + object: &mut o, + ty, + rest_id: &rest_id, + connector_snapshot, + connector_terminal, + depth, + included, + slot_content, + }); + if stand.is_some_and(|s| s.0 == "FRAME") { + kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id)); + kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY")); + } + if !kids.is_empty() { + o.insert("children".into(), Value::Array(kids)); + } + Some(Value::Object(o)) + } + + fn map_layer_children(&mut self, context: LayerChildren<'_, 'a>) -> Vec { + let LayerChildren { + id, + inst, + base, + child_space, + view, + object: o, + ty, + rest_id, + connector_snapshot, + connector_terminal, + depth, + included, + slot_content, + } = context; + let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(view, rest_id) } else { Vec::new() }; if connector_terminal { // The saved vector is flattened into the connector node when no child slot remains. } else if ty == "INSTANCE" { - if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) { + if let Some((sub, sym)) = self.instance(o, view, id, inst, rest_id) { let main = self.nodes.get(&sym).map(|m| View::of(m)); let fit = Fit::frame(main.as_ref().and_then(View::size), view.size()); // An instance that records no auto layout of its own has its main's. let view = match main { Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => { let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) }; - self.auto_layout(&mut o, &layered); + self.auto_layout(o, &layered); layered } _ => view.clone(), @@ -252,7 +303,7 @@ impl<'a> Mapper<'a> { content_inst.slot_content = true; } for c in self.children(&content) { - kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included)); + kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(view), None, depth + 1, included)); } } else { // Inside an instance a layer's children are its main's: they follow it from the main's @@ -263,17 +314,29 @@ impl<'a> Mapper<'a> { (Some(_), _) => Fit::frame(View::of(base).size(), view.size()), }; for c in self.children(id) { - kids.extend(self.layer_filtered(&c, inst, child_space, Some(&view), fit, depth + 1, included)); + kids.extend(self.layer_filtered(&c, inst, child_space, Some(view), fit, depth + 1, included)); } } - if stand.is_some_and(|s| s.0 == "FRAME") { - kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id)); - kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY")); + kids + } + + fn mapped_type<'s>( + own: &'s str, + view: &View<'a>, + connector_snapshot: bool, + connector_terminal: bool, + stand: Option<(&'static str, &'static str)>, + ) -> Option<&'s str> { + match own { + "ROUNDED_RECTANGLE" => Some("RECTANGLE"), + "SYMBOL" => Some("COMPONENT"), + "FRAME" if view.flag("isStateGroup") == Some(true) => Some("COMPONENT_SET"), + "FRAME" if view.flag("resizeToFit") == Some(true) => Some("GROUP"), + "VARIABLE" | "VARIABLE_SET" => None, + "CONNECTOR" if connector_snapshot && connector_terminal => Some("VECTOR"), + "CONNECTOR" if connector_snapshot => Some("FRAME"), + t => Some(stand.map_or(t, |s| s.0)), } - if !kids.is_empty() { - o.insert("children".into(), Value::Array(kids)); - } - Some(Value::Object(o)) } /// A non-rectangular shape-with-text's shape, as a vector under its frame (which gives up its diff --git a/crates/format/src/fig/source_identity_tests.rs b/crates/format/src/fig/source_identity_tests.rs index 927b0227..09ec0c60 100644 --- a/crates/format/src/fig/source_identity_tests.rs +++ b/crates/format/src/fig/source_identity_tests.rs @@ -983,6 +983,11 @@ fn assert_external_namespace(doc: &Document) { let copied_frame = child(doc, copied_body, 0); assert_eq!(doc.node(frame).unwrap().source, None, "materialized ordinary layers are owned by the frame"); assert_eq!(doc.node(copied_frame).unwrap().source, Some(frame)); + assert_external_slots(doc, frame, copied_frame, main); + assert_external_copy_state(doc, frame, copied_frame); +} + +fn assert_external_slots(doc: &Document, frame: NodeId, copied_frame: NodeId, main: NodeId) { for index in 0..2 { let slot = child(doc, frame, index); let copied_slot = child(doc, copied_frame, index); @@ -999,6 +1004,9 @@ fn assert_external_namespace(doc: &Document) { assert_eq!(doc.node(child(doc, current, 0)).unwrap().source, Some(child(doc, inherited, 0))); assert_eq!(doc.node(child(doc, current, 0)).unwrap().transform.translation().x, 4.0); } +} + +fn assert_external_copy_state(doc: &Document, frame: NodeId, copied_frame: NodeId) { let old = doc.node(child(doc, frame, 0)).unwrap(); let current = doc.node(child(doc, copied_frame, 0)).unwrap(); assert!(!old.visible && old.is_mask && old.opacity < 1.0); diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 35d8d08a..4ebb117f 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -630,63 +630,82 @@ fn imports_the_fixture() { assert!(is_fig(&bytes)); let (doc, warnings) = import(&bytes, "fallback").unwrap(); assert_eq!(doc.name, "Fixture"); + assert_fixture_layers(&doc); + assert_fixture_styles(&doc); + assert_fixture_variables(&doc); + assert_fixture_components(&doc); + assert_fixture_prototype(&doc, &warnings); +} + +fn assert_fixture_layers(doc: &Document) { // The internal page is skipped. assert_eq!(doc.pages.len(), 1); let page = doc.node(doc.pages[0]).unwrap(); assert_eq!(page.name, "Page A"); // Siblings by position: the frame comes first even though it's listed after the component. assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen"); - let screen = named(&doc, "Screen"); + let screen = named(doc, "Screen"); let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy", "Note"]); // Image fill from the archive. - let photo = named(&doc, "Photo"); + let photo = named(doc, "Photo"); let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") }; assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1))); assert_eq!((photo.corners.radii[0], photo.stroke.weight), (6.0, 2.0)); // Text with a bold run over "the". - let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") }; + let NodeKind::Text(t) = &named(doc, "Title").kind else { panic!("not text") }; assert_eq!(t.characters, "Hi there"); assert_eq!(t.style.font_size, 16.0); assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)"); assert_eq!(t.runs.len(), 1); assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold")); // The group's children keep their place on the page (group at 100, 100). - assert!(matches!(named(&doc, "Group").kind, NodeKind::Group)); - let tri = named(&doc, "Tri"); + assert!(matches!(named(doc, "Group").kind, NodeKind::Group)); + let tri = named(doc, "Tri"); assert!(matches!(&tri.kind, NodeKind::Vector { network } if !network.regions.is_empty())); let abs = doc.world_transform(tri.id).translation(); assert!((abs.x - 105.0).abs() < 1e-6 && (abs.y - 106.0).abs() < 1e-6, "{abs:?}"); - assert!(matches!(named(&doc, "Star").kind, NodeKind::Star { count: 6, .. })); - // The linked style supplies the paint. - let styled = named(&doc, "Styled"); + assert!(matches!(named(doc, "Star").kind, NodeKind::Star { count: 6, .. })); +} + +fn assert_fixture_styles(doc: &Document) { + let styled = named(doc, "Styled"); assert_eq!(styled.styles.fill.as_deref(), Some("1:3")); assert!(doc.styles.iter().any(|s| s.name == "Brand")); let figcraft_doc::color::PaintKind::Solid { color } = styled.fills[0].kind else { panic!("not solid") }; assert_eq!((color.r, color.g, color.b), (0.0, 0.0, 1.0)); - // Variables, the fill binding and the explicit mode. +} + +fn assert_fixture_variables(doc: &Document) { + let screen = named(doc, "Screen"); assert_eq!(doc.variables.collections.len(), 1); assert_eq!(doc.variables.variables[0].kind, VariableType::Color); - assert_eq!(named(&doc, "Card").bound.get("fill").map(String::as_str), Some("1:5")); + assert_eq!(named(doc, "Card").bound.get("fill").map(String::as_str), Some("1:5")); assert_eq!(screen.var_modes.get("1:4").map(String::as_str), Some("1:91")); - // The component keeps its property; the instance is expanded with the value applied. - let button = named(&doc, "Button"); +} + +fn assert_fixture_components(doc: &Document) { + let button = named(doc, "Button"); assert!(matches!(&button.kind, NodeKind::Component { props, .. } if props.contains_key("Show icon"))); - let inst = named(&doc, "Button copy"); + let inst = named(doc, "Button copy"); assert!( matches!(&inst.kind, NodeKind::Instance { component, props } if *component == button.id && props.get("Show icon") == Some(&Value::Bool(false))) ); let icon = doc.node(inst.children[0]).unwrap(); assert_eq!(icon.name, "Renamed icon"); assert!(!icon.visible); - // Prototype: the click navigates to the component; the flow starts at the screen. - let it = &photo.interactions[0]; +} + +fn assert_fixture_prototype(doc: &Document, warnings: &[String]) { + let button = named(doc, "Button"); + let screen = named(doc, "Screen"); + let it = &named(doc, "Photo").interactions[0]; assert!(matches!(it.actions[0], figcraft_doc::proto::Action::Navigate { dest, .. } if dest == button.id)); assert_eq!(it.duration_ms, 250); let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") }; assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id)); // The sticky note comes in as a frame, with a warning. - assert!(matches!(named(&doc, "Note").kind, NodeKind::Frame)); + assert!(matches!(named(doc, "Note").kind, NodeKind::Frame)); assert!(warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{warnings:?}"); } diff --git a/crates/format/src/rest/proto_variable_tests.rs b/crates/format/src/rest/proto_variable_tests.rs index cbaa3749..d2fdde9a 100644 --- a/crates/format/src/rest/proto_variable_tests.rs +++ b/crates/format/src/rest/proto_variable_tests.rs @@ -209,8 +209,14 @@ fn embedded_variables_match_separate_ones() { #[test] fn interactions_triggers_actions_and_transitions() { let (doc, warnings) = import_with(&file_json(), Some(&variables_json()), &no_images).unwrap(); - let id = |n: &str| named(&doc, n).id; - let button = named(&doc, "Button"); + assert_interactions(&doc, &warnings); + assert_legacy_transition(&doc); + assert_interactions_without_variables(); +} + +fn assert_interactions(doc: &Document, warnings: &[String]) { + let id = |n: &str| named(doc, n).id; + let button = named(doc, "Button"); let its = &button.interactions; assert_eq!(its.len(), 8, "{its:#?}"); @@ -239,18 +245,22 @@ fn interactions_triggers_actions_and_transitions() { assert_eq!(its[7].trigger, Trigger::MouseUp); assert_eq!(its[7].actions, [Action::ScrollTo { dest: id("Home"), offset: [0.0, 0.0] }], "unresolved destinations are dropped"); - // The legacy single transition. - let details = named(&doc, "Details"); + assert!(!has(warnings, "modifier keys"), "{warnings:?}"); + for w in ["video triggers", "without a destination"] { + assert!(has(warnings, w), "{w}: {warnings:?}"); + } +} + +fn assert_legacy_transition(doc: &Document) { + let id = |n: &str| named(doc, n).id; + let details = named(doc, "Details"); assert_eq!(details.interactions.len(), 1); let legacy = &details.interactions[0]; assert_eq!((&legacy.trigger, &legacy.actions[..]), (&Trigger::OnClick, &[Action::Navigate { dest: id("Home"), preserve_scroll: false }][..])); assert_eq!((legacy.transition, legacy.duration_ms, legacy.easing), (Transition::Dissolve, 200, Easing::EaseIn)); +} - assert!(!has(&warnings, "modifier keys"), "{warnings:?}"); - for w in ["video triggers", "without a destination"] { - assert!(has(&warnings, w), "{w}: {warnings:?}"); - } - // Without variables, set-variable actions have nothing to set. +fn assert_interactions_without_variables() { let (doc, warnings) = import(&file_json(), &no_images).unwrap(); assert_eq!(named(&doc, "Button").interactions.len(), 6); assert!(has(&warnings, "set-variable actions") && has(&warnings, "set-variable-mode actions"), "{warnings:?}");