Files
rdbms-playground/tests/iteration4b_lifecycle_commands.rs
T
claude@clouddev1 67d68db5f8 Iteration 6: --resume + persistent input history + migration scaffold
Closes out track 2's ADR-0015 backlog.

* `--resume` CLI flag (L1a, ADR-0015 §7) opens the most-
  recently-used project, tracked in <data-root>/last_project.
  Mutually exclusive with a positional <project-path>; errors
  cleanly to stderr (above the shell prompt) on missing file
  or stale recorded path. last_project is rewritten on every
  successful project open (startup, load, new, save as,
  import).
* Persistent input history (I2-persist, ADR-0015 §12). On
  project open, the in-memory navigable history is hydrated
  from the tail of history.log (capped at the in-memory cap).
  ProjectSwitched gains a `history_entries` payload field;
  App::seed_history is the entry point. Pipes inside source
  text round-trip via splitn(3); unknown escape sequences are
  passed through literally.
* Migration framework scaffold (F3, ADR-0015 §9). New
  persistence::migrations module with MigratorRegistry +
  migrate_to_latest + ensure_project_yaml_migrated. Empty
  in v1 (production registry has no migrators); the loader
  runs through it on every project open and is exercised by
  tests with a fake v1→v2 migrator. Writes
  project.yaml.v<N>.bak before any migrator runs; verifies
  each step bumps the version field.

Refreshes docs/requirements.md (A1 / I2 / F3 / E1 / L1a /
test baseline) and adds docs/handoff/20260508-handoff-3.md
covering both Iter 5 and Iter 6.

Total tests: 408 passing, 0 failing, 0 skipped (up from 345
at handoff-2). Clippy clean.
2026-05-08 08:27:50 +00:00

527 lines
17 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, 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<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,
history_entries: Vec::new(),
});
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 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());
}
#[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);
}
}