feat(orchestrator): dashboard_columns — placeholder height + child reorder
Ports TS orchestrator.ts:486-547 to Rust (Task B3 of S3b-3). - Add `generated_root_id: Option<String>` field (#[serde(skip)]) to `Subtask` so run.rs can record the top-level node id post-generation. - Implement `get_dashboard_placeholder_height` — estimates dashboard main-column height from first 2-3 rows' tallest subtasks + sidebar, clamped to [560, 680]. - Implement `reorder_dashboard_main_children` — emits sequential `EditorCommand::MoveNode` commands to re-sort the main column's children back to plan order after sub-agent generation. - Both functions live in the new split module `dashboard_columns/height_reorder.rs` (207 lines). - 9 new tests in `dashboard_columns_tests_b3.rs`; suite grows from 298 → 307 passing.
This commit is contained in:
parent
8ab8db7b35
commit
a3c457ec2c
|
|
@ -33,6 +33,7 @@ fn subtask_with_screen(id: &str, screen: Option<&str>) -> Subtask {
|
|||
parent_frame_id: None,
|
||||
elements: None,
|
||||
screen: screen.map(|s| s.to_string()),
|
||||
generated_root_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +63,7 @@ fn make_plan_with_subtasks(subtask_ids: &[&str]) -> crate::plan::OrchestratorPla
|
|||
parent_frame_id: Some("root".into()),
|
||||
elements: None,
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
})
|
||||
.collect(),
|
||||
style_guide_name: None,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ fn make_two_screen_plan() -> crate::plan::OrchestratorPlan {
|
|||
parent_frame_id: Some("root".into()),
|
||||
elements: None,
|
||||
screen: Some("login".into()),
|
||||
generated_root_id: None,
|
||||
},
|
||||
crate::plan::Subtask {
|
||||
id: "s1".into(),
|
||||
|
|
@ -62,6 +63,7 @@ fn make_two_screen_plan() -> crate::plan::OrchestratorPlan {
|
|||
parent_frame_id: Some("root".into()),
|
||||
elements: None,
|
||||
screen: Some("home".into()),
|
||||
generated_root_id: None,
|
||||
},
|
||||
],
|
||||
style_guide_name: None,
|
||||
|
|
@ -83,6 +85,7 @@ fn make_three_screen_plan() -> crate::plan::OrchestratorPlan {
|
|||
parent_frame_id: Some("root".into()),
|
||||
elements: None,
|
||||
screen: Some(screen.into()),
|
||||
generated_root_id: None,
|
||||
};
|
||||
OrchestratorPlan {
|
||||
root_frame: RootFrameSpec {
|
||||
|
|
|
|||
|
|
@ -720,6 +720,16 @@ mod slots;
|
|||
#[allow(unused_imports)] // consumed by run.rs in a later task
|
||||
pub(crate) use slots::{assign_dashboard_main_parents, RowFrameEntry};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §4.7 Placeholder height + child reorder — sibling file (800-line cap)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
mod height_reorder;
|
||||
#[allow(unused_imports)] // consumed by run.rs in a later task
|
||||
pub(crate) use height_reorder::{
|
||||
get_dashboard_placeholder_height, reorder_dashboard_main_children,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — split into sibling files to stay under 800 lines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -735,3 +745,7 @@ mod tests_b;
|
|||
#[cfg(test)]
|
||||
#[path = "dashboard_columns_tests_b2.rs"]
|
||||
mod tests_b2;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "dashboard_columns_tests_b3.rs"]
|
||||
mod tests_b3;
|
||||
|
|
|
|||
207
crates/op-orchestrator/src/dashboard_columns/height_reorder.rs
Normal file
207
crates/op-orchestrator/src/dashboard_columns/height_reorder.rs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
//! §4.7 Placeholder height + child reorder.
|
||||
//!
|
||||
//! Split from `dashboard_columns.rs` to keep both files under the 800-line
|
||||
//! ceiling. Faithful port of `orchestrator.ts:486-547`.
|
||||
|
||||
use super::{group_dashboard_main_rows, is_sidebar_subtask};
|
||||
use crate::plan::OrchestratorPlan;
|
||||
use jian_ops_schema::node::PenNode;
|
||||
use op_editor_core::{EditorCommand, EditorState, NodeId, PenNodeExt};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §4.7a: `get_dashboard_placeholder_height`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Estimates the dashboard main-column placeholder height from the first 2-3
|
||||
/// rows' tallest subtasks + the sidebar height.
|
||||
///
|
||||
/// Port of TS `getDashboardPlaceholderHeight` (`orchestrator.ts:486-508`).
|
||||
///
|
||||
/// Formula:
|
||||
/// 1. `sidebarHeight = Σ max(0, st.region.height)` for sidebar subtasks.
|
||||
/// 2. Group main rows via `group_dashboard_main_rows`.
|
||||
/// 3. `visibleRows = rows[..min(rows.len(), if rows.len() >= 3 { 3 } else { 2 })]`.
|
||||
/// 4. `foldHeight = Σ tallest-height-in-row + (visibleRows.len - 1) * rowGap`.
|
||||
/// 5. `mainFoldHint = if !visibleRows.is_empty() { foldHeight } else { 560 }`.
|
||||
/// 6. `sidebarHint = if sidebarHeight > 0 { min(sidebarHeight, 600) } else { 0 }`.
|
||||
/// 7. Return `clamp(max(mainFoldHint, sidebarHint), 560, 680)`.
|
||||
pub(crate) fn get_dashboard_placeholder_height(plan: &OrchestratorPlan) -> f64 {
|
||||
let row_groups = group_dashboard_main_rows(plan);
|
||||
let rows = &row_groups.rows;
|
||||
let row_gap = row_groups.row_gap;
|
||||
|
||||
// Sidebar height: sum of max(0, height) for sidebar subtasks.
|
||||
let sidebar_height: f64 = plan
|
||||
.subtasks
|
||||
.iter()
|
||||
.filter(|st| is_sidebar_subtask(st))
|
||||
.map(|st| f64::max(0.0, st.region.height))
|
||||
.sum();
|
||||
|
||||
// TS: `rows.slice(0, rows.length >= 3 ? 3 : 2)`
|
||||
let take = if rows.len() >= 3 { 3 } else { 2 };
|
||||
let visible_rows: Vec<&Vec<usize>> = rows.iter().take(take).collect();
|
||||
|
||||
// foldHeight: sum per row of the max height among its subtasks + row gaps.
|
||||
let fold_height: f64 = visible_rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
// TS: `Math.max(0, ...row.map(st => typeof st.region.height === 'number' ? st.region.height : 0))`
|
||||
row.iter()
|
||||
.map(|&i| {
|
||||
let h = plan.subtasks[i].region.height;
|
||||
if h.is_finite() {
|
||||
f64::max(0.0, h)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.fold(0.0_f64, f64::max)
|
||||
})
|
||||
.sum::<f64>()
|
||||
+ f64::max(0.0, visible_rows.len() as f64 - 1.0) * row_gap;
|
||||
|
||||
// TS: `mainFoldHint = visibleRows.length > 0 ? foldHeight : 560`
|
||||
let main_fold_hint = if visible_rows.is_empty() {
|
||||
560.0
|
||||
} else {
|
||||
fold_height
|
||||
};
|
||||
|
||||
// TS: `sidebarHint = sidebarHeight > 0 ? Math.min(sidebarHeight, 600) : 0`
|
||||
let sidebar_hint = if sidebar_height > 0.0 {
|
||||
f64::min(sidebar_height, 600.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// TS: `Math.max(560, Math.min(Math.max(mainFoldHint, sidebarHint), 680))`
|
||||
f64::max(main_fold_hint, sidebar_hint).clamp(560.0, 680.0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §4.7b: `reorder_dashboard_main_children`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// After sub-agent generation, re-sorts the main column's children to plan order.
|
||||
///
|
||||
/// For each non-sidebar subtask (in plan order), resolves which child of `main_id`
|
||||
/// contains it (slot or row frame), then emits a `MoveNode` command for that
|
||||
/// ancestor. Duplicate ancestors are deduplicated.
|
||||
///
|
||||
/// Port of TS `reorderDashboardMainChildren` (`orchestrator.ts:510-547`).
|
||||
///
|
||||
/// # Algorithm
|
||||
/// For each non-sidebar subtask, the ancestor under `main_id` is resolved as:
|
||||
/// 1. If `subtask.parent_frame_id.is_some() && != main_id`:
|
||||
/// a. If that frame is a direct child of `main_id` → use it (slot = direct).
|
||||
/// b. Else if *its* parent is a direct child of `main_id` → use that parent (row).
|
||||
/// 2. Else → fall back to `subtask.generated_root_id`.
|
||||
///
|
||||
/// Each resolved ancestor id is pushed (once) to `desiredOrder`, then
|
||||
/// `MoveNode { node_id: ancestor, target_parent: main_id }` is emitted for
|
||||
/// every id in `desiredOrder` order — which causes `EditorState::apply` to
|
||||
/// detach + re-append them in the desired sequence.
|
||||
pub(crate) fn reorder_dashboard_main_children(
|
||||
plan: &OrchestratorPlan,
|
||||
main_id: &str,
|
||||
state: &EditorState,
|
||||
) -> Vec<EditorCommand> {
|
||||
// Find the main node in the live document.
|
||||
let main_children: Vec<String> = {
|
||||
let top = state.active_children();
|
||||
// The main frame may be a top-level child or nested (inside the root scaffold).
|
||||
match find_children_of(top, main_id) {
|
||||
Some(ch) => ch.iter().map(|n| n.id_str().to_string()).collect(),
|
||||
None => return vec![],
|
||||
}
|
||||
};
|
||||
|
||||
// Build desiredOrder.
|
||||
let mut desired_order: Vec<String> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
let push_once =
|
||||
|id: &str, desired: &mut Vec<String>, seen: &mut std::collections::HashSet<String>| {
|
||||
if !id.is_empty() && !seen.contains(id) {
|
||||
seen.insert(id.to_string());
|
||||
desired.push(id.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
for subtask in &plan.subtasks {
|
||||
if is_sidebar_subtask(subtask) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(pfid) = subtask.parent_frame_id.as_deref() {
|
||||
if pfid != main_id {
|
||||
// Is pfid a direct child of main?
|
||||
if main_children.contains(&pfid.to_string()) {
|
||||
push_once(pfid, &mut desired_order, &mut seen);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Is pfid's parent a direct child of main? (pfid is a slot inside a row)
|
||||
let parent_of_pfid = find_parent_id_of(state.active_children(), pfid);
|
||||
if let Some(row_id) = parent_of_pfid {
|
||||
if main_children.contains(&row_id) {
|
||||
push_once(&row_id, &mut desired_order, &mut seen);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use generatedRootId.
|
||||
if let Some(root_id) = subtask.generated_root_id.as_deref() {
|
||||
push_once(root_id, &mut desired_order, &mut seen);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit MoveNode for each id in desiredOrder — applied in sequence, these
|
||||
// detach + re-append each child, producing the desired order.
|
||||
let main_node_id = NodeId::new(main_id.to_string());
|
||||
desired_order
|
||||
.into_iter()
|
||||
.map(|id| EditorCommand::MoveNode {
|
||||
node_id: NodeId::new(id),
|
||||
target_parent: main_node_id.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns the children of the node with `target_id` anywhere in the forest,
|
||||
/// or `None` when not found / not a container.
|
||||
fn find_children_of<'a>(children: &'a [PenNode], target_id: &str) -> Option<&'a Vec<PenNode>> {
|
||||
for child in children {
|
||||
if child.id_str() == target_id {
|
||||
return child.children();
|
||||
}
|
||||
if let Some(grand) = child.children() {
|
||||
if let Some(found) = find_children_of(grand, target_id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the id of the direct parent of `target_id` in the forest, or `None`.
|
||||
fn find_parent_id_of(children: &[PenNode], target_id: &str) -> Option<String> {
|
||||
for child in children {
|
||||
if let Some(grand) = child.children() {
|
||||
if grand.iter().any(|n| n.id_str() == target_id) {
|
||||
return Some(child.id_str().to_string());
|
||||
}
|
||||
if let Some(id) = find_parent_id_of(grand, target_id) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ fn subtask(id: &str, label: &str, elements: Option<&str>) -> Subtask {
|
|||
parent_frame_id: None,
|
||||
elements: elements.map(String::from),
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ fn subtask(id: &str, label: &str, elements: Option<&str>) -> Subtask {
|
|||
parent_frame_id: None,
|
||||
elements: elements.map(String::from),
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ fn subtask(id: &str, label: &str, elements: Option<&str>) -> Subtask {
|
|||
parent_frame_id: None,
|
||||
elements: elements.map(String::from),
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
343
crates/op-orchestrator/src/dashboard_columns_tests_b3.rs
Normal file
343
crates/op-orchestrator/src/dashboard_columns_tests_b3.rs
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
//! Task B3 tests for `get_dashboard_placeholder_height` +
|
||||
//! `reorder_dashboard_main_children`.
|
||||
//! Linked via `#[path]` in `dashboard_columns.rs`.
|
||||
|
||||
use super::*;
|
||||
use crate::plan::{OrchestratorPlan, Region, RootFrameSpec, Subtask};
|
||||
use crate::test_support::VecDocSink;
|
||||
use op_editor_core::{EditorCommand, NodeId, PenNodeExt};
|
||||
use serde_json::json;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn root_spec(width: f64) -> RootFrameSpec {
|
||||
RootFrameSpec {
|
||||
id: "root".into(),
|
||||
name: "Design".into(),
|
||||
width,
|
||||
height: 800.0,
|
||||
layout: Some("vertical".into()),
|
||||
gap: None,
|
||||
padding: None,
|
||||
fill: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn st(id: &str, label: &str, width: f64, height: f64) -> Subtask {
|
||||
Subtask {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
region: Region { width, height },
|
||||
id_prefix: id.into(),
|
||||
parent_frame_id: None,
|
||||
elements: None,
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sidebar_st(height: f64) -> Subtask {
|
||||
Subtask {
|
||||
id: "sidebar-nav".into(),
|
||||
label: "Sidebar Navigation".into(),
|
||||
region: Region {
|
||||
width: 260.0,
|
||||
height,
|
||||
},
|
||||
id_prefix: "sidebar-nav".into(),
|
||||
parent_frame_id: None,
|
||||
elements: Some("nav links".into()),
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_with(width: f64, subtasks: Vec<Subtask>) -> OrchestratorPlan {
|
||||
OrchestratorPlan {
|
||||
root_frame: root_spec(width),
|
||||
subtasks,
|
||||
style_guide_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── `get_dashboard_placeholder_height` tests ─────────────────────────────────
|
||||
|
||||
/// Below-floor: no non-sidebar rows → mainFoldHint=560, sidebarHint=0 → 560.
|
||||
#[test]
|
||||
fn placeholder_height_empty_rows_returns_floor() {
|
||||
// Only a sidebar subtask with height 0 → no non-sidebar rows produced.
|
||||
let plan = plan_with(1200.0, vec![sidebar_st(0.0)]);
|
||||
let h = get_dashboard_placeholder_height(&plan);
|
||||
assert_eq!(h, 560.0, "empty rows → floor 560");
|
||||
}
|
||||
|
||||
/// In-range: 1 row with topbar h=600, sidebar h=700 (capped to 600 → sidebarHint=600).
|
||||
/// visibleRows = first 2 (rows.len()=1 < 3) = [topbar row].
|
||||
/// foldHeight = 600 (tallest in 1 row) + 0 gaps = 600.
|
||||
/// mainFoldHint = 600; sidebarHint = min(700,600)=600.
|
||||
/// max(600,600)=600 → clamp(560..680) = 600.
|
||||
#[test]
|
||||
fn placeholder_height_in_range() {
|
||||
let sidebar = sidebar_st(700.0);
|
||||
let topbar = st("topbar", "Top Bar", 940.0, 600.0);
|
||||
let plan = plan_with(1200.0, vec![sidebar, topbar]);
|
||||
let h = get_dashboard_placeholder_height(&plan);
|
||||
assert!(
|
||||
(560.0..=680.0).contains(&h),
|
||||
"in-range plan should give h in [560,680]: got {h}"
|
||||
);
|
||||
assert_eq!(h, 600.0, "specific formula check: expected 600");
|
||||
}
|
||||
|
||||
/// Above-ceiling: 2 standalone rows each h=500.
|
||||
/// foldHeight = 500 + 24 + 500 = 1024 → clamped to 680.
|
||||
#[test]
|
||||
fn placeholder_height_above_ceiling_clamped() {
|
||||
let sidebar = sidebar_st(0.0);
|
||||
// full_width = max(1200-260=940, 940) = 940; standalone if w >= 940*0.82=770.8
|
||||
let s1 = st("s1", "S1", 940.0, 500.0);
|
||||
let s2 = st("s2", "S2", 940.0, 500.0);
|
||||
let plan = plan_with(1200.0, vec![sidebar, s1, s2]);
|
||||
let h = get_dashboard_placeholder_height(&plan);
|
||||
assert_eq!(h, 680.0, "above-ceiling: expected 680 (clamped)");
|
||||
}
|
||||
|
||||
/// When rows.len() >= 3, TS uses first 3.
|
||||
/// 3 standalone rows h=300,200,100; rowGap=24.
|
||||
/// foldHeight = 300+24+200+24+100 = 648 → clamp = 648.
|
||||
#[test]
|
||||
fn placeholder_height_three_rows_uses_first_three() {
|
||||
let sidebar = sidebar_st(0.0);
|
||||
let s1 = st("s1", "S1", 940.0, 300.0);
|
||||
let s2 = st("s2", "S2", 940.0, 200.0);
|
||||
let s3 = st("s3", "S3", 940.0, 100.0);
|
||||
let plan = plan_with(1200.0, vec![sidebar, s1, s2, s3]);
|
||||
let h = get_dashboard_placeholder_height(&plan);
|
||||
assert_eq!(h, 648.0, "3 rows: expected 648");
|
||||
}
|
||||
|
||||
/// When rows.len() < 3 (here 2 rows), TS uses first 2.
|
||||
/// heights: 350, 250; rowGap=24; foldHeight = 350+24+250 = 624.
|
||||
#[test]
|
||||
fn placeholder_height_two_rows_uses_first_two() {
|
||||
let sidebar = sidebar_st(0.0);
|
||||
let s1 = st("s1", "S1", 940.0, 350.0);
|
||||
let s2 = st("s2", "S2", 940.0, 250.0);
|
||||
let plan = plan_with(1200.0, vec![sidebar, s1, s2]);
|
||||
let h = get_dashboard_placeholder_height(&plan);
|
||||
assert_eq!(h, 624.0, "2 rows: expected 624");
|
||||
}
|
||||
|
||||
// ── `reorder_dashboard_main_children` tests ──────────────────────────────────
|
||||
|
||||
/// Builds a PenNode frame from JSON for test use.
|
||||
fn frame_val(id: &str, name: &str, children: serde_json::Value) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "frame", "id": id, "name": name,
|
||||
"width": 100, "height": 100,
|
||||
"children": children,
|
||||
})
|
||||
}
|
||||
|
||||
/// Finds the actual id of the first node named `name` in `state`'s active children
|
||||
/// (recursively).
|
||||
fn find_by_name(state: &op_editor_core::EditorState, name: &str) -> Option<String> {
|
||||
fn search(nodes: &[jian_ops_schema::node::PenNode], name: &str) -> Option<String> {
|
||||
for node in nodes {
|
||||
if node.base().name.as_deref() == Some(name) {
|
||||
return Some(node.id_str().to_string());
|
||||
}
|
||||
if let Some(ch) = node.children() {
|
||||
if let Some(id) = search(ch, name) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
search(state.active_children(), name)
|
||||
}
|
||||
|
||||
/// Basic reorder: 3 direct-child slots in order [C, B, A]; plan order [A, B, C].
|
||||
/// Each subtask's parent_frame_id = its slot id (direct child of main).
|
||||
/// Expected: 3 MoveNode commands in order slot-A, slot-B, slot-C.
|
||||
#[test]
|
||||
fn reorder_produces_move_node_commands_in_plan_order() {
|
||||
let mut sink = VecDocSink::new();
|
||||
|
||||
// Insert main frame with children in [C, B, A] order.
|
||||
let main_node: jian_ops_schema::node::PenNode = serde_json::from_value(json!({
|
||||
"type": "frame", "id": "root-main", "name": "Main",
|
||||
"width": 940, "height": 100,
|
||||
"children": [
|
||||
frame_val("slot-c", "SlotC", json!([])),
|
||||
frame_val("slot-b", "SlotB", json!([])),
|
||||
frame_val("slot-a", "SlotA", json!([])),
|
||||
],
|
||||
}))
|
||||
.unwrap();
|
||||
sink.state.apply(EditorCommand::InsertSubtree {
|
||||
nodes: vec![main_node],
|
||||
parent_id: NodeId::NONE,
|
||||
});
|
||||
|
||||
let main_id = find_by_name(&sink.state, "Main").expect("Main not found");
|
||||
let slot_a_id = find_by_name(&sink.state, "SlotA").expect("SlotA not found");
|
||||
let slot_b_id = find_by_name(&sink.state, "SlotB").expect("SlotB not found");
|
||||
let slot_c_id = find_by_name(&sink.state, "SlotC").expect("SlotC not found");
|
||||
|
||||
// Plan: sidebar + subtask-a (pfid=slot-a) + subtask-b (pfid=slot-b) + subtask-c (pfid=slot-c).
|
||||
let sidebar = sidebar_st(0.0);
|
||||
let mut st_a = st("subtask-a", "Subtask A", 940.0, 200.0);
|
||||
st_a.parent_frame_id = Some(slot_a_id.clone());
|
||||
let mut st_b = st("subtask-b", "Subtask B", 940.0, 200.0);
|
||||
st_b.parent_frame_id = Some(slot_b_id.clone());
|
||||
let mut st_c = st("subtask-c", "Subtask C", 940.0, 200.0);
|
||||
st_c.parent_frame_id = Some(slot_c_id.clone());
|
||||
let plan = plan_with(1200.0, vec![sidebar, st_a, st_b, st_c]);
|
||||
|
||||
let cmds = reorder_dashboard_main_children(&plan, &main_id, &sink.state);
|
||||
|
||||
// Extract moved node ids.
|
||||
let moved_ids: Vec<String> = cmds
|
||||
.iter()
|
||||
.filter_map(|cmd| match cmd {
|
||||
EditorCommand::MoveNode { node_id, .. } => Some(node_id.as_str().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// All MoveNode targets should be main_id.
|
||||
for cmd in &cmds {
|
||||
if let EditorCommand::MoveNode { target_parent, .. } = cmd {
|
||||
assert_eq!(
|
||||
target_parent.as_str(),
|
||||
main_id,
|
||||
"MoveNode target must be main_id"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(moved_ids.len(), 3, "expected 3 MoveNode commands");
|
||||
assert_eq!(moved_ids[0], slot_a_id, "first move: slot-a");
|
||||
assert_eq!(moved_ids[1], slot_b_id, "second move: slot-b");
|
||||
assert_eq!(moved_ids[2], slot_c_id, "third move: slot-c");
|
||||
}
|
||||
|
||||
/// Slot-in-row: subtask's parentFrameId is a slot under a row under main.
|
||||
/// Expected: the row id (not the slot id) is used in the MoveNode command.
|
||||
#[test]
|
||||
fn reorder_resolves_slot_to_row_ancestor() {
|
||||
let mut sink = VecDocSink::new();
|
||||
|
||||
// main has: [row-frame (with slot-a, slot-b), standalone-slot]
|
||||
let main_node: jian_ops_schema::node::PenNode = serde_json::from_value(json!({
|
||||
"type": "frame", "id": "root-main", "name": "Main",
|
||||
"width": 940, "height": 100,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame", "id": "row-2", "name": "Row2",
|
||||
"width": "fill_container", "height": "fit_content",
|
||||
"layout": "horizontal",
|
||||
"children": [
|
||||
frame_val("slot-a", "SlotA", json!([])),
|
||||
frame_val("slot-b", "SlotB", json!([])),
|
||||
],
|
||||
},
|
||||
frame_val("standalone-slot", "StandaloneSlot", json!([])),
|
||||
],
|
||||
}))
|
||||
.unwrap();
|
||||
sink.state.apply(EditorCommand::InsertSubtree {
|
||||
nodes: vec![main_node],
|
||||
parent_id: NodeId::NONE,
|
||||
});
|
||||
|
||||
let main_id = find_by_name(&sink.state, "Main").expect("Main not found");
|
||||
let row2_id = find_by_name(&sink.state, "Row2").expect("Row2 not found");
|
||||
let standalone_id =
|
||||
find_by_name(&sink.state, "StandaloneSlot").expect("StandaloneSlot not found");
|
||||
let slot_a_id = find_by_name(&sink.state, "SlotA").expect("SlotA not found");
|
||||
|
||||
// Plan: sidebar + standalone (pfid=standalone-slot, direct child of main)
|
||||
// + subtask-a (pfid=slot-a, child of row-2 which is child of main)
|
||||
let sidebar = sidebar_st(0.0);
|
||||
let mut standalone_subtask = st("standalone", "Standalone", 940.0, 96.0);
|
||||
standalone_subtask.parent_frame_id = Some(standalone_id.clone());
|
||||
let mut subtask_a = st("subtask-a", "Subtask A", 460.0, 300.0);
|
||||
subtask_a.parent_frame_id = Some(slot_a_id.clone());
|
||||
let plan = plan_with(1200.0, vec![sidebar, standalone_subtask, subtask_a]);
|
||||
|
||||
let cmds = reorder_dashboard_main_children(&plan, &main_id, &sink.state);
|
||||
let moved_ids: Vec<String> = cmds
|
||||
.iter()
|
||||
.filter_map(|cmd| match cmd {
|
||||
EditorCommand::MoveNode { node_id, .. } => Some(node_id.as_str().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(moved_ids.len(), 2, "expected 2 MoveNode commands");
|
||||
assert_eq!(
|
||||
moved_ids[0], standalone_id,
|
||||
"first: standalone slot (direct child of main)"
|
||||
);
|
||||
assert_eq!(moved_ids[1], row2_id, "second: row-2 (parent of slot-a)");
|
||||
}
|
||||
|
||||
/// Sidebar subtasks are skipped.
|
||||
#[test]
|
||||
fn reorder_skips_sidebar_subtasks() {
|
||||
let sink = VecDocSink::new();
|
||||
let plan = plan_with(1200.0, vec![sidebar_st(300.0)]);
|
||||
let cmds = reorder_dashboard_main_children(&plan, "root-main", &sink.state);
|
||||
assert!(cmds.is_empty(), "all-sidebar plan → no commands");
|
||||
}
|
||||
|
||||
/// Falls back to `generated_root_id` when `parent_frame_id` is None.
|
||||
#[test]
|
||||
fn reorder_uses_generated_root_id_as_fallback() {
|
||||
let mut sink = VecDocSink::new();
|
||||
|
||||
let main_node: jian_ops_schema::node::PenNode = serde_json::from_value(json!({
|
||||
"type": "frame", "id": "root-main", "name": "Main",
|
||||
"width": 940, "height": 100,
|
||||
"children": [
|
||||
frame_val("gen-b", "GenB", json!([])),
|
||||
frame_val("gen-a", "GenA", json!([])),
|
||||
],
|
||||
}))
|
||||
.unwrap();
|
||||
sink.state.apply(EditorCommand::InsertSubtree {
|
||||
nodes: vec![main_node],
|
||||
parent_id: NodeId::NONE,
|
||||
});
|
||||
|
||||
let main_id = find_by_name(&sink.state, "Main").expect("Main not found");
|
||||
let gen_a_id = find_by_name(&sink.state, "GenA").expect("GenA not found");
|
||||
let gen_b_id = find_by_name(&sink.state, "GenB").expect("GenB not found");
|
||||
|
||||
// subtask-a: no parent_frame_id → falls back to generated_root_id = gen_a.
|
||||
// subtask-b: no parent_frame_id → falls back to generated_root_id = gen_b.
|
||||
let sidebar = sidebar_st(0.0);
|
||||
let mut st_a = st("subtask-a", "Subtask A", 940.0, 200.0);
|
||||
st_a.parent_frame_id = None;
|
||||
st_a.generated_root_id = Some(gen_a_id.clone());
|
||||
let mut st_b = st("subtask-b", "Subtask B", 940.0, 200.0);
|
||||
st_b.parent_frame_id = None;
|
||||
st_b.generated_root_id = Some(gen_b_id.clone());
|
||||
let plan = plan_with(1200.0, vec![sidebar, st_a, st_b]);
|
||||
|
||||
let cmds = reorder_dashboard_main_children(&plan, &main_id, &sink.state);
|
||||
let moved_ids: Vec<String> = cmds
|
||||
.iter()
|
||||
.filter_map(|cmd| match cmd {
|
||||
EditorCommand::MoveNode { node_id, .. } => Some(node_id.as_str().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(moved_ids.len(), 2, "expected 2 MoveNode commands");
|
||||
assert_eq!(moved_ids[0], gen_a_id, "first: gen_a");
|
||||
assert_eq!(moved_ids[1], gen_b_id, "second: gen_b");
|
||||
}
|
||||
|
|
@ -71,6 +71,10 @@ pub struct Subtask {
|
|||
/// 对应的屏幕 / 页面名 —— port of TS `SubTask.screen`。
|
||||
#[serde(default)]
|
||||
pub screen: Option<String>,
|
||||
/// sub-agent 运行后记录下的顶层节点 id —— port of TS
|
||||
/// `SubTask.generatedRootId`。仪器由 run.rs 填写;规划阶段为 None。
|
||||
#[serde(skip)]
|
||||
pub generated_root_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 规划阶段的完整产物。字段对齐规划语料 `decomposition.md`。
|
||||
|
|
@ -172,6 +176,7 @@ pub fn build_fallback_plan(req: &DesignRequest) -> OrchestratorPlan {
|
|||
parent_frame_id: None,
|
||||
elements: None,
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ mod tests {
|
|||
parent_frame_id: None,
|
||||
elements: None,
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -358,6 +358,7 @@ fn coerce_subtask(
|
|||
parent_frame_id: None,
|
||||
elements: None,
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -408,6 +409,7 @@ fn coerce_subtask(
|
|||
parent_frame_id: None,
|
||||
elements,
|
||||
screen,
|
||||
generated_root_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -309,6 +309,7 @@ mod tests {
|
|||
parent_frame_id: Some("root".into()),
|
||||
elements: None,
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
};
|
||||
let cr = build_subagent_prompt(&st, &plan(), &req(), AbortFlag::new(), false, false);
|
||||
assert!(cr.user_prompt.contains("Hero"));
|
||||
|
|
@ -329,6 +330,7 @@ mod tests {
|
|||
parent_frame_id: None,
|
||||
elements: None,
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
};
|
||||
// minimal_skills=true: the system prompt should contain "schema" skill
|
||||
// content and "jsonl-format" skill content, but NOT layout/text-rules etc.
|
||||
|
|
@ -359,6 +361,7 @@ mod tests {
|
|||
parent_frame_id: None,
|
||||
elements: None,
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
};
|
||||
// req() uses model "claude" which is Full tier — no narrowing.
|
||||
// Use a basic-tier model to test narrowing.
|
||||
|
|
@ -392,6 +395,7 @@ mod tests {
|
|||
parent_frame_id: None,
|
||||
elements: None,
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
};
|
||||
// req() uses "claude" which maps to Full tier → reduced_complexity is no-op
|
||||
let full_cr = build_subagent_prompt(&st, &plan(), &req(), AbortFlag::new(), false, false);
|
||||
|
|
@ -503,6 +507,7 @@ mod tests {
|
|||
parent_frame_id: None,
|
||||
elements: None,
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ mod tests {
|
|||
parent_frame_id: None,
|
||||
elements: None,
|
||||
screen: screen.map(|s| s.to_string()),
|
||||
generated_root_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ mod tests {
|
|||
parent_frame_id: None,
|
||||
elements: None,
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue