//! 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("\"bias_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 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 run_real_no_geometry_symbol_refuses_with_exit_1() { // The geometry-sidecar lookup precedes any bar-data access, so a symbol with no // recorded geometry refuses with NO local data required (CI-safe). "NONEXISTENT" // has no recorded geometry on any host. let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "--real", "NONEXISTENT"]) .output() .expect("spawn aura"); assert_eq!(out.status.code(), Some(1), "expected exit 1"); let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("no recorded geometry for symbol 'NONEXISTENT'"), "expected the per-instrument-pip refusal message, got: {stderr}" ); } /// Property: the pip-refusal is sourced ONLY from recorded provider geometry — the /// removed authored instrument floor (the hand-authored "vetted" table) no /// longer participates in the refusal path. The cycle-0074 headline is the floor's /// REMOVAL, not a rename; the observable signature is that the refusal message /// never points the user at a hand-authored table to extend. A regression that /// re-introduced an authored fallback (or restored the old "add it to the /// instrument table" wording) would re-leak this vocabulary. Asserts the OLD /// authored-floor phrasing is ABSENT — the negative complement to the positive /// "no recorded geometry" assertion above. Archive-independent: "NONEXISTENT" has /// no sidecar on any host, so the refusal fires without local data. #[test] fn run_real_refusal_names_no_authored_floor() { let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "--real", "NONEXISTENT"]) .output() .expect("spawn aura"); assert_eq!(out.status.code(), Some(1), "expected exit 1"); let stderr = String::from_utf8_lossy(&out.stderr); assert!( !stderr.contains("instrument table"), "refusal must not point at a removed authored table, got: {stderr}" ); assert!( !stderr.contains("vetted"), "refusal must not use the removed vetted-floor vocabulary, got: {stderr}" ); assert!( !stderr.contains("instrument spec"), "refusal must not reference the removed authored instrument floor, got: {stderr}" ); } /// Property: the un-specced-symbol refusal is a CLEAN refusal — it emits NOTHING /// on stdout, never a partial `RunReport`. The #22 acceptance is "refuse INSTEAD /// of emitting nonsense": the pip lookup precedes harness construction and data /// access, so a `None` spec must short-circuit to `exit(1)` before any JSON line /// can reach stdout. A regression that moved the lookup after a partial run would /// leak a `{"manifest":...}` line here while still exiting non-zero; this pins /// that it cannot. Archive-independent (the lookup never touches local data). #[test] fn run_real_no_geometry_symbol_emits_no_stdout_report() { let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "--real", "NONEXISTENT"]) .output() .expect("spawn aura"); assert_eq!(out.status.code(), Some(1), "expected exit 1: {:?}", out.status); assert!( out.stdout.is_empty(), "the refusal must not leak a partial report to stdout, got: {:?}", String::from_utf8_lossy(&out.stdout) ); } /// Property: the pip-honesty refusal keys on the SYMBOL having no recorded geometry, /// not on `--real` being taken at all. A symbol *with* a recorded sidecar (`EURUSD`) /// gets PAST the geometry lookup, so it never produces the pip-refusal message — it /// either runs to a report (local data present) or hits the DISTINCT no-local-data /// usage path ("no local data for symbol"). Either way the "no recorded geometry" /// string is absent. This distinguishes the per-instrument-pip honesty lever from /// the unrelated arg-grammar / no-data error paths, and is archive-independent /// (asserts only on the absence of the pip-refusal string, true whether or not /// EURUSD data is on the machine). #[test] fn run_real_sidecar_symbol_never_hits_the_pip_refusal() { let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "--real", "EURUSD"]) .output() .expect("spawn aura"); let stderr = String::from_utf8_lossy(&out.stderr); assert!( !stderr.contains("no recorded geometry"), "a symbol with a recorded sidecar must clear the pip lookup, never the pip-refusal; stderr: {stderr}" ); // It resolves to exactly one of the two legitimate outcomes: a clean report // (exit 0, data present) or the distinct no-local-data refusal (exit 1). match out.status.code() { Some(0) => assert!( String::from_utf8_lossy(&out.stdout) .trim_start() .starts_with("{\"manifest\":"), "exit 0 must carry a JSON report, got: {:?}", String::from_utf8_lossy(&out.stdout) ), Some(1) => assert!( stderr.contains("no local data for symbol 'EURUSD'"), "exit 1 for a symbol with a recorded sidecar must be the no-data path, got: {stderr}" ), other => panic!("unexpected exit for a real run with a recorded sidecar: {other:?}; stderr: {stderr}"), } } /// Property: a recorded-sidecar symbol threads its looked-up pip all the way to the /// binary's emitted manifest — `aura run --real GER40` renders the INDEX pip /// `sim-optimal(pip_size=1)`, not the FX `0.0001` literal the synthetic path uses. /// This is the #22 headline at the built-binary boundary (criterion 2: GER40=1.0 /// vs EURUSD=0.0001 vs no-geometry→refuse): the metadata channel reaches stdout, so /// equity is honestly scaled per instrument. Gated on local GER40 data — skip /// cleanly when the archive is absent (the project's skip-on-no-data convention), /// so it never fails on a data-less machine; the no-data path is the distinct /// "no local data" refusal (exit 1), not the pip-refusal, asserted above. #[test] fn run_real_sidecar_index_pip_reaches_the_emitted_manifest() { // The GER40 Sept-2024 window (inclusive Unix-ms), the same calendar // month the gated ingest path drives; bounding it keeps the run fast. const FROM_MS: &str = "1725148800000"; const TO_MS: &str = "1727740799999"; let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "--real", "GER40", "--from", FROM_MS, "--to", TO_MS]) .output() .expect("spawn aura"); // Skip on a data-less machine: a recorded-sidecar symbol with no local data takes // the distinct no-data path (exit 1, "no local data"), never the pip-refusal. if out.status.code() == Some(1) { let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("no local data for symbol 'GER40'"), "exit 1 for GER40 must be the no-data path, got: {stderr}" ); eprintln!("skip: no local GER40 data"); return; } assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); let line = stdout.trim_end(); // the looked-up index pip (1.0 -> "1") reaches the manifest, not the FX 0.0001. assert!( line.contains("\"broker\":\"sim-optimal(pip_size=1)\""), "GER40 must render the index pip in the manifest, got: {line}" ); } /// Property: the authored instrument table no longer gates real runs — a symbol /// absent from the (removed) vetted floor but carrying a recorded geometry sidecar /// resolves its pip from the sidecar and runs. USDJPY (never vetted; JPY pip 0.01) /// renders the JPY pip in the manifest, proving the pip is sourced from the provider /// geometry, not a hand-authored table entry. Gated: skips cleanly when USDJPY has /// no local geometry/data (no sidecar → "no recorded geometry"; sidecar but no bars /// → "no local data"). #[test] fn run_real_nonvetted_symbol_resolves_pip_from_sidecar() { // FX trades continuously; the GER40 Sept-2024 window also has USDJPY bars. const FROM_MS: &str = "1725148800000"; const TO_MS: &str = "1727740799999"; let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "--real", "USDJPY", "--from", FROM_MS, "--to", TO_MS]) .output() .expect("spawn aura"); // Skip on a host without USDJPY geometry/data — either refusal is a clean skip, // sourced from recorded geometry, never an authored-table miss. if out.status.code() == Some(1) { let stderr = String::from_utf8_lossy(&out.stderr); if stderr.contains("no recorded geometry") || stderr.contains("no local data") { eprintln!("skip: no local USDJPY geometry/data"); return; } } assert_eq!(out.status.code(), Some(0), "USDJPY should resolve and run; exit: {:?}, stderr: {}", out.status, String::from_utf8_lossy(&out.stderr)); let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); assert!( stdout.contains("pip_size=0.01"), "USDJPY must render the JPY sidecar pip (0.01) in the manifest, got: {}", stdout.trim_end() ); } /// `aura run ` loads a serialized signal blueprint and runs it /// end-to-end (#165): exit 0, a RunReport whose manifest carries the topology_hash. #[test] fn aura_run_loads_and_runs_a_blueprint_file() { let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "tests/fixtures/sma_signal.json"]) .output() .expect("spawn aura run blueprint"); assert_eq!( out.status.code(), Some(0), "exit: {:?} stderr={}", out.status, String::from_utf8_lossy(&out.stderr) ); let stdout = String::from_utf8(out.stdout).expect("utf-8"); assert!(stdout.contains("\"topology_hash\":\""), "manifest carries the topology hash: {stdout}"); } /// A blueprint referencing a node type outside the closed vocabulary fails clean /// at load (the #160 closed-set guard at the data-plane face; invariant 9). #[test] fn aura_run_rejects_an_unknown_node_blueprint() { let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["run", "tests/fixtures/unknown_node.json"]) .output() .expect("spawn aura run unknown"); assert_ne!(out.status.code(), Some(0), "an unknown node type must fail the run"); let stderr = String::from_utf8(out.stderr).expect("utf-8"); assert!(stderr.contains("UnknownNodeType"), "names the cause: {stderr}"); } /// Property (#175, refuse-don't-guess): built-in-only flags on `aura run /// ` are REFUSED, not silently dropped. The loaded-blueprint /// grammar accepts only `--params`/`--seed`/`--real`/`--from`/`--to`; the removed /// hand-parser rejected any other flag (exit 2). clap's optional `[blueprint]` /// positional makes `--trace` and the cost flags structurally parseable, so the /// dispatch guard must re-reject them — mirroring the sweep/mc blueprint branches, /// which reject their non-branch flags exhaustively. A regression that dropped the /// guard would silently no-op a user-provided flag. #[test] fn run_blueprint_rejects_builtin_only_flags_exit_two() { for extra in [ &["--trace", "foo"][..], &["--cost-per-trade", "1"][..], &["--slip-vol-mult", "1"][..], &["--carry-per-cycle", "1"][..], ] { let mut args = vec!["run", "tests/fixtures/sma_signal.json"]; args.extend_from_slice(extra); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(&args) .output() .expect("spawn aura run blueprint + builtin-only flag"); assert_eq!( out.status.code(), Some(2), "{args:?} must be refused (exit 2), got {:?}; stderr: {}", out.status, String::from_utf8_lossy(&out.stderr) ); assert!( out.stdout.is_empty(), "{args:?} must not emit a report on stdout: {:?}", String::from_utf8_lossy(&out.stdout) ); } } #[test] fn run_trace_persists_taps_and_plain_run_writes_no_traces() { let cwd = temp_cwd("run-trace"); // plain `aura run` (no --trace) persists nothing to disk. let plain = Command::new(BIN).arg("run").current_dir(&cwd).output().expect("spawn aura run"); assert!(plain.status.success(), "plain run exit: {:?}", plain.status); assert!(!cwd.join("runs/traces").exists(), "plain run must write no trace files"); // `aura run --trace demo` persists the two taps + index, and still prints the report. let traced = Command::new(BIN) .args(["run", "--trace", "demo"]) .current_dir(&cwd) .output() .expect("spawn aura run --trace"); assert!(traced.status.success(), "traced run exit: {:?}", traced.status); let stdout = String::from_utf8(traced.stdout).expect("utf-8 stdout"); assert!(stdout.trim_end().starts_with("{\"manifest\":{"), "report still printed: {stdout:?}"); assert!(cwd.join("runs/traces/demo/index.json").exists(), "index.json written"); assert!(cwd.join("runs/traces/demo/equity.json").exists(), "equity tap written"); assert!(cwd.join("runs/traces/demo/exposure.json").exists(), "exposure tap written"); let _ = std::fs::remove_dir_all(&cwd); } /// Property: a persisted `.json` is the **columnar SoA form** a downstream /// chart can consume, not an opaque blob — it carries the four /// `tap`/`kinds`/`ts`/`columns` keys, the equity tap is kind-tagged `["F64"]` /// (C7: the base type survives on disk), and the SoA invariant holds: `ts` has one /// entry per recorded cycle and EVERY column has exactly that many entries (parallel /// arrays, one timestamp per row). This is acceptance criterion 2 at the built-binary /// file boundary — the unit round-trip pins the in-memory transpose, but only an /// end-to-end run pins that the bytes actually written to disk hold that shape. /// Asserts on structure + array-length parity, never on the exact float values /// (pip equity's low bits vary build-to-build), so it stays deterministic. #[test] fn run_trace_persists_columnar_soa_shape_on_disk() { let cwd = temp_cwd("run-trace-shape"); let out = Command::new(BIN) .args(["run", "--trace", "demo"]) .current_dir(&cwd) .output() .expect("spawn aura run --trace"); assert!(out.status.success(), "traced run exit: {:?}", out.status); let equity = std::fs::read_to_string(cwd.join("runs/traces/demo/equity.json")) .expect("read equity.json"); // The four SoA keys are present, equity is f64-kind-tagged, and the parallel // arrays open as expected (the synthetic sample harness has a single column). assert!(equity.contains("\"tap\":\"equity\""), "tap name missing: {equity}"); assert!(equity.contains("\"kinds\":[\"F64\"]"), "F64 kind tag missing: {equity}"); assert!(equity.contains("\"ts\":["), "ts axis missing: {equity}"); assert!(equity.contains("\"columns\":[["), "columns array missing: {equity}"); // SoA invariant: one timestamp per row, and every column has exactly that many // entries. Count the ts entries (the synthetic window is 7 cycles) and the // single column's entries by their comma-separated cardinality. let ts_body = json_array_body(&equity, "\"ts\":["); let col_body = json_array_body(&equity, "\"columns\":[["); let ts_len = ts_body.split(',').count(); let col_len = col_body.split(',').count(); assert_eq!(ts_len, 7, "synthetic run records 7 cycles; ts was: {ts_body:?}"); assert_eq!( col_len, ts_len, "SoA parity broken: ts has {ts_len} entries but the column has {col_len}; file: {equity}" ); let _ = std::fs::remove_dir_all(&cwd); } /// Property: `index.json` is the run's **table of contents** — it carries the run's /// `RunManifest` (so a chart page can show run context: commit, broker, window) and /// the tap names in a **deterministic order** (`["equity","exposure"]`). The serve /// half (`aura chart`, iteration 2) reads taps in this index order; a regression that /// dropped the manifest, or made the tap list order-unstable, would silently corrupt /// the chart header / the per-series mapping. Asserts on the manifest's structural /// presence (the commit value is the volatile git HEAD, pinned elsewhere) and the /// exact deterministic tap order. #[test] fn run_trace_index_carries_manifest_and_ordered_tap_list() { let cwd = temp_cwd("run-trace-index"); let out = Command::new(BIN) .args(["run", "--trace", "demo"]) .current_dir(&cwd) .output() .expect("spawn aura run --trace"); assert!(out.status.success(), "traced run exit: {:?}", out.status); let index = std::fs::read_to_string(cwd.join("runs/traces/demo/index.json")) .expect("read index.json"); // The manifest is embedded with its identifying context for the chart header. assert!(index.contains("\"manifest\":{\"commit\":\""), "manifest missing: {index}"); assert!(index.contains("\"window\":[1,7]"), "window missing from manifest: {index}"); assert!( index.contains("\"broker\":\"sim-optimal(pip_size=0.0001)\""), "broker label missing: {index}" ); // The tap list is the deterministic read order the serve path depends on. assert!( index.contains("\"taps\":[\"equity\",\"exposure\"]"), "tap order must be deterministic [equity, exposure]: {index}" ); let _ = std::fs::remove_dir_all(&cwd); } /// Property: `--trace` is an **orthogonal modifier** — it composes with `aura run /// --real `, not only plain `run`, and is strictly **additive**: the looked-up /// instrument pip still reaches stdout byte-for-byte while the same two taps land on /// disk. The spec's headline is "one shared persist helper, every run form"; the /// existing E2E only exercised the synthetic `run` path. Gated on local GER40 data /// (skip cleanly when absent, the project convention) so it never fails on a /// data-less machine; the no-data path is the distinct "no local data" refusal. #[test] fn run_real_with_trace_persists_taps_and_keeps_stdout_unchanged() { const FROM_MS: &str = "1725148800000"; const TO_MS: &str = "1727740799999"; let cwd = temp_cwd("run-real-trace"); let out = Command::new(BIN) .args(["run", "--real", "GER40", "--from", FROM_MS, "--to", TO_MS, "--trace", "ger"]) .current_dir(&cwd) .output() .expect("spawn aura run --real --trace"); // Skip on a data-less machine: GER40 with no local data takes the // distinct no-data path (exit 1, runtime), never the pip-refusal, and writes no traces. if out.status.code() == Some(1) { let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("no local data for symbol 'GER40'"), "exit 1 for GER40 --trace must be the no-data path, got: {stderr}" ); eprintln!("skip: no local GER40 data"); let _ = std::fs::remove_dir_all(&cwd); return; } assert_eq!(out.status.code(), Some(0), "real --trace exit: {:?}", out.status); // Additive: the index-pip report still reaches stdout unchanged. let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); let line = stdout.trim_end(); assert!(line.starts_with("{\"manifest\":{"), "report still printed: {line}"); assert!( line.contains("\"broker\":\"sim-optimal(pip_size=1)\""), "real --trace must keep the GER40 index pip on stdout: {line}" ); // And the same two taps landed under the named run dir. assert!(cwd.join("runs/traces/ger/index.json").exists(), "index.json written"); assert!(cwd.join("runs/traces/ger/equity.json").exists(), "equity tap written"); assert!(cwd.join("runs/traces/ger/exposure.json").exists(), "exposure tap written"); let _ = std::fs::remove_dir_all(&cwd); } /// Extract the body of a JSON array opened by `marker` (e.g. `"ts":[`) up to its /// closing `]`, by substring — no serde dependency, mirroring the sibling tests' /// substring parsing. Used only to count comma-separated entries for the SoA /// length-parity check. fn json_array_body<'a>(json: &'a str, marker: &str) -> &'a str { let start = json.find(marker).expect("array marker present") + marker.len(); let rest = &json[start..]; let end = rest.find(']').expect("array is closed"); &rest[..end] } #[test] fn chart_emits_self_contained_html_for_a_persisted_run() { let cwd = temp_cwd("chart-serve"); // persist a run first (iteration 1), then chart it. let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace"); assert!(traced.status.success(), "run --trace exit: {:?}", traced.status); let chart = Command::new(BIN).args(["chart", "demo"]).current_dir(&cwd).output().expect("spawn chart"); assert!(chart.status.success(), "chart exit: {:?}", chart.status); let html = String::from_utf8(chart.stdout).expect("utf-8 html"); assert!(html.starts_with(""), "not an HTML doc: {:?}", &html[..html.len().min(40)]); assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected"); assert!(html.contains("\"equity\""), "equity series missing from the chart"); assert!(html.contains("uPlot=function"), "vendored uPlot not inlined"); // a missing run is a runtime failure (stderr + exit 1), not a panic. let missing = Command::new(BIN).args(["chart", "nope"]).current_dir(&cwd).output().expect("spawn chart nope"); assert_eq!(missing.status.code(), Some(1), "missing run must exit 1"); assert!(!missing.status.success()); let _ = std::fs::remove_dir_all(&cwd); } /// Property: the `--panels` flag is an honest CLI dispatch axis — `chart ` /// serves the overlay page and `chart --panels` serves the panels page over /// the SAME persisted run. The mode the viewer reads (`AURA_TRACES.mode`) reflects /// which arm dispatched: a `--panels`→Overlay miswiring would leave every shape and /// render test green while silently ignoring the flag, so this pins it at the /// binary's observable stdout. #[test] fn chart_panels_flag_selects_panels_mode() { let cwd = temp_cwd("chart-panels"); let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace"); assert!(traced.status.success(), "run --trace exit: {:?}", traced.status); // default (no flag) -> overlay. let overlay = Command::new(BIN).args(["chart", "demo"]).current_dir(&cwd).output().expect("spawn chart"); assert!(overlay.status.success(), "chart exit: {:?}", overlay.status); let overlay_html = String::from_utf8(overlay.stdout).expect("utf-8 html"); assert!(overlay_html.contains("\"mode\":\"overlay\""), "default chart must serve overlay mode"); assert!(!overlay_html.contains("\"mode\":\"panels\""), "default chart must not serve panels mode"); // --panels -> panels, over the very same persisted run. let panels = Command::new(BIN).args(["chart", "demo", "--panels"]).current_dir(&cwd).output().expect("spawn chart --panels"); assert!(panels.status.success(), "chart --panels exit: {:?}", panels.status); let panels_html = String::from_utf8(panels.stdout).expect("utf-8 html"); assert!(panels_html.contains("\"mode\":\"panels\""), "--panels must serve panels mode"); assert!(!panels_html.contains("\"mode\":\"overlay\""), "--panels must not fall back to overlay"); let _ = std::fs::remove_dir_all(&cwd); } /// Property: `--tap ` on a single-run chart is a CLEAN refusal at the /// CLI seam — refuse-don't-guess (C10). The builder's no-tap `Err` is unit-tested, /// but this pins the user-facing wiring: `filter_to_tap` returning `Err` must reach /// `eprintln!` + `exit(1)`, never a panic and never a chart of zero series. A /// regression that swallowed the `Err` (e.g. charting an empty `ChartData`) would /// exit 0 with an empty page; this fails it. Archive-local (persists its own run). #[test] fn chart_tap_nonexistent_on_run_refuses_with_exit_1() { let cwd = temp_cwd("chart-tap-missing"); // persist a single run; its taps are `equity` / `exposure`, never `nope`. let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace"); assert!(traced.status.success(), "run --trace exit: {:?}", traced.status); let out = Command::new(BIN).args(["chart", "demo", "--tap", "nope"]).current_dir(&cwd).output().expect("spawn chart --tap"); assert_eq!(out.status.code(), Some(1), "nonexistent tap must exit 1: {:?}", out.status); assert!(out.stdout.is_empty(), "the refusal must not leak a chart page to stdout, got: {:?}", String::from_utf8_lossy(&out.stdout)); let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("run has no tap named 'nope'"), "expected the no-such-tap refusal message, got: {stderr}" ); // #131: the refusal lists the valid taps so the user can correct the typo // (the demo run's taps are `equity` and `exposure`). assert!( stderr.contains("available:") && stderr.contains("equity") && stderr.contains("exposure"), "the refusal should enumerate the available taps, got: {stderr}" ); let _ = std::fs::remove_dir_all(&cwd); } /// Property: an unknown `chart ` is a runtime failure pinned at BOTH the exit code /// and the user-facing message — exit 1 (never a panic) AND the NotFound branch's /// "no recorded run or family ''" wording, which now covers families too. A /// regression that drifted the message (e.g. back to the run-only phrasing) or /// downgraded the exit would slip past the bundled exit-only check; this pins the /// message/exit contract. Archive-local: charts into an empty cwd so the name is /// genuinely absent. #[test] fn chart_unknown_name_refuses_with_exit_1_and_message() { let cwd = temp_cwd("chart-unknown"); let out = Command::new(BIN).args(["chart", "ghost"]).current_dir(&cwd).output().expect("spawn chart ghost"); assert_eq!(out.status.code(), Some(1), "unknown name must exit 1: {:?}", out.status); assert!(out.stdout.is_empty(), "the refusal must not leak a chart page to stdout, got: {:?}", String::from_utf8_lossy(&out.stdout)); let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("no recorded run or family 'ghost'"), "expected the unknown-name (run-or-family) refusal message, got: {stderr}" ); let _ = std::fs::remove_dir_all(&cwd); } #[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 (post-clap migration, #175): subcommand help is SCOPED — `aura /// --help` (and `-h`) prints that subcommand's own options to stdout and exits 0, /// with nothing on stderr, for every subcommand. This supersedes the retired #131 /// "uniform (byte-identical) help" surface: clap gives each subcommand its own /// scoped Options section rather than one shared global usage blob. #[test] fn per_subcommand_help_is_scoped_stdout_exit_zero() { for sub in ["run", "chart", "sweep", "walkforward", "mc", "graph", "runs"] { for help in ["--help", "-h"] { let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([sub, help]).output().expect("spawn"); assert_eq!(out.status.code(), Some(0), "{sub} {help} exit"); assert!(!out.stdout.is_empty(), "{sub} {help} stdout empty"); assert!(out.stderr.is_empty(), "{sub} {help} stderr not empty: {:?}", String::from_utf8_lossy(&out.stderr)); } } } /// Property (#153): the cost-flag units/constraints are discoverable from /// `aura run --help` — the appended note states the in-R charge, the per-ENGINE- /// cycle carry semantics, the `>= 0` constraint, and that cost flags are r-sma /// only. Pre-#153 the usage listed the flags with no unit or scale guidance. #[test] fn run_help_surfaces_cost_flag_units_note() { let out = Command::new(BIN).args(["run", "--help"]).output().expect("spawn aura run --help"); assert_eq!(out.status.code(), Some(0), "run --help exits 0: {:?}", out.status); let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); // clap wraps `long_help` at the render width, which can split a multi-word phrase // across lines; pin the single tokens clap never hyphenates instead of the // multi-word "per ENGINE cycle". assert!(stdout.contains("ENGINE"), "help must explain carry units: {stdout}"); assert!(stdout.contains("cycle"), "help must explain the per-cycle carry scale: {stdout}"); assert!(stdout.contains("r-sma"), "help must state the R-harness requirement: {stdout}"); } /// Property (#153/#175): a malformed cost value (`--cost-per-trade x`, not a f64) is /// a usage error — exit 2 with a Usage line on STDERR and nothing on stdout. Post-clap /// migration the terse parse error names the offending flag and points to `--help` /// (where the units note lives, pinned by `run_help_surfaces_cost_flag_units_note`); /// clap does not inline `long_help` into a parse error, so the multi-word units note /// no longer rides the grammar-error path — the pin is the exit code + Usage surface. #[test] fn run_malformed_cost_value_usage_error_carries_note_on_stderr() { let out = Command::new(BIN) .args(["run", "--harness", "r-sma", "--cost-per-trade", "x"]) .output() .expect("spawn aura run --cost-per-trade x"); assert_eq!(out.status.code(), Some(2), "a malformed cost value is a usage error (exit 2); stderr: {}", String::from_utf8_lossy(&out.stderr)); assert!(out.stdout.is_empty(), "a usage error must not emit a report on stdout: {:?}", out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!(stderr.contains("--cost-per-trade"), "the parse error names the offending flag: {stderr:?}"); } /// 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(""), "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("