diff --git a/crates/op-orchestrator/src/cleanup.rs b/crates/op-orchestrator/src/cleanup.rs index e42ada714..dadb727a7 100644 --- a/crates/op-orchestrator/src/cleanup.rs +++ b/crates/op-orchestrator/src/cleanup.rs @@ -82,6 +82,75 @@ fn remove_duplicate_status_bars(sink: &mut dyn DocSink, root_id: &str) { } } +pub(crate) fn remove_duplicate_bottom_nav_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 { + remove_duplicate_bottom_nav_sections(sink, &root_id); + } +} + +/// 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. +fn remove_duplicate_bottom_nav_sections(sink: &mut dyn DocSink, root_id: &str) { + let dupes: Vec = { + let Some(root) = find_root(sink.state(), root_id) else { + return; + }; + if !is_mobile_root(root) { + return; + } + let Some(children) = root.children() else { + return; + }; + let nav_indices: Vec = children + .iter() + .enumerate() + .filter_map(|(index, child)| is_bottom_nav_section(child).then_some(index)) + .collect(); + if nav_indices.len() < 2 { + return; + } + let keep_index = nav_indices + .iter() + .copied() + .max_by(|a, b| compare_bottom_nav_position(children, *a, *b)) + .expect("nav_indices is non-empty"); + nav_indices + .into_iter() + .filter(|index| *index != keep_index) + .map(|index| NodeId::new(children[index].id_str().to_string())) + .collect() + }; + for id in dupes { + sink.apply(EditorCommand::DeleteNode { + node_id: id, + page_id: None, + }); + } +} + +fn is_bottom_nav_section(node: &PenNode) -> bool { + cleanup_mobile_chrome::bottom_nav_surface_target(node, false).is_some() +} + +fn compare_bottom_nav_position( + children: &[PenNode], + left_index: usize, + right_index: usize, +) -> std::cmp::Ordering { + let left_y = children[left_index].base().y.unwrap_or(left_index as f64); + let right_y = children[right_index].base().y.unwrap_or(right_index as f64); + left_y + .total_cmp(&right_y) + .then_with(|| left_index.cmp(&right_index)) +} + /// Pass ②:移动端浅色 root 下的 nav surface 纠偏。弱模型常把 /// bottom nav / tab bar 套用成黑色安全模板,和当前浅色页面调性断裂。 /// TS 端只补"缺失 fill"的 nav;Rust cleanup 还需要兜住已写 @@ -818,6 +887,7 @@ pub fn run_cleanup_passes(sink: &mut dyn DocSink, plan: &OrchestratorPlan, root_ let rid = rid.as_str(); remove_duplicate_status_bars(sink, rid); + remove_duplicate_bottom_nav_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); @@ -964,6 +1034,10 @@ mod tests_mobile_dense; #[path = "cleanup_mobile_chrome_tests.rs"] mod tests_mobile_chrome; +#[cfg(test)] +#[path = "cleanup_mobile_bottom_nav_dedup_tests.rs"] +mod tests_mobile_bottom_nav_dedup; + #[cfg(test)] #[path = "cleanup_desktop_dashboard_tests.rs"] mod tests_desktop_dashboard; diff --git a/crates/op-orchestrator/src/cleanup_mobile_bottom_nav_dedup_tests.rs b/crates/op-orchestrator/src/cleanup_mobile_bottom_nav_dedup_tests.rs new file mode 100644 index 000000000..07efab041 --- /dev/null +++ b/crates/op-orchestrator/src/cleanup_mobile_bottom_nav_dedup_tests.rs @@ -0,0 +1,268 @@ +use super::*; +use crate::plan::{OrchestratorPlan, RootFrameSpec}; +use crate::test_support::VecDocSink; + +fn plan() -> OrchestratorPlan { + OrchestratorPlan { + root_frame: RootFrameSpec { + id: "root".into(), + name: "Mobile".into(), + width: 390.0, + height: 844.0, + layout: None, + gap: None, + padding: None, + fill: None, + }, + subtasks: vec![], + style_guide_name: None, + } +} + +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) -> Option<&'a PenNode> { + sink.state + .active_children() + .iter() + .find_map(|node| find_node(node, id)) +} + +fn active_root(sink: &VecDocSink) -> &PenNode { + find_active_node(sink, "root").expect("root survives") +} + +fn direct_child_ids(root: &PenNode) -> Vec<&str> { + root.children() + .into_iter() + .flatten() + .map(PenNode::id_str) + .collect() +} + +#[test] +fn bottom_nav_detected_by_cjk_name() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Chinese Mobile Screen", + "width": 390, + "height": 844, + "layout": "vertical", + "fill": [{ "type": "solid", "color": "#FFFFFF" }], + "children": [ + { + "type": "frame", + "id": "content", + "name": "Content", + "role": "section", + "width": "fill_container", + "height": 300, + "children": [] + }, + { + "type": "frame", + "id": "cjk-nav-section", + "name": "底部导航栏", + "role": "section", + "x": 24, + "width": 342, + "height": 88, + "layout": "horizontal", + "children": [] + } + ] + }"##, + ); + + run_cleanup_passes(&mut sink, &plan(), &["root"]); + + let nav = find_active_node(&sink, "cjk-nav-section").expect("CJK bottom nav survives"); + assert_eq!(nav.base().x, Some(0.0)); + assert_eq!(nav.width_px(), Some(390.0)); + assert_eq!(nav.height_px(), Some(72.0)); +} + +#[test] +fn duplicate_cjk_and_english_bottom_nav_deduped() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Chinese Mobile Screen", + "width": 390, + "height": 844, + "layout": "vertical", + "fill": [{ "type": "solid", "color": "#FFFFFF" }], + "children": [ + { + "type": "frame", + "id": "content", + "name": "Content", + "role": "section", + "width": "fill_container", + "height": 620, + "children": [] + }, + { + "type": "frame", + "id": "cjk-nav-section", + "name": "底部导航栏", + "role": "section", + "width": "fill_container", + "height": 72, + "layout": "horizontal", + "children": [] + }, + { + "type": "frame", + "id": "english-bottom-nav", + "name": "Bottom Navigation", + "role": "section", + "width": "fill_container", + "height": 72, + "layout": "horizontal", + "children": [] + } + ] + }"##, + ); + + run_cleanup_passes(&mut sink, &plan(), &["root"]); + + let ids = direct_child_ids(active_root(&sink)); + assert!( + !ids.contains(&"cjk-nav-section"), + "earlier duplicate is removed" + ); + assert!( + ids.contains(&"english-bottom-nav"), + "bottom-most duplicate is kept" + ); + assert_eq!( + ids.iter() + .filter(|id| id.contains("bottom-nav")) + .copied() + .collect::>(), + vec!["english-bottom-nav"] + ); +} + +#[test] +fn single_bottom_nav_not_removed() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Single Nav Mobile Screen", + "width": 390, + "height": 844, + "layout": "vertical", + "fill": [{ "type": "solid", "color": "#FFFFFF" }], + "children": [ + { + "type": "frame", + "id": "content", + "name": "Content", + "role": "section", + "width": "fill_container", + "height": 620, + "children": [] + }, + { + "type": "frame", + "id": "cjk-nav-section", + "name": "底部导航栏", + "role": "section", + "width": "fill_container", + "height": 72, + "layout": "horizontal", + "children": [] + } + ] + }"##, + ); + + run_cleanup_passes(&mut sink, &plan(), &["root"]); + + let ids = direct_child_ids(active_root(&sink)); + assert!(ids.contains(&"cjk-nav-section")); +} + +#[test] +fn top_navbar_not_treated_as_bottom_nav() { + let mut sink = VecDocSink::new(); + insert_tree( + &mut sink, + r##"{ + "type": "frame", + "id": "root", + "name": "Top And Bottom Nav Mobile Screen", + "width": 390, + "height": 844, + "layout": "vertical", + "fill": [{ "type": "solid", "color": "#FFFFFF" }], + "children": [ + { + "type": "frame", + "id": "top-navbar", + "name": "Top Navigation Bar", + "role": "top-app-bar", + "width": "fill_container", + "height": 64, + "layout": "horizontal", + "children": [] + }, + { + "type": "frame", + "id": "content", + "name": "Content", + "role": "section", + "width": "fill_container", + "height": 556, + "children": [] + }, + { + "type": "frame", + "id": "bottom-nav", + "name": "Bottom Navigation", + "role": "section", + "width": "fill_container", + "height": 72, + "layout": "horizontal", + "children": [] + } + ] + }"##, + ); + + run_cleanup_passes(&mut sink, &plan(), &["root"]); + + let ids = direct_child_ids(active_root(&sink)); + assert!(ids.contains(&"top-navbar"), "top navbar is untouched"); + assert!(ids.contains(&"bottom-nav"), "sole bottom nav is untouched"); +} diff --git a/crates/op-orchestrator/src/cleanup_mobile_chrome.rs b/crates/op-orchestrator/src/cleanup_mobile_chrome.rs index 9f860de63..a45d18cb9 100644 --- a/crates/op-orchestrator/src/cleanup_mobile_chrome.rs +++ b/crates/op-orchestrator/src/cleanup_mobile_chrome.rs @@ -247,7 +247,10 @@ fn collect_bottom_nav_chrome_repairs( } } -fn bottom_nav_surface_target(root_child: &PenNode, allow_structural: bool) -> Option<&PenNode> { +pub(super) fn bottom_nav_surface_target( + root_child: &PenNode, + allow_structural: bool, +) -> Option<&PenNode> { if is_bottom_nav_surface(root_child, allow_structural) { return Some(root_child); } @@ -286,6 +289,12 @@ fn is_bottom_nav_surface(node: &PenNode, allow_structural: bool) -> bool { "bottom-navigation", "bottom tab", "bottom-tab", + "底部导航", + "底部导航栏", + "导航栏", + "底栏", + "标签栏", + "底部标签栏", ], ) { return true; diff --git a/crates/op-orchestrator/src/cleanup_mobile_dense.rs b/crates/op-orchestrator/src/cleanup_mobile_dense.rs index f7d7190f6..d4b9e90cb 100644 --- a/crates/op-orchestrator/src/cleanup_mobile_dense.rs +++ b/crates/op-orchestrator/src/cleanup_mobile_dense.rs @@ -151,6 +151,12 @@ fn dense_row_kind(node: &PenNode) -> Option { "tab bar", "tab-bar", "tabbar", + "底部导航", + "底部导航栏", + "导航栏", + "底栏", + "标签栏", + "底部标签栏", ], ) { return Some(DenseRowKind::Nav); diff --git a/crates/op-orchestrator/src/loop_finalize.rs b/crates/op-orchestrator/src/loop_finalize.rs index 0ddfb409a..83c73fe0a 100644 --- a/crates/op-orchestrator/src/loop_finalize.rs +++ b/crates/op-orchestrator/src/loop_finalize.rs @@ -446,6 +446,7 @@ pub fn apply_loop_finalize(state: &mut EditorState) { { let mut sink = StateDocSink { state: &mut *state }; crate::abandoned_duplicate_roots::remove_abandoned_duplicate_roots(&mut sink); + crate::cleanup::remove_duplicate_bottom_nav_sections_for_all_roots(&mut sink); } if state.active_children().is_empty() { return;