Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
496 lines
15 KiB
Rust
496 lines
15 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::{
|
|
Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell,
|
|
SplitContainer, WeightedChild,
|
|
};
|
|
use domain::ids::AgentId;
|
|
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,
|
|
}
|
|
}
|
|
|
|
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::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)));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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"),
|
|
}
|
|
}
|