Files
rdbms-playground/tests/iteration4b_lifecycle_commands.rs
T
claude@clouddev1 f2198275f0 Iteration 4b: save / save as / new / load with project switching
Adds the rest of the track-2 lifecycle commands (ADR-0015 §11)
and the project-switching machinery they need at runtime.

Temp vs named distinction: replaced the fragile naming heuristic
with an explicit `[temp]` marker in the directory pattern
(`<YYYYMMDD>-[temp]-<word>-<word>-<word>`). validate_user_name
already rejects brackets, so user-typed names can never collide
with a temp marker. The status bar shows `[TEMP] <Display Name>`
for temp projects; the prettifier strips both the date and the
marker so display names are clean.

save / save as: temp project's `save` opens a path-entry modal
(acts as save as); named project's `save` reports "already
auto-saved; use `save as`". `save as` always prompts. Relative
names resolve under <data-root>/projects/; absolute paths used
as-is. Copy excludes the per-process lock file; everything else
(.db, yaml, csvs, history.log) is copied.

new: closes current project, creates a fresh auto-named temp,
switches.

load: opens a picker. List sub-mode shows projects in the active
data root, sorted newest-first by project.yaml mtime; arrow keys
navigate, Enter loads, `b` switches to a path-entry sub-mode for
projects elsewhere, Esc cancels. Empty data root jumps straight
to path entry.

Runtime: `Session` holds Option<Project> + Option<Database> so
project switches can drop old (releasing lock + stopping worker)
before opening new -- required for the "load my own current
project" case. `perform_switch` handles Load / SaveAs / NewTemp
uniformly.

Tests: 332 passing (270 lib + 9 + 5 + 6 + 16 new + 9 + 17),
0 failing, 0 skipped. Clippy clean.
2026-05-08 06:23:46 +00:00

338 lines
10 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::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 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 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);
}
}