diff --git a/crates/op-orchestrator/src/plan_home_intent.rs b/crates/op-orchestrator/src/plan_home_intent.rs new file mode 100644 index 000000000..609f9bc54 --- /dev/null +++ b/crates/op-orchestrator/src/plan_home_intent.rs @@ -0,0 +1,59 @@ +//! Classify mobile app roots whose bottom tab bar is structural anatomy. + +use crate::plan::OrchestratorPlan; + +/// A multi-section mobile plan whose root frame is named like an app +/// home/main/feed screen — the shape whose bottom tab bar is mandatory. +pub(super) fn plan_is_app_home_screen(plan: &OrchestratorPlan) -> bool { + if plan.subtasks.len() < 3 { + return false; + } + + let name = normalize_screen_name(&plan.root_frame.name); + let secondary_flow = [ + "detail", + "details", + "form", + "login", + "log in", + "sign in", + "sign up", + "signup", + "register", + "checkout", + "onboarding", + "confirmation", + "success", + "wizard", + "buy now", + ] + .iter() + .any(|phrase| screen_name_has_phrase(&name, phrase)); + if secondary_flow { + return false; + } + + // Preserve the legacy substring behavior for established primary-screen + // markers. Planner names commonly compact these into HomeScreen, + // Newsfeed, Discovery, or Browser. + ["home", "feed", "discover", "browse", "dashboard"] + .iter() + .any(|marker| name.contains(marker)) + || ["main screen", "now screen"] + .iter() + .any(|phrase| screen_name_has_phrase(&name, phrase)) +} + +fn normalize_screen_name(name: &str) -> String { + name.to_lowercase() + .split(|ch: char| !ch.is_alphanumeric()) + .filter(|part| !part.is_empty()) + .collect::>() + .join(" ") +} + +fn screen_name_has_phrase(name: &str, phrase: &str) -> bool { + let padded_name = format!(" {name} "); + let padded_phrase = format!(" {phrase} "); + padded_name.contains(&padded_phrase) +} diff --git a/crates/op-orchestrator/src/plan_normalize.rs b/crates/op-orchestrator/src/plan_normalize.rs index aac61dbef..d5e79d2f3 100644 --- a/crates/op-orchestrator/src/plan_normalize.rs +++ b/crates/op-orchestrator/src/plan_normalize.rs @@ -10,12 +10,20 @@ use crate::dashboard_columns::{ use crate::plan::{OrchestratorPlan, Region, Subtask}; use crate::types::DesignRequest; +#[path = "plan_home_intent.rs"] +mod plan_home_intent; +use plan_home_intent::plan_is_app_home_screen; + // multiscreen-fanout-break fix (item A) — screen-grouping tests, split out // to keep this file's inline `mod tests` from crossing the 800-line cap. #[cfg(test)] #[path = "plan_normalize_screen_groups_tests.rs"] mod tests_screen_groups; +#[cfg(test)] +#[path = "plan_normalize_nav_tests.rs"] +mod tests_nav; + /// 规范化产出的派生信息。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct NormInfo { @@ -59,12 +67,7 @@ fn strip_status_bar_fragments(text: &str) -> Option { fn prompt_requests_bottom_nav(prompt: &str) -> bool { let hay = prompt.to_lowercase(); - let negated = hay.contains("no bottom nav") - || hay.contains("without bottom nav") - || hay.contains("without bottom navigation") - || hay.contains("不要底部导航") - || hay.contains("不需要底部导航"); - if negated { + if prompt_forbids_bottom_nav(prompt) { return false; } hay.contains("bottom nav") @@ -77,6 +80,15 @@ fn prompt_requests_bottom_nav(prompt: &str) -> bool { || hay.contains("底栏") } +fn prompt_forbids_bottom_nav(prompt: &str) -> bool { + let hay = prompt.to_lowercase(); + hay.contains("no bottom nav") + || hay.contains("without bottom nav") + || hay.contains("without bottom navigation") + || hay.contains("不要底部导航") + || hay.contains("不需要底部导航") +} + fn is_bottom_nav_subtask(st: &Subtask) -> bool { let hay = format!( "{} {} {}", @@ -95,6 +107,10 @@ fn is_bottom_nav_subtask(st: &Subtask) -> bool { } fn ensure_requested_bottom_nav_subtask(plan: &mut OrchestratorPlan, req: &DesignRequest) { + if prompt_forbids_bottom_nav(&req.prompt) { + plan.subtasks.retain(|st| !is_bottom_nav_subtask(st)); + return; + } if plan.subtasks.iter().any(is_bottom_nav_subtask) { return; } @@ -129,25 +145,6 @@ fn ensure_requested_bottom_nav_subtask(plan: &mut OrchestratorPlan, req: &Design }); } -/// A multi-section mobile plan whose root frame is named like an app -/// home/main/feed screen — the shape whose bottom tab bar is mandatory. -fn plan_is_app_home_screen(plan: &OrchestratorPlan) -> bool { - if plan.subtasks.len() < 3 { - return false; - } - let name = plan.root_frame.name.to_lowercase(); - [ - "home", - "main screen", - "feed", - "discover", - "browse", - "dashboard", - ] - .iter() - .any(|k| name.contains(k)) -} - /// 就地规范化 `plan`: /// - 一次性判定 `is_mobile`(根 frame 宽度); /// - 移动端剔除 plan 自带的状态栏 subtask(状态栏改由 scaffold 注入); diff --git a/crates/op-orchestrator/src/plan_normalize_nav_tests.rs b/crates/op-orchestrator/src/plan_normalize_nav_tests.rs new file mode 100644 index 000000000..fcf49b984 --- /dev/null +++ b/crates/op-orchestrator/src/plan_normalize_nav_tests.rs @@ -0,0 +1,219 @@ +use super::*; +use crate::plan::{OrchestratorPlan, Region, RootFrameSpec, Subtask}; + +fn request(prompt: &str) -> DesignRequest { + DesignRequest { + prompt: prompt.into(), + model: None, + provider: None, + design_md: None, + concurrency: 1, + append_context: None, + validation_enabled: true, + visual_ref_enabled: false, + } +} + +fn section(id: &str, label: &str) -> Subtask { + Subtask { + id: id.into(), + label: label.into(), + region: Region { + width: 375.0, + height: 120.0, + }, + id_prefix: String::new(), + parent_frame_id: None, + elements: None, + screen: None, + generated_root_id: None, + existing_section_labels: None, + retry_feedback: None, + } +} + +fn mobile_plan(name: &str, labels: &[(&str, &str)]) -> OrchestratorPlan { + OrchestratorPlan { + root_frame: RootFrameSpec { + id: "root".into(), + name: name.into(), + width: 375.0, + height: 812.0, + layout: None, + gap: None, + padding: None, + fill: None, + }, + subtasks: labels + .iter() + .map(|(id, label)| section(id, label)) + .collect(), + style_guide_name: None, + } +} + +fn has_bottom_nav(plan: &OrchestratorPlan) -> bool { + plan.subtasks.iter().any(is_bottom_nav_subtask) +} + +#[test] +fn meteo_now_screen_gets_bottom_navigation_backstop() { + let mut plan = mobile_plan( + "Meteo - Now Screen", + &[ + ("hero", "Header & Main Temperature Hero"), + ("telemetry", "Telemetry & Wind Grid"), + ("hourly", "Hourly Forecast Strip"), + ("forecast", "7-Day Forecast List"), + ("sun-arc", "Sunrise & Sunset Arc"), + ], + ); + + normalize( + &mut plan, + &request("Design the Now screen for the Meteo weather app"), + ); + + assert!(has_bottom_nav(&plan)); + let nav = plan + .subtasks + .last() + .expect("navbar should be appended last"); + assert_eq!(nav.id, "bottom-navigation"); + assert_eq!(nav.parent_frame_id.as_deref(), Some("root")); + assert_eq!(nav.region.width, 375.0); +} + +#[test] +fn detail_screen_does_not_get_implicit_bottom_navigation() { + let mut plan = mobile_plan( + "Meteo - Forecast Detail Screen", + &[ + ("header", "Forecast Header"), + ("chart", "Precipitation Chart"), + ("metrics", "Weather Metrics"), + ("advisory", "Weather Advisory"), + ], + ); + + normalize(&mut plan, &request("Design a forecast detail screen")); + + assert!(!has_bottom_nav(&plan)); +} + +#[test] +fn form_flow_does_not_get_nav_even_when_name_mentions_main_screen() { + let mut plan = mobile_plan( + "Account Form - Main Screen", + &[ + ("header", "Account Header"), + ("fields", "Profile Fields"), + ("preferences", "Preferences"), + ("actions", "Save Actions"), + ], + ); + + normalize(&mut plan, &request("Design a mobile account form")); + + assert!(!has_bottom_nav(&plan)); +} + +#[test] +fn screen_name_matching_uses_word_boundaries() { + let mut plan = mobile_plan( + "Performance Screen", + &[ + ("summary", "Summary"), + ("chart", "Trend Chart"), + ("metrics", "Metrics"), + ], + ); + + normalize(&mut plan, &request("Design a performance screen")); + + assert!(!has_bottom_nav(&plan), "form must not match performance"); +} + +#[test] +fn legacy_compound_primary_screen_names_keep_bottom_navigation() { + for name in [ + "FoodHomeScreen", + "Community Newsfeed", + "Discovery Screen", + "In-App Browser", + ] { + let mut plan = mobile_plan( + name, + &[ + ("header", "Header"), + ("content", "Primary Content"), + ("summary", "Summary"), + ], + ); + + normalize(&mut plan, &request("Design a primary mobile app screen")); + + assert!( + has_bottom_nav(&plan), + "legacy primary-screen marker should still match: {name}" + ); + } +} + +#[test] +fn buy_now_screen_does_not_get_implicit_bottom_navigation() { + let mut plan = mobile_plan( + "Buy Now Screen", + &[ + ("summary", "Order Summary"), + ("payment", "Payment Method"), + ("actions", "Purchase Actions"), + ], + ); + + normalize(&mut plan, &request("Design a Buy Now purchase flow")); + + assert!(!has_bottom_nav(&plan)); +} + +#[test] +fn explicit_no_nav_request_overrides_primary_screen_backstop() { + let mut plan = mobile_plan( + "Meteo - Now Screen", + &[ + ("hero", "Temperature Hero"), + ("hourly", "Hourly Forecast"), + ("forecast", "7-Day Forecast"), + ], + ); + + normalize( + &mut plan, + &request("Design the Meteo Now screen without bottom navigation"), + ); + + assert!(!has_bottom_nav(&plan)); +} + +#[test] +fn explicit_no_nav_request_removes_planned_bottom_navigation() { + let mut plan = mobile_plan( + "Meteo - Now Screen", + &[ + ("hero", "Temperature Hero"), + ("hourly", "Hourly Forecast"), + ("forecast", "7-Day Forecast"), + ("bottom-nav", "Bottom Navigation"), + ], + ); + + normalize( + &mut plan, + &request("Design the Meteo Now screen with no bottom nav"), + ); + + assert!( + !has_bottom_nav(&plan), + "an explicit no-nav instruction must override the model plan" + ); +}