Files
rdbms-playground/tests/iteration4b_lifecycle_commands.rs
T
claude@clouddev1 b7addd6161 Cleanup pass: --help, in-app help, post-rebuild message, unmodified-temp cleanup
Four post-Iteration-4 polish items surfaced by manual testing.

1. `--help` / `-h` CLI flag prints a usage banner (options +
   app-level commands + DSL grammar reference) and exits. Parse
   errors also print the banner to stderr.

2. `help` app-level command notes the same list of supported
   commands to the output panel -- a simple stand-in for the
   richer H3 help system, kept in sync with what's actually
   wired up.

3. The silent rebuild that runs when playground.db is missing
   now surfaces a system message in the output panel ("[ok]
   rebuild -- N tables, M rows reconstructed; ...") via a new
   initial_events plumbing. The user no longer wonders whether
   the .db was magically restored or whether anything happened
   on launch.

4. Unmodified empty temp projects (kind=Temp, project.yaml has
   tables: [] and relationships: []) are now auto-deleted when
   the user switches away (load / new / save as) or quits. This
   addresses the "launch app, load existing project, quit"
   pattern that was leaving an empty temp directory behind
   every time. Modified temps (with any user-created tables or
   relationships) are never auto-deleted; corrupted projects
   are also never auto-deleted (defensive default-to-false on
   yaml read/parse errors).

Tests: 338 passing (272 lib + 9 + 5 + 6 + 20 + 9 + 17),
0 failing, 0 skipped. Clippy clean.
2026-05-08 06:43:49 +00:00

417 lines
13 KiB
Rust

//! 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<Action> {
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::<Vec<_>>()
.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);
}
}