use super::*; use crate::server::server_api::ServerApiProvider; use crate::test_util::settings::initialize_settings_for_tests; use crate::workspace::ToastStack; use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; use std::path::PathBuf; use galaxy_core::ui::appearance::Appearance; use galaxyui::elements::{ChildView, Empty}; use galaxyui::platform::WindowStyle; use galaxyui::{App, AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle}; fn init_modal_test_models(app: &mut App) { initialize_settings_for_tests(app); app.add_singleton_model(|_| ServerApiProvider::new_for_test()); app.add_singleton_model(|_| Appearance::mock()); app.add_singleton_model(|_| ToastStack); // The modal queries CodebaseIndexManager for locally indexed repos. // Register a test instance so `available_indexed_repos(...)` doesn't panic. app.add_singleton_model(|ctx| { CodebaseIndexManager::new_for_test(ServerApiProvider::as_ref(ctx).get(), ctx) }); } #[derive(Default)] // Simple view that owns the modal and records its emitted events. struct ModalHarness { modal: Option>, events: Vec, } impl ModalHarness { fn new(ctx: &mut ViewContext) -> Self { let modal = ctx.add_typed_action_view(AgentAssistedEnvironmentModal::new); ctx.subscribe_to_view(&modal, |me, _, event, ctx| { me.events.push(event.clone()); ctx.notify(); }); Self { modal: Some(modal), events: Vec::new(), } } fn modal(&self) -> ViewHandle { self.modal .clone() .expect("ModalHarness.modal should be initialized") } } impl Entity for ModalHarness { type Event = (); } impl View for ModalHarness { fn ui_name() -> &'static str { "AgentAssistedEnvironmentModalTestHarness" } fn render(&self, _app: &AppContext) -> Box { let Some(modal) = &self.modal else { return Empty::new().finish(); }; ChildView::new(modal).finish() } } impl TypedActionView for ModalHarness { type Action = (); } #[test] fn test_modal_default_render_is_empty() { // When not visible, the modal renders as an Empty element. App::test((), |mut app| async move { init_modal_test_models(&mut app); let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new); harness.read(&app, |harness, ctx| { let modal = harness.modal(); let element = modal.as_ref(ctx).render(ctx); let text_content = element.debug_text_content().unwrap_or_default(); assert!( text_content.is_empty(), "Expected empty modal render when hidden, got: {}", text_content ); }); }) } #[test] fn test_modal_show_renders_expected_copy_with_empty_repos_message() { // We validate the copy via the section renderers (selected/available) rather than the full dialog, // because the dialog includes icons/buttons that may rely on asset providers in unit tests. App::test((), |mut app| async move { init_modal_test_models(&mut app); let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new); // Show modal, then force it into the non-loading empty state so the content is deterministic. harness.update(&mut app, |harness, ctx| { harness.events.clear(); let modal = harness.modal(); modal.update(ctx, |modal, ctx| { modal.show(ctx); modal.stop_available_repos_loading(); modal.available_repos.clear(); }); }); harness.read(&app, |harness, ctx| { let appearance = Appearance::as_ref(ctx); let modal = harness.modal(); let modal = modal.as_ref(ctx); let selected_section = modal.render_selected_section(appearance); let selected_text = selected_section.debug_text_content().unwrap_or_default(); assert!( selected_text.contains("Selected repos"), "Expected selected section title in rendered content: {}", selected_text ); assert!( selected_text.contains("No repos selected yet"), "Expected selected empty-state message in rendered content: {}", selected_text ); let available_section = modal.render_available_section(appearance); let available_text = available_section.debug_text_content().unwrap_or_default(); assert!( available_text.contains("Available indexed repos"), "Expected available section title in rendered content: {}", available_text ); assert!( available_text .contains("No locally indexed repos found yet. Index a repo, then try again."), "Expected available empty-state message in rendered content: {}", available_text ); }); }) } #[test] fn test_modal_cancel_emits_event() { App::test((), |mut app| async move { init_modal_test_models(&mut app); let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new); harness.update(&mut app, |harness, ctx| { harness.events.clear(); let modal = harness.modal(); modal.update(ctx, |modal, ctx| { modal.handle_action(&AgentAssistedEnvironmentModalAction::Cancel, ctx); }); }); harness.read(&app, |harness, _ctx| { assert!( matches!( harness.events.as_slice(), [AgentAssistedEnvironmentModalEvent::Cancelled] ), "Expected a single Cancelled event" ); }); }) } #[test] fn test_modal_confirm_only_emits_event_when_repos_selected() { App::test((), |mut app| async move { init_modal_test_models(&mut app); let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new); // Confirm with no selection should not emit. harness.update(&mut app, |harness, ctx| { harness.events.clear(); let modal = harness.modal(); modal.update(ctx, |modal, ctx| { modal.selected_repo_paths.clear(); modal.selected_row_mouse_states.clear(); modal.handle_action(&AgentAssistedEnvironmentModalAction::Confirm, ctx); }); }); harness.read(&app, |harness, _ctx| { assert!( harness.events.is_empty(), "Did not expect any events when confirming with no selected repos" ); }); // Confirm with a selected repo should emit Confirmed. let selected_repo = PathBuf::from("/tmp/repo-a"); harness.update(&mut app, |harness, ctx| { harness.events.clear(); let modal = harness.modal(); modal.update(ctx, |modal, ctx| { modal.selected_repo_paths = vec![selected_repo.clone()]; modal.selected_row_mouse_states = vec![MouseStateHandle::default()]; modal.handle_action(&AgentAssistedEnvironmentModalAction::Confirm, ctx); }); }); harness.read(&app, |harness, _ctx| { assert!( matches!( harness.events.as_slice(), [AgentAssistedEnvironmentModalEvent::Confirmed { repo_paths }] if repo_paths == &vec!["/tmp/repo-a".to_string()] ), "Expected a single Confirmed event with selected repo path" ); }); }) } #[test] fn test_modal_show_clears_selection() { App::test((), |mut app| async move { init_modal_test_models(&mut app); let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new); harness.update(&mut app, |harness, ctx| { let modal = harness.modal(); modal.update(ctx, |modal, ctx| { modal.selected_repo_paths = vec![PathBuf::from("/tmp/should-clear")]; modal.selected_row_mouse_states = vec![MouseStateHandle::default()]; modal.show(ctx); assert!(modal.selected_repo_paths.is_empty()); assert!(modal.selected_row_mouse_states.is_empty()); }); }); }) } #[test] fn test_modal_directory_picked_adds_repo_and_confirm_emits_event() { App::test((), |mut app| async move { init_modal_test_models(&mut app); let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new); let tmp_dir = tempfile::TempDir::new().expect("TempDir should be creatable"); git2::Repository::init(tmp_dir.path()).expect("git repo should be init-able"); let selected_repo = tmp_dir.path().to_path_buf(); let expected_repo_string = dunce::canonicalize(tmp_dir.path()) .unwrap_or_else(|_| tmp_dir.path().to_path_buf()) .to_string_lossy() .to_string(); harness.update(&mut app, |harness, ctx| { harness.events.clear(); let modal = harness.modal(); modal.update(ctx, |modal, ctx| { modal.show(ctx); modal.stop_available_repos_loading(); modal.handle_action( &AgentAssistedEnvironmentModalAction::DirectoryPicked( Ok(selected_repo.clone()), ), ctx, ); modal.handle_action(&AgentAssistedEnvironmentModalAction::Confirm, ctx); }); }); harness.read(&app, |harness, _ctx| { let [AgentAssistedEnvironmentModalEvent::Confirmed { repo_paths }] = harness.events.as_slice() else { panic!( "Expected a single Confirmed event with selected repo path, got: {:?}", harness.events ); }; assert_eq!(repo_paths.len(), 1); let actual = dunce::canonicalize(PathBuf::from(&repo_paths[0])) .unwrap_or_else(|_| PathBuf::from(&repo_paths[0])) .to_string_lossy() .to_string(); assert_eq!(actual, expected_repo_string); }); }) } #[test] fn test_modal_directory_picked_dedupes_paths() { App::test((), |mut app| async move { init_modal_test_models(&mut app); let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new); let tmp_dir = tempfile::TempDir::new().expect("TempDir should be creatable"); git2::Repository::init(tmp_dir.path()).expect("git repo should be init-able"); let selected_repo = tmp_dir.path().to_path_buf(); harness.update(&mut app, |harness, ctx| { let modal = harness.modal(); modal.update(ctx, |modal, ctx| { modal.show(ctx); modal.handle_action( &AgentAssistedEnvironmentModalAction::DirectoryPicked( Ok(selected_repo.clone()), ), ctx, ); modal.handle_action( &AgentAssistedEnvironmentModalAction::DirectoryPicked( Ok(selected_repo.clone()), ), ctx, ); assert_eq!(modal.selected_repo_paths.len(), 1); assert_eq!(modal.selected_row_mouse_states.len(), 1); }); }); }) } #[test] fn test_modal_directory_picked_rejects_non_repos() { App::test((), |mut app| async move { init_modal_test_models(&mut app); let (_window_id, harness) = app.add_window(WindowStyle::NotStealFocus, ModalHarness::new); let tmp_dir = tempfile::TempDir::new().expect("TempDir should be creatable"); let selected_dir = tmp_dir.path().to_path_buf(); harness.update(&mut app, |harness, ctx| { let modal = harness.modal(); modal.update(ctx, |modal, ctx| { modal.show(ctx); modal.handle_action( &AgentAssistedEnvironmentModalAction::DirectoryPicked(Ok(selected_dir.clone())), ctx, ); assert!(modal.selected_repo_paths.is_empty()); assert!(modal.selected_row_mouse_states.is_empty()); }); }); }) }