//! R0c tests for [`ReconcileLayouts`]. //! //! At project open, a persisted `layouts.json` may already hold several leaves //! pinning the **same** agent id. The use case de-duplicates them through the //! pure domain op [`domain::LayoutTree::reconcile_duplicate_agents`] and //! persists the doc **only** when something changed (idempotent no-op //! otherwise). //! //! Harness mirrors `tests/snapshot_running_agents.rs` (its twin use case): the //! same in-memory `FakeFs` / `FakeStore`. The fake FS additionally counts the //! number of `write` **calls** so test 7 can prove that a no-op performs zero //! writes (file-count alone cannot, since `layouts.json` already exists). use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::layout::Workspace; use domain::ports::{DirEntry, FileSystem, FsError, ProjectStore, RemotePath, StoreError}; use domain::{ AgentId, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, Project, ProjectId, ProjectPath, RemoteRef, SplitContainer, WeightedChild, }; use uuid::Uuid; use application::{ReconcileLayouts, ReconcileLayoutsInput}; // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- #[derive(Default)] struct FakeFsInner { files: HashMap>, dirs: HashSet, } #[derive(Default, Clone)] struct FakeFs { inner: Arc>, /// Count of `write` **calls** (not files). Lets a no-op be asserted even /// though the target file already exists. write_calls: Arc, } impl FakeFs { fn read_file(&self, path: &str) -> Option> { self.inner.lock().unwrap().files.get(path).cloned() } fn put(&self, path: &str, data: &[u8]) { self.inner .lock() .unwrap() .files .insert(path.to_owned(), data.to_vec()); } fn write_calls(&self) -> usize { self.write_calls.load(Ordering::SeqCst) } } #[async_trait] impl FileSystem for FakeFs { async fn read(&self, path: &RemotePath) -> Result, FsError> { self.inner .lock() .unwrap() .files .get(path.as_str()) .cloned() .ok_or_else(|| FsError::NotFound(path.as_str().to_owned())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { self.write_calls.fetch_add(1, Ordering::SeqCst); self.inner .lock() .unwrap() .files .insert(path.as_str().to_owned(), data.to_vec()); Ok(()) } async fn exists(&self, path: &RemotePath) -> Result { let inner = self.inner.lock().unwrap(); Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str())) } async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> { self.inner .lock() .unwrap() .dirs .insert(path.as_str().to_owned()); Ok(()) } async fn list(&self, _path: &RemotePath) -> Result, FsError> { Ok(Vec::new()) } async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { Ok(()) } } #[derive(Default)] struct FakeStoreInner { projects: Vec, } #[derive(Default, Clone)] struct FakeStore(Arc>); #[async_trait] impl ProjectStore for FakeStore { async fn list_projects(&self) -> Result, StoreError> { Ok(self.0.lock().unwrap().projects.clone()) } async fn load_project(&self, id: ProjectId) -> Result { self.0 .lock() .unwrap() .projects .iter() .find(|p| p.id == id) .cloned() .ok_or(StoreError::NotFound) } async fn save_project(&self, project: &Project) -> Result<(), StoreError> { self.0.lock().unwrap().projects.push(project.clone()); Ok(()) } async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> { Ok(()) } async fn load_workspace(&self) -> Result { Ok(Workspace::default()) } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const ROOT: &str = "/home/me/proj"; const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json"; fn pid(n: u128) -> ProjectId { ProjectId::from_uuid(Uuid::from_u128(n)) } fn nid(n: u128) -> NodeId { NodeId::from_uuid(Uuid::from_u128(n)) } fn aid(n: u128) -> AgentId { AgentId::from_uuid(Uuid::from_u128(n)) } fn lid(n: u128) -> LayoutId { LayoutId::from_uuid(Uuid::from_u128(n)) } fn agent_leaf(node: NodeId, agent: Option, conv: Option<&str>, running: bool) -> LeafCell { LeafCell { id: node, session: None, agent, conversation_id: conv.map(str::to_string), engine_session_id: None, agent_was_running: running, } } /// Two leaves of the same agent, both flagged running, under a Row split. fn duplicate_tree(agent: AgentId, a: NodeId, b: NodeId) -> LayoutTree { LayoutTree::new(LayoutNode::Split(SplitContainer { id: nid(1), direction: Direction::Row, children: vec![ WeightedChild { node: LayoutNode::Leaf(agent_leaf(a, Some(agent), Some("c-a"), true)), weight: 1.0, }, WeightedChild { node: LayoutNode::Leaf(agent_leaf(b, Some(agent), Some("c-b"), true)), weight: 1.0, }, ], })) } async fn register_project(store: &FakeStore, id: ProjectId) { let project = Project::new( id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0, ) .unwrap(); store.save_project(&project).await.unwrap(); } fn seed_layouts(fs: &FakeFs, id: LayoutId, tree: &LayoutTree) { let doc = serde_json::json!({ "version": 1, "activeId": id.to_string(), "layouts": [ { "id": id.to_string(), "name": "Default", "tree": tree } ], }); fs.put(LAYOUTS_PATH, &serde_json::to_vec(&doc).unwrap()); } fn doc_json(fs: &FakeFs) -> serde_json::Value { serde_json::from_slice(&fs.read_file(LAYOUTS_PATH).expect("layouts.json present")).unwrap() } /// `agent_was_running` for the leaf `node` in the active persisted layout. fn was_running(fs: &FakeFs, node: NodeId) -> Option { let doc = doc_json(fs); let tree: LayoutTree = serde_json::from_value(doc["layouts"][0]["tree"].clone()).expect("tree parseable"); fn find(node: &LayoutNode, target: NodeId) -> Option { match node { LayoutNode::Leaf(l) if l.id == target => Some(l.agent_was_running), LayoutNode::Leaf(_) => None, LayoutNode::Split(s) => s.children.iter().find_map(|c| find(&c.node, target)), LayoutNode::Grid(g) => g.cells.iter().find_map(|c| find(&c.node, target)), } } find(&tree.root, node) } fn make_use_case(store: &FakeStore, fs: &FakeFs) -> ReconcileLayouts { ReconcileLayouts::new( Arc::new(store.clone()) as Arc, Arc::new(fs.clone()) as Arc, ) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- /// 6. A persisted layout with a duplicate agent ⇒ `changed == true` and the /// persisted version keeps a single "was running" leaf for the agent. #[tokio::test] async fn reconcile_persists_and_reports_changed_on_duplicate() { let store = FakeStore::default(); let fs = FakeFs::default(); register_project(&store, pid(1)).await; let a = nid(10); let b = nid(11); let agent = aid(100); seed_layouts(&fs, lid(1), &duplicate_tree(agent, a, b)); let uc = make_use_case(&store, &fs); let out = uc .execute(ReconcileLayoutsInput { project_id: pid(1) }) .await .unwrap(); assert_eq!(out.changed, true, "duplicate must be reconciled"); // Exactly one of the two leaves is still flagged running in the persisted doc. let ra = was_running(&fs, a).expect("leaf a"); let rb = was_running(&fs, b).expect("leaf b"); assert_eq!( [ra, rb].iter().filter(|x| **x).count(), 1, "only one host stays running after reconciliation" ); // First in pre-order is the host (both carried a signal). assert_eq!(ra, true); assert_eq!(rb, false); } /// 7. No-op: a second `execute` on an already-reconciled project ⇒ /// `changed == false` and **zero** writes occur. #[tokio::test] async fn reconcile_second_pass_is_noop_no_write() { let store = FakeStore::default(); let fs = FakeFs::default(); register_project(&store, pid(1)).await; let a = nid(10); let b = nid(11); let agent = aid(100); seed_layouts(&fs, lid(1), &duplicate_tree(agent, a, b)); let uc = make_use_case(&store, &fs); // First pass reconciles + writes once. let first = uc .execute(ReconcileLayoutsInput { project_id: pid(1) }) .await .unwrap(); assert_eq!(first.changed, true); assert_eq!(fs.write_calls(), 1, "first pass persists once"); // Second pass must be a strict no-op. let writes_before = fs.write_calls(); let second = uc .execute(ReconcileLayoutsInput { project_id: pid(1) }) .await .unwrap(); assert_eq!(second.changed, false, "already reconciled → no change"); assert_eq!( fs.write_calls(), writes_before, "no-op must not write anything" ); } /// 8. A layout WITHOUT duplicates ⇒ `changed == false`, unchanged, no write. #[tokio::test] async fn reconcile_no_duplicate_is_noop() { let store = FakeStore::default(); let fs = FakeFs::default(); register_project(&store, pid(1)).await; let a = nid(10); let b = nid(11); // Distinct agents — no duplicate. let tree = LayoutTree::new(LayoutNode::Split(SplitContainer { id: nid(1), direction: Direction::Row, children: vec![ WeightedChild { node: LayoutNode::Leaf(agent_leaf(a, Some(aid(100)), Some("c-a"), true)), weight: 1.0, }, WeightedChild { node: LayoutNode::Leaf(agent_leaf(b, Some(aid(101)), None, false)), weight: 1.0, }, ], })); seed_layouts(&fs, lid(1), &tree); let before = doc_json(&fs); let writes_before = fs.write_calls(); let uc = make_use_case(&store, &fs); let out = uc .execute(ReconcileLayoutsInput { project_id: pid(1) }) .await .unwrap(); assert_eq!(out.changed, false, "no duplicate → no change"); assert_eq!(fs.write_calls(), writes_before, "no write on no-op"); assert_eq!(doc_json(&fs), before, "persisted doc unchanged"); assert_eq!(was_running(&fs, a), Some(true)); }