//! 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, safely_delete_temp_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 help_describes_auto_generated_type_behaviour() { // ADR-0017 / ADR-0018: the in-app help must surface the // auto-fill contract for serial / shortid columns and the // change-column conversion flags. Captured as a regression // check so a future help-text edit doesn't silently drop the // pedagogical lines. let mut app = App::new(); type_str(&mut app, "help"); submit(&mut app); let body = app .output .iter() .map(|l| l.text.as_str()) .collect::>() .join("\n"); for keyword in [ "--force-conversion", "--dont-convert", "Auto-generated types", "auto-filled", "UNIQUE", ] { 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, history_entries: Vec::new(), mode: rdbms_playground::mode::Mode::Simple, }); 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::new("id".to_string(), 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 safely_delete_removes_genuine_unmodified_temp() { let data = tempdir(); let project = project::open_or_create(None, Some(data.path())).unwrap(); let path = project.path().to_path_buf(); drop(project); // release lock so we can delete assert!(path.exists()); safely_delete_temp_project(&path, data.path()).expect("should delete"); assert!(!path.exists()); } #[test] fn safely_delete_refuses_path_outside_data_root() { let data = tempdir(); let other = tempdir(); // Construct a directory outside the data root that LOOKS // like a temp project (has [temp] marker + project.yaml). let foreign = other.path().join("20260507-[temp]-fake-fake-fake"); fs::create_dir_all(&foreign).unwrap(); fs::write( foreign.join("project.yaml"), "version: 1\nproject:\n created_at: x\ntables: []\nrelationships: []\n", ) .unwrap(); let err = safely_delete_temp_project(&foreign, data.path()).expect_err("must refuse"); assert!(format!("{err}").contains("not inside"), "got: {err}"); assert!(foreign.exists(), "foreign dir must still exist"); } #[test] fn safely_delete_refuses_directory_without_temp_marker() { let data = tempdir(); // Create a project directory under the data root that // doesn't carry the [temp] marker. let projects_dir = data.path().join(project::PROJECTS_SUBDIR); fs::create_dir_all(&projects_dir).unwrap(); let named = projects_dir.join("MyOrders"); fs::create_dir(&named).unwrap(); fs::write(named.join("project.yaml"), "version: 1\n").unwrap(); let err = safely_delete_temp_project(&named, data.path()).expect_err("must refuse"); assert!(format!("{err}").contains("[temp]"), "got: {err}"); assert!(named.exists()); } #[test] fn safely_delete_refuses_directory_with_unexpected_file() { let data = tempdir(); let project = project::open_or_create(None, Some(data.path())).unwrap(); let path = project.path().to_path_buf(); // Drop a stranger file into the project dir. fs::write(path.join("notes.md"), "user notes\n").unwrap(); drop(project); let err = safely_delete_temp_project(&path, data.path()).expect_err("must refuse"); assert!(format!("{err}").contains("unexpected file"), "got: {err}"); assert!(path.exists()); assert!(path.join("notes.md").exists()); } #[test] fn safely_delete_allows_migration_backups_and_tmp_files() { let data = tempdir(); let project = project::open_or_create(None, Some(data.path())).unwrap(); let path = project.path().to_path_buf(); fs::write(path.join("project.yaml.v1.bak"), "old\n").unwrap(); fs::write(path.join("project.yaml.tmp"), "stage\n").unwrap(); drop(project); safely_delete_temp_project(&path, data.path()).expect("should delete"); assert!(!path.exists()); } #[test] fn safely_delete_allows_undo_snapshot_ring() { // A temp that was modified then undone back to empty can still // carry the `.snapshots/` ring; it must remain auto-deletable // (ADR-0006 Amendment 1). let data = tempdir(); let project = project::open_or_create(None, Some(data.path())).unwrap(); let path = project.path().to_path_buf(); let snaps = path.join(".snapshots"); fs::create_dir_all(snaps.join("3")).unwrap(); fs::write(snaps.join("index.yaml"), "next_id: 4\nundo: []\nredo: []\n").unwrap(); fs::write(snaps.join("3").join("playground.db"), [0u8; 16]).unwrap(); drop(project); safely_delete_temp_project(&path, data.path()).expect("should delete"); assert!(!path.exists()); } #[cfg(unix)] #[test] fn safely_delete_refuses_symlink_top_level() { use std::os::unix::fs::symlink; let data = tempdir(); let real_target = tempdir(); let projects_dir = data.path().join(project::PROJECTS_SUBDIR); fs::create_dir_all(&projects_dir).unwrap(); let link = projects_dir.join("20260507-[temp]-aaa-bbb-ccc"); symlink(real_target.path(), &link).unwrap(); let err = safely_delete_temp_project(&link, data.path()).expect_err("must refuse"); assert!(format!("{err}").contains("symbolic link"), "got: {err}"); // Real target untouched. assert!(real_target.path().exists()); // Symlink itself untouched. assert!(link.exists()); } #[test] fn unmodified_temp_with_residual_csv_in_data_dir_is_not_unmodified() { let data = tempdir(); let project = project::open_or_create(None, Some(data.path())).unwrap(); // Hand-drop a CSV into the data dir without going through // the persistence layer. Schema in yaml is still empty. let csv = project.path().join("data").join("Stranger.csv"); fs::write(&csv, "id\n1\n").unwrap(); assert!( !project.is_unmodified_temp(), "non-empty data dir must disqualify the unmodified-temp check", ); } #[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); } }