581934886d
# Conflicts: # context/test-evidence/formula-and-io.md # crates/improvise-io/src/import/wizard.rs # src/main.rs
1089 lines
38 KiB
Rust
1089 lines
38 KiB
Rust
//! The `improvise cmd` / `improvise script` contract, pinned at the process
|
|
//! boundary. Why a spawned binary: `context/test-evidence/cli.md`.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Output};
|
|
|
|
fn bin() -> &'static str {
|
|
env!("CARGO_BIN_EXE_improvise")
|
|
}
|
|
|
|
struct Scratch(PathBuf);
|
|
|
|
impl Scratch {
|
|
fn new(tag: &str) -> Self {
|
|
let dir = std::env::temp_dir().join(format!("improv_cli_{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).expect("a scratch directory");
|
|
Self(dir)
|
|
}
|
|
|
|
fn path(&self, name: &str) -> PathBuf {
|
|
self.0.join(name)
|
|
}
|
|
}
|
|
|
|
impl Drop for Scratch {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn run_cmd(args: &[&str], file: &Path) -> Output {
|
|
Command::new(bin())
|
|
.arg("cmd")
|
|
.args(args)
|
|
.arg("-f")
|
|
.arg(file)
|
|
.output()
|
|
.expect("the improvise binary runs")
|
|
}
|
|
|
|
fn stderr_of(out: &Output) -> String {
|
|
String::from_utf8_lossy(&out.stderr).to_string()
|
|
}
|
|
|
|
fn stdout_of(out: &Output) -> String {
|
|
String::from_utf8_lossy(&out.stdout).to_string()
|
|
}
|
|
|
|
/// A document with one category, so `add-item` has somewhere legitimate to go.
|
|
fn seeded(scratch: &Scratch, name: &str) -> PathBuf {
|
|
let doc = scratch.path(name);
|
|
let out = run_cmd(&["add-category Region"], &doc);
|
|
assert!(doc.exists(), "the seed run wrote a document: {out:?}");
|
|
doc
|
|
}
|
|
|
|
#[test]
|
|
fn a_refused_command_says_what_went_wrong() {
|
|
let scratch = Scratch::new("refused");
|
|
let doc = seeded(&scratch, "m.improv");
|
|
let out = run_cmd(&["add-item Nonexistent Thing"], &doc);
|
|
let said = stderr_of(&out);
|
|
assert!(
|
|
!said.trim().is_empty(),
|
|
"a refused command said nothing at all; stdout={:?}",
|
|
stdout_of(&out)
|
|
);
|
|
assert!(
|
|
said.contains("Nonexistent"),
|
|
"the refusal does not name the category that does not exist: {said:?}"
|
|
);
|
|
}
|
|
|
|
/// The rival this separates from: a fix that reports on the happy path and
|
|
/// stays silent on the refusal. `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_command_with_nothing_to_say_says_nothing() {
|
|
let scratch = Scratch::new("quiet");
|
|
let doc = scratch.path("m.improv");
|
|
let out = run_cmd(&["add-category Region"], &doc);
|
|
assert!(
|
|
stderr_of(&out).trim().is_empty(),
|
|
"a command that succeeded with nothing to report spoke anyway: {:?}",
|
|
stderr_of(&out)
|
|
);
|
|
assert!(
|
|
out.status.success(),
|
|
"a command that succeeded did not exit 0"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_aborted_batch_exits_non_zero_and_says_why() {
|
|
let scratch = Scratch::new("abort");
|
|
let doc = seeded(&scratch, "m.improv");
|
|
let bad = scratch.path("no-such-dir").join("out.improv");
|
|
let out = run_cmd(&[&format!("w {}", bad.display())], &doc);
|
|
let said = stderr_of(&out);
|
|
assert!(
|
|
!said.trim().is_empty(),
|
|
"a failed save said nothing: stdout={:?}",
|
|
stdout_of(&out)
|
|
);
|
|
assert!(
|
|
!out.status.success(),
|
|
"a failed save exited 0; stderr={said:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_parse_error_still_exits_non_zero() {
|
|
let scratch = Scratch::new("parse");
|
|
let doc = seeded(&scratch, "m.improv");
|
|
let out = run_cmd(&["no-such-command-at-all"], &doc);
|
|
assert!(!out.status.success(), "an unparseable line exited 0");
|
|
assert!(
|
|
stderr_of(&out).contains("no-such-command-at-all"),
|
|
"the parse error does not name the word typed: {:?}",
|
|
stderr_of(&out)
|
|
);
|
|
}
|
|
|
|
/// Diagnostics go to stderr so that stdout stays free for a data output, and
|
|
/// so `2>/dev/null` is the whole suppression story. `context/code/cli.md`.
|
|
#[test]
|
|
fn what_a_command_says_goes_to_stderr_and_stdout_stays_empty() {
|
|
let scratch = Scratch::new("stream");
|
|
let doc = seeded(&scratch, "m.improv");
|
|
let out = run_cmd(&["add-item Nonexistent Thing"], &doc);
|
|
assert!(
|
|
stdout_of(&out).trim().is_empty(),
|
|
"a diagnostic reached stdout: {:?}",
|
|
stdout_of(&out)
|
|
);
|
|
assert!(!stderr_of(&out).trim().is_empty(), "stderr was empty too");
|
|
}
|
|
|
|
#[test]
|
|
fn a_script_reports_exactly_as_cmd_does() {
|
|
let scratch = Scratch::new("script");
|
|
let doc = seeded(&scratch, "m.improv");
|
|
let script = scratch.path("s.txt");
|
|
std::fs::write(&script, "# a comment\nadd-item Nonexistent Thing\n").expect("a script file");
|
|
let out = Command::new(bin())
|
|
.arg("script")
|
|
.arg(&script)
|
|
.arg("-f")
|
|
.arg(&doc)
|
|
.output()
|
|
.expect("the improvise binary runs");
|
|
assert!(
|
|
stderr_of(&out).contains("Nonexistent"),
|
|
"`script` swallowed the refusal `cmd` reports: {:?}",
|
|
stderr_of(&out)
|
|
);
|
|
}
|
|
|
|
/// A run that changed nothing must leave the bytes alone: a fixture edited by
|
|
/// hand between runs is otherwise silently normalised. `context/code/cli.md`.
|
|
#[test]
|
|
fn a_run_that_changes_nothing_does_not_rewrite_the_document() {
|
|
let scratch = Scratch::new("noop");
|
|
let doc = seeded(&scratch, "m.improv");
|
|
let before = std::fs::read(&doc).expect("the seeded document");
|
|
let out = run_cmd(&["move-cursor down"], &doc);
|
|
let after = std::fs::read(&doc).expect("the document after a view-only command");
|
|
assert_eq!(
|
|
before,
|
|
after,
|
|
"a view-only command rewrote the document; stderr={:?}",
|
|
stderr_of(&out)
|
|
);
|
|
}
|
|
|
|
/// The reported instance: `1e20` is expanded to its twenty digits by a run that
|
|
/// was asked to change nothing at all.
|
|
#[test]
|
|
fn a_no_op_run_leaves_an_unnormalised_literal_alone() {
|
|
let scratch = Scratch::new("literal");
|
|
let doc = scratch.path("m.improv");
|
|
std::fs::write(
|
|
&doc,
|
|
"v2025-04-09\n# Big\nInitial View: Default\n\n\
|
|
## View: Default\nRegion: row\n_Measure: column\n\n\
|
|
## Category: Region\n- North\n\n\
|
|
## Category: _Measure\n- Score\n\n\
|
|
## Data\nRegion=North, _Measure=Score = 1e20\n",
|
|
)
|
|
.expect("a hand-written document");
|
|
let before = std::fs::read_to_string(&doc).expect("the document");
|
|
assert!(before.contains("1e20"), "the fixture itself is wrong");
|
|
let out = run_cmd(&["move-cursor down"], &doc);
|
|
let after = std::fs::read_to_string(&doc).expect("the document after");
|
|
assert!(
|
|
after.contains("1e20"),
|
|
"a no-op run normalised the literal; stderr={:?}, after={after:?}",
|
|
stderr_of(&out)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_cmd_help_does_not_advertise_a_language_it_refuses() {
|
|
for sub in ["cmd", "script"] {
|
|
let out = Command::new(bin())
|
|
.args([sub, "--help"])
|
|
.output()
|
|
.expect("the improvise binary runs");
|
|
let text = format!("{}{}", stdout_of(&out), stderr_of(&out));
|
|
assert!(
|
|
!text.contains("JSON") && !text.contains("json"),
|
|
"`{sub} --help` still advertises JSON, which it refuses: {text}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The language it does accept, demonstrated end to end so the help above has
|
|
/// something true to point at.
|
|
#[test]
|
|
fn the_language_the_help_names_is_the_one_it_accepts() {
|
|
let scratch = Scratch::new("lang");
|
|
let doc = seeded(&scratch, "m.improv");
|
|
let out = run_cmd(&["add-item Region East"], &doc);
|
|
assert!(out.status.success(), "the command line form was refused");
|
|
let text = std::fs::read_to_string(&doc).expect("the document");
|
|
assert!(text.contains("East"), "the item was not added: {text}");
|
|
|
|
let json = run_cmd(&[r#"{"cmd":"add-item","args":["Region","West"]}"#], &doc);
|
|
assert!(
|
|
!json.status.success(),
|
|
"the JSON form the help used to advertise was accepted after all"
|
|
);
|
|
}
|
|
|
|
/// The rival this separates from: making any emitted status a non-zero exit.
|
|
/// `fit-measure` reports its reproducing seed the same way — `context/code/cli.md`.
|
|
#[test]
|
|
fn a_command_that_succeeds_while_reporting_still_exits_zero() {
|
|
let scratch = Scratch::new("reports");
|
|
let doc = seeded(&scratch, "m.improv");
|
|
let save = format!("w {}", doc.display());
|
|
let out = run_cmd(&["add-item Region East", &save], &doc);
|
|
assert!(
|
|
!stderr_of(&out).trim().is_empty(),
|
|
"a successful save reported nothing, so this test proves nothing"
|
|
);
|
|
assert!(
|
|
out.status.success(),
|
|
"a successful command that reported was treated as a failure: {:?}",
|
|
stderr_of(&out)
|
|
);
|
|
}
|
|
|
|
/// A run that did change the document still writes it, even when the last thing
|
|
/// it did was save somewhere else and clear the dirty flag.
|
|
#[test]
|
|
fn a_run_that_changed_the_document_still_writes_it() {
|
|
let scratch = Scratch::new("elsewhere");
|
|
let doc = seeded(&scratch, "m.improv");
|
|
let other = scratch.path("other.improv");
|
|
let save = format!("w {}", other.display());
|
|
run_cmd(&["add-item Region East", &save], &doc);
|
|
let text = std::fs::read_to_string(&doc).expect("the -f document");
|
|
assert!(
|
|
text.contains("East"),
|
|
"the -f document lost an edit because `:w` elsewhere cleared the flag: {text}"
|
|
);
|
|
}
|
|
|
|
/// A document whose row axis carries three items, so a cursor move has
|
|
/// somewhere to go and the three landing places are distinguishable.
|
|
fn three_rows(scratch: &Scratch, name: &str) -> PathBuf {
|
|
let doc = scratch.path(name);
|
|
run_cmd(&["add-category Region"], &doc);
|
|
run_cmd(&["add-items Region North South East"], &doc);
|
|
doc
|
|
}
|
|
|
|
fn hidden_line(doc: &Path) -> String {
|
|
std::fs::read_to_string(doc)
|
|
.expect("the document")
|
|
.lines()
|
|
.find(|l| l.starts_with("hidden:"))
|
|
.unwrap_or("(nothing hidden)")
|
|
.to_string()
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_script_can_move_the_cursor_and_the_next_command_sees_it() {
|
|
let scratch = Scratch::new("cursor");
|
|
let moved = three_rows(&scratch, "moved.improv");
|
|
let still = three_rows(&scratch, "still.improv");
|
|
|
|
run_cmd(&["move-selection 2 0 . hide-selected-row-item"], &moved);
|
|
run_cmd(&["hide-selected-row-item"], &still);
|
|
|
|
assert_eq!(
|
|
hidden_line(&still),
|
|
"hidden: Region/North",
|
|
"without a move the cursor is at the origin; if this changes, the other \
|
|
half of this test stops meaning anything"
|
|
);
|
|
assert_eq!(
|
|
hidden_line(&moved),
|
|
"hidden: Region/East",
|
|
"`move-selection 2 0` must move the cursor two rows down before the \
|
|
next command reads it (improvise-6uva)"
|
|
);
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_script_cursor_move_survives_the_line_that_made_it() {
|
|
let scratch = Scratch::new("cursor-lines");
|
|
let doc = three_rows(&scratch, "lines.improv");
|
|
run_cmd(&["move-selection 1 0", "hide-selected-row-item"], &doc);
|
|
assert_eq!(hidden_line(&doc), "hidden: Region/South");
|
|
}
|
|
|
|
fn cell_lines(doc: &Path) -> Vec<String> {
|
|
std::fs::read_to_string(doc)
|
|
.expect("the document")
|
|
.lines()
|
|
.filter(|l| l.contains(" = "))
|
|
.map(|l| l.trim().to_string())
|
|
.collect()
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn an_explicit_coordinate_still_beats_the_cursor() {
|
|
let scratch = Scratch::new("explicit");
|
|
let doc = scratch.path("cells.improv");
|
|
run_cmd(&["add-category Region"], &doc);
|
|
run_cmd(&["add-items Region North South East"], &doc);
|
|
run_cmd(
|
|
&[
|
|
"set-cell 1 Region/North",
|
|
"set-cell 2 Region/South",
|
|
"set-cell 3 Region/East",
|
|
],
|
|
&doc,
|
|
);
|
|
assert_eq!(cell_lines(&doc).len(), 3, "the fixture wrote three cells");
|
|
|
|
// The cursor is at the origin, which is North. Clearing East by name must
|
|
// clear East, not whatever the cursor happens to be sitting on.
|
|
run_cmd(&["clear-cell Region/East"], &doc);
|
|
let left = cell_lines(&doc);
|
|
assert!(
|
|
left.iter().any(|l| l.contains("North")),
|
|
"the cursor cell was cleared instead of the named one: {left:?}"
|
|
);
|
|
assert!(
|
|
!left.iter().any(|l| l.contains("East")),
|
|
"the named cell survived: {left:?}"
|
|
);
|
|
}
|
|
|
|
/// North=1, South=2, East=3 on the row axis; the cursor starts on North.
|
|
fn three_cells(scratch: &Scratch, name: &str) -> PathBuf {
|
|
let doc = three_rows(scratch, name);
|
|
run_cmd(
|
|
&[
|
|
"set-cell 1 Region/North",
|
|
"set-cell 2 Region/South",
|
|
"set-cell 3 Region/East",
|
|
],
|
|
&doc,
|
|
);
|
|
assert_eq!(cell_lines(&doc).len(), 3, "the fixture wrote three cells");
|
|
doc
|
|
}
|
|
|
|
fn value_of(doc: &Path, item: &str) -> Option<String> {
|
|
cell_lines(doc)
|
|
.into_iter()
|
|
.find(|l| l.contains(&format!("Region={item}")))
|
|
.and_then(|l| l.rsplit(" = ").next().map(str::to_string))
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn commit_cell_edit_writes_the_named_cell_and_spares_the_cursors() {
|
|
let scratch = Scratch::new("commit-explicit");
|
|
let doc = three_cells(&scratch, "commit.improv");
|
|
|
|
let out = run_cmd(
|
|
&["move-selection 1 0 . commit-cell-edit editing 5 Region/East"],
|
|
&doc,
|
|
);
|
|
assert!(out.status.success(), "the line failed: {}", stderr_of(&out));
|
|
let cells = cell_lines(&doc);
|
|
assert_eq!(value_of(&doc, "East").as_deref(), Some("5"), "{cells:?}");
|
|
assert_eq!(
|
|
value_of(&doc, "South").as_deref(),
|
|
Some("2"),
|
|
"the cell under the cursor was touched: {cells:?}"
|
|
);
|
|
assert_eq!(
|
|
value_of(&doc, "North").as_deref(),
|
|
Some("1"),
|
|
"the origin cell was touched: {cells:?}"
|
|
);
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_line_whose_gate_passes_runs_whole() {
|
|
let scratch = Scratch::new("commit-atomic");
|
|
let doc = scratch.path("atomic.improv");
|
|
let out = run_cmd(
|
|
&["add-category Foo . commit-cell-edit editing 5 Foo/x"],
|
|
&doc,
|
|
);
|
|
assert!(
|
|
!stderr_of(&out).contains("Parse error"),
|
|
"a line the gate accepted was refused part-way: {}",
|
|
stderr_of(&out)
|
|
);
|
|
let text = std::fs::read_to_string(&doc).expect("the document");
|
|
assert!(
|
|
text.lines()
|
|
.any(|l| l.contains("Foo=x") && l.ends_with("= 5")),
|
|
"the commit did not land: {text}"
|
|
);
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_malformed_commit_line_is_refused_whole() {
|
|
let scratch = Scratch::new("commit-malformed");
|
|
let doc = seeded(&scratch, "malformed.improv");
|
|
let out = run_cmd(&["add-category Foo . commit-cell-edit editing 5"], &doc);
|
|
assert!(!out.status.success(), "a malformed line exited 0");
|
|
let text = std::fs::read_to_string(&doc).expect("the document");
|
|
assert!(
|
|
!text.contains("Foo"),
|
|
"the first half of a refused line landed: {text}"
|
|
);
|
|
}
|
|
|
|
/// Runs `improvise cmd` with no controlling terminal and the given `LINES`,
|
|
/// so the only size the process can discover is the one handed to it here.
|
|
#[cfg(unix)]
|
|
fn run_cmd_in_a_terminal_of(rows: u16, cols: u16, args: &[&str], file: &Path) -> Output {
|
|
use std::os::unix::process::CommandExt;
|
|
let mut cmd = Command::new(bin());
|
|
cmd.arg("cmd")
|
|
.args(args)
|
|
.arg("-f")
|
|
.arg(file)
|
|
.env("TERM", "xterm")
|
|
.env("LINES", rows.to_string())
|
|
.env("COLUMNS", cols.to_string())
|
|
.stdin(std::process::Stdio::null());
|
|
// SAFETY: `setsid` is async-signal-safe, and leaving the runner's terminal
|
|
// is what makes `LINES` the only size the child can find.
|
|
unsafe {
|
|
cmd.pre_exec(|| {
|
|
libc::setsid();
|
|
Ok(())
|
|
});
|
|
}
|
|
cmd.output().expect("the improvise binary runs")
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/cli.md`.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_script_writes_the_same_record_whatever_terminal_ran_it() {
|
|
let scratch = Scratch::new("extent");
|
|
let items: Vec<String> = (1..=60).map(|i| format!("R{i:02}")).collect();
|
|
let seed = scratch.path("seed.improv");
|
|
run_cmd(&["add-category Region"], &seed);
|
|
run_cmd(&[&format!("add-items Region {}", items.join(" "))], &seed);
|
|
let script = ["page-scroll 1 . hide-selected-row-item"];
|
|
|
|
let mut hidden = Vec::new();
|
|
for (rows, cols) in [(24, 80), (60, 200)] {
|
|
let doc = scratch.path(&format!("{rows}x{cols}.improv"));
|
|
std::fs::copy(&seed, &doc).expect("a copy of the seed");
|
|
let out = run_cmd_in_a_terminal_of(rows, cols, &script, &doc);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
hidden.push(hidden_line(&doc));
|
|
}
|
|
assert_ne!(
|
|
hidden[0], "hidden: Region/R01",
|
|
"the cursor never moved, so the page size was never read"
|
|
);
|
|
assert!(
|
|
hidden[0].starts_with("hidden: Region/R"),
|
|
"the script hid nothing, so agreement proves nothing: {hidden:?}"
|
|
);
|
|
assert_eq!(
|
|
hidden[0], hidden[1],
|
|
"the same script hid a different record at a different terminal size"
|
|
);
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_commit_whose_advance_has_nowhere_to_go_still_exits_zero() {
|
|
let scratch = Scratch::new("commit-tail");
|
|
let doc = three_cells(&scratch, "tail.improv");
|
|
let out = run_cmd(
|
|
&["move-selection 2 0 . commit-cell-edit editing 9 Region/South"],
|
|
&doc,
|
|
);
|
|
assert_eq!(
|
|
value_of(&doc, "South").as_deref(),
|
|
Some("9"),
|
|
"the write did not land"
|
|
);
|
|
assert!(
|
|
out.status.success(),
|
|
"the write landed and was saved, yet the run exited non-zero: {:?}",
|
|
stderr_of(&out)
|
|
);
|
|
|
|
let empty = scratch.path("empty.improv");
|
|
let out = run_cmd(
|
|
&["add-category Foo . commit-cell-edit editing 5 Foo/x"],
|
|
&empty,
|
|
);
|
|
assert!(
|
|
out.status.success(),
|
|
"a commit on an empty grid landed and exited non-zero: {:?}",
|
|
stderr_of(&out)
|
|
);
|
|
}
|
|
|
|
/// Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_refusal_that_only_writes_a_status_exits_non_zero() {
|
|
let scratch = Scratch::new("refusal-exit");
|
|
let doc = seeded(&scratch, "m.improv");
|
|
let out = run_cmd(&["add-item Nonexistent Thing"], &doc);
|
|
assert!(
|
|
stderr_of(&out).contains("Nonexistent"),
|
|
"the refusal was not reported, so its exit code proves nothing: {:?}",
|
|
stderr_of(&out)
|
|
);
|
|
assert!(
|
|
!out.status.success(),
|
|
"a refused command exited 0: {:?}",
|
|
stderr_of(&out)
|
|
);
|
|
}
|
|
|
|
/// Two regions, East repeated: records #1 and #3 share every coordinate.
|
|
fn colliding_csv(scratch: &Scratch) -> PathBuf {
|
|
let csv = scratch.path("dups.csv");
|
|
std::fs::write(&csv, "Region,Revenue\nEast,10\nWest,5\nEast,20\n").unwrap();
|
|
csv
|
|
}
|
|
|
|
fn run_import(csv: &Path, out: &Path, extra: &[&str]) -> Output {
|
|
Command::new(bin())
|
|
.arg("import")
|
|
.arg(csv)
|
|
.arg("--no-wizard")
|
|
.args(extra)
|
|
.arg("-o")
|
|
.arg(out)
|
|
.output()
|
|
.expect("the improvise binary runs")
|
|
}
|
|
|
|
/// improvise-cgqt: nobody is there to ask, so an unanswered collision stops
|
|
/// the import and names the records. `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_headless_import_with_duplicates_refuses_by_default_and_names_them() {
|
|
let scratch = Scratch::new("dup_refuse");
|
|
let out_path = scratch.path("out.improv");
|
|
let out = run_import(&colliding_csv(&scratch), &out_path, &[]);
|
|
let err = stderr_of(&out);
|
|
assert!(!out.status.success(), "{err}");
|
|
assert!(err.contains("2 records (#1, #3): Region=East"), "{err}");
|
|
assert!(err.contains("--on-duplicate"), "names the way out: {err}");
|
|
assert!(!out_path.exists(), "nothing half-imported is written");
|
|
}
|
|
|
|
#[test]
|
|
fn a_headless_import_sums_duplicates_when_told_to() {
|
|
let scratch = Scratch::new("dup_sum");
|
|
let out_path = scratch.path("out.improv");
|
|
let out = run_import(
|
|
&colliding_csv(&scratch),
|
|
&out_path,
|
|
&["--on-duplicate", "sum"],
|
|
);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
assert!(doc.contains("= 30"), "{doc}");
|
|
assert!(!doc.contains("Occurrence"), "{doc}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_headless_import_keeps_duplicates_separate_when_told_to() {
|
|
let scratch = Scratch::new("dup_separate");
|
|
let out_path = scratch.path("out.improv");
|
|
let out = run_import(
|
|
&colliding_csv(&scratch),
|
|
&out_path,
|
|
&["--on-duplicate", "separate"],
|
|
);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
assert!(doc.contains("## Category: Occurrence"), "{doc}");
|
|
assert!(doc.contains("= 10") && doc.contains("= 20"), "{doc}");
|
|
assert!(!doc.contains("= 30"), "{doc}");
|
|
}
|
|
|
|
/// Dates across two years, one per month, so nothing collides.
|
|
fn dated_csv(scratch: &Scratch) -> PathBuf {
|
|
let csv = scratch.path("dated.csv");
|
|
std::fs::write(
|
|
&csv,
|
|
"Date,Revenue\n2024-01-15,1\n2024-04-02,10\n2025-01-03,100\n",
|
|
)
|
|
.unwrap();
|
|
csv
|
|
}
|
|
|
|
/// improvise-8jk6.3, headless: `--extract` builds ONE hierarchical category by
|
|
/// default, as the wizard does, and it is written with its `>` lines.
|
|
#[test]
|
|
fn a_headless_date_import_is_one_hierarchical_category() {
|
|
let scratch = Scratch::new("date_hierarchy");
|
|
let out_path = scratch.path("out.improv");
|
|
let out = run_import(
|
|
&dated_csv(&scratch),
|
|
&out_path,
|
|
&[
|
|
"--extract",
|
|
"Date:Year",
|
|
"--extract",
|
|
"Date:Quarter",
|
|
"--extract",
|
|
"Date:Month",
|
|
],
|
|
);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
assert!(!doc.contains("Date_Month"), "{doc}");
|
|
assert!(doc.contains("- |2024-01|[|2024-Q1|]"), "{doc}");
|
|
assert!(doc.contains("> |2024-Q1|[|2024|]"), "{doc}");
|
|
assert!(!doc.contains("expanded"), "it opens folded: {doc}");
|
|
}
|
|
|
|
/// The flat split stays available headless, as `s` makes it in the wizard.
|
|
#[test]
|
|
fn a_headless_date_import_splits_when_told_to() {
|
|
let scratch = Scratch::new("date_split");
|
|
let out_path = scratch.path("out.improv");
|
|
let out = run_import(
|
|
&dated_csv(&scratch),
|
|
&out_path,
|
|
&["--extract", "Date:Month", "--split-date", "Date"],
|
|
);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
assert!(doc.contains("## Category: Date_Month"), "{doc}");
|
|
assert!(doc.contains("2024-01-15"), "the raw dates stay: {doc}");
|
|
}
|
|
|
|
/// improvise-8jk6.3 review: a `--split-date` that names nothing splittable
|
|
/// said nothing and exited 0.
|
|
#[test]
|
|
fn split_date_naming_an_unknown_or_non_date_field_is_refused() {
|
|
let scratch = Scratch::new("split_date_unknown");
|
|
for field in ["Nope", "Revenue"] {
|
|
let out_path = scratch.path("out.improv");
|
|
let out = run_import(
|
|
&dated_csv(&scratch),
|
|
&out_path,
|
|
&["--extract", "Date:Month", "--split-date", field],
|
|
);
|
|
assert!(!out.status.success(), "{field}: {}", stderr_of(&out));
|
|
let err = stderr_of(&out);
|
|
assert!(err.contains("--split-date") && err.contains(field), "{err}");
|
|
}
|
|
}
|
|
|
|
/// improvise-8jk6.3 review: `--axis Date_Month:column` names a category the
|
|
/// hierarchical import no longer builds, and was dropped without a word.
|
|
#[test]
|
|
fn an_axis_naming_a_category_the_import_did_not_build_is_refused() {
|
|
let scratch = Scratch::new("axis_unknown");
|
|
let out_path = scratch.path("out.improv");
|
|
let out = run_import(
|
|
&dated_csv(&scratch),
|
|
&out_path,
|
|
&["--extract", "Date:Month", "--axis", "Date_Month:column"],
|
|
);
|
|
assert!(!out.status.success(), "{}", stderr_of(&out));
|
|
let err = stderr_of(&out);
|
|
assert!(
|
|
err.contains("Date_Month") && err.contains("--split-date"),
|
|
"{err}"
|
|
);
|
|
}
|
|
|
|
/// `--time` on a column with no date format makes it a time category without
|
|
/// dates, so there is nothing to split.
|
|
#[test]
|
|
fn split_date_on_a_time_field_with_no_date_format_is_refused() {
|
|
let scratch = Scratch::new("split_date_no_format");
|
|
let out_path = scratch.path("out.improv");
|
|
let out = run_import(
|
|
&dated_csv(&scratch),
|
|
&out_path,
|
|
&["--time", "Revenue", "--split-date", "Revenue"],
|
|
);
|
|
assert!(!out.status.success(), "{}", stderr_of(&out));
|
|
assert!(
|
|
stderr_of(&out).contains("--split-date"),
|
|
"{}",
|
|
stderr_of(&out)
|
|
);
|
|
}
|
|
|
|
/// A name that is no date part gets the categories that exist, never the date
|
|
/// hint (the README's old `--axis Measure:column` was this).
|
|
#[test]
|
|
fn an_absent_axis_category_that_is_no_date_part_names_what_was_built() {
|
|
let scratch = Scratch::new("axis_unknown_plain");
|
|
let out_path = scratch.path("out.improv");
|
|
let out = run_import(
|
|
&dated_csv(&scratch),
|
|
&out_path,
|
|
&["--axis", "Measure:column"],
|
|
);
|
|
assert!(!out.status.success(), "{}", stderr_of(&out));
|
|
let err = stderr_of(&out);
|
|
assert!(
|
|
err.contains("'Measure'") && err.contains("_Measure"),
|
|
"{err}"
|
|
);
|
|
assert!(!err.contains("--split-date"), "no date hint: {err}");
|
|
}
|
|
|
|
/// The README's import recipe runs as printed.
|
|
#[test]
|
|
fn the_readmes_import_recipe_runs() {
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
let readme = std::fs::read_to_string(root.join("README.md")).unwrap();
|
|
let start = readme.find("improvise import examples/demo.csv").unwrap();
|
|
let recipe = readme[start..]
|
|
.split("```")
|
|
.next()
|
|
.unwrap()
|
|
.replace("\\\n", " ");
|
|
let mut args: Vec<String> = vec![];
|
|
for (i, part) in recipe.split('"').enumerate() {
|
|
match i % 2 {
|
|
1 => args.push(part.to_string()),
|
|
_ => args.extend(part.split_whitespace().map(String::from)),
|
|
}
|
|
}
|
|
let at = args.iter().position(|a| a == "-o").unwrap();
|
|
args.truncate(at);
|
|
let scratch = Scratch::new("readme_recipe");
|
|
let out = Command::new(bin())
|
|
.arg("import")
|
|
.arg(root.join("examples/demo.csv"))
|
|
.args(args.iter().skip(3))
|
|
.arg("-o")
|
|
.arg(scratch.path("out.improv"))
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
}
|
|
|
|
/// code-maat's reports: three schemas, a stray note, and room for the output.
|
|
fn code_maat_folder(scratch: &Scratch) -> PathBuf {
|
|
let dir = scratch.path("maat");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
for (name, body) in [
|
|
(
|
|
"coupling.csv",
|
|
"entity,coupled,degree,average-revs\nsrc/a.rs,src/b.rs,80,5\n",
|
|
),
|
|
("age.csv", "entity,age-months\nsrc/a.rs,2\nsrc/b.rs,9\n"),
|
|
(
|
|
"abs-churn.csv",
|
|
"date,added,deleted,commits\n2026-01-02,10,3,2\n",
|
|
),
|
|
("README.txt", "not a report\n"),
|
|
] {
|
|
std::fs::write(dir.join(name), body).unwrap();
|
|
}
|
|
dir
|
|
}
|
|
|
|
/// improvise-lpzu: a folder argument is its `*.csv` files, merged sparsely and
|
|
/// in name order. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_headless_import_of_a_folder_merges_its_csv_files_and_repeats_byte_for_byte() {
|
|
let scratch = Scratch::new("folder_import");
|
|
let dir = code_maat_folder(&scratch);
|
|
let out_path = dir.join("maat.improv");
|
|
|
|
let first = run_import(&dir, &out_path, &[]);
|
|
assert!(first.status.success(), "{}", stderr_of(&first));
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
for line in [
|
|
"File=coupling, _Measure=degree, coupled=|src/b.rs|, entity=|src/a.rs| = 80",
|
|
"File=age, _Measure=age-months, entity=|src/b.rs| = 9",
|
|
"File=abs-churn, _Measure=added, date=|2026-01-02| = 10",
|
|
] {
|
|
assert!(doc.lines().any(|l| l == line), "{line} missing from\n{doc}");
|
|
}
|
|
assert!(!doc.contains("README"), "{doc}");
|
|
let views: Vec<&str> = doc.lines().filter(|l| l.starts_with("## View: ")).collect();
|
|
assert_eq!(
|
|
views,
|
|
[
|
|
"## View: Default",
|
|
"## View: abs-churn",
|
|
"## View: age",
|
|
"## View: coupling"
|
|
]
|
|
);
|
|
assert!(doc.lines().any(|l| l == "File: filter, coupling"), "{doc}");
|
|
assert!(doc.contains("Initial View: Default"), "{doc}");
|
|
|
|
// The output now sits in the folder; a second run must not read it.
|
|
let second = run_import(&dir, &out_path, &[]);
|
|
assert!(second.status.success(), "{}", stderr_of(&second));
|
|
assert_eq!(std::fs::read_to_string(&out_path).unwrap(), doc);
|
|
}
|
|
|
|
/// improvise-lpzu: a file and a folder together are one merge, and a folder
|
|
/// with nothing to import is named. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_headless_import_takes_files_and_folders_together_and_refuses_an_empty_folder() {
|
|
let scratch = Scratch::new("folder_mixed");
|
|
let dir = code_maat_folder(&scratch);
|
|
let extra = scratch.path("extra.csv");
|
|
std::fs::write(&extra, "entity,age-months\nsrc/z.rs,4\n").unwrap();
|
|
let out_path = scratch.path("out.improv");
|
|
|
|
let out = Command::new(bin())
|
|
.args(["import", "--no-wizard", "-o"])
|
|
.arg(&out_path)
|
|
.arg(&extra)
|
|
.arg(&dir)
|
|
.output()
|
|
.unwrap();
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
assert!(
|
|
doc.contains("File=extra, _Measure=age-months, entity=|src/z.rs| = 4"),
|
|
"{doc}"
|
|
);
|
|
assert!(doc.contains("File=coupling"), "{doc}");
|
|
|
|
let empty = scratch.path("empty");
|
|
std::fs::create_dir_all(&empty).unwrap();
|
|
let refused = run_import(&empty, &scratch.path("never.improv"), &[]);
|
|
assert!(!refused.status.success());
|
|
let err = stderr_of(&refused);
|
|
assert!(
|
|
err.contains(&format!("no CSV files in folder {}", empty.display())),
|
|
"{err}"
|
|
);
|
|
assert!(!scratch.path("never.improv").exists());
|
|
}
|
|
|
|
/// Runs `import <source> --no-wizard <extra> -o out.improv` on a source it writes.
|
|
fn import_of(scratch: &Scratch, name: &str, body: &str, extra: &[&str]) -> (Output, PathBuf) {
|
|
let source = scratch.path(name);
|
|
std::fs::write(&source, body).unwrap();
|
|
let out_path = scratch.path("out.improv");
|
|
(run_import(&source, &out_path, extra), out_path)
|
|
}
|
|
|
|
/// improvise-8jk6.4: a forced category's values are item names, so they keep
|
|
/// their source text. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_csv_column_forced_to_category_keeps_its_text_verbatim() {
|
|
let scratch = Scratch::new("forced_category_text");
|
|
let (out, out_path) = import_of(
|
|
&scratch,
|
|
"commits.csv",
|
|
"commit,lines\n00565796,3\n220944e2,5\n",
|
|
&["--category", "commit", "--measure", "lines"],
|
|
);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
assert!(
|
|
doc.lines().any(|l| l == "- |00565796|, |220944e2|"),
|
|
"the items are the source text:\n{doc}"
|
|
);
|
|
for line in [
|
|
"_Measure=lines, commit=|00565796| = 3",
|
|
"_Measure=lines, commit=|220944e2| = 5",
|
|
] {
|
|
assert!(doc.lines().any(|l| l == line), "{line} missing from\n{doc}");
|
|
}
|
|
}
|
|
|
|
/// improvise-8jk6.4: JSON has no source text for a number, so a forced
|
|
/// category names it as serde_json writes it. Evidence file.
|
|
#[test]
|
|
fn a_json_number_forced_to_category_is_named_as_json_writes_it() {
|
|
let scratch = Scratch::new("forced_category_json");
|
|
let (out, out_path) = import_of(
|
|
&scratch,
|
|
"commits.json",
|
|
r#"[{"commit": 42, "n": 1}, {"commit": 1.5, "n": 2}, {"commit": 1e3, "n": 3}]"#,
|
|
&["--category", "commit", "--measure", "n"],
|
|
);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
assert!(
|
|
doc.lines().any(|l| l == "- |42|, |1.5|, |1000.0|"),
|
|
"the items are the numbers' JSON text:\n{doc}"
|
|
);
|
|
}
|
|
|
|
/// improvise-8jk6.5: a formula the user named and that does not parse is a
|
|
/// refusal, not a silent drop. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn an_import_formula_that_does_not_parse_refuses_names_it_and_writes_nothing() {
|
|
let scratch = Scratch::new("bad_formula");
|
|
let (out, out_path) = import_of(
|
|
&scratch,
|
|
"f.csv",
|
|
"a,x\nk,1\n",
|
|
&[
|
|
"--measure",
|
|
"x",
|
|
"--formula",
|
|
"Y = x +",
|
|
"--formula",
|
|
"Z = x",
|
|
"--formula",
|
|
"W = * 2",
|
|
],
|
|
);
|
|
let err = stderr_of(&out);
|
|
assert!(!out.status.success(), "a dropped formula exited 0: {err}");
|
|
assert!(err.contains("'Y = x +'"), "names the formula: {err}");
|
|
assert!(err.contains("'W = * 2'"), "names every bad one: {err}");
|
|
assert!(err.contains("expected"), "carries the parse error: {err}");
|
|
assert!(!err.contains("'Z = x'"), "names only the bad one: {err}");
|
|
assert!(!out_path.exists(), "nothing is written");
|
|
}
|
|
|
|
/// improvise-8jk6.6: an import that kept no record says why, and exits
|
|
/// non-zero writing nothing. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn an_import_that_keeps_no_record_refuses_and_says_why() {
|
|
let scratch = Scratch::new("kept_none");
|
|
let (out, out_path) = import_of(&scratch, "r.json", r#"[1, 2, "three"]"#, &[]);
|
|
let err = stderr_of(&out);
|
|
assert!(!out.status.success(), "{err}");
|
|
assert!(
|
|
err.contains("all 3 records were skipped as not JSON objects"),
|
|
"says why: {err}"
|
|
);
|
|
assert!(!out_path.exists(), "nothing is written");
|
|
}
|
|
|
|
/// improvise-8jk6.6: a partial skip warns with a count and exits 0, a choice
|
|
/// put to the user. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn an_import_that_skips_some_records_warns_with_a_count_and_still_writes() {
|
|
let scratch = Scratch::new("kept_some");
|
|
let (out, out_path) = import_of(
|
|
&scratch,
|
|
"p.json",
|
|
r#"[{"a": "k", "x": 1}, 2, {"a": "m", "x": 4}]"#,
|
|
&[],
|
|
);
|
|
let err = stderr_of(&out);
|
|
assert!(out.status.success(), "{err}");
|
|
assert!(
|
|
err.contains("warning: Skipped 1 of 3 records that were not JSON objects"),
|
|
"{err}"
|
|
);
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
assert!(doc.contains("_Measure=x, a=m = 4"), "{doc}");
|
|
}
|
|
|
|
/// A JSON array with one stray scalar among its records.
|
|
const ONE_STRAY: &str = r#"[{"a": "k", "x": 1}, 2, {"a": "m", "x": 4}]"#;
|
|
|
|
/// improvise-8jk6.6, the user's ruling: `--strict` makes any skip a refusal
|
|
/// that writes nothing. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_strict_import_that_skips_a_record_refuses_and_writes_nothing() {
|
|
let scratch = Scratch::new("strict_skip");
|
|
let (out, out_path) = import_of(&scratch, "p.json", ONE_STRAY, &["--strict"]);
|
|
let err = stderr_of(&out);
|
|
assert!(!out.status.success(), "{err}");
|
|
assert!(
|
|
err.contains("--strict: Skipped 1 of 3 records that were not JSON objects"),
|
|
"{err}"
|
|
);
|
|
assert!(!out_path.exists(), "a strict refusal writes nothing");
|
|
}
|
|
|
|
/// The control: `--strict` refuses a skip, not every import.
|
|
#[test]
|
|
fn a_strict_import_that_skips_nothing_still_writes() {
|
|
let scratch = Scratch::new("strict_clean");
|
|
let body = r#"[{"a": "k", "x": 1}, {"a": "m", "x": 4}]"#;
|
|
let (out, out_path) = import_of(&scratch, "p.json", body, &["--strict"]);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
assert!(doc.contains("_Measure=x, a=m = 4"), "{doc}");
|
|
}
|
|
|
|
/// improvise-8jk6.17 (F3): a CSV decimal measure is its number, not dropped
|
|
/// text. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_csv_decimal_measure_imports_as_its_number() {
|
|
let scratch = Scratch::new("csv_decimal");
|
|
let body = "Region,Revenue\nEast,1000.50\nWest,-2.25\n";
|
|
let (out, out_path) = import_of(&scratch, "f.csv", body, &[]);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&out_path).unwrap();
|
|
for line in [
|
|
"Region=East, _Measure=Revenue = 1000.5",
|
|
"Region=West, _Measure=Revenue = -2.25",
|
|
] {
|
|
assert!(doc.lines().any(|l| l == line), "{line} missing from\n{doc}");
|
|
}
|
|
}
|
|
|
|
/// improvise-8jk6.17 (F1): `import-json` reads a CSV as CSV, so its numeric
|
|
/// column is a measure. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn import_json_on_a_csv_reads_its_numeric_column_as_a_measure() {
|
|
let scratch = Scratch::new("import_json_csv");
|
|
let csv = scratch.path("f.csv");
|
|
std::fs::write(&csv, "Region,Revenue\nEast,1000.50\nWest,7\n").unwrap();
|
|
let doc = scratch.path("m.improv");
|
|
let out = run_cmd(&[&format!("import-json {}", csv.display())], &doc);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let text = std::fs::read_to_string(&doc).unwrap();
|
|
assert!(!text.contains("## Category: Revenue"), "{text}");
|
|
for line in [
|
|
"Region=East, _Measure=Revenue = 1000.5",
|
|
"Region=West, _Measure=Revenue = 7",
|
|
] {
|
|
assert!(
|
|
text.lines().any(|l| l == line),
|
|
"{line} missing from\n{text}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// improvise-lpzu, the user's ruling: a folder always merges, so even one CSV
|
|
/// keeps its file identity. Evidence: `context/test-evidence/cli.md`.
|
|
#[test]
|
|
fn a_folder_of_one_csv_still_gets_a_file_category_and_its_view_but_a_lone_file_does_not() {
|
|
let scratch = Scratch::new("folder_single");
|
|
let dir = scratch.path("one");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let csv = dir.join("age.csv");
|
|
std::fs::write(&csv, "entity,age-months\nsrc/a.rs,2\n").unwrap();
|
|
|
|
let from_folder = scratch.path("folder.improv");
|
|
let out = run_import(&dir, &from_folder, &[]);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&from_folder).unwrap();
|
|
assert!(doc.contains("## Category: File\n- age\n"), "{doc}");
|
|
assert!(doc.lines().any(|l| l == "File: filter, age"), "{doc}");
|
|
assert!(
|
|
doc.contains("File=age, _Measure=age-months, entity=|src/a.rs| = 2"),
|
|
"{doc}"
|
|
);
|
|
|
|
let from_file = scratch.path("file.improv");
|
|
let out = run_import(&csv, &from_file, &[]);
|
|
assert!(out.status.success(), "{}", stderr_of(&out));
|
|
let doc = std::fs::read_to_string(&from_file).unwrap();
|
|
assert!(
|
|
!doc.contains("File"),
|
|
"a lone file keeps today's shape: {doc}"
|
|
);
|
|
}
|