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>
1332 lines
40 KiB
Rust
1332 lines
40 KiB
Rust
//! 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, PluginManifestBytes,
|
|
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError, ProjectStore,
|
|
RemotePath, StoreError,
|
|
};
|
|
use domain::{
|
|
AgentId, ContentHash, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, LocalPath, NodeId,
|
|
PluginBundleUrl, PluginId, PluginInstallSource, PluginLifecycleState, PluginPackageRef,
|
|
PluginRegistry, PluginRegistryEntry, PreferredView, Project, ProjectId, ProjectPath,
|
|
RelativePath, RemoteRef, RemovalOutcome, SessionId, StagedPluginPackage,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
use application::plugin::JsonPluginManifestValidator;
|
|
use application::{
|
|
CreateLayout, CreateLayoutInput, DeleteLayout, DeleteLayoutInput, LayoutKind, LayoutOperation,
|
|
ListLayouts, ListLayoutsInput, LoadLayout, LoadLayoutInput, MutateLayout, MutateLayoutInput,
|
|
PluginLayoutOrigin, 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
|
|
}
|
|
}
|
|
|
|
fn plugin_manifest() -> Vec<u8> {
|
|
br#"{
|
|
"ideaPluginManifestVersion": 1,
|
|
"id": "dev.idea.android-plugin",
|
|
"displayName": "Android",
|
|
"version": "1.0.0",
|
|
"engines": {"idea": ">=0.1.0 <1.0.0"},
|
|
"main": "dist/index.js",
|
|
"trustLevel": "full",
|
|
"capabilities": ["ui"],
|
|
"contributes": {
|
|
"layouts": [{"type":"idea-android.health","label":"Android Health","component":"AndroidHealth"}]
|
|
}
|
|
}"#.to_vec()
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct FakePluginPackages {
|
|
manifest: Arc<Mutex<Vec<u8>>>,
|
|
}
|
|
|
|
impl FakePluginPackages {
|
|
fn new(manifest: Vec<u8>) -> Self {
|
|
Self {
|
|
manifest: Arc::new(Mutex::new(manifest)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PluginPackageStore for FakePluginPackages {
|
|
async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn read_manifest(
|
|
&self,
|
|
_package: &PluginPackageRef,
|
|
) -> Result<PluginManifestBytes, PluginStoreError> {
|
|
Ok(PluginManifestBytes {
|
|
bytes: self.manifest.lock().unwrap().clone(),
|
|
})
|
|
}
|
|
|
|
async fn install_from_archive(
|
|
&self,
|
|
_archive: &LocalPath,
|
|
) -> Result<StagedPluginPackage, PluginStoreError> {
|
|
Err(PluginStoreError::Invalid("unused".to_owned()))
|
|
}
|
|
|
|
async fn install_from_directory(
|
|
&self,
|
|
_dir: &LocalPath,
|
|
) -> Result<StagedPluginPackage, PluginStoreError> {
|
|
Err(PluginStoreError::Invalid("unused".to_owned()))
|
|
}
|
|
|
|
async fn commit_install(
|
|
&self,
|
|
staged: StagedPluginPackage,
|
|
plugin_id: &PluginId,
|
|
) -> Result<PluginPackageRef, PluginStoreError> {
|
|
Ok(PluginPackageRef {
|
|
plugin_id: Some(plugin_id.clone()),
|
|
root: staged.root,
|
|
})
|
|
}
|
|
|
|
async fn remove_package(
|
|
&self,
|
|
_plugin_id: &PluginId,
|
|
) -> Result<RemovalOutcome, PluginStoreError> {
|
|
Ok(RemovalOutcome::NotFound)
|
|
}
|
|
|
|
fn bundle_url(
|
|
&self,
|
|
plugin_id: &PluginId,
|
|
entry: &RelativePath,
|
|
hash: &ContentHash,
|
|
) -> Result<PluginBundleUrl, PluginStoreError> {
|
|
Ok(PluginBundleUrl::new(format!(
|
|
"idea-plugin://{}/current/{}/{}",
|
|
plugin_id.as_str(),
|
|
hash.as_str(),
|
|
entry.as_str()
|
|
)))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct FakePluginRegistry {
|
|
registry: Arc<Mutex<PluginRegistry>>,
|
|
}
|
|
|
|
impl FakePluginRegistry {
|
|
fn with_state(lifecycle_state: PluginLifecycleState) -> Self {
|
|
Self {
|
|
registry: Arc::new(Mutex::new(PluginRegistry {
|
|
version: 1,
|
|
plugins: vec![PluginRegistryEntry {
|
|
id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
|
lifecycle_state,
|
|
source: PluginInstallSource::Directory {
|
|
path_label: "/plugin".to_owned(),
|
|
},
|
|
content_hash: ContentHash::new("abc123").unwrap(),
|
|
restart_required: false,
|
|
error: None,
|
|
}],
|
|
})),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PluginRegistryStore for FakePluginRegistry {
|
|
async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
|
|
Ok(self.registry.lock().unwrap().clone())
|
|
}
|
|
|
|
async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError> {
|
|
*self.registry.lock().unwrap() = registry.clone();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn create_layout(
|
|
store: FakeStore,
|
|
fs: FakeFs,
|
|
ids: SeqIds,
|
|
bus: SpyBus,
|
|
registry: FakePluginRegistry,
|
|
) -> CreateLayout {
|
|
CreateLayout::new(
|
|
Arc::new(store),
|
|
Arc::new(fs),
|
|
Arc::new(ids),
|
|
Arc::new(bus),
|
|
Arc::new(FakePluginPackages::new(plugin_manifest())),
|
|
Arc::new(registry),
|
|
Arc::new(JsonPluginManifestValidator::new("0.3.0")),
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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,
|
|
conversation_id: None,
|
|
engine_session_id: None,
|
|
agent_was_running: false,
|
|
preferred_view: domain::PreferredView::Tui,
|
|
})
|
|
}
|
|
|
|
/// 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_self_heals_to_active() {
|
|
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)));
|
|
|
|
// A stale requested id (e.g. left over after git overwrote layouts.json)
|
|
// must NOT hard-error and freeze the workspace; it degrades silently to the
|
|
// valid active layout (invariant I3), whose id is authoritative (I4).
|
|
let load = LoadLayout::new(Arc::new(store), Arc::new(fs));
|
|
let out = load
|
|
.execute(LoadLayoutInput {
|
|
project_id: id,
|
|
layout_id: Some(lid(999)),
|
|
})
|
|
.await
|
|
.expect("stale layout id must self-heal, not error");
|
|
assert_eq!(out.layout_id, lid(1));
|
|
}
|
|
|
|
#[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_attach_session_moves_session_between_cells() {
|
|
let env = mut_env(pid(13)).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
|
|
.expect("split");
|
|
env.mutate
|
|
.execute(MutateLayoutInput {
|
|
project_id: env.project_id,
|
|
layout_id: None,
|
|
operation: LayoutOperation::SetSession {
|
|
target: nid(1),
|
|
session: Some(sid(77)),
|
|
},
|
|
})
|
|
.await
|
|
.expect("set initial session");
|
|
|
|
let out = env
|
|
.mutate
|
|
.execute(MutateLayoutInput {
|
|
project_id: env.project_id,
|
|
layout_id: None,
|
|
operation: LayoutOperation::AttachSession {
|
|
target: nid(2),
|
|
session: sid(77),
|
|
},
|
|
})
|
|
.await
|
|
.expect("attach existing session");
|
|
|
|
match &out.layout.root {
|
|
LayoutNode::Split(split) => {
|
|
match &split.children[0].node {
|
|
LayoutNode::Leaf(leaf) => assert!(leaf.session.is_none()),
|
|
_ => panic!("expected first leaf"),
|
|
}
|
|
match &split.children[1].node {
|
|
LayoutNode::Leaf(leaf) => assert_eq!(leaf.session, Some(sid(77))),
|
|
_ => panic!("expected second leaf"),
|
|
}
|
|
}
|
|
_ => panic!("expected split root"),
|
|
}
|
|
}
|
|
|
|
#[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:?}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SetCellConversation (T4b — persist the assigned CLI conversation id)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn mutate_set_cell_conversation_persists_id_on_leaf() {
|
|
let env = mut_env(pid(52)).await;
|
|
// Record a conversation id on the single root leaf (nid(1)).
|
|
env.mutate
|
|
.execute(MutateLayoutInput {
|
|
project_id: env.project_id,
|
|
layout_id: None,
|
|
operation: LayoutOperation::SetCellConversation {
|
|
target: nid(1),
|
|
conversation_id: Some("conv-42".to_owned()),
|
|
},
|
|
})
|
|
.await
|
|
.expect("set_cell_conversation records the id");
|
|
|
|
// The id must survive in the persisted JSON, so the next open resumes.
|
|
let tree_json = active_tree_json(&env.fs);
|
|
assert_eq!(
|
|
tree_json["root"]["node"]["conversationId"], "conv-42",
|
|
"conversation id 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::SetCellConversation {
|
|
target: nid(1),
|
|
conversation_id: None,
|
|
},
|
|
})
|
|
.await
|
|
.expect("set_cell_conversation clears the id");
|
|
|
|
match &out.layout.root {
|
|
LayoutNode::Leaf(l) => assert_eq!(l.conversation_id, None, "id must be cleared"),
|
|
_ => panic!("expected leaf root"),
|
|
}
|
|
assert!(
|
|
active_tree_json(&env.fs)["root"]["node"]
|
|
.get("conversationId")
|
|
.is_none(),
|
|
"cleared id must not be serialised"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mutate_set_cell_conversation_missing_leaf_is_not_found() {
|
|
let env = mut_env(pid(53)).await;
|
|
let err = env
|
|
.mutate
|
|
.execute(MutateLayoutInput {
|
|
project_id: env.project_id,
|
|
layout_id: None,
|
|
operation: LayoutOperation::SetCellConversation {
|
|
target: nid(404),
|
|
conversation_id: Some("x".to_owned()),
|
|
},
|
|
})
|
|
.await
|
|
.expect_err("unknown node rejected");
|
|
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mutate_set_cell_preferred_view_persists_chat_then_tui_default() {
|
|
let env = mut_env(pid(54)).await;
|
|
|
|
env.mutate
|
|
.execute(MutateLayoutInput {
|
|
project_id: env.project_id,
|
|
layout_id: None,
|
|
operation: LayoutOperation::SetCellPreferredView {
|
|
target: nid(1),
|
|
preferred_view: PreferredView::Chat,
|
|
},
|
|
})
|
|
.await
|
|
.expect("set_cell_preferred_view records chat");
|
|
|
|
let tree_json = active_tree_json(&env.fs);
|
|
assert_eq!(tree_json["root"]["node"]["preferredView"], "chat");
|
|
|
|
let out = env
|
|
.mutate
|
|
.execute(MutateLayoutInput {
|
|
project_id: env.project_id,
|
|
layout_id: None,
|
|
operation: LayoutOperation::SetCellPreferredView {
|
|
target: nid(1),
|
|
preferred_view: PreferredView::Tui,
|
|
},
|
|
})
|
|
.await
|
|
.expect("set_cell_preferred_view restores tui");
|
|
|
|
match &out.layout.root {
|
|
LayoutNode::Leaf(l) => assert_eq!(l.preferred_view, PreferredView::Tui),
|
|
_ => panic!("expected leaf root"),
|
|
}
|
|
let tree_json = active_tree_json(&env.fs);
|
|
assert!(
|
|
tree_json["root"]["node"].get("preferredView").is_none(),
|
|
"default TUI view is omitted from persisted JSON"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 = create_layout(
|
|
store.clone(),
|
|
fs.clone(),
|
|
SeqIds::new(0xABC),
|
|
bus.clone(),
|
|
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
|
);
|
|
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 = create_layout(
|
|
store,
|
|
fs,
|
|
SeqIds::new(1),
|
|
bus,
|
|
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
|
)
|
|
.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 create_layout_plugin_validates_active_runtime_contribution_and_persists_root() {
|
|
let (store, fs, bus) = mgmt_env(pid(131)).await;
|
|
let create = create_layout(
|
|
store.clone(),
|
|
fs.clone(),
|
|
SeqIds::new(0x141),
|
|
bus,
|
|
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
|
);
|
|
let out = create
|
|
.execute(CreateLayoutInput {
|
|
project_id: pid(131),
|
|
name: "Android Health".to_owned(),
|
|
kind: LayoutKind::Plugin {
|
|
plugin_origin: PluginLayoutOrigin {
|
|
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
|
layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
|
|
},
|
|
state: serde_json::json!({ "deviceId": "pixel-8" }),
|
|
},
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let list = ListLayouts::new(Arc::new(store), Arc::new(fs.clone()))
|
|
.execute(ListLayoutsInput {
|
|
project_id: pid(131),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(list.active_id, out.layout_id);
|
|
assert_eq!(
|
|
list.layouts[1].kind,
|
|
LayoutKind::Plugin {
|
|
plugin_origin: PluginLayoutOrigin {
|
|
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
|
layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
|
|
},
|
|
state: serde_json::json!({ "deviceId": "pixel-8" }),
|
|
}
|
|
);
|
|
|
|
let tree_json = active_tree_json(&fs);
|
|
assert_eq!(tree_json["root"]["type"], "customPluginLayout");
|
|
assert_eq!(
|
|
tree_json["root"]["node"]["pluginId"],
|
|
"dev.idea.android-plugin"
|
|
);
|
|
assert_eq!(
|
|
tree_json["root"]["node"]["layoutType"],
|
|
"idea-android.health"
|
|
);
|
|
assert_eq!(tree_json["root"]["node"]["state"]["deviceId"], "pixel-8");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_layout_plugin_rejects_inactive_runtime_contribution() {
|
|
let (store, fs, bus) = mgmt_env(pid(132)).await;
|
|
let err = create_layout(
|
|
store,
|
|
fs,
|
|
SeqIds::new(0x142),
|
|
bus,
|
|
FakePluginRegistry::with_state(PluginLifecycleState::Disabled),
|
|
)
|
|
.execute(CreateLayoutInput {
|
|
project_id: pid(132),
|
|
name: "Android Health".to_owned(),
|
|
kind: LayoutKind::Plugin {
|
|
plugin_origin: PluginLayoutOrigin {
|
|
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
|
layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
|
|
},
|
|
state: serde_json::json!({}),
|
|
},
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mutate_set_plugin_layout_state_persists_opaque_state() {
|
|
let (store, fs, bus) = mgmt_env(pid(133)).await;
|
|
let create = create_layout(
|
|
store.clone(),
|
|
fs.clone(),
|
|
SeqIds::new(0x143),
|
|
bus.clone(),
|
|
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
|
);
|
|
create
|
|
.execute(CreateLayoutInput {
|
|
project_id: pid(133),
|
|
name: "Android Health".to_owned(),
|
|
kind: LayoutKind::Plugin {
|
|
plugin_origin: PluginLayoutOrigin {
|
|
plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
|
|
layout_type: domain::PluginLayoutType::new("idea-android.health").unwrap(),
|
|
},
|
|
state: serde_json::json!({ "deviceId": "pixel-8" }),
|
|
},
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let loaded = LoadLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()))
|
|
.execute(LoadLayoutInput {
|
|
project_id: pid(133),
|
|
layout_id: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let target = match loaded.layout.root {
|
|
LayoutNode::CustomPluginLayout(cell) => cell.id,
|
|
other => panic!("expected custom plugin layout root, got {other:?}"),
|
|
};
|
|
|
|
let out = MutateLayout::new(Arc::new(store), Arc::new(fs.clone()), Arc::new(bus))
|
|
.execute(MutateLayoutInput {
|
|
project_id: pid(133),
|
|
layout_id: None,
|
|
operation: LayoutOperation::SetPluginLayoutState {
|
|
target,
|
|
state: serde_json::json!({ "deviceId": "pixel-8", "healthy": true }),
|
|
},
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
match out.layout.root {
|
|
LayoutNode::CustomPluginLayout(cell) => {
|
|
assert_eq!(cell.state["healthy"], true);
|
|
}
|
|
other => panic!("expected custom plugin layout root, got {other:?}"),
|
|
}
|
|
assert_eq!(
|
|
active_tree_json(&fs)["root"]["node"]["state"]["healthy"],
|
|
true
|
|
);
|
|
}
|
|
|
|
#[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 = create_layout(
|
|
store.clone(),
|
|
fs.clone(),
|
|
SeqIds::new(0xD),
|
|
bus.clone(),
|
|
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
|
)
|
|
.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 = create_layout(
|
|
store.clone(),
|
|
fs.clone(),
|
|
SeqIds::new(0xE),
|
|
bus.clone(),
|
|
FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
|
|
)
|
|
.execute(CreateLayoutInput {
|
|
project_id: pid(35),
|
|
name: "Second".to_owned(),
|
|
kind: LayoutKind::Terminal,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
// Switch back to the Default layout.
|
|
let set = 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();
|
|
// Output echoes the actually-activated id (I4).
|
|
assert_eq!(set.active_id, lid(1));
|
|
|
|
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);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn set_active_layout_stale_id_self_heals_to_current_active() {
|
|
// Seeded env: a single "Default" layout, active = lid(1).
|
|
let (store, fs, bus) = mgmt_env(pid(36)).await;
|
|
|
|
// Targeting a stale id (e.g. an activeId left over after git overwrote
|
|
// layouts.json) must NOT error and must NOT freeze the workspace (I3):
|
|
// it degrades to the current active id and returns it (I4).
|
|
let out = SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
|
|
.execute(SetActiveLayoutInput {
|
|
project_id: pid(36),
|
|
layout_id: lid(999),
|
|
})
|
|
.await
|
|
.expect("stale set_active must self-heal, not error");
|
|
assert_eq!(out.active_id, lid(1));
|
|
|
|
// And a subsequent load of the current view resolves to that valid id.
|
|
let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs))
|
|
.execute(LoadLayoutInput {
|
|
project_id: pid(36),
|
|
layout_id: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(loaded.layout_id, lid(1));
|
|
}
|