//! Iteration-4b integration tests: `save` / `save as` / //! `new` / `load` (ADR-0015 ยง11) and the modal infrastructure //! that hosts their dialogs. //! //! Modal flows are tested at the App layer (synthetic events). //! Filesystem effects (recursive copy, project switching at //! runtime) are tested through the public `project` and //! `runtime` helpers without booting a Tokio loop. use std::fs; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use rdbms_playground::action::Action; use rdbms_playground::app::{ App, LoadPickerEntry, LoadPickerModal, LoadPickerSubMode, Modal, PathEntryModal, PathEntryPurpose, }; use rdbms_playground::event::AppEvent; use rdbms_playground::db::Database; use rdbms_playground::dsl::{ColumnSpec, Type}; use rdbms_playground::persistence::Persistence; use rdbms_playground::project::{self, Project, ProjectKind, copy_project}; const fn key(code: KeyCode) -> AppEvent { AppEvent::Key(KeyEvent { code, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: crossterm::event::KeyEventState::NONE, }) } fn type_str(app: &mut App, s: &str) { for c in s.chars() { app.update(key(KeyCode::Char(c))); } } fn submit(app: &mut App) -> Vec { app.update(key(KeyCode::Enter)) } fn tempdir() -> tempfile::TempDir { tempfile::tempdir().expect("create tempdir") } #[test] fn help_command_lists_supported_commands() { let mut app = App::new(); type_str(&mut app, "help"); let actions = submit(&mut app); assert!(actions.is_empty()); let body = app .output .iter() .map(|l| l.text.as_str()) .collect::>() .join("\n"); for keyword in ["quit", "rebuild", "save", "load", "new", "create table"] { assert!( body.contains(keyword), "help output missing `{keyword}`:\n{body}", ); } } #[test] fn save_on_temp_opens_path_entry_modal() { let mut app = App::new(); app.project_is_temp = true; type_str(&mut app, "save"); let actions = submit(&mut app); assert!(actions.is_empty()); match app.modal.as_ref() { Some(Modal::PathEntry(PathEntryModal { purpose, title, .. })) => { assert_eq!(*purpose, PathEntryPurpose::SaveAs); assert_eq!(title, "Save"); } other => panic!("expected PathEntry modal, got {other:?}"), } } #[test] fn save_on_named_project_emits_hint_and_no_modal() { let mut app = App::new(); app.project_is_temp = false; type_str(&mut app, "save"); let actions = submit(&mut app); assert!(actions.is_empty()); assert!(app.modal.is_none()); let last = app.output.iter().last().expect("an output line"); assert!( last.text.contains("already auto-saved"), "got: {}", last.text, ); } #[test] fn save_as_always_opens_path_entry_modal() { let mut app = App::new(); app.project_is_temp = false; type_str(&mut app, "save as"); let actions = submit(&mut app); assert!(actions.is_empty()); match app.modal.as_ref() { Some(Modal::PathEntry(PathEntryModal { purpose, title, .. })) => { assert_eq!(*purpose, PathEntryPurpose::SaveAs); assert_eq!(title, "Save as"); } other => panic!("expected PathEntry modal, got {other:?}"), } } #[test] fn new_command_emits_action() { let mut app = App::new(); type_str(&mut app, "new"); let actions = submit(&mut app); assert_eq!( actions, vec![Action::NewProject { source: "new".to_string() }] ); } #[test] fn load_command_emits_open_picker_action() { let mut app = App::new(); type_str(&mut app, "load"); let actions = submit(&mut app); assert_eq!(actions, vec![Action::OpenLoadPicker]); } #[test] fn path_entry_modal_typing_and_enter_emits_save_as() { let mut app = App::new(); app.project_is_temp = true; type_str(&mut app, "save as"); submit(&mut app); // Type a name and press Enter. type_str(&mut app, "MyOrders"); let actions = submit(&mut app); assert_eq!(actions.len(), 1); let Action::SaveAs { target, source } = &actions[0] else { panic!("expected SaveAs, got {:?}", actions[0]); }; assert_eq!(target, "MyOrders"); assert_eq!(source, "save as"); assert!(app.modal.is_none()); } #[test] fn path_entry_modal_esc_cancels() { let mut app = App::new(); app.project_is_temp = true; type_str(&mut app, "save as"); submit(&mut app); type_str(&mut app, "TheBest"); let actions = app.update(key(KeyCode::Esc)); assert!(actions.is_empty()); assert!(app.modal.is_none()); } #[test] fn path_entry_modal_backspace_edits_input() { let mut app = App::new(); app.project_is_temp = true; type_str(&mut app, "save as"); submit(&mut app); type_str(&mut app, "abc"); app.update(key(KeyCode::Backspace)); match app.modal.as_ref() { Some(Modal::PathEntry(m)) => assert_eq!(m.input, "ab"), other => panic!("expected PathEntry, got {other:?}"), } } #[test] fn load_picker_renders_entries_and_navigates() { let mut app = App::new(); app.update(AppEvent::LoadPickerReady { entries: vec![ LoadPickerEntry { display_name: "Newer".to_string(), modified: "2026-05-07 14:30".to_string(), path: std::path::PathBuf::from("/tmp/newer"), is_temp: true, }, LoadPickerEntry { display_name: "Older".to_string(), modified: "2026-05-01 09:15".to_string(), path: std::path::PathBuf::from("/tmp/older"), is_temp: false, }, ], }); let Some(Modal::LoadPicker(picker)) = app.modal.clone() else { panic!("expected LoadPicker modal"); }; assert_eq!(picker.selected, 0); assert!(matches!(picker.sub_mode, LoadPickerSubMode::List)); // Down โ†’ select index 1. app.update(key(KeyCode::Down)); let Some(Modal::LoadPicker(picker)) = app.modal.clone() else { panic!("expected LoadPicker still active"); }; assert_eq!(picker.selected, 1); // Enter โ†’ emit LoadProject for entries[1]. let actions = app.update(key(KeyCode::Enter)); assert_eq!(actions.len(), 1); let Action::LoadProject { path, source } = &actions[0] else { panic!("expected LoadProject, got {:?}", actions[0]); }; assert_eq!(path, std::path::Path::new("/tmp/older")); assert_eq!(source, "load"); } #[test] fn load_picker_b_enters_path_entry_submode() { let mut app = App::new(); app.update(AppEvent::LoadPickerReady { entries: vec![LoadPickerEntry { display_name: "Foo".to_string(), modified: "2026-05-07 14:30".to_string(), path: std::path::PathBuf::from("/tmp/foo"), is_temp: true, }], }); app.update(key(KeyCode::Char('b'))); let Some(Modal::LoadPicker(LoadPickerModal { sub_mode: LoadPickerSubMode::PathEntry { input, .. }, .. })) = app.modal.clone() else { panic!("expected LoadPicker in PathEntry sub-mode"); }; assert_eq!(input, ""); type_str(&mut app, "/some/path"); let actions = app.update(key(KeyCode::Enter)); let Action::LoadProject { path, .. } = &actions[0] else { panic!("expected LoadProject"); }; assert_eq!(path, std::path::Path::new("/some/path")); } #[test] fn empty_data_root_load_picker_opens_in_path_entry_mode() { let mut app = App::new(); app.update(AppEvent::LoadPickerReady { entries: vec![] }); match app.modal.as_ref() { Some(Modal::LoadPicker(LoadPickerModal { sub_mode: LoadPickerSubMode::PathEntry { .. }, .. })) => {} other => panic!("expected LoadPicker in PathEntry sub-mode, got {other:?}"), } } #[test] fn project_switched_event_updates_state() { let mut app = App::new(); app.project_name = Some("Old".to_string()); app.project_is_temp = true; app.tables = vec!["Stale".to_string()]; app.update(AppEvent::ProjectSwitched { display_name: "New Name".to_string(), is_temp: false, }); assert_eq!(app.project_name.as_deref(), Some("New Name")); assert!(!app.project_is_temp); assert!(app.tables.is_empty(), "tables should clear on switch"); } // === Filesystem-level tests for project::copy_project === #[test] fn copy_project_excludes_lock_file() { let data = tempdir(); let project = project::open_or_create(None, Some(data.path())).unwrap(); let src = project.path().to_path_buf(); // Confirm the lock exists in the source. assert!(src.join(".rdbms-playground.lock").exists()); let dst = data.path().join("CopyDestination"); copy_project(&src, &dst).unwrap(); // Destination has the project skeleton but not the lock. assert!(dst.join("project.yaml").exists()); assert!(dst.join("data").is_dir()); assert!(!dst.join(".rdbms-playground.lock").exists()); drop(project); } #[test] fn copy_project_refuses_existing_destination() { let data = tempdir(); let project = project::open_or_create(None, Some(data.path())).unwrap(); let src = project.path().to_path_buf(); let dst = data.path().join("ExistingDir"); fs::create_dir(&dst).unwrap(); let err = copy_project(&src, &dst).expect_err("must refuse"); assert!(format!("{err}").contains("already exists")); } #[test] fn project_kind_recovered_from_dirname_on_open() { let data = tempdir(); // Create a temp project. Its dirname will contain `[temp]`. let temp = project::open_or_create(None, Some(data.path())).unwrap(); let temp_path = temp.path().to_path_buf(); drop(temp); // Reopen โ€” should still report Temp. let reopened = Project::open(&temp_path).unwrap(); assert_eq!(reopened.kind(), ProjectKind::Temp); drop(reopened); // Now copy to a named directory. let named_dir = data.path().join("MyProject"); copy_project(&temp_path, &named_dir).unwrap(); let opened_named = Project::open(&named_dir).unwrap(); assert_eq!(opened_named.kind(), ProjectKind::Named); assert_eq!(opened_named.display_name(), "My Project"); } #[test] fn fresh_temp_is_unmodified() { let data = tempdir(); let project = project::open_or_create(None, Some(data.path())).unwrap(); assert!(project.is_unmodified_temp()); } #[test] fn temp_with_a_table_is_no_longer_unmodified() { let data = tempdir(); let project = project::open_or_create(None, Some(data.path())).unwrap(); let path = project.path().to_path_buf(); let db = Database::open_with_persistence( project.db_path(), Persistence::new(path.clone()), ) .unwrap(); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); rt.block_on(async { db.create_table( "T".to_string(), vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }], vec!["id".to_string()], Some("create".to_string()), ) .await .unwrap(); }); drop(db); drop(project); let reopened = Project::open(&path).unwrap(); assert!( !reopened.is_unmodified_temp(), "a temp with a table should not be considered unmodified", ); } #[test] fn named_project_is_never_unmodified_temp() { let data = tempdir(); let temp = project::open_or_create(None, Some(data.path())).unwrap(); let temp_path = temp.path().to_path_buf(); drop(temp); let named = data.path().join("MyOrders"); copy_project(&temp_path, &named).unwrap(); let opened = Project::open(&named).unwrap(); // Even though the schema is empty, kind is Named. assert_eq!(opened.kind(), ProjectKind::Named); assert!(!opened.is_unmodified_temp()); } #[test] fn list_projects_sorts_by_mtime() { let data = tempdir(); // Create two projects in succession; the second has a // newer mtime on its project.yaml. let _first = project::open_or_create(None, Some(data.path())).unwrap(); let _first_path = _first.path().to_path_buf(); drop(_first); // Sleep a hair to ensure different mtimes on filesystems // with second-resolution timestamps. std::thread::sleep(std::time::Duration::from_millis(1100)); let _second = project::open_or_create(None, Some(data.path())).unwrap(); let _second_path = _second.path().to_path_buf(); drop(_second); let listings = project::list_projects(data.path()); assert!(listings.len() >= 2, "got {} listings", listings.len()); // Newer first. assert!(listings[0].path > listings[1].path || listings[0].modified >= listings[1].modified); for l in &listings { // Both are temp projects (auto-named with [temp]). assert_eq!(l.kind, ProjectKind::Temp); } }