//! Pure layout logic: split / merge / resize / move_session, nominal and error //! paths, grid validation, and immutability of the source tree (ARCHITECTURE §7). mod helpers; use domain::ids::AgentId; use domain::{ Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell, SplitContainer, WeightedChild, }; use helpers::{node, session}; fn agent_id(n: u128) -> AgentId { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } fn leaf(id: u128, sess: Option) -> LeafCell { LeafCell { id: node(id), session: sess.map(session), agent: None, conversation_id: None, engine_session_id: None, agent_was_running: false, } } /// A leaf pre-populated with the two resume fields, used by the /// non-regression tests that assert these fields survive other operations. fn leaf_with_resume(id: u128, sess: Option) -> LeafCell { LeafCell { id: node(id), session: sess.map(session), agent: None, conversation_id: Some("conv-1".to_string()), engine_session_id: None, agent_was_running: true, } } /// Walks the public tree to fetch the full [`LeafCell`] for `id`. fn leaf_for(tree: &LayoutTree, id: domain::NodeId) -> Option { fn walk(n: &LayoutNode, id: domain::NodeId) -> Option { match n { LayoutNode::Leaf(l) if l.id == id => Some(l.clone()), LayoutNode::Leaf(_) => None, LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)), LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)), } } walk(&tree.root, id) } fn single(id: u128, sess: Option) -> LayoutTree { LayoutTree::single(leaf(id, sess)) } // --------------------------------------------------------------------------- // split // --------------------------------------------------------------------------- #[test] fn split_nominal_produces_two_children() { let tree = single(1, Some(100)); let out = tree .split(node(1), Direction::Row, leaf(2, None), node(9)) .unwrap(); match out.root { LayoutNode::Split(s) => { assert_eq!(s.id, node(9)); assert_eq!(s.direction, Direction::Row); assert_eq!(s.children.len(), 2); assert!(s.children.iter().all(|c| c.weight > 0.0)); } _ => panic!("expected a split at the root"), } } #[test] fn split_is_immutable_source_unchanged() { let tree = single(1, Some(100)); let before = tree.clone(); let _ = tree .split(node(1), Direction::Column, leaf(2, None), node(9)) .unwrap(); assert_eq!(tree, before, "source tree must not be mutated"); } #[test] fn split_missing_target_is_node_not_found() { let tree = single(1, None); let err = tree .split(node(404), Direction::Row, leaf(2, None), node(9)) .unwrap_err(); assert_eq!(err, LayoutError::NodeNotFound(node(404))); } #[test] fn split_into_a_session_already_present_elsewhere_is_duplicate() { // root leaf 1 holds session 100; we split it adding a NEW leaf that reuses // the same session id → duplicate must be rejected by validation. let tree = single(1, Some(100)); let err = tree .split(node(1), Direction::Row, leaf(2, Some(100)), node(9)) .unwrap_err(); assert_eq!(err, LayoutError::DuplicateSession(session(100))); } // --------------------------------------------------------------------------- // merge // --------------------------------------------------------------------------- #[test] fn merge_keeps_selected_child() { let tree = single(1, Some(100)) .split(node(1), Direction::Row, leaf(2, Some(200)), node(9)) .unwrap(); // keep index 1 (the new leaf with session 200). let merged = tree.merge(node(9), 1).unwrap(); assert_eq!(merged.root, LayoutNode::Leaf(leaf(2, Some(200)))); } #[test] fn merge_is_immutable() { let tree = single(1, Some(100)) .split(node(1), Direction::Row, leaf(2, None), node(9)) .unwrap(); let before = tree.clone(); let _ = tree.merge(node(9), 0).unwrap(); assert_eq!(tree, before); } #[test] fn merge_unknown_container_is_node_not_found() { let tree = single(1, None) .split(node(1), Direction::Row, leaf(2, None), node(9)) .unwrap(); let err = tree.merge(node(404), 0).unwrap_err(); assert_eq!(err, LayoutError::NodeNotFound(node(404))); } #[test] fn merge_index_out_of_range_is_cross_container() { let tree = single(1, None) .split(node(1), Direction::Row, leaf(2, None), node(9)) .unwrap(); let err = tree.merge(node(9), 5).unwrap_err(); assert_eq!(err, LayoutError::CrossContainer); } // --------------------------------------------------------------------------- // resize // --------------------------------------------------------------------------- #[test] fn resize_nominal_updates_weights() { let tree = single(1, None) .split(node(1), Direction::Row, leaf(2, None), node(9)) .unwrap(); let out = tree.resize(node(9), &[2.0, 3.0]).unwrap(); match out.root { LayoutNode::Split(s) => { assert_eq!(s.children[0].weight, 2.0); assert_eq!(s.children[1].weight, 3.0); } _ => panic!("expected split"), } } #[test] fn resize_is_immutable() { let tree = single(1, None) .split(node(1), Direction::Row, leaf(2, None), node(9)) .unwrap(); let before = tree.clone(); let _ = tree.resize(node(9), &[2.0, 3.0]).unwrap(); assert_eq!(tree, before); } #[test] fn resize_nonpositive_weight_rejected() { let tree = single(1, None) .split(node(1), Direction::Row, leaf(2, None), node(9)) .unwrap(); let err = tree.resize(node(9), &[0.0, 1.0]).unwrap_err(); assert_eq!(err, LayoutError::NonPositiveWeight { weight: 0.0 }); let err = tree.resize(node(9), &[-1.0, 1.0]).unwrap_err(); assert_eq!(err, LayoutError::NonPositiveWeight { weight: -1.0 }); } #[test] fn resize_wrong_arity_is_cross_container() { let tree = single(1, None) .split(node(1), Direction::Row, leaf(2, None), node(9)) .unwrap(); let err = tree.resize(node(9), &[1.0, 2.0, 3.0]).unwrap_err(); assert_eq!(err, LayoutError::CrossContainer); } #[test] fn resize_unknown_container_is_node_not_found() { let tree = single(1, None) .split(node(1), Direction::Row, leaf(2, None), node(9)) .unwrap(); let err = tree.resize(node(404), &[1.0, 2.0]).unwrap_err(); assert_eq!(err, LayoutError::NodeNotFound(node(404))); } // --------------------------------------------------------------------------- // move_session // --------------------------------------------------------------------------- fn two_leaves(from_sess: Option, to_sess: Option) -> LayoutTree { LayoutTree::new(LayoutNode::Split(SplitContainer { id: node(9), direction: Direction::Row, children: vec![ WeightedChild { node: LayoutNode::Leaf(leaf(1, from_sess)), weight: 1.0, }, WeightedChild { node: LayoutNode::Leaf(leaf(2, to_sess)), weight: 1.0, }, ], })) } /// Looks up the session held by the leaf `id` in a tree (test-only helper that /// walks the public structure, since the domain's lookup is private). fn session_for(tree: &LayoutTree, id: domain::NodeId) -> Option { fn walk(n: &LayoutNode, id: domain::NodeId) -> Option> { match n { LayoutNode::Leaf(l) if l.id == id => Some(l.session), LayoutNode::Leaf(_) => None, LayoutNode::Split(s) => s.children.iter().find_map(|c| walk(&c.node, id)), LayoutNode::Grid(g) => g.cells.iter().find_map(|c| walk(&c.node, id)), } } walk(&tree.root, id).flatten() } #[test] fn move_session_nominal() { let tree = two_leaves(Some(100), None); let out = tree.move_session(node(1), node(2)).unwrap(); assert_eq!(session_for(&out, node(1)), None); assert_eq!(session_for(&out, node(2)), Some(session(100))); } #[test] fn move_session_is_immutable() { let tree = two_leaves(Some(100), None); let before = tree.clone(); let _ = tree.move_session(node(1), node(2)).unwrap(); assert_eq!(tree, before); } #[test] fn move_session_from_empty_rejected() { let tree = two_leaves(None, None); let err = tree.move_session(node(1), node(2)).unwrap_err(); assert_eq!(err, LayoutError::CrossContainer); } #[test] fn move_session_to_occupied_rejected() { let tree = two_leaves(Some(100), Some(200)); let err = tree.move_session(node(1), node(2)).unwrap_err(); assert_eq!(err, LayoutError::CrossContainer); } #[test] fn move_session_missing_leaf_is_node_not_found() { let tree = two_leaves(Some(100), None); assert_eq!( tree.move_session(node(404), node(2)).unwrap_err(), LayoutError::NodeNotFound(node(404)) ); assert_eq!( tree.move_session(node(1), node(404)).unwrap_err(), LayoutError::NodeNotFound(node(404)) ); } // --------------------------------------------------------------------------- // validate(): grid invariants & duplicate sessions // --------------------------------------------------------------------------- fn grid(col_w: Vec, row_w: Vec, cells: Vec) -> LayoutTree { LayoutTree::new(LayoutNode::Grid(GridContainer { id: node(50), col_weights: col_w, row_weights: row_w, cells, })) } fn gcell(id: u128, row: u16, col: u16, rs: u16, cs: u16) -> GridCell { GridCell { node: LayoutNode::Leaf(leaf(id, None)), row, col, row_span: rs, col_span: cs, } } #[test] fn grid_fully_covered_2x2_ok() { let cells = vec![ gcell(1, 0, 0, 1, 1), gcell(2, 0, 1, 1, 1), gcell(3, 1, 0, 1, 1), gcell(4, 1, 1, 1, 1), ]; let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], cells); assert!(t.validate().is_ok()); } #[test] fn grid_merged_span_full_coverage_ok() { // one cell spanning the whole top row + two cells on the bottom row. let cells = vec![ gcell(1, 0, 0, 1, 2), // top row merged gcell(2, 1, 0, 1, 1), gcell(3, 1, 1, 1, 1), ]; let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], cells); assert!(t.validate().is_ok()); } #[test] fn grid_overlap_rejected() { let cells = vec![ gcell(1, 0, 0, 1, 2), gcell(2, 0, 1, 1, 1), // overlaps column 1 of the spanning cell gcell(3, 1, 0, 1, 1), gcell(4, 1, 1, 1, 1), ]; let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], cells); assert!(matches!( t.validate().unwrap_err(), LayoutError::OverlappingCells { .. } )); } #[test] fn grid_uncovered_surface_rejected() { // 2x2 grid but only one cell → three cells uncovered. let t = grid(vec![1.0, 1.0], vec![1.0, 1.0], vec![gcell(1, 0, 0, 1, 1)]); assert!(matches!( t.validate().unwrap_err(), LayoutError::UncoveredCell { .. } )); } #[test] fn grid_span_out_of_bounds_rejected() { let t = grid(vec![1.0], vec![1.0], vec![gcell(1, 0, 0, 2, 1)]); assert!(matches!( t.validate().unwrap_err(), LayoutError::SpanOutOfBounds { .. } )); } #[test] fn grid_zero_span_rejected() { let t = grid(vec![1.0], vec![1.0], vec![gcell(1, 0, 0, 0, 1)]); assert_eq!(t.validate().unwrap_err(), LayoutError::InvalidSpan); } #[test] fn grid_nonpositive_weight_rejected() { let t = grid(vec![0.0], vec![1.0], vec![gcell(1, 0, 0, 1, 1)]); assert_eq!( t.validate().unwrap_err(), LayoutError::NonPositiveWeight { weight: 0.0 } ); } #[test] fn duplicate_session_across_leaves_rejected() { let tree = two_leaves(Some(100), Some(100)); assert_eq!( tree.validate().unwrap_err(), LayoutError::DuplicateSession(session(100)) ); } #[test] fn empty_split_rejected() { let t = LayoutTree::new(LayoutNode::Split(SplitContainer { id: node(9), direction: Direction::Row, children: vec![], })); assert_eq!(t.validate().unwrap_err(), LayoutError::EmptySplit); } // --------------------------------------------------------------------------- // set_session (L4: cell ↔ terminal binding bridge) // --------------------------------------------------------------------------- #[test] fn set_session_attaches_to_leaf() { let tree = single(1, None); let out = tree.set_session(node(1), Some(session(100))).unwrap(); match out.root { LayoutNode::Leaf(l) => { assert_eq!(l.id, node(1)); assert_eq!(l.session, Some(session(100))); } _ => panic!("expected a leaf at the root"), } } #[test] fn set_session_detaches_with_none() { let tree = single(1, Some(100)); let out = tree.set_session(node(1), None).unwrap(); match out.root { LayoutNode::Leaf(l) => assert_eq!(l.session, None), _ => panic!("expected a leaf at the root"), } } #[test] fn set_session_is_immutable_source_unchanged() { let tree = single(1, None); let before = tree.clone(); let _ = tree.set_session(node(1), Some(session(100))).unwrap(); assert_eq!(tree, before, "source tree must not be mutated"); } #[test] fn set_session_reaches_nested_leaf() { // Attach onto the second leaf of a split, leaving the first empty. let tree = two_leaves(None, None); let out = tree.set_session(node(2), Some(session(7))).unwrap(); match out.root { LayoutNode::Split(s) => match &s.children[1].node { LayoutNode::Leaf(l) => assert_eq!(l.session, Some(session(7))), _ => panic!("expected a leaf child"), }, _ => panic!("expected a split root"), } } #[test] fn set_session_missing_leaf_is_node_not_found() { let tree = single(1, None); let err = tree.set_session(node(404), Some(session(100))).unwrap_err(); assert_eq!(err, LayoutError::NodeNotFound(node(404))); } #[test] fn set_session_duplicate_across_leaves_rejected() { // Leaf 1 already hosts session 100; attaching the same session to leaf 2 // must fail validation rather than producing a duplicate. let tree = two_leaves(Some(100), None); let err = tree.set_session(node(2), Some(session(100))).unwrap_err(); assert_eq!(err, LayoutError::DuplicateSession(session(100))); } #[test] fn attach_session_attaches_background_session_to_leaf() { let tree = two_leaves(None, None); let out = tree.attach_session(node(2), session(100)).unwrap(); assert_eq!(session_for(&out, node(1)), None); assert_eq!(session_for(&out, node(2)), Some(session(100))); } #[test] fn attach_session_moves_visible_session_to_target_leaf() { let tree = two_leaves(Some(100), None); let out = tree.attach_session(node(2), session(100)).unwrap(); assert_eq!(session_for(&out, node(1)), None); assert_eq!(session_for(&out, node(2)), Some(session(100))); } #[test] fn attach_session_same_leaf_is_idempotent() { let tree = two_leaves(Some(100), None); let out = tree.attach_session(node(1), session(100)).unwrap(); assert_eq!(out, tree); } #[test] fn attach_session_rejects_target_occupied_by_other_session() { let tree = two_leaves(Some(100), Some(200)); let err = tree.attach_session(node(2), session(100)).unwrap_err(); assert_eq!(err, LayoutError::CrossContainer); } // --------------------------------------------------------------------------- // set_cell_agent (#3: per-cell agent) // --------------------------------------------------------------------------- #[test] fn set_cell_agent_attaches_agent_to_leaf() { let tree = single(1, None); let out = tree.set_cell_agent(node(1), Some(agent_id(42))).unwrap(); match out.root { LayoutNode::Leaf(l) => { assert_eq!(l.id, node(1)); assert_eq!(l.agent, Some(agent_id(42))); } _ => panic!("expected a leaf at root"), } } #[test] fn set_cell_agent_detaches_with_none() { // First attach, then detach. let tree = single(1, None); let with_agent = tree.set_cell_agent(node(1), Some(agent_id(42))).unwrap(); let out = with_agent.set_cell_agent(node(1), None).unwrap(); match out.root { LayoutNode::Leaf(l) => assert_eq!(l.agent, None), _ => panic!("expected a leaf at root"), } } #[test] fn set_cell_agent_is_immutable_source_unchanged() { let tree = single(1, None); let before = tree.clone(); let _ = tree.set_cell_agent(node(1), Some(agent_id(99))).unwrap(); assert_eq!(tree, before, "source tree must not be mutated"); } #[test] fn set_cell_agent_missing_leaf_is_node_not_found() { let tree = single(1, None); let err = tree .set_cell_agent(node(404), Some(agent_id(1))) .unwrap_err(); assert_eq!(err, LayoutError::NodeNotFound(node(404))); } #[test] fn set_cell_agent_preserves_session() { // Session must survive an agent attachment. let tree = single(1, Some(100)); let out = tree.set_cell_agent(node(1), Some(agent_id(7))).unwrap(); match out.root { LayoutNode::Leaf(l) => { assert_eq!(l.session, Some(session(100))); assert_eq!(l.agent, Some(agent_id(7))); } _ => panic!("expected leaf"), } } // --------------------------------------------------------------------------- // set_cell_conversation (resume: persistent CLI conversation id) // --------------------------------------------------------------------------- #[test] fn set_cell_conversation_attaches_to_leaf() { let tree = single(1, None); let out = tree .set_cell_conversation(node(1), Some("conv-xyz".to_string())) .unwrap(); let l = leaf_for(&out, node(1)).expect("leaf"); assert_eq!(l.conversation_id, Some("conv-xyz".to_string())); } #[test] fn set_cell_conversation_clears_with_none() { let tree = LayoutTree::single(leaf_with_resume(1, None)); let out = tree.set_cell_conversation(node(1), None).unwrap(); let l = leaf_for(&out, node(1)).expect("leaf"); assert_eq!(l.conversation_id, None); } #[test] fn set_cell_conversation_targets_correct_leaf() { // Two leaves; only leaf 2 should receive the conversation id. let tree = two_leaves(None, None); let out = tree .set_cell_conversation(node(2), Some("conv-2".to_string())) .unwrap(); assert_eq!( leaf_for(&out, node(1)).unwrap().conversation_id, None, "untouched leaf must stay clear" ); assert_eq!( leaf_for(&out, node(2)).unwrap().conversation_id, Some("conv-2".to_string()) ); } #[test] fn set_cell_conversation_is_immutable_source_unchanged() { let tree = single(1, None); let before = tree.clone(); let _ = tree .set_cell_conversation(node(1), Some("conv-1".to_string())) .unwrap(); assert_eq!(tree, before, "source tree must not be mutated"); } #[test] fn set_cell_conversation_missing_leaf_is_node_not_found() { let tree = single(1, None); let err = tree .set_cell_conversation(node(404), Some("conv".to_string())) .unwrap_err(); assert_eq!(err, LayoutError::NodeNotFound(node(404))); } #[test] fn set_cell_conversation_preserves_session_and_agent_running() { // conversation id must coexist with session and agent_was_running. let tree = LayoutTree::single(leaf_with_resume(1, Some(100))); let out = tree .set_cell_conversation(node(1), Some("conv-new".to_string())) .unwrap(); let l = leaf_for(&out, node(1)).expect("leaf"); assert_eq!(l.session, Some(session(100))); assert_eq!(l.agent_was_running, true); assert_eq!(l.conversation_id, Some("conv-new".to_string())); } // --------------------------------------------------------------------------- // set_agent_running (resume: agent process state at close) // --------------------------------------------------------------------------- #[test] fn set_agent_running_sets_true_then_false() { let tree = single(1, None); let out = tree.set_agent_running(node(1), true).unwrap(); assert_eq!(leaf_for(&out, node(1)).unwrap().agent_was_running, true); let out2 = out.set_agent_running(node(1), false).unwrap(); assert_eq!(leaf_for(&out2, node(1)).unwrap().agent_was_running, false); } #[test] fn set_agent_running_targets_correct_leaf() { let tree = two_leaves(None, None); let out = tree.set_agent_running(node(2), true).unwrap(); assert_eq!( leaf_for(&out, node(1)).unwrap().agent_was_running, false, "untouched leaf must stay false" ); assert_eq!(leaf_for(&out, node(2)).unwrap().agent_was_running, true); } // --------------------------------------------------------------------------- // agent_leaves (close-time snapshot traversal) // --------------------------------------------------------------------------- #[test] fn agent_leaves_empty_when_no_agent() { let tree = two_leaves(Some(1), None); assert!(tree.agent_leaves().is_empty()); } #[test] fn agent_leaves_collects_only_agent_bearing_leaves() { // Split: leaf 1 with agent 100, leaf 2 plain, leaf 3 with agent 101. let tree = LayoutTree::new(LayoutNode::Split(SplitContainer { id: node(99), direction: Direction::Row, children: vec![ WeightedChild { node: LayoutNode::Leaf(LeafCell { id: node(1), session: None, agent: Some(agent_id(100)), conversation_id: None, engine_session_id: None, agent_was_running: false, }), weight: 1.0, }, WeightedChild { node: LayoutNode::Leaf(leaf(2, None)), weight: 1.0, }, WeightedChild { node: LayoutNode::Leaf(LeafCell { id: node(3), session: None, agent: Some(agent_id(101)), conversation_id: None, engine_session_id: None, agent_was_running: false, }), weight: 1.0, }, ], })); let mut found = tree.agent_leaves(); found.sort_by_key(|(n, _)| *n); assert_eq!( found, vec![(node(1), agent_id(100)), (node(3), agent_id(101))] ); } #[test] fn set_agent_running_is_immutable_source_unchanged() { let tree = single(1, None); let before = tree.clone(); let _ = tree.set_agent_running(node(1), true).unwrap(); assert_eq!(tree, before, "source tree must not be mutated"); } #[test] fn set_agent_running_missing_leaf_is_node_not_found() { let tree = single(1, None); let err = tree.set_agent_running(node(404), true).unwrap_err(); assert_eq!(err, LayoutError::NodeNotFound(node(404))); } #[test] fn set_agent_running_preserves_session_and_conversation() { let tree = LayoutTree::single(leaf_with_resume(1, Some(100))); let out = tree.set_agent_running(node(1), false).unwrap(); let l = leaf_for(&out, node(1)).expect("leaf"); assert_eq!(l.session, Some(session(100))); assert_eq!(l.conversation_id, Some("conv-1".to_string())); assert_eq!(l.agent_was_running, false); } // --------------------------------------------------------------------------- // NON-REGRESSION (piège n°1): the resume fields (conversation_id, // agent_was_running) MUST survive every leaf-rebuilding operation. // --------------------------------------------------------------------------- #[test] fn set_session_preserves_resume_fields() { // leaf starts with conversation_id = Some("conv-1") and agent_was_running = true. let tree = LayoutTree::single(leaf_with_resume(1, None)); let out = tree.set_session(node(1), Some(session(100))).unwrap(); let l = leaf_for(&out, node(1)).expect("leaf"); assert_eq!(l.session, Some(session(100))); assert_eq!( l.conversation_id, Some("conv-1".to_string()), "set_session must NOT erase conversation_id" ); assert_eq!( l.agent_was_running, true, "set_session must NOT erase agent_was_running" ); } #[test] fn set_cell_agent_preserves_resume_fields() { let tree = LayoutTree::single(leaf_with_resume(1, None)); let out = tree.set_cell_agent(node(1), Some(agent_id(42))).unwrap(); let l = leaf_for(&out, node(1)).expect("leaf"); assert_eq!(l.agent, Some(agent_id(42))); assert_eq!( l.conversation_id, Some("conv-1".to_string()), "set_cell_agent must NOT erase conversation_id" ); assert_eq!( l.agent_was_running, true, "set_cell_agent must NOT erase agent_was_running" ); } #[test] fn move_session_preserves_resume_fields_on_both_leaves() { // from-leaf carries a session + resume fields; to-leaf is empty but ALSO // carries resume fields. After the move, BOTH leaves must keep their own // conversation_id / agent_was_running (only the session moves). let tree = LayoutTree::new(LayoutNode::Split(SplitContainer { id: node(9), direction: Direction::Row, children: vec![ WeightedChild { node: LayoutNode::Leaf(LeafCell { id: node(1), session: Some(session(100)), agent: None, conversation_id: Some("conv-from".to_string()), engine_session_id: None, agent_was_running: true, }), weight: 1.0, }, WeightedChild { node: LayoutNode::Leaf(LeafCell { id: node(2), session: None, agent: None, conversation_id: Some("conv-to".to_string()), engine_session_id: None, agent_was_running: true, }), weight: 1.0, }, ], })); let out = tree.move_session(node(1), node(2)).unwrap(); let from = leaf_for(&out, node(1)).expect("from leaf"); assert_eq!(from.session, None, "session left the from-leaf"); assert_eq!( from.conversation_id, Some("conv-from".to_string()), "move_session must NOT erase from-leaf conversation_id" ); assert_eq!( from.agent_was_running, true, "move_session must NOT erase from-leaf agent_was_running" ); let to = leaf_for(&out, node(2)).expect("to leaf"); assert_eq!(to.session, Some(session(100)), "session arrived at to-leaf"); assert_eq!( to.conversation_id, Some("conv-to".to_string()), "move_session must NOT erase to-leaf conversation_id" ); assert_eq!( to.agent_was_running, true, "move_session must NOT erase to-leaf agent_was_running" ); } // --------------------------------------------------------------------------- // reconcile_duplicate_agents (R0c: dé-doublonnage à l'ouverture) // --------------------------------------------------------------------------- /// Builds an agent-bearing leaf with explicit resume signals. fn agent_leaf_full( id: u128, agent: u128, conv: Option<&str>, running: bool, ) -> LeafCell { LeafCell { id: node(id), session: None, agent: Some(agent_id(agent)), conversation_id: conv.map(str::to_string), engine_session_id: None, agent_was_running: running, } } /// Wraps an ordered list of leaves into a single Row split (pre-order = the /// children order, same traversal as `agent_leaves`). fn split_of(leaves: Vec) -> LayoutTree { LayoutTree::new(LayoutNode::Split(SplitContainer { id: node(900), direction: Direction::Row, children: leaves .into_iter() .map(|l| WeightedChild { node: LayoutNode::Leaf(l), weight: 1.0, }) .collect(), })) } /// 1. Two leaves of the SAME agent, both `agent_was_running = true`: after /// reconciliation exactly ONE stays running; the other is neutralised /// (running=false, conversation_id=None) but the leaf/agent SURVIVES. #[test] fn reconcile_basic_duplicate_keeps_single_host() { let tree = split_of(vec![ agent_leaf_full(1, 7, None, true), agent_leaf_full(2, 7, None, true), ]); let out = tree.reconcile_duplicate_agents(); // First in pre-order is the host (no resume signal differentiates them). let host = leaf_for(&out, node(1)).expect("leaf 1"); let other = leaf_for(&out, node(2)).expect("leaf 2"); assert_eq!(host.agent_was_running, true, "host stays running"); assert_eq!(other.agent_was_running, false, "duplicate neutralised"); assert_eq!(other.conversation_id, None); // The leaf and its agent binding still exist (cell not removed). assert_eq!(host.agent, Some(agent_id(7))); assert_eq!(other.agent, Some(agent_id(7))); // Exactly one running leaf for the agent. let running = [&host, &other] .iter() .filter(|l| l.agent_was_running) .count(); assert_eq!(running, 1); } /// 2a. Host chosen by resume signal: the FIRST leaf carries no signal, the /// SECOND carries one (conversation_id). The second must become the host; the /// first (no signal) is left untouched (already neutral). #[test] fn reconcile_host_is_first_leaf_carrying_resume_signal() { let tree = split_of(vec![ agent_leaf_full(1, 7, None, false), // no signal, first in order agent_leaf_full(2, 7, Some("conv-2"), false), // signal → becomes host ]); let out = tree.reconcile_duplicate_agents(); let first = leaf_for(&out, node(1)).expect("leaf 1"); let second = leaf_for(&out, node(2)).expect("leaf 2"); assert_eq!( second.conversation_id, Some("conv-2".to_string()), "the signal-bearing leaf is kept as host" ); // The first leaf has no signal to strip; stays neutral. assert_eq!(first.conversation_id, None); assert_eq!(first.agent_was_running, false); } /// 2b. When NO leaf carries a signal, the host is simply the first encountered. /// Here both are inert; reconciliation must leave them unchanged (nothing to /// strip), and not invent a running flag. #[test] fn reconcile_no_signal_host_is_first_and_tree_unchanged() { let tree = split_of(vec![ agent_leaf_full(1, 7, None, false), agent_leaf_full(2, 7, None, false), ]); let out = tree.reconcile_duplicate_agents(); assert_eq!(out, tree, "no resume signal anywhere → nothing to strip"); } /// 2c. The signal-bearing second leaf wins even against a first leaf that has a /// signal too? No — the FIRST signal-bearer wins. Verify the first running leaf /// stays host and the second (also running) is neutralised. #[test] fn reconcile_first_signal_bearer_wins_over_later_signal() { let tree = split_of(vec![ agent_leaf_full(1, 7, Some("conv-1"), true), // first signal → host agent_leaf_full(2, 7, Some("conv-2"), true), ]); let out = tree.reconcile_duplicate_agents(); let host = leaf_for(&out, node(1)).expect("leaf 1"); let other = leaf_for(&out, node(2)).expect("leaf 2"); assert_eq!(host.conversation_id, Some("conv-1".to_string())); assert_eq!(host.agent_was_running, true); assert_eq!(other.conversation_id, None, "later duplicate stripped"); assert_eq!(other.agent_was_running, false); } /// 3. Triplet (N=3) of the same agent: exactly one host, two neutralised. #[test] fn reconcile_triplet_one_host_two_neutralised() { let tree = split_of(vec![ agent_leaf_full(1, 7, None, true), agent_leaf_full(2, 7, None, true), agent_leaf_full(3, 7, Some("c3"), true), ]); let out = tree.reconcile_duplicate_agents(); let l1 = leaf_for(&out, node(1)).unwrap(); let l2 = leaf_for(&out, node(2)).unwrap(); let l3 = leaf_for(&out, node(3)).unwrap(); // First signal-bearer in pre-order is leaf 1 (running=true is a signal). let running_count = [&l1, &l2, &l3] .iter() .filter(|l| l.agent_was_running) .count(); assert_eq!(running_count, 1, "exactly one host remains running"); assert_eq!(l1.agent_was_running, true, "leaf 1 is the host"); assert_eq!(l2.agent_was_running, false); assert_eq!(l3.agent_was_running, false); assert_eq!(l3.conversation_id, None, "duplicate conv stripped"); // All three cells survive. for l in [&l1, &l2, &l3] { assert_eq!(l.agent, Some(agent_id(7))); } } /// 4. Distinct agents are never affected: two leaves with DIFFERENT agents, /// each running, must both stay running. #[test] fn reconcile_distinct_agents_unaffected() { let tree = split_of(vec![ agent_leaf_full(1, 7, Some("c1"), true), agent_leaf_full(2, 8, Some("c2"), true), ]); let out = tree.reconcile_duplicate_agents(); assert_eq!(out, tree, "different agents → no duplicates → unchanged"); } /// 5a. Idempotence (fixed point): reconcile(reconcile(t)) == reconcile(t). #[test] fn reconcile_is_idempotent_fixed_point() { let tree = split_of(vec![ agent_leaf_full(1, 7, Some("c1"), true), agent_leaf_full(2, 7, Some("c2"), true), agent_leaf_full(3, 7, None, true), ]); let once = tree.reconcile_duplicate_agents(); let twice = once.reconcile_duplicate_agents(); assert_eq!(twice, once, "second pass is a no-op (fixed point)"); } /// 5b. A tree WITHOUT duplicates is returned unchanged (== t). #[test] fn reconcile_no_duplicate_returns_identical() { let tree = split_of(vec![ agent_leaf_full(1, 7, Some("c1"), true), agent_leaf_full(2, 8, None, false), leaf(3, Some(100)), // plain non-agent leaf ]); let out = tree.reconcile_duplicate_agents(); assert_eq!(out, tree, "no duplicate agent → identical tree"); } /// Source immutability: reconciliation must not mutate the input tree. #[test] fn reconcile_is_immutable_source_unchanged() { let tree = split_of(vec![ agent_leaf_full(1, 7, None, true), agent_leaf_full(2, 7, None, true), ]); let before = tree.clone(); let _ = tree.reconcile_duplicate_agents(); assert_eq!(tree, before, "source tree must not be mutated"); } // --------------------------------------------------------------------------- // serde: compat ascendante & combinaisons des nouveaux champs // --------------------------------------------------------------------------- #[test] fn leaf_serde_all_four_combinations_roundtrip() { let combos = [ (None, false), (Some("c".to_string()), false), (None, true), (Some("c".to_string()), true), ]; for (conv, running) in combos { let cell = LeafCell { id: node(1), session: None, agent: None, conversation_id: conv.clone(), engine_session_id: None, agent_was_running: running, }; let json = serde_json::to_string(&cell).unwrap(); let back: LeafCell = serde_json::from_str(&json).unwrap(); assert_eq!(back, cell, "roundtrip failed for {conv:?}/{running}"); } } #[test] fn leaf_serde_omits_defaults() { let cell = LeafCell { id: node(1), session: None, agent: None, conversation_id: None, engine_session_id: None, agent_was_running: false, }; let json = serde_json::to_string(&cell).unwrap(); assert!( !json.contains("conversationId"), "default conversation_id must be omitted; json was {json}" ); assert!( !json.contains("agentWasRunning"), "default agent_was_running must be omitted; json was {json}" ); } #[test] fn leaf_serde_field_names_are_camel_case_when_present() { let cell = LeafCell { id: node(1), session: None, agent: None, conversation_id: Some("c".to_string()), engine_session_id: None, agent_was_running: true, }; let json = serde_json::to_string(&cell).unwrap(); assert!(json.contains("conversationId"), "json was {json}"); assert!(json.contains("agentWasRunning"), "json was {json}"); } #[test] fn legacy_leaf_json_deserialises_to_defaults() { // A leaf persisted before these fields existed (only id present). let json = r#"{"id":"00000000-0000-0000-0000-000000000001"}"#; let cell: LeafCell = serde_json::from_str(json).unwrap(); assert_eq!(cell.conversation_id, None); assert_eq!(cell.agent_was_running, false); assert_eq!(cell.session, None); assert_eq!(cell.agent, None); } #[test] fn leaf_can_carry_conversation_without_session_and_inversely() { // conversation_id present, no session. let a = LeafCell { id: node(1), session: None, agent: None, conversation_id: Some("c".to_string()), engine_session_id: None, agent_was_running: false, }; let a_back: LeafCell = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap(); assert_eq!(a_back, a); // session present, no conversation_id. let b = LeafCell { id: node(2), session: Some(session(7)), agent: None, conversation_id: None, engine_session_id: None, agent_was_running: false, }; let b_back: LeafCell = serde_json::from_str(&serde_json::to_string(&b).unwrap()).unwrap(); assert_eq!(b_back, b); }