5bded0ca83
Final iteration of the run-registry cycle (#33): the read surface that makes a sweep family — and runs across separate invocations — comparable over time (C18's "compare experiments over time", which has no home in git or Gitea). - `aura sweep` now persists each point's RunReport to runs/runs.jsonl (append- only, one serde_json line per run) in addition to printing it. - `aura runs list` prints every stored record, in store order. - `aura runs rank <metric>` prints the stored runs best-first by the metric (total_pips desc; max_drawdown / exposure_sign_flips asc); an unknown metric is a usage error (exit 2), like every other aura arg error. sweep_report splits into a production sweep_family() (the pure run) and a #[cfg(test)] renderer kept for the in-bin goldens; the dispatch gains run_sweep / runs_list / runs_rank over default_registry() (runs/runs.jsonl under cwd). Process goldens run the binary in a temp cwd so persistence never dirties the repo; /runs/ is git-ignored as a backstop. Verified end-to-end: two `aura sweep` invocations accumulate 8 records; `aura runs list` shows all 8; `aura runs rank total_pips` orders them best-first; `aura runs rank bogus` exits 2 with the known-metrics hint. cargo test --workspace green (cli_run 11, registry 5, engine 93, …); clippy --workspace --all-targets -D warnings clean; no stray runs/ in the work tree. refs #33
282 lines
13 KiB
Rust
282 lines
13 KiB
Rust
//! Integration test: drive the built `aura` binary as a downstream user would,
|
|
//! asserting the `run` subcommand's stdout/exit contract and the bad-args path.
|
|
|
|
use std::process::Command;
|
|
|
|
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
|
|
/// crate; the binary is named `aura` in `Cargo.toml`).
|
|
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
|
|
|
/// A fresh, unique working directory for a process test that persists run
|
|
/// records (so `aura sweep`'s `./runs/runs.jsonl` never dirties the repo).
|
|
/// Unique per test + per process; no external tempfile dependency.
|
|
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn run_prints_json_and_exits_zero() {
|
|
let out = Command::new(BIN).arg("run").output().expect("spawn aura run");
|
|
assert!(out.status.success(), "exit status: {:?}", out.status);
|
|
|
|
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
|
// exactly one line (the JSON object + a trailing newline from println!).
|
|
assert_eq!(stdout.lines().count(), 1, "stdout was: {stdout:?}");
|
|
let line = stdout.trim_end();
|
|
|
|
// canonical cycle-0009 JSON shape: nested manifest + metrics, stable keys.
|
|
// The manifest leads with a `commit` field; its value is the build's git
|
|
// identity (asserted by `run_manifest_commit_carries_real_git_head`), so
|
|
// this shape check pins only the surrounding structure, not the value.
|
|
assert!(line.starts_with("{\"manifest\":{\"commit\":\""), "got: {line}");
|
|
assert!(line.contains("\"broker\":\"sim-optimal(pip_size=0.0001)\""), "got: {line}");
|
|
assert!(line.contains("\"window\":[1,7]"), "got: {line}");
|
|
assert!(line.contains("\"metrics\":{\"total_pips\":"), "got: {line}");
|
|
// the integer sign-flip count is stable across float renderings.
|
|
assert!(line.ends_with("\"exposure_sign_flips\":1}}"), "got: {line}");
|
|
}
|
|
|
|
/// Property: the RunManifest is self-identifying — its `commit` carries the
|
|
/// real code identity that produced the run, not a placeholder. C8/C18 audit
|
|
/// trail (this run = this commit): once runs are archived, `manifest.commit`
|
|
/// must point back at the source. The build captures the current git HEAD at
|
|
/// compile time; the `"unknown"` literal survives only when there is no git.
|
|
///
|
|
/// Structural assertion (not exact-equality) on purpose: the working tree may
|
|
/// be dirty when this runs (e.g. this very test file uncommitted), so a build
|
|
/// that appends a `-dirty` suffix would not equal `rev-parse HEAD` verbatim.
|
|
/// The honest contract is: the field CONTAINS the current HEAD sha AND is not
|
|
/// the bare `"unknown"` placeholder — true regardless of dirtiness/suffix.
|
|
#[test]
|
|
fn run_manifest_commit_carries_real_git_head() {
|
|
// The current HEAD sha, obtained the same way the build is expected to.
|
|
let head = Command::new("git")
|
|
.args(["rev-parse", "HEAD"])
|
|
.output()
|
|
.expect("spawn git rev-parse HEAD");
|
|
assert!(head.status.success(), "git rev-parse HEAD failed");
|
|
let head_sha = String::from_utf8(head.stdout).expect("utf-8 sha");
|
|
let head_sha = head_sha.trim();
|
|
assert!(!head_sha.is_empty(), "empty HEAD sha");
|
|
|
|
// Drive the binary and pull `manifest.commit` out of the single JSON line.
|
|
let out = Command::new(BIN).arg("run").output().expect("spawn aura run");
|
|
assert!(out.status.success(), "exit status: {:?}", out.status);
|
|
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
|
let line = stdout.trim_end();
|
|
|
|
// Locate the `"commit":"<value>"` value without a JSON dependency (the
|
|
// sibling tests parse this output by substring too).
|
|
let key = "\"commit\":\"";
|
|
let start = line.find(key).expect("manifest has a commit field") + key.len();
|
|
let rest = &line[start..];
|
|
let end = rest.find('"').expect("commit field is a closed string");
|
|
let commit = &rest[..end];
|
|
|
|
assert_ne!(
|
|
commit, "unknown",
|
|
"manifest.commit is still the placeholder: {line}"
|
|
);
|
|
assert!(
|
|
commit.contains(head_sha),
|
|
"manifest.commit {commit:?} should contain the current HEAD sha {head_sha:?}; line: {line}"
|
|
);
|
|
}
|
|
|
|
/// Property: `aura run` is strict about its argument vector — a trailing
|
|
/// unexpected token (e.g. a typo'd flag like `aura run --sweep`) takes the
|
|
/// error path (usage on stderr, exit 2), never a silent full run. This is the
|
|
/// strict reading ratified in #16: keying only on the first token being `run`
|
|
/// and ignoring the rest would let a typo masquerade as a successful run.
|
|
/// The same test pins positive-preservation — bare `aura run` still emits the
|
|
/// JSON report on stdout and exits 0 — so the strict guard cannot regress the
|
|
/// happy path.
|
|
#[test]
|
|
fn run_with_trailing_token_is_strict_and_exits_two() {
|
|
// Negative: an unexpected trailing token is rejected like a bad-args call.
|
|
let out = Command::new(BIN)
|
|
.args(["run", "extra-garbage"])
|
|
.output()
|
|
.expect("spawn aura run extra-garbage");
|
|
assert_eq!(
|
|
out.status.code(),
|
|
Some(2),
|
|
"`aura run extra-garbage` must reject the trailing token: {:?}",
|
|
out.status
|
|
);
|
|
assert!(
|
|
out.stdout.is_empty(),
|
|
"stdout should be empty on the usage path, got: {:?}",
|
|
String::from_utf8_lossy(&out.stdout)
|
|
);
|
|
let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
|
|
assert!(stderr.contains("usage"), "stderr was: {stderr:?}");
|
|
|
|
// Positive-preservation: bare `aura run` still runs and prints the report.
|
|
let ok = Command::new(BIN).arg("run").output().expect("spawn aura run");
|
|
assert_eq!(
|
|
ok.status.code(),
|
|
Some(0),
|
|
"bare `aura run` must still succeed: {:?}",
|
|
ok.status
|
|
);
|
|
let ok_stdout = String::from_utf8(ok.stdout).expect("utf-8 stdout");
|
|
assert!(
|
|
ok_stdout.trim_start().starts_with("{\"manifest\":"),
|
|
"bare `aura run` should print the JSON report, got: {ok_stdout:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_args_prints_usage_and_exits_two() {
|
|
let out = Command::new(BIN).output().expect("spawn aura");
|
|
assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
|
|
assert!(out.stdout.is_empty(), "stdout should be empty on the usage path");
|
|
let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
|
|
assert!(stderr.contains("usage"), "stderr was: {stderr:?}");
|
|
}
|
|
|
|
/// Property: `--help`/`-h` is the success-path help affordance, not the error
|
|
/// path. A newcomer's reflex first command (C22 first-contact ergonomics, #20)
|
|
/// prints the usage to stdout and exits 0; the conventional `-h` alias behaves
|
|
/// identically. An *unknown* subcommand keeps the error path (exit 2) so the
|
|
/// help fix does not regress bad-args handling.
|
|
#[test]
|
|
fn help_flag_prints_usage_to_stdout_and_exits_zero() {
|
|
for flag in ["--help", "-h"] {
|
|
let out = Command::new(BIN).arg(flag).output().expect("spawn aura --help");
|
|
assert_eq!(out.status.code(), Some(0), "`aura {flag}` exit: {:?}", out.status);
|
|
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
|
assert!(
|
|
!stdout.trim().is_empty(),
|
|
"`aura {flag}` should print help to stdout, got: {stdout:?}"
|
|
);
|
|
// The usage names the only subcommand a newcomer can run.
|
|
assert!(
|
|
stdout.contains("run"),
|
|
"`aura {flag}` help should mention the `run` subcommand, got: {stdout:?}"
|
|
);
|
|
}
|
|
|
|
// Negative-preservation: an unknown subcommand is still the error path.
|
|
let out = Command::new(BIN).arg("frobnicate").output().expect("spawn aura frobnicate");
|
|
assert_eq!(
|
|
out.status.code(),
|
|
Some(2),
|
|
"unknown subcommand must stay exit 2: {:?}",
|
|
out.status
|
|
);
|
|
}
|
|
|
|
/// Property: `aura graph` emits a single self-contained HTML page — the
|
|
/// interactive pin-graph viewer with the deterministic model inlined and the
|
|
/// vendored Graphviz-WASM + pan-zoom inlined (no remote fetch). Driven through
|
|
/// the built binary so it pins the observable CLI contract. The DOT/SVG are
|
|
/// Graphviz-version-dependent and not asserted (the prototype is the by-hand
|
|
/// visual reference); this pins the page envelope + the retirement of the old
|
|
/// ascii-dag notation.
|
|
#[test]
|
|
fn graph_emits_self_contained_html_viewer() {
|
|
let out = Command::new(BIN).arg("graph").output().expect("spawn aura graph");
|
|
assert_eq!(out.status.code(), Some(0), "`aura graph` exit: {:?}", out.status);
|
|
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
|
// a self-contained HTML document...
|
|
assert!(
|
|
stdout.trim_start().starts_with("<!doctype html>"),
|
|
"not an HTML doc: {:?}",
|
|
&stdout[..stdout.len().min(80)]
|
|
);
|
|
// ...with the deterministic model inlined as the viewer's data source...
|
|
assert!(stdout.contains("window.AURA_MODEL = {\"root\":"), "model not inlined: {stdout:?}");
|
|
// ...the Graphviz-WASM bootstrap present, and no remote asset fetch.
|
|
assert!(stdout.contains("Viz.instance"), "viewer bootstrap missing");
|
|
assert!(!stdout.contains("<script src"), "assets must be inlined, found remote <script src>");
|
|
// the retired ascii-dag invented notation is gone.
|
|
assert!(!stdout.contains("Sub(#Sf,#Ss)"), "retired ascii-dag notation must be gone: {stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn sweep_prints_four_json_lines_and_exits_zero() {
|
|
let cwd = temp_cwd("sweep4");
|
|
let out = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn aura sweep");
|
|
assert!(out.status.success(), "exit status: {:?}", out.status);
|
|
|
|
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
|
assert_eq!(stdout.lines().count(), 4, "stdout was: {stdout:?}");
|
|
let first = stdout.lines().next().unwrap();
|
|
// each line is a full RunReport; commit is the real git HEAD (volatile), so
|
|
// pin the first odometer point's manifest params, not the commit value.
|
|
assert!(first.starts_with("{\"manifest\":{\"commit\":\""), "got: {stdout}");
|
|
assert!(
|
|
first.contains("\"params\":{\"sma_cross.fast\":2,\"sma_cross.slow\":4,\"scale\":0.5}"),
|
|
"got: {stdout}"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&cwd);
|
|
}
|
|
|
|
#[test]
|
|
fn sweep_with_trailing_token_is_strict_and_exits_two() {
|
|
// strict-arg reading (#16): an unexpected trailing token is a usage error.
|
|
let out = Command::new(BIN).args(["sweep", "extra"]).output().expect("spawn aura");
|
|
assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
|
|
}
|
|
|
|
#[test]
|
|
fn runs_list_and_rank_across_invocations() {
|
|
let cwd = temp_cwd("runs-flow");
|
|
// two sweeps accumulate 8 records over time
|
|
for _ in 0..2 {
|
|
let out = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn sweep");
|
|
assert!(out.status.success(), "sweep exit: {:?}", out.status);
|
|
}
|
|
|
|
// `runs list` shows all 8 stored records
|
|
let list = Command::new(BIN).args(["runs", "list"]).current_dir(&cwd).output().expect("spawn list");
|
|
assert!(list.status.success(), "list exit: {:?}", list.status);
|
|
let list_out = String::from_utf8(list.stdout).expect("utf-8");
|
|
assert_eq!(list_out.lines().count(), 8, "list: {list_out:?}");
|
|
assert!(list_out.lines().all(|l| l.starts_with("{\"manifest\":{")), "list shape: {list_out:?}");
|
|
|
|
// `runs rank total_pips`: 8 lines, highest total_pips first
|
|
let rank = Command::new(BIN).args(["runs", "rank", "total_pips"]).current_dir(&cwd).output().expect("spawn rank");
|
|
assert!(rank.status.success(), "rank exit: {:?}", rank.status);
|
|
let rank_out = String::from_utf8(rank.stdout).expect("utf-8");
|
|
assert_eq!(rank_out.lines().count(), 8, "rank: {rank_out:?}");
|
|
let pips: Vec<f64> = rank_out
|
|
.lines()
|
|
.map(|l| {
|
|
let key = "\"total_pips\":";
|
|
let start = l.find(key).expect("line has total_pips") + key.len();
|
|
let tail = &l[start..];
|
|
let end = tail.find([',', '}']).expect("token end");
|
|
tail[..end].parse().expect("total_pips is an f64")
|
|
})
|
|
.collect();
|
|
assert!(pips.windows(2).all(|w| w[0] >= w[1]), "rank not descending: {pips:?}");
|
|
|
|
// unknown metric is a usage error
|
|
let bogus = Command::new(BIN).args(["runs", "rank", "bogus"]).current_dir(&cwd).output().expect("spawn bogus");
|
|
assert_eq!(bogus.status.code(), Some(2), "bogus exit: {:?}", bogus.status);
|
|
|
|
let _ = std::fs::remove_dir_all(&cwd);
|
|
}
|
|
|
|
#[test]
|
|
fn runs_list_on_empty_registry_is_zero_lines_exit_zero() {
|
|
let cwd = temp_cwd("runs-empty");
|
|
let out = Command::new(BIN).args(["runs", "list"]).current_dir(&cwd).output().expect("spawn list");
|
|
assert!(out.status.success(), "list exit: {:?}", out.status);
|
|
assert_eq!(out.stdout.len(), 0, "expected no output, got: {:?}", out.stdout);
|
|
let _ = std::fs::remove_dir_all(&cwd);
|
|
}
|
|
|
|
#[test]
|
|
fn runs_bare_subcommand_is_strict_and_exits_two() {
|
|
// `aura runs` with no/unknown sub-subcommand is a usage error (#16 strict).
|
|
let out = Command::new(BIN).arg("runs").output().expect("spawn aura runs");
|
|
assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
|
|
}
|