3dbaedc1da
ADR-0017 added --force-conversion / --dont-convert as opt-in flags on `change column`; the help text already mentioned the flags but didn't explain when they apply. ADR-0018 generalised serial beyond PK and added auto-fill on `add column ... (serial|shortid)` for non-empty tables; none of that was reflected in user-visible help. This commit: - Annotates the `add column` line with a continuation note that adding serial/shortid to a non-empty table auto-fills existing rows. - Annotates the `change column` line with a continuation note that converting to serial/shortid auto-fills null cells. - Appends an "Auto-generated types" section explaining serial and shortid: how they auto-fill, that they imply UNIQUE outside a PK (serial) or always (shortid), and that adding/converting-to either type on a non-empty table auto-fills existing/null cells. The new test `help_describes_auto_generated_type_behaviour` pins these phrases so a future help-text edit can't silently drop the pedagogical lines. The existing `help_command_lists_supported_commands` and `help_lists_export_and_import` tests still pass — they only assert substring presence. No engine vocabulary leaks (ADR-0002 posture preserved). 536 -> 537 passing, clippy clean.
557 lines
18 KiB
Rust
557 lines
18 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 help_describes_auto_generated_type_behaviour() {
|
|
// ADR-0017 / ADR-0018: the in-app help must surface the
|
|
// auto-fill contract for serial / shortid columns and the
|
|
// change-column conversion flags. Captured as a regression
|
|
// check so a future help-text edit doesn't silently drop the
|
|
// pedagogical lines.
|
|
let mut app = App::new();
|
|
type_str(&mut app, "help");
|
|
submit(&mut app);
|
|
let body = app
|
|
.output
|
|
.iter()
|
|
.map(|l| l.text.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
for keyword in [
|
|
"--force-conversion",
|
|
"--dont-convert",
|
|
"Auto-generated types",
|
|
"auto-filled",
|
|
"UNIQUE",
|
|
] {
|
|
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);
|
|
}
|
|
}
|