diff --git a/crates/op-orchestrator/src/cleanup.rs b/crates/op-orchestrator/src/cleanup.rs index 2d2718b34..ce7757d8b 100644 --- a/crates/op-orchestrator/src/cleanup.rs +++ b/crates/op-orchestrator/src/cleanup.rs @@ -11,14 +11,14 @@ use crate::cleanup_typography::repair_overbold_text_hierarchy; use crate::plan::OrchestratorPlan; use crate::types::DocSink; use jian_ops_schema::node::{ - container::{ContainerProps, Padding}, + container::{ContainerProps, LayoutMode, Padding}, PenNode, }; use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword}; use jian_ops_schema::style::PenEffect; use op_editor_core::{ - first_fill_type, first_solid_fill_hex, EditorCommand, EditorState, FillType, LayoutPropValue, - NodeId, PenNodeExt, + fills::node_stroke_width, first_fill_type, first_solid_fill_hex, EditorCommand, EditorState, + FillType, LayoutPropValue, NodeId, PenNodeExt, }; #[path = "cleanup_desktop_dashboard.rs"] @@ -110,6 +110,126 @@ pub(crate) fn collapse_nested_horizontal_padding_for_all_roots(sink: &mut dyn Do } } +pub(crate) fn expand_absolute_container_to_children_for_all_roots(sink: &mut dyn DocSink) { + let root_ids: Vec = sink + .state() + .active_children() + .iter() + .map(|node| node.id_str().to_string()) + .collect(); + for root_id in root_ids { + expand_absolute_container_to_children(sink, &root_id); + } +} + +pub(crate) fn pad_clipping_horizontal_row_for_stroke_for_all_roots(sink: &mut dyn DocSink) { + let root_ids: Vec = sink + .state() + .active_children() + .iter() + .map(|node| node.id_str().to_string()) + .collect(); + for root_id in root_ids { + pad_clipping_horizontal_row_for_stroke(sink, &root_id); + } +} + +pub(crate) fn collapse_fill_container_content_sections_for_all_roots(sink: &mut dyn DocSink) { + let root_ids: Vec = sink + .state() + .active_children() + .iter() + .map(|node| node.id_str().to_string()) + .collect(); + for root_id in root_ids { + collapse_fill_container_content_sections(sink, &root_id); + } +} + +pub(crate) fn equalize_horizontal_card_heights_for_all_roots(sink: &mut dyn DocSink) { + let root_ids: Vec = sink + .state() + .active_children() + .iter() + .map(|node| node.id_str().to_string()) + .collect(); + for root_id in root_ids { + equalize_horizontal_card_heights(sink, &root_id); + } +} + +fn pad_clipping_horizontal_row_for_stroke(sink: &mut dyn DocSink, root_id: &str) { + let repairs: Vec = { + let Some(root) = find_root(sink.state(), root_id) else { + return; + }; + let mut repairs = Vec::new(); + collect_clip_row_stroke_padding_repairs(root, &mut repairs); + repairs + }; + + for repair in repairs { + sink.apply(EditorCommand::SetNodeLayoutProp { + node_id: repair.node_id, + property: "padding".to_string(), + value: LayoutPropValue::NumberArray(vec![ + repair.padding[0], + repair.padding[1], + repair.padding[2], + repair.padding[3], + ]), + }); + } +} + +#[derive(Debug, Clone)] +struct ClipRowStrokePaddingRepair { + node_id: NodeId, + padding: [f64; 4], +} + +fn collect_clip_row_stroke_padding_repairs( + node: &PenNode, + repairs: &mut Vec, +) { + if let Some(repair) = clip_row_stroke_padding_repair(node) { + repairs.push(repair); + } + if let Some(children) = node.children() { + for child in children { + collect_clip_row_stroke_padding_repairs(child, repairs); + } + } +} + +fn clip_row_stroke_padding_repair(node: &PenNode) -> Option { + let props = frame_container_props(node)?; + if props.layout.as_ref() != Some(&LayoutMode::Horizontal) || props.clip_content != Some(true) { + return None; + } + let max_stroke = node + .children()? + .iter() + .filter_map(node_stroke_width) + .max_by(f64::total_cmp)?; + let mut padding = props + .padding + .as_ref() + .map(padding_sides) + .unwrap_or([0.0, 0.0, 0.0, 0.0]); + let stroke_padding = max_stroke.ceil(); + if padding.iter().all(|side| *side >= stroke_padding) { + return None; + } + for side in &mut padding { + *side = side.max(stroke_padding); + } + Some(ClipRowStrokePaddingRepair { + node_id: NodeId::new(node.id_str().to_string()), + padding, + }) +} + /// Mobile root-level bottom-nav dedupe. Weak-model Chinese prompts can produce /// both a localized bottom nav section and an English normalized bottom nav. /// Keep the bottom-most/last top-level nav and remove earlier duplicates. @@ -151,6 +271,211 @@ fn remove_duplicate_bottom_nav_sections(sink: &mut dyn DocSink, root_id: &str) { } } +/// Fixed-height vertical mobile artboards must keep direct content sections +/// content-driven; fill-height direct children can consume all leftover space +/// when a sibling subtask emits zero nodes. +fn collapse_fill_container_content_sections(sink: &mut dyn DocSink, root_id: &str) { + let repairs: Vec = { + let Some(root) = find_root(sink.state(), root_id) else { + return; + }; + if !is_fixed_height_vertical_root(root) { + return; + } + let Some(children) = root.children() else { + return; + }; + children + .iter() + .filter(|child| is_fill_height_content_frame(child)) + .map(|child| NodeId::new(child.id_str().to_string())) + .collect() + }; + + for node_id in repairs { + sink.apply(EditorCommand::SetNodeLayoutProp { + node_id, + property: "height".to_string(), + value: LayoutPropValue::Keyword("fit_content".to_string()), + }); + } +} + +fn is_fixed_height_vertical_root(root: &PenNode) -> bool { + let Some(props) = frame_container_props(root) else { + return false; + }; + props + .layout + .as_ref() + .is_some_and(|layout| layout == &LayoutMode::Vertical) + && matches!(props.height.as_ref(), Some(SizingBehavior::Number(_))) +} + +fn is_fill_height_content_frame(node: &PenNode) -> bool { + let Some(props) = frame_container_props(node) else { + return false; + }; + matches!( + props.height.as_ref(), + Some(SizingBehavior::Keyword(SizingKeyword::FillContainer)) + ) && node + .children() + .map(|children| !children.is_empty()) + .unwrap_or(false) +} + +fn equalize_horizontal_card_heights(sink: &mut dyn DocSink, root_id: &str) { + let repairs: Vec = { + let Some(root) = find_root(sink.state(), root_id) else { + return; + }; + let mut repairs = Vec::new(); + collect_horizontal_card_height_repairs(root, &mut repairs); + repairs + }; + + for repair in repairs { + sink.apply(EditorCommand::SetNodeLayoutProp { + node_id: repair.card_id, + property: "height".to_string(), + value: LayoutPropValue::Keyword("fill_container".to_string()), + }); + } +} + +#[derive(Debug, Clone)] +struct CardHeightRepair { + card_id: NodeId, +} + +fn collect_horizontal_card_height_repairs(node: &PenNode, repairs: &mut Vec) { + if let Some(card_repairs) = horizontal_card_height_repairs(node) { + repairs.extend(card_repairs); + } + if let Some(children) = node.children() { + for child in children { + collect_horizontal_card_height_repairs(child, repairs); + } + } +} + +fn horizontal_card_height_repairs(node: &PenNode) -> Option> { + let props = frame_container_props(node)?; + if props.layout.as_ref() != Some(&LayoutMode::Horizontal) { + return None; + } + + let mut cards = Vec::new(); + for child in node.children()? { + let Some(child_props) = frame_container_props(child) else { + continue; + }; + if child + .children() + .map(|children| children.is_empty()) + .unwrap_or(true) + { + continue; + } + if !width_is_fill_container(child_props) { + return None; + } + if matches!( + child_props.height.as_ref(), + Some(SizingBehavior::Keyword(SizingKeyword::FitContent)) + ) { + cards.push(child); + } + } + + (cards.len() >= 2).then(|| { + cards + .into_iter() + .map(|card| CardHeightRepair { + card_id: NodeId::new(card.id_str().to_string()), + }) + .collect() + }) +} + +fn expand_absolute_container_to_children(sink: &mut dyn DocSink, root_id: &str) { + let repairs: Vec = { + let Some(root) = find_root(sink.state(), root_id) else { + return; + }; + let mut repairs = Vec::new(); + collect_absolute_container_repairs(root, &mut repairs); + repairs + }; + + for repair in repairs { + sink.apply(EditorCommand::UpdateNode { + node_id: repair.node_id, + x: None, + y: None, + width: None, + height: Some(repair.height.ceil() as i32), + name: None, + fill_hex: None, + page_id: None, + }); + } +} + +#[derive(Debug, Clone)] +struct AbsoluteContainerRepair { + node_id: NodeId, + height: f64, +} + +fn collect_absolute_container_repairs(node: &PenNode, repairs: &mut Vec) { + if let Some(repair) = absolute_container_repair(node) { + repairs.push(repair); + } + if let Some(children) = node.children() { + for child in children { + collect_absolute_container_repairs(child, repairs); + } + } +} + +fn absolute_container_repair(node: &PenNode) -> Option { + let props = frame_container_props(node)?; + if props.layout.as_ref() != Some(&LayoutMode::None) + || !matches!( + props.height.as_ref(), + Some(SizingBehavior::Keyword(SizingKeyword::FitContent)) + ) + { + return None; + } + + let children = node.children()?; + if !children + .iter() + .any(|child| matches!(child, PenNode::Image(_))) + { + return None; + } + + let height = children + .iter() + .filter_map(|child| { + if !matches!(child, PenNode::Image(_)) { + return None; + } + child + .height_px() + .map(|height| child.base().y.unwrap_or(0.0) + height) + }) + .max_by(f64::total_cmp)?; + (height > 0.0).then(|| AbsoluteContainerRepair { + node_id: NodeId::new(node.id_str().to_string()), + height, + }) +} + /// Collapse `section > transparent fill-width wrapper` double horizontal /// padding while preserving all vertical padding. fn collapse_nested_horizontal_padding(sink: &mut dyn DocSink, root_id: &str) { @@ -1017,6 +1342,10 @@ pub fn run_cleanup_passes(sink: &mut dyn DocSink, plan: &OrchestratorPlan, root_ remove_duplicate_status_bars(sink, rid); remove_duplicate_bottom_nav_sections(sink, rid); collapse_nested_horizontal_padding(sink, rid); + expand_absolute_container_to_children(sink, rid); + pad_clipping_horizontal_row_for_stroke(sink, rid); + equalize_horizontal_card_heights(sink, rid); + collapse_fill_container_content_sections(sink, rid); repair_light_mobile_nav_surfaces(sink, rid); repair_mobile_content_sections(sink, rid); cleanup_mobile_chrome::repair_mobile_structural_chrome(sink, rid); @@ -1171,6 +1500,22 @@ mod tests_mobile_bottom_nav_dedup; #[path = "cleanup_nested_horizontal_padding_tests.rs"] mod tests_nested_horizontal_padding; +#[cfg(test)] +#[path = "cleanup_absolute_container_tests.rs"] +mod tests_absolute_container; + +#[cfg(test)] +#[path = "cleanup_fill_container_content_tests.rs"] +mod tests_fill_container_content; + +#[cfg(test)] +#[path = "cleanup_clip_row_stroke_tests.rs"] +mod tests_clip_row_stroke; + +#[cfg(test)] +#[path = "cleanup_card_height_equalize_tests.rs"] +mod tests_card_height_equalize; + #[cfg(test)] #[path = "cleanup_desktop_dashboard_tests.rs"] mod tests_desktop_dashboard; diff --git a/crates/op-orchestrator/src/cleanup_absolute_container_tests.rs b/crates/op-orchestrator/src/cleanup_absolute_container_tests.rs new file mode 100644 index 000000000..928765348 --- /dev/null +++ b/crates/op-orchestrator/src/cleanup_absolute_container_tests.rs @@ -0,0 +1,289 @@ +use super::*; +use crate::test_support::VecDocSink; +use serde_json::{json, Value}; + +fn insert_tree(sink: &mut VecDocSink, json: &str) { + let tree: PenNode = serde_json::from_str(json).expect("test tree json"); + sink.state.apply(EditorCommand::InsertAuthoredSubtree { + nodes: vec![tree], + parent_id: NodeId::NONE, + page_id: None, + }); + sink.applied.clear(); +} + +fn find_node<'a>(node: &'a PenNode, id: &str) -> Option<&'a PenNode> { + if node.id_str() == id { + return Some(node); + } + node.children()? + .iter() + .find_map(|child| find_node(child, id)) +} + +fn find_active_node<'a>(sink: &'a VecDocSink, id: &str) -> &'a PenNode { + sink.state + .active_children() + .iter() + .find_map(|node| find_node(node, id)) + .expect("node exists") +} + +fn node_json(sink: &VecDocSink, id: &str) -> Value { + serde_json::to_value(find_active_node(sink, id)).expect("serialize node") +} + +#[test] +fn absolute_fit_content_wrapper_expands_to_image_height() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Root", + "width": 390, + "height": 844, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "image-wrapper", + "name": "Image Wrapper", + "width": 210, + "height": "fit_content", + "layout": "none", + "children": [ + { + "type": "image", + "id": "product-image", + "name": "Product Image", + "src": "", + "width": 210, + "height": 240 + } + ] + } + ] + }"##, + ); + + expand_absolute_container_to_children(&mut sink, "root"); + + assert_eq!(node_json(&sink, "image-wrapper")["height"], json!(240.0)); +} + +#[test] +fn absolute_wrapper_with_y_offset_uses_y_plus_height() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Root", + "width": 390, + "height": 844, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "image-wrapper", + "name": "Image Wrapper", + "width": 210, + "height": "fit_content", + "layout": "none", + "children": [ + { + "type": "image", + "id": "product-image", + "name": "Product Image", + "src": "", + "y": 10, + "width": 210, + "height": 240 + } + ] + } + ] + }"##, + ); + + expand_absolute_container_to_children(&mut sink, "root"); + + assert_eq!(node_json(&sink, "image-wrapper")["height"], json!(250.0)); +} + +#[test] +fn layout_none_container_without_image_child_untouched() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Root", + "width": 390, + "height": 844, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "guest-counter", + "name": "Guest Counter", + "width": 112, + "height": "fit_content", + "layout": "none", + "children": [ + { + "type": "icon_font", + "id": "minus-icon", + "name": "Minus Icon", + "iconFontName": "minus", + "y": 200, + "width": 20, + "height": 20 + }, + { + "type": "text", + "id": "guest-count", + "name": "Guest Count", + "content": "2", + "y": 212, + "width": 16, + "height": 20 + } + ] + } + ] + }"##, + ); + let before = node_json(&sink, "guest-counter"); + + expand_absolute_container_to_children(&mut sink, "root"); + + assert_eq!(node_json(&sink, "guest-counter"), before); +} + +#[test] +fn flex_fit_content_untouched() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Root", + "width": 390, + "height": 844, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "image-wrapper", + "name": "Image Wrapper", + "width": 210, + "height": "fit_content", + "layout": "vertical", + "children": [ + { + "type": "image", + "id": "product-image", + "name": "Product Image", + "src": "", + "width": 210, + "height": 240 + } + ] + } + ] + }"##, + ); + let before = node_json(&sink, "image-wrapper"); + + expand_absolute_container_to_children(&mut sink, "root"); + + assert_eq!(node_json(&sink, "image-wrapper"), before); +} + +#[test] +fn absolute_wrapper_with_numeric_height_untouched() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Root", + "width": 390, + "height": 844, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "status-bar", + "name": "Status Bar", + "width": "fill_container", + "height": 62, + "layout": "none", + "children": [ + { + "type": "frame", + "id": "status-content", + "name": "Status Content", + "width": 210, + "height": 240, + "children": [] + } + ] + } + ] + }"##, + ); + let before = node_json(&sink, "status-bar"); + + expand_absolute_container_to_children(&mut sink, "root"); + + assert_eq!(node_json(&sink, "status-bar"), before); +} + +#[test] +fn absolute_wrapper_only_fill_children_untouched() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Root", + "width": 390, + "height": 844, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "image-wrapper", + "name": "Image Wrapper", + "width": 210, + "height": "fit_content", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "fill-child", + "name": "Fill Child", + "width": "fill_container", + "height": "fill_container", + "children": [] + } + ] + } + ] + }"##, + ); + let before = node_json(&sink, "image-wrapper"); + + expand_absolute_container_to_children(&mut sink, "root"); + + assert_eq!(node_json(&sink, "image-wrapper"), before); +} diff --git a/crates/op-orchestrator/src/cleanup_card_height_equalize_tests.rs b/crates/op-orchestrator/src/cleanup_card_height_equalize_tests.rs new file mode 100644 index 000000000..24b8cb751 --- /dev/null +++ b/crates/op-orchestrator/src/cleanup_card_height_equalize_tests.rs @@ -0,0 +1,288 @@ +use super::*; +use crate::test_support::VecDocSink; +use serde_json::{json, Value}; + +fn insert_tree(sink: &mut VecDocSink, json: &str) { + let tree: PenNode = serde_json::from_str(json).expect("test tree json"); + sink.state.apply(EditorCommand::InsertAuthoredSubtree { + nodes: vec![tree], + parent_id: NodeId::NONE, + page_id: None, + }); + sink.applied.clear(); +} + +fn find_node<'a>(node: &'a PenNode, id: &str) -> Option<&'a PenNode> { + if node.id_str() == id { + return Some(node); + } + node.children()? + .iter() + .find_map(|child| find_node(child, id)) +} + +fn find_active_node<'a>(sink: &'a VecDocSink, id: &str) -> &'a PenNode { + sink.state + .active_children() + .iter() + .find_map(|node| find_node(node, id)) + .expect("node exists") +} + +fn node_json(sink: &VecDocSink, id: &str) -> Value { + serde_json::to_value(find_active_node(sink, id)).expect("serialize node") +} + +#[test] +fn two_fill_width_fit_height_cards_in_row_become_fill_height() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Page", + "width": 390, + "height": "fit_content", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "deals-row", + "name": "Deals of the Week", + "width": "fill_container", + "height": "fit_content", + "layout": "horizontal", + "children": [ + { + "type": "frame", + "id": "card-a", + "name": "Deal Card A", + "width": "fill_container", + "height": "fit_content", + "layout": "vertical", + "children": [ + {"type": "text", "id": "card-a-title", "content": "Weekend Bento"} + ] + }, + { + "type": "frame", + "id": "card-b", + "name": "Deal Card B", + "width": "fill_container", + "height": "fit_content", + "layout": "vertical", + "children": [ + {"type": "text", "id": "card-b-title", "content": "Family feast with extra dessert"} + ] + } + ] + } + ] + }"##, + ); + + equalize_horizontal_card_heights(&mut sink, "root"); + + assert_eq!( + node_json(&sink, "card-a")["height"], + json!("fill_container") + ); + assert_eq!( + node_json(&sink, "card-b")["height"], + json!("fill_container") + ); +} + +#[test] +fn single_card_row_untouched() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Page", + "width": 390, + "height": "fit_content", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "deals-row", + "name": "Deals of the Week", + "width": "fill_container", + "height": "fit_content", + "layout": "horizontal", + "children": [ + { + "type": "frame", + "id": "card-a", + "name": "Deal Card A", + "width": "fill_container", + "height": "fit_content", + "layout": "vertical", + "children": [ + {"type": "text", "id": "card-a-title", "content": "Weekend Bento"} + ] + } + ] + } + ] + }"##, + ); + let before = node_json(&sink, "root"); + + equalize_horizontal_card_heights(&mut sink, "root"); + + assert_eq!(node_json(&sink, "root"), before); +} + +#[test] +fn mixed_width_row_untouched() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Page", + "width": 390, + "height": "fit_content", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "deals-row", + "name": "Deals of the Week", + "width": "fill_container", + "height": "fit_content", + "layout": "horizontal", + "children": [ + { + "type": "frame", + "id": "card-a", + "name": "Deal Card A", + "width": "fill_container", + "height": "fit_content", + "layout": "vertical", + "children": [ + {"type": "text", "id": "card-a-title", "content": "Weekend Bento"} + ] + }, + { + "type": "frame", + "id": "card-b", + "name": "Deal Card B", + "width": 180, + "height": "fit_content", + "layout": "vertical", + "children": [ + {"type": "text", "id": "card-b-title", "content": "Family feast"} + ] + } + ] + } + ] + }"##, + ); + let before = node_json(&sink, "root"); + + equalize_horizontal_card_heights(&mut sink, "root"); + + assert_eq!(node_json(&sink, "root"), before); +} + +#[test] +fn vertical_container_untouched() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Page", + "width": 390, + "height": "fit_content", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "card-a", + "name": "Deal Card A", + "width": "fill_container", + "height": "fit_content", + "layout": "vertical", + "children": [ + {"type": "text", "id": "card-a-title", "content": "Weekend Bento"} + ] + }, + { + "type": "frame", + "id": "card-b", + "name": "Deal Card B", + "width": "fill_container", + "height": "fit_content", + "layout": "vertical", + "children": [ + {"type": "text", "id": "card-b-title", "content": "Family feast"} + ] + } + ] + }"##, + ); + let before = node_json(&sink, "root"); + + equalize_horizontal_card_heights(&mut sink, "root"); + + assert_eq!(node_json(&sink, "root"), before); +} + +#[test] +fn empty_frame_siblings_untouched() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Page", + "width": 390, + "height": "fit_content", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "deals-row", + "name": "Deals of the Week", + "width": "fill_container", + "height": "fit_content", + "layout": "horizontal", + "children": [ + { + "type": "frame", + "id": "spacer-a", + "name": "Spacer A", + "width": "fill_container", + "height": "fit_content", + "children": [] + }, + { + "type": "frame", + "id": "spacer-b", + "name": "Spacer B", + "width": "fill_container", + "height": "fit_content", + "children": [] + } + ] + } + ] + }"##, + ); + let before = node_json(&sink, "root"); + + equalize_horizontal_card_heights(&mut sink, "root"); + + assert_eq!(node_json(&sink, "root"), before); +} diff --git a/crates/op-orchestrator/src/cleanup_clip_row_stroke_tests.rs b/crates/op-orchestrator/src/cleanup_clip_row_stroke_tests.rs new file mode 100644 index 000000000..d925aa39b --- /dev/null +++ b/crates/op-orchestrator/src/cleanup_clip_row_stroke_tests.rs @@ -0,0 +1,171 @@ +use super::*; +use crate::test_support::VecDocSink; +use serde_json::{json, Value}; + +fn insert_tree(sink: &mut VecDocSink, json: &str) { + let tree: PenNode = serde_json::from_str(json).expect("test tree json"); + sink.state.apply(EditorCommand::InsertAuthoredSubtree { + nodes: vec![tree], + parent_id: NodeId::NONE, + page_id: None, + }); + sink.applied.clear(); +} + +fn find_node<'a>(node: &'a PenNode, id: &str) -> Option<&'a PenNode> { + if node.id_str() == id { + return Some(node); + } + node.children()? + .iter() + .find_map(|child| find_node(child, id)) +} + +fn find_active_node<'a>(sink: &'a VecDocSink, id: &str) -> &'a PenNode { + sink.state + .active_children() + .iter() + .find_map(|node| find_node(node, id)) + .expect("node exists") +} + +fn node_json(sink: &VecDocSink, id: &str) -> Value { + serde_json::to_value(find_active_node(sink, id)).expect("serialize node") +} + +fn insert_date_scroller( + sink: &mut VecDocSink, + row_layout: &str, + clip_content: bool, + row_padding: &str, + child_stroke: &str, +) { + insert_tree( + sink, + &format!( + r##"{{ + "type": "frame", + "id": "root", + "name": "Mobile Root", + "width": 390, + "height": 844, + "layout": "vertical", + "children": [ + {{ + "type": "frame", + "id": "section", + "name": "Upcoming", + "width": "fill_container", + "height": "fit_content", + "layout": "vertical", + "children": [ + {{ + "type": "frame", + "id": "date-row", + "name": "Date Scroller", + "width": "fill_container", + "height": "fit_content", + "layout": "{row_layout}", + "clipContent": {clip_content}, + "padding": {row_padding}, + "children": [ + {{ + "type": "frame", + "id": "date-chip", + "name": "Tue 14", + "width": 48, + "height": 60, + "layout": "vertical", + {child_stroke} + "children": [] + }} + ] + }} + ] + }} + ] + }}"## + ), + ); +} + +#[test] +fn clipping_horizontal_row_with_stroked_chip_gets_stroke_padding_on_all_sides() { + let mut sink = VecDocSink::new(); + insert_date_scroller( + &mut sink, + "horizontal", + true, + "[0, 0, 0, 0]", + r##""stroke": {"thickness": 1, "fill": [{"type": "solid", "color": "#E5E7EB"}]},"##, + ); + + pad_clipping_horizontal_row_for_stroke(&mut sink, "root"); + + assert_eq!( + node_json(&sink, "date-row")["padding"], + json!([1.0, 1.0, 1.0, 1.0]) + ); +} + +#[test] +fn non_clipping_row_untouched() { + let mut sink = VecDocSink::new(); + insert_date_scroller( + &mut sink, + "horizontal", + false, + "[0, 12]", + r##""stroke": {"thickness": 1, "fill": [{"type": "solid", "color": "#E5E7EB"}]},"##, + ); + let before = node_json(&sink, "date-row"); + + pad_clipping_horizontal_row_for_stroke(&mut sink, "root"); + + assert_eq!(node_json(&sink, "date-row"), before); +} + +#[test] +fn row_without_stroked_children_untouched() { + let mut sink = VecDocSink::new(); + insert_date_scroller(&mut sink, "horizontal", true, "[0, 12]", ""); + let before = node_json(&sink, "date-row"); + + pad_clipping_horizontal_row_for_stroke(&mut sink, "root"); + + assert_eq!(node_json(&sink, "date-row"), before); +} + +#[test] +fn row_with_sufficient_vertical_padding_untouched() { + let mut sink = VecDocSink::new(); + insert_date_scroller( + &mut sink, + "horizontal", + true, + "[2, 12, 2, 12]", + r##""stroke": {"thickness": 1, "fill": [{"type": "solid", "color": "#E5E7EB"}]},"##, + ); + let before = node_json(&sink, "date-row"); + + pad_clipping_horizontal_row_for_stroke(&mut sink, "root"); + + assert_eq!(node_json(&sink, "date-row"), before); +} + +#[test] +fn vertical_container_untouched() { + let mut sink = VecDocSink::new(); + insert_date_scroller( + &mut sink, + "vertical", + true, + "[0, 12]", + r##""stroke": {"thickness": 1, "fill": [{"type": "solid", "color": "#E5E7EB"}]},"##, + ); + let before = node_json(&sink, "date-row"); + + pad_clipping_horizontal_row_for_stroke(&mut sink, "root"); + + assert_eq!(node_json(&sink, "date-row"), before); +} diff --git a/crates/op-orchestrator/src/cleanup_fill_container_content_tests.rs b/crates/op-orchestrator/src/cleanup_fill_container_content_tests.rs new file mode 100644 index 000000000..8a0daa3b0 --- /dev/null +++ b/crates/op-orchestrator/src/cleanup_fill_container_content_tests.rs @@ -0,0 +1,205 @@ +use super::*; +use crate::plan::{OrchestratorPlan, RootFrameSpec}; +use crate::test_support::VecDocSink; +use serde_json::{json, Value}; + +fn plan() -> OrchestratorPlan { + OrchestratorPlan { + root_frame: RootFrameSpec { + id: "root".into(), + name: "Mobile Food App".into(), + width: 375.0, + height: 812.0, + layout: None, + gap: None, + padding: None, + fill: None, + }, + subtasks: vec![], + style_guide_name: None, + } +} + +fn insert_root(value: Value) -> VecDocSink { + let mut sink = VecDocSink::new(); + let root: PenNode = serde_json::from_value(value).expect("root json"); + sink.state.apply(EditorCommand::InsertAuthoredSubtree { + nodes: vec![root], + parent_id: NodeId::NONE, + page_id: None, + }); + sink.applied.clear(); + sink +} + +fn find_node<'a>(node: &'a PenNode, id: &str) -> Option<&'a PenNode> { + if node.id_str() == id { + return Some(node); + } + node.children()? + .iter() + .find_map(|child| find_node(child, id)) +} + +fn active_node_value(sink: &VecDocSink, id: &str) -> Value { + let node = sink + .state + .active_children() + .iter() + .find_map(|node| find_node(node, id)) + .expect("node exists"); + serde_json::to_value(node).expect("serialize node") +} + +#[test] +fn fill_container_content_section_on_fixed_vertical_root_collapses() { + let mut sink = insert_root(json!({ + "type": "frame", + "id": "root", + "name": "Food App", + "width": 375, + "height": 812, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "promo", + "name": "Featured Promo Banner", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + {"type": "text", "id": "promo-title", "content": "Half-price ramen"} + ] + }, + { + "type": "frame", + "id": "categories", + "name": "Food Categories", + "width": "fill_container", + "height": "fit_content", + "layout": "horizontal", + "children": [ + {"type": "text", "id": "cat-title", "content": "Sushi"} + ] + }, + { + "type": "frame", + "id": "restaurants", + "name": "Popular Restaurants", + "width": "fill_container", + "height": "fit_content", + "layout": "vertical", + "children": [] + } + ] + })); + + run_cleanup_passes(&mut sink, &plan(), &["root"]); + + assert_eq!( + active_node_value(&sink, "promo")["height"], + json!("fit_content") + ); + assert_eq!( + active_node_value(&sink, "categories")["height"], + json!("fit_content") + ); + assert_eq!( + active_node_value(&sink, "restaurants")["height"], + json!("fit_content") + ); +} + +#[test] +fn empty_spacer_fill_container_not_collapsed() { + let mut sink = insert_root(json!({ + "type": "frame", + "id": "root", + "name": "Food App", + "width": 375, + "height": 812, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "spacer", + "name": "Flexible Spacer", + "width": "fill_container", + "height": "fill_container", + "children": [] + } + ] + })); + + run_cleanup_passes(&mut sink, &plan(), &["root"]); + + assert_eq!( + active_node_value(&sink, "spacer")["height"], + json!("fill_container") + ); +} + +#[test] +fn fill_container_on_horizontal_root_child_untouched() { + let mut sink = insert_root(json!({ + "type": "frame", + "id": "root", + "name": "Web App Shell", + "width": 1200, + "height": 800, + "layout": "horizontal", + "children": [ + { + "type": "frame", + "id": "sidebar", + "name": "Sidebar", + "width": 260, + "height": "fill_container", + "layout": "vertical", + "children": [ + {"type": "text", "id": "nav", "content": "Home"} + ] + } + ] + })); + + run_cleanup_passes(&mut sink, &plan(), &["root"]); + + assert_eq!( + active_node_value(&sink, "sidebar")["height"], + json!("fill_container") + ); +} + +#[test] +fn fit_content_root_untouched() { + let mut sink = insert_root(json!({ + "type": "frame", + "id": "root", + "name": "Scrolling Food App", + "width": 375, + "height": "fit_content", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "promo", + "name": "Featured Promo Banner", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + {"type": "text", "id": "promo-title", "content": "Daily deal"} + ] + } + ] + })); + + run_cleanup_passes(&mut sink, &plan(), &["root"]); + + assert_eq!( + active_node_value(&sink, "promo")["height"], + json!("fill_container") + ); +} diff --git a/crates/op-orchestrator/src/loop_finalize.rs b/crates/op-orchestrator/src/loop_finalize.rs index b70175863..07d8c2292 100644 --- a/crates/op-orchestrator/src/loop_finalize.rs +++ b/crates/op-orchestrator/src/loop_finalize.rs @@ -448,6 +448,10 @@ pub fn apply_loop_finalize(state: &mut EditorState) { crate::abandoned_duplicate_roots::remove_abandoned_duplicate_roots(&mut sink); crate::cleanup::remove_duplicate_bottom_nav_sections_for_all_roots(&mut sink); crate::cleanup::collapse_nested_horizontal_padding_for_all_roots(&mut sink); + crate::cleanup::expand_absolute_container_to_children_for_all_roots(&mut sink); + crate::cleanup::pad_clipping_horizontal_row_for_stroke_for_all_roots(&mut sink); + crate::cleanup::equalize_horizontal_card_heights_for_all_roots(&mut sink); + crate::cleanup::collapse_fill_container_content_sections_for_all_roots(&mut sink); } if state.active_children().is_empty() { return;