feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
814
crates/application/tests/layout_usecases.rs
Normal file
814
crates/application/tests/layout_usecases.rs
Normal file
@ -0,0 +1,814 @@
|
||||
//! 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<String, Vec<u8>>,
|
||||
dirs: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeFs(Arc<Mutex<FakeFsInner>>);
|
||||
|
||||
impl FakeFs {
|
||||
fn read_file(&self, path: &str) -> Option<Vec<u8>> {
|
||||
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<Vec<u8>, 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<bool, FsError> {
|
||||
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<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeStoreInner {
|
||||
projects: Vec<Project>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct FakeStore(Arc<Mutex<FakeStoreInner>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectStore for FakeStore {
|
||||
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().projects.clone())
|
||||
}
|
||||
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
|
||||
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<Workspace, StoreError> {
|
||||
Ok(Workspace::default())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
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<u128>);
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user