From 8ab8db7b35dbbbfe50abd6401e56615cc4133336 Mon Sep 17 00:00:00 2001 From: Fini Date: Sat, 23 May 2026 05:29:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(orchestrator):=20dashboard=5Fcolumns=20?= =?UTF-8?q?=E2=80=94=20slot=20assignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port TS assignDashboardMainParents (orchestrator.ts:401-484) to Rust. Implements §4.6: synthesizes fill_container Slot frames for single-subtask rows, and horizontal Dashboard Row frames + proportional-width Slot frames for multi-subtask rows (min 220 px per slot, last slot always fill_container, available_width floor 320 px). Splits §4.6 into dashboard_columns/slots.rs to keep all files under the 800-line ceiling; TDD test suite in dashboard_columns_tests_b2.rs (10 tests, 298 total passing). --- .../op-orchestrator/src/dashboard_columns.rs | 18 +- .../src/dashboard_columns/slots.rs | 188 +++++++ .../src/dashboard_columns_tests.rs | 352 +------------ .../src/dashboard_columns_tests_b.rs | 403 +++++++++++++++ .../src/dashboard_columns_tests_b2.rs | 472 ++++++++++++++++++ 5 files changed, 1082 insertions(+), 351 deletions(-) create mode 100644 crates/op-orchestrator/src/dashboard_columns/slots.rs create mode 100644 crates/op-orchestrator/src/dashboard_columns_tests_b.rs create mode 100644 crates/op-orchestrator/src/dashboard_columns_tests_b2.rs diff --git a/crates/op-orchestrator/src/dashboard_columns.rs b/crates/op-orchestrator/src/dashboard_columns.rs index a99663a42..09feebe05 100644 --- a/crates/op-orchestrator/src/dashboard_columns.rs +++ b/crates/op-orchestrator/src/dashboard_columns.rs @@ -713,9 +713,25 @@ pub(crate) fn group_dashboard_main_rows(plan: &OrchestratorPlan) -> DashboardRow } // --------------------------------------------------------------------------- -// Tests — split into sibling file to stay under 800 lines +// §4.6 Slot assignment — implementation in sibling file (800-line cap) +// --------------------------------------------------------------------------- + +mod slots; +#[allow(unused_imports)] // consumed by run.rs in a later task +pub(crate) use slots::{assign_dashboard_main_parents, RowFrameEntry}; + +// --------------------------------------------------------------------------- +// Tests — split into sibling files to stay under 800 lines // --------------------------------------------------------------------------- #[cfg(test)] #[path = "dashboard_columns_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "dashboard_columns_tests_b.rs"] +mod tests_b; + +#[cfg(test)] +#[path = "dashboard_columns_tests_b2.rs"] +mod tests_b2; diff --git a/crates/op-orchestrator/src/dashboard_columns/slots.rs b/crates/op-orchestrator/src/dashboard_columns/slots.rs new file mode 100644 index 000000000..5d1803197 --- /dev/null +++ b/crates/op-orchestrator/src/dashboard_columns/slots.rs @@ -0,0 +1,188 @@ +//! §4.6 Slot assignment — `assign_dashboard_main_parents`. +//! +//! Split from `dashboard_columns.rs` to keep it under the 800-line ceiling. +//! Faithful port of TS `assignDashboardMainParents` (`orchestrator.ts:401-484`). + +use super::group_dashboard_main_rows; +use crate::plan::OrchestratorPlan; + +/// Return item from [`assign_dashboard_main_parents`]: +/// synthesized row/slot frame plus the id of its direct parent. +pub(crate) type RowFrameEntry = (jian_ops_schema::node::PenNode, String); + +/// Synthesizes row and slot frames for the dashboard main column, and assigns +/// each non-sidebar subtask's `parent_frame_id` to its slot. +/// +/// Port of TS `assignDashboardMainParents` (`orchestrator.ts:401-484`). +/// +/// # Arguments +/// * `plan` — mutable plan (subtask `parent_frame_id` fields are written). +/// * `main_parent_id` — the id of the main column frame (parent for row frames). +/// +/// # Returns +/// `Ok(Vec<(PenNode, parent_id)>)` — synthesized row and slot frames in +/// insertion order (row frame first, then its slot children left-to-right). +/// Returns `Err` only on internal JSON template bugs. +/// +/// ## Layout rules (faithful TS port) +/// * **1-subtask row** → one vertical `fill_container`-width Slot frame, +/// `id = {main_parent_id}-row-{n}-{subtask_id}-slot`, parented to `main_parent_id`. +/// * **N-subtask row** → one horizontal Dashboard Row frame +/// (`id = {main_parent_id}-row-{n}`, `gap = row_gap`, `fill_container` width), +/// parented to `main_parent_id`, followed by N vertical Slot frames each +/// parented to the row id. +/// - Slot widths are proportional: `round(available_width * st.width / Σ widths)`. +/// - `available_width = max(320, full_width - slot_gap)` where +/// `slot_gap = row_gap * (row.len - 1)`. +/// - Each slot width is floored at **220**. +/// - The **last slot** always has `fill_container` width (absorbs rounding). +/// - The capping formula guards against over-allocation: +/// `min(available - assigned - 220*(remaining_after), proportional)`. +pub(crate) fn assign_dashboard_main_parents( + plan: &mut OrchestratorPlan, + main_parent_id: &str, +) -> Result, String> { + use jian_ops_schema::node::PenNode; + + let row_groups = group_dashboard_main_rows(plan); + let rows = &row_groups.rows; + let full_width = row_groups.full_width; + let row_gap = row_groups.row_gap; + + if rows.is_empty() { + return Ok(vec![]); + } + + // Clone rows before mutating subtasks' parent_frame_id. + let rows_snapshot: Vec> = rows.clone(); + + let mut result: Vec = Vec::new(); + + for (row_idx, row_indices) in rows_snapshot.iter().enumerate() { + let row_n = row_idx + 1; // 1-based, matches TS `rowIndex` + + if row_indices.len() == 1 { + let idx = row_indices[0]; + let (subtask_id, subtask_label) = { + let st = &plan.subtasks[idx]; + (st.id.clone(), st.label.clone()) + }; + let slot_id = format!("{main_parent_id}-row-{row_n}-{subtask_id}-slot"); + let slot_name = format!("{subtask_label} Slot"); + + let slot_node = make_slot_frame(&slot_id, &slot_name, SlotWidth::FillContainer)?; + result.push((PenNode::Frame(slot_node), main_parent_id.to_string())); + + plan.subtasks[idx].parent_frame_id = Some(slot_id); + } else { + let row_id = format!("{main_parent_id}-row-{row_n}"); + let slot_gap = row_gap * (row_indices.len() as f64 - 1.0); + let available_width = f64::max(320.0, full_width - slot_gap); + + // Sum of subtask widths (with floor 1.0 per TS `Math.max(1, ...)`) + let width_sum: f64 = row_indices + .iter() + .map(|&i| f64::max(1.0, plan.subtasks[i].region.width)) + .sum(); + + // Push the Dashboard Row frame first. + let row_frame = make_row_frame(&row_id, row_gap)?; + result.push((PenNode::Frame(row_frame), main_parent_id.to_string())); + + let mut assigned_width: f64 = 0.0; + let row_len = row_indices.len(); + for (local_idx, &st_idx) in row_indices.iter().enumerate() { + let is_last = local_idx == row_len - 1; + let (subtask_id, subtask_label, st_width) = { + let st = &plan.subtasks[st_idx]; + let w = f64::max(1.0, st.region.width); + (st.id.clone(), st.label.clone(), w) + }; + + let proportional_width = + f64::max(220.0, (available_width * st_width / width_sum).round()); + // Remaining after this slot (0-based from right) + let remaining_after = row_len - local_idx - 1; + let slot_width: SlotWidth = if is_last { + SlotWidth::FillContainer + } else { + let capped = f64::min( + available_width - assigned_width - 220.0 * remaining_after as f64, + proportional_width, + ); + SlotWidth::Px(capped) + }; + + if let SlotWidth::Px(w) = slot_width { + assigned_width += w; + } + + let slot_id = format!("{row_id}-{subtask_id}-slot"); + let slot_name = format!("{subtask_label} Slot"); + let slot_node = make_slot_frame(&slot_id, &slot_name, slot_width)?; + result.push((PenNode::Frame(slot_node), row_id.clone())); + + plan.subtasks[st_idx].parent_frame_id = Some(slot_id); + } + } + } + + Ok(result) +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Width variant for a slot frame: a fixed pixel value or `fill_container`. +enum SlotWidth { + Px(f64), + FillContainer, +} + +/// Build a vertical slot frame node (deserialized via `PenNode` enum tag). +fn make_slot_frame( + id: &str, + name: &str, + width: SlotWidth, +) -> Result { + let width_json = match width { + SlotWidth::Px(w) => serde_json::json!(w), + SlotWidth::FillContainer => serde_json::json!("fill_container"), + }; + let frame = serde_json::json!({ + "type": "frame", + "id": id, + "name": name, + "width": width_json, + "height": "fit_content", + "layout": "vertical", + "children": [], + }); + match serde_json::from_value::(frame) + .map_err(|e| format!("slot frame `{id}`: {e}"))? + { + jian_ops_schema::node::PenNode::Frame(f) => Ok(f), + other => Err(format!("slot frame `{id}`: unexpected variant {other:?}")), + } +} + +/// Build a horizontal Dashboard Row frame node (deserialized via `PenNode` enum tag). +fn make_row_frame(id: &str, gap: f64) -> Result { + let frame = serde_json::json!({ + "type": "frame", + "id": id, + "name": format!("Dashboard Row {}", id.split('-').next_back().unwrap_or("?")), + "width": "fill_container", + "height": "fit_content", + "layout": "horizontal", + "gap": gap, + "children": [], + }); + match serde_json::from_value::(frame) + .map_err(|e| format!("row frame `{id}`: {e}"))? + { + jian_ops_schema::node::PenNode::Frame(f) => Ok(f), + other => Err(format!("row frame `{id}`: unexpected variant {other:?}")), + } +} diff --git a/crates/op-orchestrator/src/dashboard_columns_tests.rs b/crates/op-orchestrator/src/dashboard_columns_tests.rs index ba44d8bf8..4e73852be 100644 --- a/crates/op-orchestrator/src/dashboard_columns_tests.rs +++ b/crates/op-orchestrator/src/dashboard_columns_tests.rs @@ -453,353 +453,5 @@ fn sidebar_color_uppercase_hex_output() { assert_eq!(result.as_deref(), Some("#1E2A3B")); } -// ---- normalize_dashboard_main_subtasks ------------------------------------- - -/// Build a plan with a mix of sidebar + main subtasks for normalisation tests. -/// -/// The container subtask uses label "Main Content" with elements that do NOT -/// contain data-panel keywords (metric/chart/table/etc.), so that -/// `is_main_content_container_subtask` returns `true` for it. -/// -/// NOTE: `is_main_content_container_subtask` checks the COMBINED -/// `id + label + elements` text and returns false when any data-panel keyword -/// (e.g. "metric") appears. Elements containing "metrics row" would be -/// disqualified. The `normalize_strips_metrics_row_from_elements` test -/// therefore verifies the top-bar fragment extraction path instead. -fn norm_plan() -> OrchestratorPlan { - let mut sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); - sidebar.region = Region { - width: 260.0, - height: 760.0, - }; - - // Elements: no data-panel keywords → container qualifies. - let mut container = subtask( - "main-content", - "Main Content", - Some("Top bar with page title, date range selector, export button"), - ); - container.region = Region { - width: 940.0, - height: 200.0, - }; - - let metrics = subtask("metrics", "Metrics Row", Some("kpi cards, revenue data")); - let mut chart = subtask("revenue-chart", "Revenue Chart", None); - chart.region = Region { - width: 940.0, - height: 320.0, - }; - - plan_with(1200.0, vec![sidebar, container, metrics, chart]) -} - -#[test] -fn normalize_relabels_main_content_to_top_bar() { - let mut plan = norm_plan(); - normalize_dashboard_main_subtasks(&mut plan); - let top_bar = plan.subtasks.iter().find(|st| st.id == "main-content"); - assert!(top_bar.is_some()); - assert_eq!(top_bar.unwrap().label, "Top Bar"); -} - -#[test] -fn normalize_strips_metrics_row_from_elements() { - // Verifies the top-bar fragment extraction path. - // norm_plan container elements = "Top bar with page title, date range selector, export button" - // → after normalise, elements should still contain "top bar" (fragment extracted). - let mut plan = norm_plan(); - normalize_dashboard_main_subtasks(&mut plan); - let top_bar = plan - .subtasks - .iter() - .find(|st| st.id == "main-content") - .unwrap(); - let elems = top_bar.elements.as_deref().unwrap_or(""); - assert!( - elems.to_lowercase().contains("top bar"), - "expected 'top bar' in elements, got: {elems}" - ); -} - -#[test] -fn normalize_clamps_height_to_88_floor() { - let mut plan = norm_plan(); - // Set container height below floor - let container = plan - .subtasks - .iter_mut() - .find(|st| st.id == "main-content") - .unwrap(); - container.region.height = 50.0; - normalize_dashboard_main_subtasks(&mut plan); - let top_bar = plan - .subtasks - .iter() - .find(|st| st.id == "main-content") - .unwrap(); - assert_eq!(top_bar.region.height, 88.0); -} - -#[test] -fn normalize_clamps_height_to_120_ceiling() { - let mut plan = norm_plan(); - let container = plan - .subtasks - .iter_mut() - .find(|st| st.id == "main-content") - .unwrap(); - container.region.height = 300.0; - normalize_dashboard_main_subtasks(&mut plan); - let top_bar = plan - .subtasks - .iter() - .find(|st| st.id == "main-content") - .unwrap(); - assert_eq!(top_bar.region.height, 120.0); -} - -#[test] -fn normalize_preserves_in_range_height() { - let mut plan = norm_plan(); - let container = plan - .subtasks - .iter_mut() - .find(|st| st.id == "main-content") - .unwrap(); - container.region.height = 100.0; - normalize_dashboard_main_subtasks(&mut plan); - let top_bar = plan - .subtasks - .iter() - .find(|st| st.id == "main-content") - .unwrap(); - assert_eq!(top_bar.region.height, 100.0); -} - -#[test] -fn normalize_noop_when_fewer_than_two_main_subtasks() { - // Only one non-sidebar subtask → no normalisation - let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); - let container = subtask( - "main-content", - "Main Content", - Some("Top bar with title, metrics row"), - ); - let mut plan = plan_with(1200.0, vec![sidebar, container]); - normalize_dashboard_main_subtasks(&mut plan); - let container_after = plan - .subtasks - .iter() - .find(|st| st.id == "main-content") - .unwrap(); - // label should NOT be changed (only 1 main subtask) - assert_eq!(container_after.label, "Main Content"); -} - -#[test] -fn normalize_noop_when_no_container_subtask() { - // No is_main_content_container_subtask in the plan → noop - let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); - let metrics = subtask("metrics", "Metrics Row", Some("kpi cards")); - let chart = subtask("revenue-chart", "Revenue Chart", None); - let mut plan = plan_with(1200.0, vec![sidebar, metrics, chart]); - normalize_dashboard_main_subtasks(&mut plan); - // Nothing was renamed - assert!(plan.subtasks.iter().all(|st| st.label != "Top Bar")); -} - -// ---- group_dashboard_main_rows --------------------------------------------- - -/// Build a plan whose main-subtask widths exercise the bin-packer. -fn group_plan() -> OrchestratorPlan { - let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); - - let mut topbar = subtask("top-bar", "Top Bar", None); - topbar.region = Region { - width: 940.0, - height: 96.0, - }; // full-width (>= 0.82*940) - - let mut chart = subtask("revenue-chart", "Revenue Chart", None); - chart.region = Region { - width: 583.0, - height: 320.0, - }; // ~62% → narrow - - let mut transactions = subtask("transactions", "Transactions", None); - transactions.region = Region { - width: 357.0, - height: 320.0, - }; // ~38% → narrow - - let mut table = subtask("customers", "Customer Table", None); - table.region = Region { - width: 940.0, - height: 340.0, - }; // full-width - - plan_with(1200.0, vec![sidebar, topbar, chart, transactions, table]) -} - -#[test] -fn group_full_width_subtask_gets_own_row() { - let plan = group_plan(); - let result = group_dashboard_main_rows(&plan); - // "Top Bar" (width 940 >= 940*0.82=770) is standalone → own row - let topbar_row = result - .rows - .iter() - .find(|row| row.iter().any(|&i| plan.subtasks[i].id == "top-bar")); - assert!(topbar_row.is_some()); - assert_eq!(topbar_row.unwrap().len(), 1); -} - -#[test] -fn group_narrow_subtasks_pack_into_shared_row() { - let plan = group_plan(); - let result = group_dashboard_main_rows(&plan); - // "Revenue Chart" (583) + "Transactions" (357) → cumulative 583+24+357=964 >= 940*0.92=864.8 - // They should be packed into the same row - let chart_row = result - .rows - .iter() - .find(|row| row.iter().any(|&i| plan.subtasks[i].id == "revenue-chart")); - let tx_row = result - .rows - .iter() - .find(|row| row.iter().any(|&i| plan.subtasks[i].id == "transactions")); - assert!(chart_row.is_some()); - assert!(tx_row.is_some()); - assert_eq!( - chart_row.unwrap().as_ptr(), - tx_row.unwrap().as_ptr(), - "chart and transactions should be in the same row" - ); -} - -#[test] -fn group_full_width_table_gets_own_row() { - let plan = group_plan(); - let result = group_dashboard_main_rows(&plan); - let table_row = result - .rows - .iter() - .find(|row| row.iter().any(|&i| plan.subtasks[i].id == "customers")); - assert!(table_row.is_some()); - assert_eq!(table_row.unwrap().len(), 1); -} - -#[test] -fn group_full_width_is_max_of_root_minus_260_and_subtask_widths() { - let plan = group_plan(); - let result = group_dashboard_main_rows(&plan); - // root.width = 1200; 1200 - 260 = 940; max subtask width is also 940 - assert_eq!(result.full_width, 940.0); -} - -#[test] -fn group_row_gap_uses_root_gap_when_positive() { - let mut plan = group_plan(); - plan.root_frame.gap = Some(16.0); - let result = group_dashboard_main_rows(&plan); - assert_eq!(result.row_gap, 16.0); -} - -#[test] -fn group_row_gap_defaults_to_24_when_gap_zero() { - let mut plan = group_plan(); - plan.root_frame.gap = Some(0.0); - let result = group_dashboard_main_rows(&plan); - assert_eq!(result.row_gap, 24.0); -} - -#[test] -fn group_row_gap_defaults_to_24_when_gap_none() { - let mut plan = group_plan(); - plan.root_frame.gap = None; - let result = group_dashboard_main_rows(&plan); - assert_eq!(result.row_gap, 24.0); -} - -#[test] -fn group_main_content_container_is_always_standalone() { - // Even if the main-content-container subtask has a narrow width, - // it should be placed in its own row. - let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); - let mut container = subtask("main-content", "Main Content", None); - // narrow: only 300px (< full_width * 0.82) - container.region = Region { - width: 300.0, - height: 96.0, - }; - let mut chart = subtask("revenue-chart", "Revenue Chart", None); - chart.region = Region { - width: 583.0, - height: 320.0, - }; - let plan = plan_with(1200.0, vec![sidebar, container, chart]); - let result = group_dashboard_main_rows(&plan); - // The main-content-container row should have exactly 1 subtask - let container_row = result - .rows - .iter() - .find(|row| row.iter().any(|&i| plan.subtasks[i].id == "main-content")); - assert!(container_row.is_some()); - assert_eq!(container_row.unwrap().len(), 1); -} - -#[test] -fn group_empty_when_no_main_subtasks() { - // Only sidebar subtasks → no rows - let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); - let plan = plan_with(1200.0, vec![sidebar]); - let result = group_dashboard_main_rows(&plan); - assert!(result.rows.is_empty()); -} - -#[test] -fn group_rows_contain_subtask_indices_into_plan() { - let plan = group_plan(); - let result = group_dashboard_main_rows(&plan); - // Every index in rows must be a valid index into plan.subtasks, and must NOT be a sidebar - for row in &result.rows { - for &idx in row { - let st = &plan.subtasks[idx]; - assert!( - !is_sidebar_subtask(st), - "row must not contain sidebar subtask: {}", - st.id - ); - } - } -} - -#[test] -fn group_pre_flush_when_would_exceed_1_05() { - // full_width = max(1200-260=940, 400) = 940 - // a(400) + b(400): cumulative = 400+24+400 = 824 < 940*0.92=864.8 → no post-flush - // before c: nextWidth = 824+24+400 = 1248 > 940*1.05=987 → pre-flush → row[a,b] - // c alone → row[c] at end flush - let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); - let mut a = subtask("a", "Section A", None); - a.region = Region { - width: 400.0, - height: 200.0, - }; - let mut b = subtask("b", "Section B", None); - b.region = Region { - width: 400.0, - height: 200.0, - }; - let mut c = subtask("c", "Section C", None); - c.region = Region { - width: 400.0, - height: 200.0, - }; - let plan = plan_with(1200.0, vec![sidebar, a, b, c]); - let result = group_dashboard_main_rows(&plan); - assert_eq!(result.rows.len(), 2); - assert_eq!(result.rows[0].len(), 2); // a + b - assert_eq!(result.rows[1].len(), 1); // c -} +// B-series tests moved to `dashboard_columns_tests_b.rs` to stay under the +// 800-line ceiling. Linked via `#[path]` in `dashboard_columns.rs`. diff --git a/crates/op-orchestrator/src/dashboard_columns_tests_b.rs b/crates/op-orchestrator/src/dashboard_columns_tests_b.rs new file mode 100644 index 000000000..d01a792db --- /dev/null +++ b/crates/op-orchestrator/src/dashboard_columns_tests_b.rs @@ -0,0 +1,403 @@ +//! B1 tests for `dashboard_columns` — `normalize_dashboard_main_subtasks` + +//! `group_dashboard_main_rows`. B2 tests live in `dashboard_columns_tests_b2.rs`. +//! Linked via `#[path]` in `dashboard_columns.rs`. + +use super::*; +use crate::plan::{OrchestratorPlan, Region, RootFrameSpec, Subtask}; + +// ---- helpers --------------------------------------------------------------- + +fn root(width: f64) -> RootFrameSpec { + RootFrameSpec { + id: "root".into(), + name: "Design".into(), + width, + height: 800.0, + layout: Some("vertical".into()), + gap: Some(0.0), + padding: Some(0.0), + fill: None, + } +} + +fn subtask(id: &str, label: &str, elements: Option<&str>) -> Subtask { + Subtask { + id: id.into(), + label: label.into(), + region: Region { + width: 1200.0, + height: 300.0, + }, + id_prefix: id.into(), + parent_frame_id: None, + elements: elements.map(String::from), + screen: None, + } +} + +fn plan_with(width: f64, subtasks: Vec) -> OrchestratorPlan { + OrchestratorPlan { + root_frame: root(width), + subtasks, + style_guide_name: None, + } +} + +// ---- normalize_dashboard_main_subtasks ------------------------------------- + +/// Build a plan with a mix of sidebar + main subtasks for normalisation tests. +/// +/// The container subtask uses label "Main Content" with elements that do NOT +/// contain data-panel keywords (metric/chart/table/etc.), so that +/// `is_main_content_container_subtask` returns `true` for it. +/// +/// NOTE: `is_main_content_container_subtask` checks the COMBINED +/// `id + label + elements` text and returns false when any data-panel keyword +/// (e.g. "metric") appears. Elements containing "metrics row" would be +/// disqualified. The `normalize_strips_metrics_row_from_elements` test +/// therefore verifies the top-bar fragment extraction path instead. +fn norm_plan() -> OrchestratorPlan { + let mut sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + sidebar.region = Region { + width: 260.0, + height: 760.0, + }; + + // Elements: no data-panel keywords → container qualifies. + let mut container = subtask( + "main-content", + "Main Content", + Some("Top bar with page title, date range selector, export button"), + ); + container.region = Region { + width: 940.0, + height: 200.0, + }; + + let metrics = subtask("metrics", "Metrics Row", Some("kpi cards, revenue data")); + let mut chart = subtask("revenue-chart", "Revenue Chart", None); + chart.region = Region { + width: 940.0, + height: 320.0, + }; + + plan_with(1200.0, vec![sidebar, container, metrics, chart]) +} + +#[test] +fn normalize_relabels_main_content_to_top_bar() { + let mut plan = norm_plan(); + normalize_dashboard_main_subtasks(&mut plan); + let top_bar = plan.subtasks.iter().find(|st| st.id == "main-content"); + assert!(top_bar.is_some()); + assert_eq!(top_bar.unwrap().label, "Top Bar"); +} + +#[test] +fn normalize_strips_metrics_row_from_elements() { + // Verifies the top-bar fragment extraction path. + // norm_plan container elements = "Top bar with page title, date range selector, export button" + // → after normalise, elements should still contain "top bar" (fragment extracted). + let mut plan = norm_plan(); + normalize_dashboard_main_subtasks(&mut plan); + let top_bar = plan + .subtasks + .iter() + .find(|st| st.id == "main-content") + .unwrap(); + let elems = top_bar.elements.as_deref().unwrap_or(""); + assert!( + elems.to_lowercase().contains("top bar"), + "expected 'top bar' in elements, got: {elems}" + ); +} + +#[test] +fn normalize_clamps_height_to_88_floor() { + let mut plan = norm_plan(); + // Set container height below floor + let container = plan + .subtasks + .iter_mut() + .find(|st| st.id == "main-content") + .unwrap(); + container.region.height = 50.0; + normalize_dashboard_main_subtasks(&mut plan); + let top_bar = plan + .subtasks + .iter() + .find(|st| st.id == "main-content") + .unwrap(); + assert_eq!(top_bar.region.height, 88.0); +} + +#[test] +fn normalize_clamps_height_to_120_ceiling() { + let mut plan = norm_plan(); + let container = plan + .subtasks + .iter_mut() + .find(|st| st.id == "main-content") + .unwrap(); + container.region.height = 300.0; + normalize_dashboard_main_subtasks(&mut plan); + let top_bar = plan + .subtasks + .iter() + .find(|st| st.id == "main-content") + .unwrap(); + assert_eq!(top_bar.region.height, 120.0); +} + +#[test] +fn normalize_preserves_in_range_height() { + let mut plan = norm_plan(); + let container = plan + .subtasks + .iter_mut() + .find(|st| st.id == "main-content") + .unwrap(); + container.region.height = 100.0; + normalize_dashboard_main_subtasks(&mut plan); + let top_bar = plan + .subtasks + .iter() + .find(|st| st.id == "main-content") + .unwrap(); + assert_eq!(top_bar.region.height, 100.0); +} + +#[test] +fn normalize_noop_when_fewer_than_two_main_subtasks() { + // Only one non-sidebar subtask → no normalisation + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let container = subtask( + "main-content", + "Main Content", + Some("Top bar with title, metrics row"), + ); + let mut plan = plan_with(1200.0, vec![sidebar, container]); + normalize_dashboard_main_subtasks(&mut plan); + let container_after = plan + .subtasks + .iter() + .find(|st| st.id == "main-content") + .unwrap(); + // label should NOT be changed (only 1 main subtask) + assert_eq!(container_after.label, "Main Content"); +} + +#[test] +fn normalize_noop_when_no_container_subtask() { + // No is_main_content_container_subtask in the plan → noop + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let metrics = subtask("metrics", "Metrics Row", Some("kpi cards")); + let chart = subtask("revenue-chart", "Revenue Chart", None); + let mut plan = plan_with(1200.0, vec![sidebar, metrics, chart]); + normalize_dashboard_main_subtasks(&mut plan); + // Nothing was renamed + assert!(plan.subtasks.iter().all(|st| st.label != "Top Bar")); +} + +// ---- group_dashboard_main_rows --------------------------------------------- + +/// Build a plan whose main-subtask widths exercise the bin-packer. +fn group_plan() -> OrchestratorPlan { + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + + let mut topbar = subtask("top-bar", "Top Bar", None); + topbar.region = Region { + width: 940.0, + height: 96.0, + }; // full-width (>= 0.82*940) + + let mut chart = subtask("revenue-chart", "Revenue Chart", None); + chart.region = Region { + width: 583.0, + height: 320.0, + }; // ~62% → narrow + + let mut transactions = subtask("transactions", "Transactions", None); + transactions.region = Region { + width: 357.0, + height: 320.0, + }; // ~38% → narrow + + let mut table = subtask("customers", "Customer Table", None); + table.region = Region { + width: 940.0, + height: 340.0, + }; // full-width + + plan_with(1200.0, vec![sidebar, topbar, chart, transactions, table]) +} + +#[test] +fn group_full_width_subtask_gets_own_row() { + let plan = group_plan(); + let result = group_dashboard_main_rows(&plan); + // "Top Bar" (width 940 >= 940*0.82=770) is standalone → own row + let topbar_row = result + .rows + .iter() + .find(|row| row.iter().any(|&i| plan.subtasks[i].id == "top-bar")); + assert!(topbar_row.is_some()); + assert_eq!(topbar_row.unwrap().len(), 1); +} + +#[test] +fn group_narrow_subtasks_pack_into_shared_row() { + let plan = group_plan(); + let result = group_dashboard_main_rows(&plan); + // "Revenue Chart" (583) + "Transactions" (357) → cumulative 583+24+357=964 >= 940*0.92=864.8 + // They should be packed into the same row + let chart_row = result + .rows + .iter() + .find(|row| row.iter().any(|&i| plan.subtasks[i].id == "revenue-chart")); + let tx_row = result + .rows + .iter() + .find(|row| row.iter().any(|&i| plan.subtasks[i].id == "transactions")); + assert!(chart_row.is_some()); + assert!(tx_row.is_some()); + assert_eq!( + chart_row.unwrap().as_ptr(), + tx_row.unwrap().as_ptr(), + "chart and transactions should be in the same row" + ); +} + +#[test] +fn group_full_width_table_gets_own_row() { + let plan = group_plan(); + let result = group_dashboard_main_rows(&plan); + let table_row = result + .rows + .iter() + .find(|row| row.iter().any(|&i| plan.subtasks[i].id == "customers")); + assert!(table_row.is_some()); + assert_eq!(table_row.unwrap().len(), 1); +} + +#[test] +fn group_full_width_is_max_of_root_minus_260_and_subtask_widths() { + let plan = group_plan(); + let result = group_dashboard_main_rows(&plan); + // root.width = 1200; 1200 - 260 = 940; max subtask width is also 940 + assert_eq!(result.full_width, 940.0); +} + +#[test] +fn group_row_gap_uses_root_gap_when_positive() { + let mut plan = group_plan(); + plan.root_frame.gap = Some(16.0); + let result = group_dashboard_main_rows(&plan); + assert_eq!(result.row_gap, 16.0); +} + +#[test] +fn group_row_gap_defaults_to_24_when_gap_zero() { + let mut plan = group_plan(); + plan.root_frame.gap = Some(0.0); + let result = group_dashboard_main_rows(&plan); + assert_eq!(result.row_gap, 24.0); +} + +#[test] +fn group_row_gap_defaults_to_24_when_gap_none() { + let mut plan = group_plan(); + plan.root_frame.gap = None; + let result = group_dashboard_main_rows(&plan); + assert_eq!(result.row_gap, 24.0); +} + +#[test] +fn group_main_content_container_is_always_standalone() { + // Even if the main-content-container subtask has a narrow width, + // it should be placed in its own row. + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let mut container = subtask("main-content", "Main Content", None); + // narrow: only 300px (< full_width * 0.82) + container.region = Region { + width: 300.0, + height: 96.0, + }; + let mut chart = subtask("revenue-chart", "Revenue Chart", None); + chart.region = Region { + width: 583.0, + height: 320.0, + }; + let plan = plan_with(1200.0, vec![sidebar, container, chart]); + let result = group_dashboard_main_rows(&plan); + // The main-content-container row should have exactly 1 subtask + let container_row = result + .rows + .iter() + .find(|row| row.iter().any(|&i| plan.subtasks[i].id == "main-content")); + assert!(container_row.is_some()); + assert_eq!(container_row.unwrap().len(), 1); +} + +#[test] +fn group_empty_when_no_main_subtasks() { + // Only sidebar subtasks → no rows + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let plan = plan_with(1200.0, vec![sidebar]); + let result = group_dashboard_main_rows(&plan); + assert!(result.rows.is_empty()); +} + +#[test] +fn group_rows_contain_subtask_indices_into_plan() { + let plan = group_plan(); + let result = group_dashboard_main_rows(&plan); + // Every index in rows must be a valid index into plan.subtasks, and must NOT be a sidebar + for row in &result.rows { + for &idx in row { + let st = &plan.subtasks[idx]; + assert!( + !is_sidebar_subtask(st), + "row must not contain sidebar subtask: {}", + st.id + ); + } + } +} + +#[test] +fn group_pre_flush_when_would_exceed_1_05() { + // full_width = max(1200-260=940, 400) = 940 + // a(400) + b(400): cumulative = 400+24+400 = 824 < 940*0.92=864.8 → no post-flush + // before c: nextWidth = 824+24+400 = 1248 > 940*1.05=987 → pre-flush → row[a,b] + // c alone → row[c] at end flush + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let mut a = subtask("a", "Section A", None); + a.region = Region { + width: 400.0, + height: 200.0, + }; + let mut b = subtask("b", "Section B", None); + b.region = Region { + width: 400.0, + height: 200.0, + }; + let mut c = subtask("c", "Section C", None); + c.region = Region { + width: 400.0, + height: 200.0, + }; + let plan = plan_with(1200.0, vec![sidebar, a, b, c]); + let result = group_dashboard_main_rows(&plan); + assert_eq!(result.rows.len(), 2); + assert_eq!(result.rows[0].len(), 2); // a + b + assert_eq!(result.rows[1].len(), 1); // c +} + +// B2 tests moved to `dashboard_columns_tests_b2.rs` (800-line cap). + +// The following comment is intentionally left to mark the end of B1 content. +// B2 tests (`assign_dashboard_main_parents`) are in `dashboard_columns_tests_b2.rs`. + +/// Placeholder to prevent empty tail — B1 tests above, B2 in sibling file. +fn _b1_end() {} diff --git a/crates/op-orchestrator/src/dashboard_columns_tests_b2.rs b/crates/op-orchestrator/src/dashboard_columns_tests_b2.rs new file mode 100644 index 000000000..1c951ed19 --- /dev/null +++ b/crates/op-orchestrator/src/dashboard_columns_tests_b2.rs @@ -0,0 +1,472 @@ +//! B2 tests for `dashboard_columns::slots::assign_dashboard_main_parents`. +//! Split from `dashboard_columns_tests_b.rs` to keep both files under 800 lines. +//! Linked via `#[path]` in `dashboard_columns.rs`. + +use super::*; +use crate::plan::{OrchestratorPlan, Region, RootFrameSpec, Subtask}; +use jian_ops_schema::node::PenNode; + +// ---- helpers --------------------------------------------------------------- + +fn root(width: f64) -> RootFrameSpec { + RootFrameSpec { + id: "root".into(), + name: "Design".into(), + width, + height: 800.0, + layout: Some("vertical".into()), + gap: Some(0.0), + padding: Some(0.0), + fill: None, + } +} + +fn subtask(id: &str, label: &str, elements: Option<&str>) -> Subtask { + Subtask { + id: id.into(), + label: label.into(), + region: Region { + width: 1200.0, + height: 300.0, + }, + id_prefix: id.into(), + parent_frame_id: None, + elements: elements.map(String::from), + screen: None, + } +} + +fn plan_with(width: f64, subtasks: Vec) -> OrchestratorPlan { + OrchestratorPlan { + root_frame: root(width), + subtasks, + style_guide_name: None, + } +} + +// ---- assign_dashboard_main_parents — B2 tests ------------------------------ + +/// Helper: pull the PenNode base id_str from a PenNode (must be a frame). +fn node_id(node: &PenNode) -> &str { + match node { + PenNode::Frame(f) => &f.base.id, + other => panic!("expected frame node, got: {other:?}"), + } +} + +/// Helper: pull the layout field from a PenNode frame as a static str. +fn node_layout(node: &PenNode) -> Option<&'static str> { + use jian_ops_schema::node::container::LayoutMode; + match node { + PenNode::Frame(f) => match &f.container.layout { + Some(LayoutMode::Vertical) => Some("vertical"), + Some(LayoutMode::Horizontal) => Some("horizontal"), + Some(LayoutMode::None) => Some("none"), + None => None, + }, + _ => None, + } +} + +/// Helper: check whether a frame node has `fill_container` width. +fn is_fill_container_width(node: &PenNode) -> bool { + use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword}; + match node { + PenNode::Frame(f) => matches!( + f.container.width, + Some(SizingBehavior::Keyword(SizingKeyword::FillContainer)) + ), + _ => false, + } +} + +/// Helper: pull numeric width from a frame node (None if keyword/missing). +fn node_width_px(node: &PenNode) -> Option { + use jian_ops_schema::sizing::SizingBehavior; + match node { + PenNode::Frame(f) => match &f.container.width { + Some(SizingBehavior::Number(n)) => Some(*n), + _ => None, + }, + _ => None, + } +} + +/// Helper: pull the gap from a frame node (numeric only). +fn node_gap(node: &PenNode) -> Option { + use jian_ops_schema::node::base::NumberOrExpression; + match node { + PenNode::Frame(f) => match &f.container.gap { + Some(NumberOrExpression::Number(n)) => Some(*n), + _ => None, + }, + _ => None, + } +} + +// ---- 1-subtask row: a single fill_container Slot frame --------------------- + +#[test] +fn assign_single_subtask_row_produces_fill_container_slot() { + // Plan: 1 sidebar + 1 main subtask → 1-row, 1-subtask. + // Expected: a fill_container Slot frame for the subtask, + // no Dashboard Row wrapper. + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let mut topbar = subtask("top-bar", "Top Bar", None); + topbar.region = Region { + width: 940.0, + height: 96.0, + }; + let mut plan = plan_with(1200.0, vec![sidebar, topbar]); + + let main_id = "root-main"; + let frames = + assign_dashboard_main_parents(&mut plan, main_id).expect("assign_dashboard_main_parents"); + + // Single-subtask row → exactly 1 synthesized frame (the slot). + assert_eq!(frames.len(), 1, "expected 1 slot frame for 1-subtask row"); + let (node, parent_id) = &frames[0]; + assert_eq!( + parent_id, main_id, + "slot frame parent must be the main column id" + ); + // The slot must be fill_container width. + assert!( + is_fill_container_width(node), + "single-subtask slot must have fill_container width" + ); + // Layout: vertical. + assert_eq!(node_layout(node), Some("vertical")); + // Slot id format: `{main_id}-row-{n}-{subtask_id}-slot` + let slot_id = node_id(node); + assert!( + slot_id.starts_with(main_id), + "slot id must start with main_id: {slot_id}" + ); + assert!( + slot_id.ends_with("-slot"), + "slot id must end with '-slot': {slot_id}" + ); + assert!( + slot_id.contains("top-bar"), + "slot id must contain subtask id 'top-bar': {slot_id}" + ); +} + +#[test] +fn assign_single_subtask_row_sets_parent_frame_id_on_subtask() { + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let mut topbar = subtask("top-bar", "Top Bar", None); + topbar.region = Region { + width: 940.0, + height: 96.0, + }; + let mut plan = plan_with(1200.0, vec![sidebar, topbar]); + let main_id = "root-main"; + let frames = + assign_dashboard_main_parents(&mut plan, main_id).expect("assign_dashboard_main_parents"); + + // The subtask's parent_frame_id must equal the slot id. + let slot_id = node_id(&frames[0].0); + let topbar_st = plan.subtasks.iter().find(|st| st.id == "top-bar").unwrap(); + assert_eq!( + topbar_st.parent_frame_id.as_deref(), + Some(slot_id), + "subtask parent_frame_id must equal slot id" + ); +} + +// ---- N-subtask row: horizontal Dashboard Row + N Slot frames --------------- + +#[test] +fn assign_three_subtask_row_produces_row_plus_slots() { + // 3 narrow subtasks pack into a single row with root_width=1400. + // full_width = max(1400-260=1140, 340) = 1140 + // All 3 narrow: 300, 300, 340 (< 1140*0.82=935). + // 300+24+300=624 < 1140*0.92=1048.8 → no post-flush after 2nd + // 624+24+340=988 < 1048.8 → no post-flush after 3rd → end flush → 1 row [a,b,c]. + // Expected: 1 Dashboard Row + 3 Slots = 4 frames. + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let mut s1 = subtask("section-a", "Section A", None); + s1.region = Region { + width: 300.0, + height: 200.0, + }; + let mut s2 = subtask("section-b", "Section B", None); + s2.region = Region { + width: 300.0, + height: 200.0, + }; + let mut s3 = subtask("section-c", "Section C", None); + s3.region = Region { + width: 340.0, + height: 200.0, + }; + let mut plan = plan_with(1400.0, vec![sidebar, s1, s2, s3]); + + let main_id = "root-main"; + let frames = + assign_dashboard_main_parents(&mut plan, main_id).expect("assign_dashboard_main_parents"); + + // Expect 4 frames: 1 row + 3 slots. + assert_eq!( + frames.len(), + 4, + "expected 1 Dashboard Row + 3 Slot frames = 4, got {}", + frames.len() + ); +} + +#[test] +fn assign_multi_subtask_row_row_frame_is_horizontal() { + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let mut s1 = subtask("section-a", "Section A", None); + s1.region = Region { + width: 300.0, + height: 200.0, + }; + let mut s2 = subtask("section-b", "Section B", None); + s2.region = Region { + width: 300.0, + height: 200.0, + }; + let mut plan = plan_with(1400.0, vec![sidebar, s1, s2]); + + let main_id = "root-main"; + let frames = + assign_dashboard_main_parents(&mut plan, main_id).expect("assign_dashboard_main_parents"); + + // The row frame (parent_id = main_id, not ending in -slot) must be horizontal. + let row_frame = frames + .iter() + .find(|(node, pid)| pid == main_id && !node_id(node).ends_with("-slot")) + .expect("Dashboard Row frame not found"); + assert_eq!( + node_layout(&row_frame.0), + Some("horizontal"), + "Dashboard Row must have horizontal layout" + ); +} + +#[test] +fn assign_multi_subtask_row_last_slot_is_fill_container() { + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let mut s1 = subtask("section-a", "Section A", None); + s1.region = Region { + width: 300.0, + height: 200.0, + }; + let mut s2 = subtask("section-b", "Section B", None); + s2.region = Region { + width: 300.0, + height: 200.0, + }; + let mut plan = plan_with(1400.0, vec![sidebar, s1, s2]); + + let main_id = "root-main"; + let frames = + assign_dashboard_main_parents(&mut plan, main_id).expect("assign_dashboard_main_parents"); + + let slot_frames: Vec<_> = frames + .iter() + .filter(|(node, _)| node_id(node).ends_with("-slot")) + .collect(); + assert_eq!(slot_frames.len(), 2, "expected 2 slot frames"); + + // The LAST slot must be fill_container. + let last_slot = slot_frames.last().unwrap(); + assert!( + is_fill_container_width(&last_slot.0), + "last slot must have fill_container width" + ); +} + +#[test] +fn assign_multi_subtask_slot_widths_proportional_min_220() { + // 2-subtask row: section-a (width 400), section-b (width 400). + // root_width = 1200 → full_width = max(940, 400) = 940 + // row_gap = 24; slot_gap = 24 * (2-1) = 24 + // available_width = max(320, 940 - 24) = 916 + // width_sum = 400 + 400 = 800 + // proportional for section-a: max(220, round(916 * 400 / 800)) = max(220, 458) = 458 + // idx=0: remaining_after=1; slotWidth = min(916-0-220, 458) = min(696, 458) = 458 + // section-b (last): fill_container + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let mut s1 = subtask("section-a", "Section A", None); + s1.region = Region { + width: 400.0, + height: 200.0, + }; + let mut s2 = subtask("section-b", "Section B", None); + s2.region = Region { + width: 400.0, + height: 200.0, + }; + let mut plan = plan_with(1200.0, vec![sidebar, s1, s2]); + + let main_id = "root-main"; + let frames = + assign_dashboard_main_parents(&mut plan, main_id).expect("assign_dashboard_main_parents"); + + let slot_frames: Vec<_> = frames + .iter() + .filter(|(node, _)| node_id(node).ends_with("-slot")) + .collect(); + assert_eq!(slot_frames.len(), 2); + + // First slot must have numeric width. + let first_slot_width = + node_width_px(&slot_frames[0].0).expect("first slot must have numeric width"); + assert!( + first_slot_width >= 220.0, + "slot width must be >= 220 (min floor), got {first_slot_width}" + ); + // Specific proportional check: available=916, widthSum=800, proportional=458 + // clamped = min(916-0-220, 458) = min(696, 458) = 458 + assert_eq!( + first_slot_width, 458.0, + "first slot width should be 458 per proportional formula" + ); + // Last slot is fill_container. + assert!( + is_fill_container_width(&slot_frames[1].0), + "second (last) slot must be fill_container" + ); +} + +#[test] +fn assign_slot_ids_match_expected_pattern() { + // Row 1: 1-subtask (standalone top bar): id = "{main}-row-1-top-bar-slot" + // Row 2: 2-subtask row: row id = "{main}-row-2", + // slot ids = "{main}-row-2-revenue-chart-slot" / "{main}-row-2-transactions-slot" + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let mut topbar = subtask("top-bar", "Top Bar", None); + topbar.region = Region { + width: 940.0, + height: 96.0, + }; + let mut chart = subtask("revenue-chart", "Revenue Chart", None); + chart.region = Region { + width: 583.0, + height: 320.0, + }; + let mut transactions = subtask("transactions", "Transactions", None); + transactions.region = Region { + width: 357.0, + height: 320.0, + }; + let mut plan = plan_with(1200.0, vec![sidebar, topbar, chart, transactions]); + + let main_id = "root-main"; + let frames = + assign_dashboard_main_parents(&mut plan, main_id).expect("assign_dashboard_main_parents"); + + // Row 1 (1-subtask): slot id = "root-main-row-1-top-bar-slot" + let slot1 = frames + .iter() + .find(|(node, _)| node_id(node) == "root-main-row-1-top-bar-slot"); + assert!(slot1.is_some(), "expected 'root-main-row-1-top-bar-slot'"); + + // Row 2 (2-subtask): row id = "root-main-row-2" + let row2 = frames + .iter() + .find(|(node, _)| node_id(node) == "root-main-row-2"); + assert!(row2.is_some(), "expected 'root-main-row-2'"); + + // Row 2 slot ids: + let slot_chart = frames + .iter() + .find(|(node, _)| node_id(node) == "root-main-row-2-revenue-chart-slot"); + let slot_tx = frames + .iter() + .find(|(node, _)| node_id(node) == "root-main-row-2-transactions-slot"); + assert!( + slot_chart.is_some(), + "expected 'root-main-row-2-revenue-chart-slot'" + ); + assert!( + slot_tx.is_some(), + "expected 'root-main-row-2-transactions-slot'" + ); +} + +#[test] +fn assign_row_frame_has_row_gap() { + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let mut s1 = subtask("section-a", "Section A", None); + s1.region = Region { + width: 300.0, + height: 200.0, + }; + let mut s2 = subtask("section-b", "Section B", None); + s2.region = Region { + width: 300.0, + height: 200.0, + }; + let mut plan = plan_with(1200.0, vec![sidebar, s1, s2]); + plan.root_frame.gap = Some(16.0); // row_gap = 16 + + let main_id = "root-main"; + let frames = + assign_dashboard_main_parents(&mut plan, main_id).expect("assign_dashboard_main_parents"); + + let row_frame = frames + .iter() + .find(|(node, pid)| pid == main_id && !node_id(node).ends_with("-slot")) + .expect("Dashboard Row frame not found"); + assert_eq!( + node_gap(&row_frame.0), + Some(16.0), + "row frame gap must equal row_gap" + ); +} + +#[test] +fn assign_empty_rows_returns_empty() { + // Only sidebar → no rows → no synthesized frames. + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let mut plan = plan_with(1200.0, vec![sidebar]); + let main_id = "root-main"; + let frames = + assign_dashboard_main_parents(&mut plan, main_id).expect("assign_dashboard_main_parents"); + assert!(frames.is_empty(), "empty rows → no synthesized frames"); +} + +#[test] +fn assign_available_width_floor_is_320() { + // full_width = max(340-260=80, 200) = 200; slot_gap = 24*(2-1)=24 + // available_width = max(320, 200-24) = max(320, 176) = 320. + let sidebar = subtask("sidebar-nav", "Sidebar Navigation", Some("nav links")); + let mut s1 = subtask("narrow-a", "Narrow A", None); + s1.region = Region { + width: 200.0, + height: 100.0, + }; + let mut s2 = subtask("narrow-b", "Narrow B", None); + s2.region = Region { + width: 200.0, + height: 100.0, + }; + let mut plan = plan_with(340.0, vec![sidebar, s1, s2]); + + let main_id = "root-main"; + let frames = + assign_dashboard_main_parents(&mut plan, main_id).expect("assign_dashboard_main_parents"); + + // Should produce row + 2 slots without panic. + assert!( + !frames.is_empty(), + "should produce frames even with narrow available_width" + ); + let slot_frames: Vec<_> = frames + .iter() + .filter(|(node, _)| node_id(node).ends_with("-slot")) + .collect(); + assert_eq!(slot_frames.len(), 2); + // First slot must be >= 220 (min floor). + if let Some(w) = node_width_px(&slot_frames[0].0) { + assert!(w >= 220.0, "slot width floor 220 must be respected: {w}"); + } + // Last slot is fill_container. + assert!(is_fill_container_width(&slot_frames[1].0)); +}