177 lines
7.0 KiB
Rust
177 lines
7.0 KiB
Rust
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
use std::io::Write;
|
|
use std::path::PathBuf;
|
|
use std::process::{Command, Output, Stdio};
|
|
|
|
fn bin() -> Command {
|
|
Command::new(env!("CARGO_BIN_EXE_gridcraft-cli"))
|
|
}
|
|
|
|
fn run(args: &[&str]) -> Output {
|
|
bin().args(args).output().expect("run gridcraft-cli")
|
|
}
|
|
|
|
fn ok(args: &[&str]) -> String {
|
|
let o = run(args);
|
|
assert!(o.status.success(), "gridcraft-cli {args:?} failed: {}", String::from_utf8_lossy(&o.stderr));
|
|
String::from_utf8(o.stdout).unwrap()
|
|
}
|
|
|
|
fn tmpdir(name: &str) -> PathBuf {
|
|
let d = std::env::temp_dir().join(format!("gridcraft-cli-test-{}-{name}", std::process::id()));
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
#[test]
|
|
fn version_and_usage() {
|
|
assert_eq!(ok(&["--version"]).trim(), format!("gridcraft-cli {}", env!("CARGO_PKG_VERSION")));
|
|
assert_eq!(ok(&["version"]).trim(), format!("gridcraft-cli {}", env!("CARGO_PKG_VERSION")));
|
|
let o = run(&["frobnicate"]);
|
|
assert!(!o.status.success());
|
|
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown subcommand"));
|
|
let o = run(&[]);
|
|
assert!(!o.status.success());
|
|
}
|
|
|
|
#[test]
|
|
fn eval_formulas() {
|
|
assert_eq!(ok(&["eval", "=SUM(1,2,3)"]).trim(), "6");
|
|
assert_eq!(ok(&["eval", "1/4"]).trim(), "0.25");
|
|
assert_eq!(ok(&["eval", "=UPPER(\"abc\")"]).trim(), "ABC");
|
|
assert_eq!(ok(&["eval", "=1/0"]).trim(), "#DIV/0!");
|
|
assert_eq!(ok(&["eval", "=2>1", "--json"]).trim(), "true");
|
|
let o = run(&["eval", "=1", "--in", "/no/such/file.xlsx"]);
|
|
assert!(!o.status.success());
|
|
assert!(!String::from_utf8_lossy(&o.stderr).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn run_then_cat_and_info_on_sample() {
|
|
let dir = tmpdir("run");
|
|
let book = dir.join("budget.xlsx");
|
|
let book_s = book.to_str().unwrap();
|
|
let script = dir.join("steps.jsonl");
|
|
std::fs::write(
|
|
&script,
|
|
"# steps\n{\"command\":\"cell.set\",\"params\":{\"cell\":\"K2\",\"input\":\"=K1*2\"}}\nhome.bold {\"range\":\"K1:K2\"}\n",
|
|
)
|
|
.unwrap();
|
|
let out = ok(&[
|
|
"run",
|
|
"--sample",
|
|
"budget",
|
|
"--cmd",
|
|
r#"cell.set={"cell":"K1","input":"21"}"#,
|
|
"--script",
|
|
script.to_str().unwrap(),
|
|
"--out",
|
|
book_s,
|
|
"--print",
|
|
"K1:K2",
|
|
"--quiet",
|
|
]);
|
|
assert_eq!(out.split_whitespace().collect::<Vec<_>>(), ["21", "42"], "{out}");
|
|
assert!(book.exists());
|
|
|
|
let table = ok(&["cat", book_s, "--range", "K1:K2"]);
|
|
assert_eq!(table.split_whitespace().collect::<Vec<_>>(), ["21", "42"]);
|
|
let formulas = ok(&["cat", book_s, "--range", "K2", "--formulas"]);
|
|
assert_eq!(formulas.trim(), "=K1*2");
|
|
let whole = ok(&["cat", book_s]);
|
|
assert!(whole.lines().count() > 3, "{whole}");
|
|
|
|
let info = ok(&["info", book_s]);
|
|
assert!(info.contains("Budget"), "{info}");
|
|
let json: serde_json::Value = serde_json::from_str(&ok(&["info", book_s, "--json"])).unwrap();
|
|
assert!(json["sheets"][0]["cells"].as_u64().unwrap() > 10);
|
|
|
|
// A failing command gives a non-zero exit and names the step.
|
|
let o = run(&["run", "--cmd", "no.such.command"]);
|
|
assert!(!o.status.success());
|
|
assert!(String::from_utf8_lossy(&o.stderr).contains("no.such.command"));
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn convert_xlsx_csv_roundtrip() {
|
|
let dir = tmpdir("convert");
|
|
let xlsx = dir.join("in.xlsx");
|
|
let csv = dir.join("out.csv");
|
|
let back = dir.join("back.xlsx");
|
|
ok(&[
|
|
"run",
|
|
"--cmd",
|
|
r#"range.setValues={"range":"A1","values":[["Name","Qty"],["apple",3],["pear, green",4],["total","=SUM(B2:B3)"]]}"#,
|
|
"--out",
|
|
xlsx.to_str().unwrap(),
|
|
"--quiet",
|
|
]);
|
|
ok(&["convert", xlsx.to_str().unwrap(), csv.to_str().unwrap()]);
|
|
let text = std::fs::read_to_string(&csv).unwrap();
|
|
let lines: Vec<&str> = text.lines().collect();
|
|
assert_eq!(lines, ["Name,Qty", "apple,3", "\"pear, green\",4", "total,7"], "{text}");
|
|
ok(&["convert", csv.to_str().unwrap(), back.to_str().unwrap()]);
|
|
let again = ok(&["cat", back.to_str().unwrap(), "--csv"]);
|
|
assert_eq!(again.lines().collect::<Vec<_>>(), lines);
|
|
let o = run(&["convert", xlsx.to_str().unwrap(), dir.join("x.unknown").to_str().unwrap()]);
|
|
assert!(!o.status.success());
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|
|
|
|
#[test]
|
|
fn commands_and_functions() {
|
|
let out = ok(&["commands", "--search", "bold"]);
|
|
assert!(out.contains("home.bold"));
|
|
let v: serde_json::Value = serde_json::from_str(&ok(&["commands", "--json"])).unwrap();
|
|
assert!(v.as_array().unwrap().len() > 100);
|
|
assert!(ok(&["functions", "--search", "vlookup"]).contains("VLOOKUP"));
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_over_stdio() {
|
|
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
|
{
|
|
let stdin = child.stdin.as_mut().unwrap();
|
|
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
|
writeln!(stdin, r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"#).unwrap();
|
|
writeln!(
|
|
stdin,
|
|
r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"evaluate_formula","arguments":{{"formula":"=6*7"}}}}}}"#
|
|
)
|
|
.unwrap();
|
|
}
|
|
let out = child.wait_with_output().unwrap();
|
|
assert!(out.status.success());
|
|
let text = String::from_utf8(out.stdout).unwrap();
|
|
let lines: Vec<serde_json::Value> = text.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
|
assert_eq!(lines.len(), 2, "{text}");
|
|
assert_eq!(lines[0]["result"]["serverInfo"]["name"], "gridcraft");
|
|
assert!(lines[1]["result"]["content"][0]["text"].as_str().unwrap().contains("42"));
|
|
}
|
|
|
|
#[test]
|
|
fn check_exit_codes_and_json() {
|
|
let dir = tmpdir("check");
|
|
let book = dir.join("calc.xlsx");
|
|
let book_s = book.to_str().unwrap();
|
|
ok(&["run", "--cmd", r#"cell.set={"cell":"A1","input":"2"}"#, "--cmd", r#"cell.set={"cell":"B1","input":"=A1*3"}"#, "--out", book_s, "--quiet"]);
|
|
// GridCraft's own file: its cached values are its own results, and it round-trips.
|
|
let o = run(&["check", dir.to_str().unwrap()]);
|
|
let out = String::from_utf8_lossy(&o.stdout);
|
|
assert_eq!(o.status.code(), Some(0), "{out}");
|
|
assert!(out.contains("1 checked: 1 matched, 0 mismatched"), "{out}");
|
|
let v: serde_json::Value = serde_json::from_str(&ok(&["check", book_s, "--json"])).unwrap();
|
|
assert_eq!(v["schema"], "gridcraft-check/1");
|
|
assert_eq!(v["totals"]["matched"], 1);
|
|
// A file that can't be opened is reported, not fatal, and fails the check.
|
|
let o = run(&["check", book_s, "/no/such/file.xlsx"]);
|
|
assert_eq!(o.status.code(), Some(1));
|
|
assert!(String::from_utf8_lossy(&o.stdout).contains("1 failed to open"));
|
|
// Usage errors exit 2.
|
|
assert_eq!(run(&["check"]).status.code(), Some(2));
|
|
assert_eq!(run(&["check", book_s, "--tolerance", "x"]).status.code(), Some(2));
|
|
assert_eq!(run(&["check", book_s, "--bogus"]).status.code(), Some(2));
|
|
}
|