638b4c9664
Add j (down), k (up), g (first) and G (last) to the load picker's list sub-mode, alongside the existing arrow keys. Typeable keys keep the picker drivable by autocast in the website's documentation casts, which cannot emit arrow keys. Footer hint left unchanged.
661 lines
21 KiB
Rust
661 lines
21 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");
|
|
}
|
|
|
|
/// Build a load picker with three entries for the vi-navigation tests.
|
|
fn three_entry_picker() -> App {
|
|
let mut app = App::new();
|
|
app.update(AppEvent::LoadPickerReady {
|
|
entries: vec![
|
|
LoadPickerEntry {
|
|
display_name: "First".to_string(),
|
|
modified: "2026-05-07 14:30".to_string(),
|
|
path: std::path::PathBuf::from("/tmp/first"),
|
|
is_temp: true,
|
|
},
|
|
LoadPickerEntry {
|
|
display_name: "Second".to_string(),
|
|
modified: "2026-05-05 10:00".to_string(),
|
|
path: std::path::PathBuf::from("/tmp/second"),
|
|
is_temp: false,
|
|
},
|
|
LoadPickerEntry {
|
|
display_name: "Third".to_string(),
|
|
modified: "2026-05-01 09:15".to_string(),
|
|
path: std::path::PathBuf::from("/tmp/third"),
|
|
is_temp: false,
|
|
},
|
|
],
|
|
});
|
|
app
|
|
}
|
|
|
|
fn picker_selected(app: &App) -> usize {
|
|
let Some(Modal::LoadPicker(picker)) = app.modal.as_ref() else {
|
|
panic!("expected LoadPicker modal");
|
|
};
|
|
picker.selected
|
|
}
|
|
|
|
#[test]
|
|
fn load_picker_jk_navigates_like_arrows() {
|
|
// vi-style j/k mirror Down/Up so autocast (typeable keys only) can drive
|
|
// the load picker in documentation casts (#24).
|
|
let mut app = three_entry_picker();
|
|
assert_eq!(picker_selected(&app), 0);
|
|
|
|
// j moves the selection down.
|
|
app.update(key(KeyCode::Char('j')));
|
|
assert_eq!(picker_selected(&app), 1);
|
|
app.update(key(KeyCode::Char('j')));
|
|
assert_eq!(picker_selected(&app), 2);
|
|
|
|
// j at the last entry does not wrap past the end.
|
|
app.update(key(KeyCode::Char('j')));
|
|
assert_eq!(picker_selected(&app), 2);
|
|
|
|
// k moves the selection up.
|
|
app.update(key(KeyCode::Char('k')));
|
|
assert_eq!(picker_selected(&app), 1);
|
|
|
|
// k at the first entry does not wrap past the start.
|
|
app.update(key(KeyCode::Char('k')));
|
|
assert_eq!(picker_selected(&app), 0);
|
|
app.update(key(KeyCode::Char('k')));
|
|
assert_eq!(picker_selected(&app), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn load_picker_g_jumps_to_first_and_last() {
|
|
// g → first entry, G → last entry (vi convention).
|
|
let mut app = three_entry_picker();
|
|
|
|
// G jumps to the last entry from the top.
|
|
app.update(key(KeyCode::Char('G')));
|
|
assert_eq!(picker_selected(&app), 2);
|
|
|
|
// G again is idempotent at the end.
|
|
app.update(key(KeyCode::Char('G')));
|
|
assert_eq!(picker_selected(&app), 2);
|
|
|
|
// g jumps back to the first entry.
|
|
app.update(key(KeyCode::Char('g')));
|
|
assert_eq!(picker_selected(&app), 0);
|
|
|
|
// g again is idempotent at the start.
|
|
app.update(key(KeyCode::Char('g')));
|
|
assert_eq!(picker_selected(&app), 0);
|
|
}
|
|
|
|
#[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(),
|
|
mode: rdbms_playground::mode::Mode::Simple,
|
|
});
|
|
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::new("id".to_string(), 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());
|
|
}
|
|
|
|
#[test]
|
|
fn safely_delete_allows_undo_snapshot_ring() {
|
|
// A temp that was modified then undone back to empty can still
|
|
// carry the `.snapshots/` ring; it must remain auto-deletable
|
|
// (ADR-0006 Amendment 1).
|
|
let data = tempdir();
|
|
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
|
let path = project.path().to_path_buf();
|
|
let snaps = path.join(".snapshots");
|
|
fs::create_dir_all(snaps.join("3")).unwrap();
|
|
fs::write(snaps.join("index.yaml"), "next_id: 4\nundo: []\nredo: []\n").unwrap();
|
|
fs::write(snaps.join("3").join("playground.db"), [0u8; 16]).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);
|
|
}
|
|
}
|