//! Iteration-4a integration tests: the explicit `rebuild` //! app-level command (ADR-0015 §7, §11). //! //! Covers the App-level dispatch (typing `rebuild` opens the //! confirmation modal) and the worker-level wipe-and-rebuild //! against a populated database. The runtime's spawn glue //! is exercised manually here since we don't boot a Tokio //! event loop in tests; we drive `Database::rebuild_from_text` //! directly to verify it works on a populated db. use std::fs; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use rdbms_playground::action::Action; use rdbms_playground::app::{App, Modal, RebuildConfirmModal}; use rdbms_playground::db::Database; use rdbms_playground::dsl::{ColumnSpec, Type, Value}; use rdbms_playground::event::AppEvent; use rdbms_playground::persistence::Persistence; use rdbms_playground::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") } fn rt() -> tokio::runtime::Runtime { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("tokio rt") } #[test] fn typing_rebuild_emits_prepare_action() { let mut app = App::new(); type_str(&mut app, "rebuild"); let actions = submit(&mut app); assert_eq!(actions, vec![Action::PrepareRebuild]); // No modal yet — the runtime still has to compute the // summary and post `RebuildPrepared` back. assert!(app.modal.is_none()); } #[test] fn rebuild_prepared_event_opens_modal_with_summary() { let mut app = App::new(); app.update(AppEvent::RebuildPrepared { summary: "3 tables and 47 rows will be reconstructed".to_string(), }); match app.modal.as_ref() { Some(Modal::RebuildConfirm(RebuildConfirmModal { summary })) => { assert!(summary.contains("3 tables")); } other => panic!("expected RebuildConfirm modal, got {other:?}"), } } #[test] fn modal_y_emits_rebuild_action_and_closes() { let mut app = App::new(); app.update(AppEvent::RebuildPrepared { summary: "summary".to_string(), }); let actions = app.update(key(KeyCode::Char('Y'))); assert_eq!(actions.len(), 1); let Action::Rebuild { source } = &actions[0] else { panic!("expected Rebuild action, got {:?}", actions[0]); }; assert_eq!(source, "rebuild"); assert!(app.modal.is_none(), "modal should close on confirm"); } #[test] fn modal_n_or_esc_dismisses_without_action() { for code in [KeyCode::Char('N'), KeyCode::Esc] { let mut app = App::new(); app.update(AppEvent::RebuildPrepared { summary: "summary".to_string(), }); let actions = app.update(key(code)); assert!(actions.is_empty(), "no actions emitted on dismiss"); assert!(app.modal.is_none(), "modal should close on dismiss"); } } #[test] fn modal_swallows_unrelated_keys() { let mut app = App::new(); app.update(AppEvent::RebuildPrepared { summary: "summary".to_string(), }); // A regular character key should not type into the input // field while the modal is up. app.update(key(KeyCode::Char('x'))); assert!(app.input.is_empty(), "modal should swallow key input"); assert!(app.modal.is_some(), "modal still active after unrelated key"); } #[test] fn rebuild_against_populated_db_wipes_and_reloads() { let data = tempdir(); let project_path = { 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(); rt().block_on(async { db.create_table( "Customers".to_string(), vec![ ColumnSpec { name: "id".to_string(), ty: Type::Serial }, ColumnSpec { name: "Name".to_string(), ty: Type::Text }, ], vec!["id".to_string()], Some("create".to_string()), ) .await .unwrap(); db.insert( "Customers".to_string(), None, vec![Value::Text("Alice".to_string())], Some("insert".to_string()), ) .await .unwrap(); }); drop(db); drop(project); path }; // Hand-edit the CSV to introduce a different row content. // Rebuild should pick up the edited content. let csv_path = project_path.join("data").join("Customers.csv"); let edited = fs::read_to_string(&csv_path).unwrap().replace("Alice", "Edna"); fs::write(&csv_path, edited).unwrap(); // Reopen with persistence (the .db still exists but has // "Alice"). Run rebuild — it should wipe and reload. let project = project::Project::open(&project_path).unwrap(); let db = Database::open_with_persistence( project.db_path(), Persistence::new(project.path().to_path_buf()), ) .unwrap(); rt().block_on(async { db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())) .await .expect("rebuild"); }); let rows = rt() .block_on(async { db.query_data("Customers".to_string(), None).await }) .unwrap(); assert_eq!(rows.rows.len(), 1); assert_eq!(rows.rows[0][1].as_deref(), Some("Edna")); // history.log should contain the rebuild entry. let history = fs::read_to_string(project_path.join("history.log")).unwrap(); assert!( history.lines().any(|l| l.ends_with("|ok|rebuild")), "history.log missing rebuild entry:\n{history}", ); }