//! L10 tests for [`MoveTabToNewWindow`] with a fake [`ProjectStore`]: the tab is //! detached and the workspace is persisted (load returns the new state). use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::ids::{ProjectId, TabId, WindowId}; use domain::layout::{ LayoutNode, LayoutTree, LeafCell, PersistedWindowKind, PersistedWindowState, Tab, Window, WindowStateSnapshot, Workspace, }; use domain::ports::{IdGenerator, ProjectStore, StoreError, WindowStateStore}; use domain::project::{Project, ProjectPath}; use domain::{NodeId, RemoteRef}; use uuid::Uuid; use application::{ MoveTabToNewWindow, MoveTabToNewWindowInput, RestoreOpenWindows, SnapshotOpenWindows, SnapshotOpenWindowsInput, }; /// A `ProjectStore` fake that only implements the workspace persistence the use /// case needs (the project methods are never called here). #[derive(Clone)] struct FakeStore(Arc>); #[async_trait] impl ProjectStore for FakeStore { async fn list_projects(&self) -> Result, StoreError> { unreachable!() } async fn load_project(&self, _id: ProjectId) -> Result { unreachable!() } async fn save_project(&self, _p: &Project) -> Result<(), StoreError> { unreachable!() } async fn save_workspace(&self, ws: &Workspace) -> Result<(), StoreError> { *self.0.lock().unwrap() = ws.clone(); Ok(()) } async fn load_workspace(&self) -> Result { Ok(self.0.lock().unwrap().clone()) } } struct SeqIds(Mutex); 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 } } #[derive(Clone, Default)] struct FakeWindowStateStore(Arc>); #[async_trait] impl WindowStateStore for FakeWindowStateStore { async fn save_window_state(&self, snapshot: &WindowStateSnapshot) -> Result<(), StoreError> { *self.0.lock().unwrap() = snapshot.clone(); Ok(()) } async fn load_window_state(&self) -> Result { Ok(self.0.lock().unwrap().clone()) } } #[derive(Clone)] struct FakeProjectRegistry { existing: Arc>>, } impl FakeProjectRegistry { fn new(existing: Vec) -> Self { Self { existing: Arc::new(Mutex::new(existing)), } } } #[async_trait] impl ProjectStore for FakeProjectRegistry { async fn list_projects(&self) -> Result, StoreError> { unreachable!() } async fn load_project(&self, id: ProjectId) -> Result { if !self.existing.lock().unwrap().contains(&id) { return Err(StoreError::NotFound); } Ok(Project { id, name: format!("project-{id}"), root: ProjectPath::new(format!("/tmp/{id}")).unwrap(), remote: RemoteRef::Local, created_at: 0, }) } async fn save_project(&self, _p: &Project) -> Result<(), StoreError> { unreachable!() } async fn save_workspace(&self, _ws: &Workspace) -> Result<(), StoreError> { unreachable!() } async fn load_workspace(&self) -> Result { unreachable!() } } fn tid(n: u128) -> TabId { TabId::from_uuid(Uuid::from_u128(n)) } fn wid(n: u128) -> WindowId { WindowId::from_uuid(Uuid::from_u128(n)) } fn tab(n: u128) -> Tab { Tab { id: tid(n), project_id: ProjectId::from_uuid(Uuid::from_u128(1000 + n)), layout: LayoutTree::new(LayoutNode::Leaf(LeafCell { id: NodeId::from_uuid(Uuid::from_u128(900 + n)), session: None, agent: None, conversation_id: None, engine_session_id: None, agent_was_running: false, })), } } fn seeded() -> FakeStore { let ws = Workspace { windows: vec![Window::new(wid(1), vec![tab(1), tab(2)], tid(1)).unwrap()], }; FakeStore(Arc::new(Mutex::new(ws))) } fn persisted_main() -> PersistedWindowState { PersistedWindowState { label: "main".to_owned(), kind: PersistedWindowKind::Main, panel: None, project_id: None, url: None, visible: true, maximized: false, fullscreen: false, outer_position: None, outer_size: None, monitor: None, last_focused_at: None, } } fn persisted_view(label: &str, project_id: ProjectId) -> PersistedWindowState { PersistedWindowState { label: label.to_owned(), kind: PersistedWindowKind::View, panel: Some("tickets".to_owned()), project_id: Some(project_id), url: Some(format!("index.html?panel=tickets&project={project_id}")), visible: true, maximized: false, fullscreen: false, outer_position: None, outer_size: None, monitor: None, last_focused_at: None, } } #[tokio::test] async fn detaches_tab_and_persists_workspace() { let store = seeded(); // The id generator's first uuid (from_u128(7)) becomes the new window id. let ids = Arc::new(SeqIds(Mutex::new(7))); let uc = MoveTabToNewWindow::new(Arc::new(store.clone()), ids); let out = uc .execute(MoveTabToNewWindowInput { tab_id: tid(1) }) .await .unwrap(); assert_eq!(out.new_window_id, WindowId::from_uuid(Uuid::from_u128(7))); assert_eq!(out.workspace.windows.len(), 2); // Persisted: reloading the store yields the detached layout. let reloaded = store.load_workspace().await.unwrap(); assert_eq!(reloaded, out.workspace); let detached = reloaded .windows .iter() .find(|w| w.id == out.new_window_id) .unwrap(); assert_eq!(detached.tabs.len(), 1); assert_eq!(detached.tabs[0].id, tid(1)); } #[tokio::test] async fn unknown_tab_is_not_found() { let store = seeded(); let uc = MoveTabToNewWindow::new(Arc::new(store), Arc::new(SeqIds(Mutex::new(7)))); let err = uc .execute(MoveTabToNewWindowInput { tab_id: tid(404) }) .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } #[tokio::test] async fn snapshot_open_windows_persists_the_supplied_snapshot() { let store = FakeWindowStateStore::default(); let uc = SnapshotOpenWindows::new(Arc::new(store.clone())); let windows = vec![persisted_main()]; uc.execute(SnapshotOpenWindowsInput { windows: windows.clone(), }) .await .unwrap(); let saved = store.load_window_state().await.unwrap(); assert_eq!(saved.version, domain::WINDOW_STATE_SNAPSHOT_VERSION); assert_eq!(saved.windows, windows); } #[tokio::test] async fn restore_open_windows_keeps_panel_views_without_reopening_projects() { let existing = ProjectId::from_uuid(Uuid::from_u128(42)); let missing = ProjectId::from_uuid(Uuid::from_u128(43)); let valid_label = "view-tickets-0000000000000000000000000000002a"; let mut url_missing = persisted_view("view-tickets-0000000000000000000000000000002c", existing); url_missing.url = None; let mut no_panel = persisted_view("view-tickets-0000000000000000000000000000002d", existing); no_panel.panel = None; let store = FakeWindowStateStore(Arc::new(Mutex::new(WindowStateSnapshot::new(vec![ persisted_main(), persisted_main(), persisted_view(valid_label, existing), persisted_view(valid_label, existing), persisted_view("view-tickets-0000000000000000000000000000002b", missing), url_missing, no_panel, ])))); let projects = FakeProjectRegistry::new(vec![]); let uc = RestoreOpenWindows::new(Arc::new(store), Arc::new(projects)); let out = uc.execute().await.unwrap(); assert_eq!( out.windows .iter() .map(|w| w.label.as_str()) .collect::>(), vec![ "main", valid_label, "view-tickets-0000000000000000000000000000002b", "view-tickets-0000000000000000000000000000002c" ] ); }