9efae59c3c
Each top-level tests/*.rs was its own crate → its own binary, each statically linking the bundled engine + every dep. 26 of them, so an edit to the lib relinked all 26. Moved the 25 standalone files into tests/it/ under one tests/it/main.rs (the pattern typing_surface already uses); cargo auto-detects it as the `it` target. End state: 2 integration-test binaries instead of 26. Result: target/debug/deps 1.5 GB → 629 MB (-58%). Build time barely moved (clean 22.9s→22.4s, lib-edit relink 13.3s→12.4s) — wall-clock is dominated by compiling, not linking, so this is a disk win, not a speed win (see docs/plans/20260602-test-consolidation.md). Tests unchanged at 2151/0/1; clippy clean; no fixups needed. typing_surface_matrix stays its own already-consolidated binary. Tradeoff: the 25 files now share one crate (a compile error fails the whole `it` binary; module-scoped namespaces, no clashes) — negligible for a solo project.
372 lines
12 KiB
Rust
372 lines
12 KiB
Rust
//! Iteration-5 integration tests: `export` / `import`
|
|
//! (ADR-0015 §11 + ADR-0007 amendment 1).
|
|
//!
|
|
//! Command parsing is exercised at the App layer (synthetic
|
|
//! events). Filesystem-level export and import semantics are
|
|
//! tested against the public `archive` helpers without booting
|
|
//! a Tokio loop.
|
|
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
|
|
|
use rdbms_playground::action::Action;
|
|
use rdbms_playground::app::App;
|
|
use rdbms_playground::archive::{
|
|
default_export_filename, export_project, extract_into, inspect_zip,
|
|
next_export_sequence, resolve_import_target,
|
|
};
|
|
use rdbms_playground::event::AppEvent;
|
|
use rdbms_playground::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML};
|
|
|
|
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")
|
|
}
|
|
|
|
fn make_demo_project(root: &std::path::Path, name: &str) -> PathBuf {
|
|
let p = root.join(name);
|
|
fs::create_dir_all(&p).unwrap();
|
|
fs::write(
|
|
p.join(PROJECT_YAML),
|
|
"version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n",
|
|
)
|
|
.unwrap();
|
|
fs::create_dir_all(p.join("data")).unwrap();
|
|
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
|
|
fs::write(p.join(HISTORY_LOG), "T|ok|seed\n").unwrap();
|
|
fs::write(p.join(PLAYGROUND_DB), [0u8; 16]).unwrap();
|
|
p
|
|
}
|
|
|
|
// --- Command-parsing tests -------------------------------------
|
|
|
|
#[test]
|
|
fn export_with_no_arg_emits_default_action() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "export");
|
|
let actions = submit(&mut app);
|
|
assert_eq!(actions.len(), 1);
|
|
match &actions[0] {
|
|
Action::Export { target, source } => {
|
|
assert!(target.is_none());
|
|
assert_eq!(source, "export");
|
|
}
|
|
other => panic!("expected Export, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn export_with_path_argument_passes_through_target() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "export backups/MyExport.zip");
|
|
let actions = submit(&mut app);
|
|
assert_eq!(actions.len(), 1);
|
|
match &actions[0] {
|
|
Action::Export { target, .. } => {
|
|
assert_eq!(target.as_deref(), Some("backups/MyExport.zip"));
|
|
}
|
|
other => panic!("expected Export, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn export_with_only_whitespace_after_keyword_errors() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "export ");
|
|
let actions = submit(&mut app);
|
|
// Trailing whitespace is trimmed by submit() before
|
|
// dispatch, so "export " trims to "export" and emits
|
|
// the default Export action — exactly the same outcome
|
|
// as a bare `export`. That is the desired behaviour.
|
|
assert_eq!(actions.len(), 1);
|
|
match &actions[0] {
|
|
Action::Export { target, .. } => assert!(target.is_none()),
|
|
other => panic!("expected Export, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn import_without_arg_emits_error() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "import");
|
|
let actions = submit(&mut app);
|
|
assert!(actions.is_empty());
|
|
let last = app.output.back().unwrap();
|
|
assert!(last.text.contains("usage: import"), "got: {}", last.text);
|
|
}
|
|
|
|
#[test]
|
|
fn import_with_zip_path_emits_action_without_target() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "import some/file.zip");
|
|
let actions = submit(&mut app);
|
|
assert_eq!(actions.len(), 1);
|
|
match &actions[0] {
|
|
Action::Import {
|
|
zip_path,
|
|
as_target,
|
|
..
|
|
} => {
|
|
assert_eq!(zip_path, "some/file.zip");
|
|
assert!(as_target.is_none());
|
|
}
|
|
other => panic!("expected Import, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn import_with_zip_and_as_target_emits_both() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "import some/file.zip as MyImported");
|
|
let actions = submit(&mut app);
|
|
assert_eq!(actions.len(), 1);
|
|
match &actions[0] {
|
|
Action::Import {
|
|
zip_path,
|
|
as_target,
|
|
..
|
|
} => {
|
|
assert_eq!(zip_path, "some/file.zip");
|
|
assert_eq!(as_target.as_deref(), Some("MyImported"));
|
|
}
|
|
other => panic!("expected Import, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn import_grammar_only_splits_on_space_around_as() {
|
|
// A zip path that *contains* the substring "as" without
|
|
// surrounding spaces must NOT be split — the separator
|
|
// is " as " (space-as-space) only.
|
|
let mut app = App::new();
|
|
type_str(&mut app, "import path/asfile.zip");
|
|
let actions = submit(&mut app);
|
|
assert_eq!(actions.len(), 1);
|
|
match &actions[0] {
|
|
Action::Import {
|
|
zip_path,
|
|
as_target,
|
|
..
|
|
} => {
|
|
assert_eq!(zip_path, "path/asfile.zip");
|
|
assert!(as_target.is_none());
|
|
}
|
|
other => panic!("expected Import, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn import_with_empty_target_after_as_errors() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "import foo.zip as ");
|
|
let actions = submit(&mut app);
|
|
// "as " trailing whitespace is trimmed by .split_once + .trim,
|
|
// making the as-target empty. We surface this as a usage
|
|
// error rather than silently importing without a target. The
|
|
// failed line is journalled `err` (ADR-0034) but no import
|
|
// dispatches.
|
|
assert!(
|
|
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
|
|
"expected only a JournalFailure, no import dispatch; got {actions:?}",
|
|
);
|
|
// The friendly parse-error rendering produces multiple
|
|
// output lines (caret, message, usage). Scan for the anchor
|
|
// phrase rather than asserting on the final line. The
|
|
// round-5 refactor moved this error from `handle_import_command`
|
|
// (single note) into the parser's pre-chumsky path (multi-
|
|
// line rendering via dispatch_dsl).
|
|
let anywhere = app
|
|
.output
|
|
.iter()
|
|
.any(|l| l.text.contains("import") && l.text.contains("target"));
|
|
assert!(
|
|
anywhere,
|
|
"expected 'import' + 'target' somewhere in output: {:?}",
|
|
app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn help_lists_export_and_import() {
|
|
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");
|
|
assert!(body.contains("export"), "help missing export: {body}");
|
|
assert!(body.contains("import"), "help missing import: {body}");
|
|
}
|
|
|
|
// --- Filesystem-level export/import semantics ------------------
|
|
|
|
#[test]
|
|
fn full_round_trip_export_then_extract() {
|
|
let tmp = tempdir();
|
|
let project = make_demo_project(tmp.path(), "MyDemo");
|
|
let zip = tmp.path().join("MyDemo-export-01.zip");
|
|
export_project(&project, "MyDemo", &zip).unwrap();
|
|
|
|
let inspect = inspect_zip(&zip).unwrap();
|
|
assert_eq!(inspect.top_folder, "MyDemo");
|
|
|
|
let target = tmp.path().join("imported");
|
|
extract_into(&zip, &target, &inspect.top_folder).unwrap();
|
|
assert!(target.join(PROJECT_YAML).exists());
|
|
assert!(target.join("data").join("Customers.csv").exists());
|
|
// history.log and playground.db were excluded from the zip,
|
|
// so neither lands in the imported project.
|
|
assert!(!target.join(HISTORY_LOG).exists());
|
|
assert!(!target.join(PLAYGROUND_DB).exists());
|
|
}
|
|
|
|
#[test]
|
|
fn next_export_sequence_increments_per_existing_file() {
|
|
let tmp = tempdir();
|
|
let date = rdbms_playground::project::naming::today_local();
|
|
|
|
let (n1_name, n1) = next_export_sequence(tmp.path(), "Demo").unwrap();
|
|
assert_eq!(n1, 1);
|
|
fs::write(tmp.path().join(&n1_name), "").unwrap();
|
|
|
|
let (n2_name, n2) = next_export_sequence(tmp.path(), "Demo").unwrap();
|
|
assert_eq!(n2, 2);
|
|
assert_eq!(n2_name, default_export_filename(&date, "Demo", 2));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_import_target_auto_suffixes_on_collision() {
|
|
let tmp = tempdir();
|
|
fs::create_dir(tmp.path().join("Imported")).unwrap();
|
|
let (resolved, suffix) = resolve_import_target(tmp.path(), "Imported").unwrap();
|
|
assert_eq!(resolved, tmp.path().join("Imported-02"));
|
|
assert_eq!(suffix, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_import_target_uses_direct_name_when_free() {
|
|
let tmp = tempdir();
|
|
let (resolved, suffix) = resolve_import_target(tmp.path(), "Fresh").unwrap();
|
|
assert_eq!(resolved, tmp.path().join("Fresh"));
|
|
assert_eq!(suffix, 0);
|
|
}
|
|
|
|
// --- End-to-end: real Project → export → import → rebuild ----
|
|
|
|
#[test]
|
|
fn end_to_end_export_then_import_real_project() {
|
|
use rdbms_playground::db::Database;
|
|
use rdbms_playground::dsl::{ColumnSpec, Type, Value};
|
|
use rdbms_playground::persistence::Persistence;
|
|
use rdbms_playground::project;
|
|
|
|
fn rt() -> tokio::runtime::Runtime {
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("tokio rt")
|
|
}
|
|
|
|
let data = tempdir();
|
|
|
|
// Build a populated source project.
|
|
let src_path = {
|
|
let p = project::Project::create_named(&data.path().join("Source")).unwrap();
|
|
let db = Database::open_with_persistence(
|
|
p.db_path(),
|
|
Persistence::new(p.path().to_path_buf()),
|
|
)
|
|
.unwrap();
|
|
rt().block_on(async {
|
|
db.create_table(
|
|
"Customers".to_string(),
|
|
vec![
|
|
ColumnSpec::new("id".to_string(), Type::Serial),
|
|
ColumnSpec::new("Name".to_string(), Type::Text),
|
|
],
|
|
vec!["id".to_string()],
|
|
Some("create table Customers with pk id(serial)".to_string()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
db.insert(
|
|
"Customers".to_string(),
|
|
None,
|
|
// Serial id auto-fills, so the values list
|
|
// covers the non-serial columns only.
|
|
vec![Value::Text("Alice".to_string())],
|
|
Some("insert into Customers values ('Alice')".to_string()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
});
|
|
let path = p.path().to_path_buf();
|
|
drop(db);
|
|
drop(p);
|
|
path
|
|
};
|
|
|
|
// Export.
|
|
let zip_path = data.path().join("Source-export.zip");
|
|
export_project(&src_path, "Source", &zip_path).unwrap();
|
|
assert!(zip_path.exists());
|
|
|
|
// Inspect: top folder is the project name we exported with.
|
|
let inspect = inspect_zip(&zip_path).unwrap();
|
|
assert_eq!(inspect.top_folder, "Source");
|
|
|
|
// Import to a fresh location and rebuild from text.
|
|
let dst = data.path().join("Imported");
|
|
extract_into(&zip_path, &dst, &inspect.top_folder).unwrap();
|
|
assert!(dst.join(PROJECT_YAML).exists());
|
|
// playground.db is excluded from the export, so the
|
|
// imported project starts without one — exactly the
|
|
// scenario rebuild_from_text is designed for.
|
|
assert!(!dst.join(PLAYGROUND_DB).exists());
|
|
|
|
let imported = project::Project::open(&dst).unwrap();
|
|
let imported_db = Database::open_with_persistence(
|
|
imported.db_path(),
|
|
Persistence::new(imported.path().to_path_buf()),
|
|
)
|
|
.unwrap();
|
|
rt().block_on(async {
|
|
imported_db
|
|
.rebuild_from_text(imported.path().to_path_buf(), None)
|
|
.await
|
|
.expect("rebuild");
|
|
});
|
|
|
|
// Round-trip: the inserted row is back.
|
|
let data_view = rt()
|
|
.block_on(async { imported_db.query_data("Customers".to_string(), None, None, None).await })
|
|
.expect("query data");
|
|
assert_eq!(data_view.rows.len(), 1);
|
|
// Serial id auto-filled to 1; Name was the inserted value.
|
|
let cells: Vec<Option<&str>> = data_view.rows[0].iter().map(|c| c.as_deref()).collect();
|
|
assert_eq!(cells, vec![Some("1"), Some("Alice")]);
|
|
}
|