Iteration 4a: rebuild command with confirmation modal
Adds the explicit `rebuild` app-level command (ADR-0015 §7, §11)
and a modal UI infrastructure to host its confirmation dialog.
Typing `rebuild` emits Action::PrepareRebuild; the runtime reads
project.yaml + data/ to compute a summary ("3 tables and 47 rows
will be reconstructed; the existing playground.db will be
replaced") and posts AppEvent::RebuildPrepared, which opens the
modal. Y confirms, N/Esc cancels. While the modal is open,
normal input is gated.
The worker's do_rebuild_from_text now wipes existing user tables
and metadata before reloading from text, so it works on both
fresh and populated databases. Source text is plumbed through
rebuild_from_text so the explicit rebuild logs to history.log
while the silent on-load rebuild from Iteration 3 stays silent.
Modal infrastructure (App.modal field + key routing + centered
overlay rendering + word-wrap) is reused by Iteration 4b's save
/ save as / load / new flows.
Tests: 314 passing (268 lib + 9 + 5 + 6 new + 9 + 17),
0 failing, 0 skipped. Clippy clean.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
//! 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<Action> {
|
||||
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}",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user