//! L4 + #4 tests for the layout use cases (`LoadLayout`, `MutateLayout`) and the //! named-layout management (`ListLayouts`, `CreateLayout`, `RenameLayout`, //! `DeleteLayout`, `SetActiveLayout`). //! //! Every port is faked in-memory so the use cases run without any real I/O. //! Layouts now persist to `.ideai/layouts.json` (a collection); a legacy //! `.ideai/layout.json` is migrated transparently. use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::events::DomainEvent; use domain::layout::Workspace; use domain::ports::{ DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, ProjectStore, RemotePath, StoreError, }; use domain::{ AgentId, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, NodeId, Project, ProjectId, ProjectPath, RemoteRef, SessionId, }; use uuid::Uuid; use application::{ CreateLayout, CreateLayoutInput, DeleteLayout, DeleteLayoutInput, LayoutKind, LayoutOperation, ListLayouts, ListLayoutsInput, LoadLayout, LoadLayoutInput, MutateLayout, MutateLayoutInput, RenameLayout, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, }; // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- #[derive(Default)] struct FakeFsInner { files: HashMap>, dirs: HashSet, } #[derive(Default, Clone)] struct FakeFs(Arc>); impl FakeFs { fn read_file(&self, path: &str) -> Option> { self.0.lock().unwrap().files.get(path).cloned() } fn has_dir(&self, path: &str) -> bool { self.0.lock().unwrap().dirs.contains(path) } fn put(&self, path: &str, data: &[u8]) { self.0 .lock() .unwrap() .files .insert(path.to_owned(), data.to_vec()); } } #[async_trait] impl FileSystem for FakeFs { async fn read(&self, path: &RemotePath) -> Result, FsError> { self.0 .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.0 .lock() .unwrap() .files .insert(path.as_str().to_owned(), data.to_vec()); Ok(()) } async fn exists(&self, path: &RemotePath) -> Result { let inner = self.0.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.0.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()) } } #[derive(Default, Clone)] struct SpyBus(Arc>>); impl SpyBus { fn events(&self) -> Vec { self.0.lock().unwrap().clone() } } impl EventBus for SpyBus { fn publish(&self, event: DomainEvent) { self.0.lock().unwrap().push(event); } fn subscribe(&self) -> EventStream { Box::new(std::iter::empty()) } } struct SeqIds(Mutex); impl SeqIds { fn new(start: u128) -> Self { Self(Mutex::new(start)) } } impl IdGenerator for SeqIds { fn new_uuid(&self) -> Uuid { let mut n = self.0.lock().unwrap(); let id = Uuid::from_u128(*n); *n += 1; id } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const ROOT: &str = "/home/me/proj"; const LAYOUTS_PATH: &str = "/home/me/proj/.ideai/layouts.json"; const LEGACY_PATH: &str = "/home/me/proj/.ideai/layout.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 sid(n: u128) -> SessionId { SessionId::from_uuid(Uuid::from_u128(n)) } fn lid(n: u128) -> LayoutId { LayoutId::from_uuid(Uuid::from_u128(n)) } async fn register_project(store: &FakeStore, id: ProjectId) -> ProjectId { let project = Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap(); store.save_project(&project).await.unwrap(); id } fn single_leaf(node_id: NodeId) -> LayoutTree { LayoutTree::single(LeafCell { id: node_id, session: None, agent: None, }) } /// Seeds a valid `layouts.json` with a single active layout holding `tree`. 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 written")).unwrap() } /// The JSON of the active layout's tree. fn active_tree_json(fs: &FakeFs) -> serde_json::Value { let doc = doc_json(fs); let active = doc["activeId"].clone(); doc["layouts"] .as_array() .unwrap() .iter() .find(|l| l["id"] == active) .expect("active layout present")["tree"] .clone() } fn read_active_tree(fs: &FakeFs) -> LayoutTree { serde_json::from_value(active_tree_json(fs)).expect("active tree parseable") } fn root_leaf_id(tree: &LayoutTree) -> NodeId { match &tree.root { LayoutNode::Leaf(l) => l.id, _ => panic!("expected a single-leaf root"), } } // --------------------------------------------------------------------------- // LoadLayout // --------------------------------------------------------------------------- #[tokio::test] async fn load_returns_persisted_active_layout() { let store = FakeStore::default(); let fs = FakeFs::default(); let id = register_project(&store, pid(1)).await; seed_layouts(&fs, lid(1), &single_leaf(nid(42))); let load = LoadLayout::new(Arc::new(store), Arc::new(fs)); let out = load .execute(LoadLayoutInput { project_id: id, layout_id: None, }) .await .expect("load succeeds"); assert_eq!(out.layout_id, lid(1)); assert_eq!(root_leaf_id(&out.layout), nid(42)); } #[tokio::test] async fn load_migrates_a_legacy_layout_json() { let store = FakeStore::default(); let fs = FakeFs::default(); let id = register_project(&store, pid(2)).await; // Only the legacy single-layout file exists. fs.put(LEGACY_PATH, &serde_json::to_vec(&single_leaf(nid(7))).unwrap()); let load = LoadLayout::new(Arc::new(store), Arc::new(fs.clone())); let out = load .execute(LoadLayoutInput { project_id: id, layout_id: None, }) .await .expect("legacy layout migrates"); assert_eq!(root_leaf_id(&out.layout), nid(7), "legacy tree preserved"); // A layouts.json was written with that tree as the active layout. assert_eq!(root_leaf_id(&read_active_tree(&fs)), nid(7)); } #[tokio::test] async fn load_defaults_to_single_empty_leaf_when_absent() { let store = FakeStore::default(); let fs = FakeFs::default(); let id = register_project(&store, pid(3)).await; let load = LoadLayout::new(Arc::new(store), Arc::new(fs.clone())); let out = load .execute(LoadLayoutInput { project_id: id, layout_id: None, }) .await .expect("absent layout does not fail"); match &out.layout.root { LayoutNode::Leaf(l) => assert!(l.session.is_none()), _ => panic!("expected a single default leaf"), } // Default was written through; two loads are deterministic. assert_eq!(root_leaf_id(&read_active_tree(&fs)), root_leaf_id(&out.layout)); assert!(fs.has_dir("/home/me/proj/.ideai")); } #[tokio::test] async fn load_tolerates_corrupt_json_with_default() { let store = FakeStore::default(); let fs = FakeFs::default(); let id = register_project(&store, pid(4)).await; fs.put(LAYOUTS_PATH, b"{ not json ]"); let load = LoadLayout::new(Arc::new(store), Arc::new(fs)); let out = load .execute(LoadLayoutInput { project_id: id, layout_id: None, }) .await .expect("corrupt JSON falls back to default"); assert!(matches!(out.layout.root, LayoutNode::Leaf(_))); assert!(out.layout.validate().is_ok()); } #[tokio::test] async fn load_unknown_layout_id_is_not_found() { let store = FakeStore::default(); let fs = FakeFs::default(); let id = register_project(&store, pid(5)).await; seed_layouts(&fs, lid(1), &single_leaf(nid(1))); let load = LoadLayout::new(Arc::new(store), Arc::new(fs)); let err = load .execute(LoadLayoutInput { project_id: id, layout_id: Some(lid(999)), }) .await .expect_err("unknown layout id rejected"); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } #[tokio::test] async fn load_unknown_project_is_not_found() { let store = FakeStore::default(); let fs = FakeFs::default(); let load = LoadLayout::new(Arc::new(store), Arc::new(fs)); let err = load .execute(LoadLayoutInput { project_id: pid(999), layout_id: None, }) .await .expect_err("unknown project rejected"); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } // --------------------------------------------------------------------------- // MutateLayout // --------------------------------------------------------------------------- struct MutEnv { fs: FakeFs, bus: SpyBus, mutate: MutateLayout, project_id: ProjectId, } async fn mut_env(project_id: ProjectId) -> MutEnv { let store = FakeStore::default(); let fs = FakeFs::default(); let bus = SpyBus::default(); register_project(&store, project_id).await; seed_layouts(&fs, lid(1), &single_leaf(nid(1))); let mutate = MutateLayout::new( Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus.clone()), ); MutEnv { fs, bus, mutate, project_id, } } #[tokio::test] async fn mutate_split_persists_camelcase_layout_and_announces() { let env = mut_env(pid(10)).await; let out = env .mutate .execute(MutateLayoutInput { project_id: env.project_id, layout_id: None, operation: LayoutOperation::Split { target: nid(1), direction: Direction::Row, new_leaf: nid(2), container: nid(9), }, }) .await .expect("split succeeds"); match &out.layout.root { LayoutNode::Split(s) => assert_eq!(s.children.len(), 2), _ => panic!("expected a split root"), } assert!(env.fs.has_dir("/home/me/proj/.ideai")); let tree = active_tree_json(&env.fs); assert_eq!(tree["root"]["type"], "split", "tagged on `type`"); assert_eq!(tree["root"]["node"]["direction"], "row"); assert_eq!(tree["root"]["node"]["children"].as_array().unwrap().len(), 2); assert_eq!( env.bus.events(), vec![DomainEvent::LayoutChanged { project_id: env.project_id }] ); } #[tokio::test] async fn mutate_resize_writes_new_weights() { let env = mut_env(pid(11)).await; env.mutate .execute(MutateLayoutInput { project_id: env.project_id, layout_id: None, operation: LayoutOperation::Split { target: nid(1), direction: Direction::Row, new_leaf: nid(2), container: nid(9), }, }) .await .unwrap(); env.mutate .execute(MutateLayoutInput { project_id: env.project_id, layout_id: None, operation: LayoutOperation::Resize { container: nid(9), weights: vec![3.0, 1.0], }, }) .await .expect("resize succeeds"); let tree = active_tree_json(&env.fs); let children = tree["root"]["node"]["children"].as_array().unwrap(); assert_eq!(children[0]["weight"], 3.0); assert_eq!(children[1]["weight"], 1.0); } #[tokio::test] async fn mutate_set_session_attaches_and_clears() { let env = mut_env(pid(12)).await; env.mutate .execute(MutateLayoutInput { project_id: env.project_id, layout_id: None, operation: LayoutOperation::SetSession { target: nid(1), session: Some(sid(77)), }, }) .await .expect("attach"); assert_eq!( active_tree_json(&env.fs)["root"]["node"]["session"], sid(77).to_string() ); let out = env .mutate .execute(MutateLayoutInput { project_id: env.project_id, layout_id: None, operation: LayoutOperation::SetSession { target: nid(1), session: None, }, }) .await .expect("detach"); match &out.layout.root { LayoutNode::Leaf(l) => assert!(l.session.is_none()), _ => panic!("expected leaf root"), } assert!(active_tree_json(&env.fs)["root"]["node"] .get("session") .is_none()); } #[tokio::test] async fn mutate_invalid_op_errors_and_does_not_persist() { let env = mut_env(pid(14)).await; // Force the layouts.json to exist first (a clean load) so we have a baseline. let before = doc_json(&env.fs); let err = env .mutate .execute(MutateLayoutInput { project_id: env.project_id, layout_id: None, operation: LayoutOperation::SetSession { target: nid(404), session: Some(sid(1)), }, }) .await .expect_err("set_session on unknown node fails"); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); assert_eq!(before, doc_json(&env.fs), "failed op must not overwrite"); assert!(env.bus.events().is_empty(), "no event on failed mutation"); } #[tokio::test] async fn load_then_set_session_on_returned_id_succeeds() { let store = FakeStore::default(); let fs = FakeFs::default(); let bus = SpyBus::default(); let id = register_project(&store, pid(22)).await; let load = LoadLayout::new(Arc::new(store.clone()), Arc::new(fs.clone())); let mutate = MutateLayout::new( Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus.clone()), ); let loaded = load .execute(LoadLayoutInput { project_id: id, layout_id: None, }) .await .expect("load"); let leaf = root_leaf_id(&loaded.layout); mutate .execute(MutateLayoutInput { project_id: id, layout_id: None, operation: LayoutOperation::SetSession { target: leaf, session: Some(sid(7)), }, }) .await .expect("set_session on the just-loaded leaf id must succeed"); match &read_active_tree(&fs).root { LayoutNode::Leaf(l) => assert_eq!(l.session, Some(sid(7))), _ => panic!("expected persisted leaf root"), } } // --------------------------------------------------------------------------- // SetCellAgent (#3 — per-cell agent) // --------------------------------------------------------------------------- fn aid(n: u128) -> AgentId { AgentId::from_uuid(Uuid::from_u128(n)) } #[tokio::test] async fn mutate_set_cell_agent_persists_agent_on_leaf() { let env = mut_env(pid(50)).await; // Attach an agent to the single root leaf (nid(1)). env.mutate .execute(MutateLayoutInput { project_id: env.project_id, layout_id: None, operation: LayoutOperation::SetCellAgent { target: nid(1), agent: Some(aid(0xAA)), }, }) .await .expect("set_cell_agent attaches"); // Verify persisted JSON has the agent field. let tree_json = active_tree_json(&env.fs); assert_eq!( tree_json["root"]["node"]["agent"], aid(0xAA).to_string(), "agent must be persisted on the leaf" ); // Now clear it. let out = env .mutate .execute(MutateLayoutInput { project_id: env.project_id, layout_id: None, operation: LayoutOperation::SetCellAgent { target: nid(1), agent: None, }, }) .await .expect("set_cell_agent clears"); match &out.layout.root { LayoutNode::Leaf(l) => assert_eq!(l.agent, None, "agent must be cleared"), _ => panic!("expected leaf root"), } assert!( active_tree_json(&env.fs)["root"]["node"] .get("agent") .is_none(), "cleared agent must not be serialised" ); } #[tokio::test] async fn mutate_set_cell_agent_missing_leaf_is_not_found() { let env = mut_env(pid(51)).await; let err = env .mutate .execute(MutateLayoutInput { project_id: env.project_id, layout_id: None, operation: LayoutOperation::SetCellAgent { target: nid(404), agent: Some(aid(1)), }, }) .await .expect_err("unknown node rejected"); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } // --------------------------------------------------------------------------- // Named-layout management (#4) // --------------------------------------------------------------------------- /// Builds a project + fs + bus with a seeded single "Default" layout (id 1). async fn mgmt_env(project_id: ProjectId) -> (FakeStore, FakeFs, SpyBus) { let store = FakeStore::default(); let fs = FakeFs::default(); let bus = SpyBus::default(); register_project(&store, project_id).await; seed_layouts(&fs, lid(1), &single_leaf(nid(1))); (store, fs, bus) } #[tokio::test] async fn create_layout_appends_and_activates_it() { let (store, fs, bus) = mgmt_env(pid(30)).await; let create = CreateLayout::new( Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(SeqIds::new(0xABC)), Arc::new(bus.clone()), ); let out = create .execute(CreateLayoutInput { project_id: pid(30), name: "Backend".to_owned(), kind: LayoutKind::Terminal, }) .await .unwrap(); let list = ListLayouts::new(Arc::new(store), Arc::new(fs)) .execute(ListLayoutsInput { project_id: pid(30) }) .await .unwrap(); assert_eq!(list.layouts.len(), 2, "Default + Backend"); assert_eq!(list.active_id, out.layout_id, "new layout is active"); assert!(list.layouts.iter().any(|l| l.name == "Backend")); } #[tokio::test] async fn create_layout_rejects_empty_name() { let (store, fs, bus) = mgmt_env(pid(31)).await; let err = CreateLayout::new( Arc::new(store), Arc::new(fs), Arc::new(SeqIds::new(1)), Arc::new(bus), ) .execute(CreateLayoutInput { project_id: pid(31), name: " ".to_owned(), kind: LayoutKind::Terminal, }) .await .unwrap_err(); assert_eq!(err.code(), "INVALID", "got {err:?}"); } #[tokio::test] async fn rename_layout_changes_the_name() { let (store, fs, bus) = mgmt_env(pid(32)).await; RenameLayout::new( Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus), ) .execute(RenameLayoutInput { project_id: pid(32), layout_id: lid(1), name: "Main".to_owned(), }) .await .unwrap(); let list = ListLayouts::new(Arc::new(store), Arc::new(fs)) .execute(ListLayoutsInput { project_id: pid(32) }) .await .unwrap(); assert_eq!(list.layouts[0].name, "Main"); } #[tokio::test] async fn delete_layout_rejects_the_last_one() { let (store, fs, bus) = mgmt_env(pid(33)).await; let err = DeleteLayout::new(Arc::new(store), Arc::new(fs), Arc::new(bus)) .execute(DeleteLayoutInput { project_id: pid(33), layout_id: lid(1), }) .await .unwrap_err(); assert_eq!(err.code(), "INVALID", "cannot delete the last layout"); } #[tokio::test] async fn delete_active_layout_reassigns_active() { let (store, fs, bus) = mgmt_env(pid(34)).await; // Add a second layout (becomes active), then delete it → active falls back. let created = CreateLayout::new( Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(SeqIds::new(0xD)), Arc::new(bus.clone()), ) .execute(CreateLayoutInput { project_id: pid(34), name: "Second".to_owned(), kind: LayoutKind::Terminal, }) .await .unwrap(); let out = DeleteLayout::new( Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus), ) .execute(DeleteLayoutInput { project_id: pid(34), layout_id: created.layout_id, }) .await .unwrap(); assert_eq!(out.active_id, lid(1), "active fell back to the Default layout"); let list = ListLayouts::new(Arc::new(store), Arc::new(fs)) .execute(ListLayoutsInput { project_id: pid(34) }) .await .unwrap(); assert_eq!(list.layouts.len(), 1); } #[tokio::test] async fn set_active_layout_switches_and_load_follows() { let (store, fs, bus) = mgmt_env(pid(35)).await; let created = CreateLayout::new( Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(SeqIds::new(0xE)), Arc::new(bus.clone()), ) .execute(CreateLayoutInput { project_id: pid(35), name: "Second".to_owned(), kind: LayoutKind::Terminal, }) .await .unwrap(); // Switch back to the Default layout. SetActiveLayout::new( Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus), ) .execute(SetActiveLayoutInput { project_id: pid(35), layout_id: lid(1), }) .await .unwrap(); let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs)) .execute(LoadLayoutInput { project_id: pid(35), layout_id: None, }) .await .unwrap(); assert_eq!(loaded.layout_id, lid(1)); assert_ne!(loaded.layout_id, created.layout_id); }