Files
IdeA/crates/domain/tests/layout.rs
Blomios dcba76b871 feat(chat): livre la CLI custom de chat agent (#147) et corrige Cancel
Implémente la vue chat structurée par cellule agent (toggle TUI/CLI custom,
préférence persistée `preferred_view`, reattach live, composer + pièces
jointes) avec le socle backend AgentSession/ChatBridge (UserPrompt,
cancel_current_turn, routage interrupt_agent, commande cancel_agent_chat).

Corrige le bug bloquant relevé par QA : le bouton Cancel de
CustomAgentChatView interrompait tout le tour via closeAgentChat au lieu
de n'annuler que le tour courant via cancelAgentChat, ce qui tuait la
session contrairement au contrat produit validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 11:59:39 +02:00

1148 lines
38 KiB
Rust

//! 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<u128>) -> LeafCell {
LeafCell {
id: node(id),
session: sess.map(session),
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}
}
/// 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<u128>) -> 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,
preferred_view: domain::PreferredView::Tui,
}
}
/// Walks the public tree to fetch the full [`LeafCell`] for `id`.
fn leaf_for(tree: &LayoutTree, id: domain::NodeId) -> Option<LeafCell> {
fn walk(n: &LayoutNode, id: domain::NodeId) -> Option<LeafCell> {
match n {
LayoutNode::Leaf(l) if l.id == id => Some(l.clone()),
LayoutNode::Leaf(_) => None,
LayoutNode::CustomPluginLayout(_) => 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<u128>) -> 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<u128>, to_sess: Option<u128>) -> 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<domain::SessionId> {
fn walk(n: &LayoutNode, id: domain::NodeId) -> Option<Option<domain::SessionId>> {
match n {
LayoutNode::Leaf(l) if l.id == id => Some(l.session),
LayoutNode::Leaf(_) => None,
LayoutNode::CustomPluginLayout(_) => 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<f32>, row_w: Vec<f32>, cells: Vec<GridCell>) -> 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,
preferred_view: domain::PreferredView::Tui,
}),
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,
preferred_view: domain::PreferredView::Tui,
}),
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,
preferred_view: domain::PreferredView::Tui,
}),
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,
preferred_view: domain::PreferredView::Tui,
}),
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,
preferred_view: domain::PreferredView::Tui,
}
}
/// 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<LeafCell>) -> 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,
preferred_view: domain::PreferredView::Tui,
};
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,
preferred_view: domain::PreferredView::Tui,
};
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,
preferred_view: domain::PreferredView::Tui,
};
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,
preferred_view: domain::PreferredView::Tui,
};
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,
preferred_view: domain::PreferredView::Tui,
};
let b_back: LeafCell = serde_json::from_str(&serde_json::to_string(&b).unwrap()).unwrap();
assert_eq!(b_back, b);
}