feat(orchestrator): dashboard_columns — slot assignment

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).
This commit is contained in:
Fini 2026-05-23 05:29:10 +08:00
parent 9417a62692
commit 8ab8db7b35
5 changed files with 1082 additions and 351 deletions

View file

@ -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;

View file

@ -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<Vec<RowFrameEntry>, 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<Vec<usize>> = rows.clone();
let mut result: Vec<RowFrameEntry> = 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<jian_ops_schema::node::FrameNode, String> {
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::<jian_ops_schema::node::PenNode>(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<jian_ops_schema::node::FrameNode, String> {
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::<jian_ops_schema::node::PenNode>(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:?}")),
}
}

View file

@ -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`.

View file

@ -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<Subtask>) -> 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() {}

View file

@ -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<Subtask>) -> 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<f64> {
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<f64> {
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));
}