//! End-to-end coverage for the `aura process` verb family (#188/#189): drives //! the built `aura` binary over argv/file, the exact surface a data-level //! author uses. use std::path::{Path, PathBuf}; use std::sync::OnceLock; /// A fresh, unique working directory for a process test that persists /// content-addressed documents under `./runs/` (so `aura process register` /// never dirties the repo). Unique per test + per process; no external /// tempfile dependency (mirrors `cli_run.rs` / `cli_broken_pipe.rs`). fn temp_cwd(name: &str) -> 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 } fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option) { let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(args) .current_dir(dir) .output() .expect("binary runs"); let text = format!( "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr) ); (text, out.status.code()) } fn run_code(args: &[&str]) -> (String, Option) { run_code_in(Path::new("."), args) } const PROCESS_DOC: &str = r#"{ "format_version": 1, "kind": "process", "name": "wf-deflated-screen", "pipeline": [ { "block": "std::sweep", "metric": "net_expectancy_r", "select": "plateau:worst", "deflate": true }, { "block": "std::gate", "all": [ { "metric": "net_expectancy_r", "cmp": "gt", "value": 0.0 }, { "metric": "overfit_probability", "cmp": "lt", "value": 0.1 } ] }, { "block": "std::walk_forward", "folds": 4, "in_sample_bars": 4000, "out_of_sample_bars": 1000, "metric": "net_expectancy_r", "select": "argmax" } ] }"#; fn write_doc(dir: &Path, name: &str, text: &str) -> PathBuf { let p = dir.join(name); std::fs::write(&p, text).expect("write doc"); p } #[test] fn process_validate_reports_intrinsic_ok() { let dir = temp_cwd("process-validate-ok"); write_doc(&dir, "p.process.json", PROCESS_DOC); let (out, code) = run_code_in(&dir, &["process", "validate", "p.process.json"]); assert_eq!(code, Some(0), "stdout/stderr: {out}"); assert!(out.contains("process document valid (intrinsic): 3 pipeline blocks, 2 gate predicates")); } #[test] fn process_validate_refuses_unknown_metric_as_prose_exit_1() { let dir = temp_cwd("process-validate-bad-metric"); let bad = PROCESS_DOC.replacen( "net_expectancy_r\", \"select\": \"plateau:worst", "netto_r\", \"select\": \"plateau:worst", 1, ); write_doc(&dir, "bad.process.json", &bad); let (out, code) = run_code_in(&dir, &["process", "validate", "bad.process.json"]); assert_eq!(code, Some(1)); assert!(out.contains("unknown metric \"netto_r\"")); assert!(!out.contains("UnknownMetric"), "Debug leak: {out}"); } #[test] fn process_introspect_vocabulary_block_and_content_id() { let (out, code) = run_code(&["process", "introspect", "--vocabulary"]); assert_eq!(code, Some(0)); for id in ["std::sweep", "std::gate", "std::walk_forward", "std::monte_carlo", "std::generalize"] { assert!(out.contains(id), "vocabulary misses {id}: {out}"); } let (out, code) = run_code(&["process", "introspect", "--block", "std::sweep"]); assert_eq!(code, Some(0)); assert!(out.contains("metric")); assert!(out.contains("required")); let dir = temp_cwd("process-introspect-content-id"); write_doc(&dir, "p.process.json", PROCESS_DOC); let (out, code) = run_code_in(&dir, &["process", "introspect", "--content-id", "p.process.json"]); assert_eq!(code, Some(0), "stdout/stderr: {out}"); let line = out.lines().find(|l| l.starts_with("content:")).expect("id line"); assert_eq!(line.len(), "content:".len() + 64); } #[test] fn process_introspect_unknown_block_is_prose_exit_1() { let (out, code) = run_code(&["process", "introspect", "--block", "std::nope"]); assert_eq!(code, Some(1), "stdout/stderr: {out}"); assert!(out.contains("unknown block \"std::nope\""), "stdout/stderr: {out}"); } #[test] fn process_introspect_unwired_lists_open_slots() { let dir = temp_cwd("process-introspect-unwired"); let partial = r#"{ "format_version": 1, "kind": "process", "pipeline": [ { "block": "std::sweep", "metric": "sqn" } ] }"#; write_doc(&dir, "partial.process.json", partial); let (out, code) = run_code_in(&dir, &["process", "introspect", "--unwired", "partial.process.json"]); assert_eq!(code, Some(0)); assert!(out.contains("open slot: name")); assert!(out.contains("open slot: pipeline[0].select")); } #[test] fn process_introspect_no_flag_is_usage_exit_2() { let (_out, code) = run_code(&["process", "introspect"]); assert_eq!(code, Some(2)); } #[test] fn process_register_stores_content_addressed_under_runs_root() { let dir = temp_cwd("process-register"); write_doc(&dir, "p.process.json", PROCESS_DOC); let (out, code) = run_code_in(&dir, &["process", "register", "p.process.json"]); assert_eq!(code, Some(0), "stdout/stderr: {out}"); let line = out.lines().find(|l| l.starts_with("registered process content:")).expect("line"); let id = line .trim_start_matches("registered process content:") .split(' ') .next() .expect("id"); assert!(dir.join("runs").join("processes").join(format!("{id}.json")).is_file()); } /// The demo-project fixture (Aura.toml present), built once — mirrors /// `project_load.rs`'s `built_fixture` (a separate test binary, so it needs /// its own `OnceLock`, but `cargo build` is idempotent). fn built_project() -> &'static PathBuf { static BUILT: OnceLock = OnceLock::new(); BUILT.get_or_init(|| { let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo-project"); let out = std::process::Command::new("cargo") .arg("build") .current_dir(&dir) .output() .expect("spawn cargo build for the fixture project"); assert!( out.status.success(), "fixture build failed:\n{}", String::from_utf8_lossy(&out.stderr) ); dir }) } /// A scratch filesystem entry this test writes under the git-tracked /// demo-project fixture root (only `runs/` there is fixture-gitignored), /// removed on drop — including during a mid-test panic — so a failed /// assertion never leaks scratch docs into tracked working-tree state. enum ScratchPath { File(PathBuf), Dir(PathBuf), } struct ScratchGuard(Vec); impl Drop for ScratchGuard { fn drop(&mut self) { for p in &self.0 { match p { ScratchPath::File(p) => { let _ = std::fs::remove_file(p); } ScratchPath::Dir(p) => { let _ = std::fs::remove_dir_all(p); } } } } } /// The `aura campaign validate` referential tier (the campaign family's /// headline addition over the intrinsic-only process family), exercised at /// the CLI seam inside a real project: a fully-resolved document prints the /// "valid (referential)" success line, and an unresolved reference prints /// the "do not resolve" fault block through `ref_fault_prose`. The /// constituents (`validate_campaign_refs`, `ref_fault_prose`) are unit- /// tested elsewhere; this pins that `aura campaign validate` actually wires /// them together end to end. #[test] fn campaign_validate_in_project_reports_referential_tier_end_to_end() { let dir = built_project(); let runs_dir = dir.join("runs"); std::fs::remove_dir_all(&runs_dir).ok(); let _cleanup = ScratchGuard(vec![ ScratchPath::Dir(runs_dir.clone()), ScratchPath::File(dir.join("seed.process.json")), ScratchPath::File(dir.join("ref-ok.campaign.json")), ScratchPath::File(dir.join("ref-bad.campaign.json")), ]); // Seed one real, content-addressed, open-param blueprint into the // project's own store via a real sweep (mirrors `project_load.rs`'s // anchor test). let open_bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); let (sweep_out, sweep_code) = run_code_in( dir, &[ "sweep", &open_bp, "--axis", "sma_signal.fast.length=2,4", "--axis", "sma_signal.slow.length=8,16", "--name", "campaign-ref-seed", ], ); assert_eq!(sweep_code, Some(0), "seed sweep failed: {sweep_out}"); let bp_id = std::fs::read_dir(runs_dir.join("blueprints")) .expect("blueprints dir") .next() .expect("one stored blueprint") .expect("dir entry") .path() .file_stem() .expect("stem") .to_string_lossy() .into_owned(); // Register a valid process into the same project store. write_doc(dir, "seed.process.json", PROCESS_DOC); let (proc_out, proc_code) = run_code_in(dir, &["process", "register", "seed.process.json"]); assert_eq!(proc_code, Some(0), "seed process register failed: {proc_out}"); let proc_id = proc_out .lines() .find(|l| l.starts_with("registered process content:")) .expect("register line") .trim_start_matches("registered process content:") .split(' ') .next() .expect("id") .to_string(); // "fast.length": the axis name is the RAW composite's `param_space` name. // `validate_campaign_refs` loads the stored blueprint bare, unlike the // sweep's `wrap_r`-wrapped axis probe, so it does NOT carry the // "sma_signal." prefix `aura sweep --axis` binds against. let campaign = format!( r#"{{ "format_version": 1, "kind": "campaign", "name": "ref-check", "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1, "to_ms": 2 }} ] }}, "strategies": [ {{ "ref": {{ "content_id": "{bp}" }}, "axes": {{ "fast.length": {{ "kind": "I64", "values": [2] }} }} }} ], "process": {{ "ref": {{ "content_id": "{proc}" }} }}, "seed": 1, "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} }}"#, bp = bp_id, proc = proc_id ); write_doc(dir, "ref-ok.campaign.json", &campaign); let (out, code) = run_code_in(dir, &["campaign", "validate", "ref-ok.campaign.json"]); assert_eq!(code, Some(0), "stdout/stderr: {out}"); assert!(out.contains( "campaign document valid (referential): all references resolve, axes are in the param space" )); // An unresolved process reference: the fault block + `ref_fault_prose` // mapping, prose exit 1, at the same CLI seam. let bad = campaign.replace(&proc_id, "0000000000000000000000000000000000000000000000000000000000000000"); write_doc(dir, "ref-bad.campaign.json", &bad); let (out2, code2) = run_code_in(dir, &["campaign", "validate", "ref-bad.campaign.json"]); assert_eq!(code2, Some(1)); assert!(out2.contains("campaign references do not resolve:"), "stdout/stderr: {out2}"); assert!(out2.contains("not found in the project store"), "stdout/stderr: {out2}"); // `_cleanup` (a `ScratchGuard`) removes the scratch docs and `runs/` on // drop, at the end of this scope — including on a mid-test panic. } const CAMPAIGN_DOC: &str = r#"{ "format_version": 1, "kind": "campaign", "name": "screen", "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] }, "strategies": [ { "ref": { "content_id": "9f3a" }, "axes": { "fast": { "kind": "I64", "values": [8] } } } ], "process": { "ref": { "content_id": "4e2d" } }, "seed": 1, "presentation": { "persist_taps": [], "emit": ["family_table"] } }"#; #[test] fn campaign_validate_outside_project_skips_referential_tier() { let dir = temp_cwd("campaign-validate-outside-project"); write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC); let (out, code) = run_code_in(&dir, &["campaign", "validate", "c.campaign.json"]); assert_eq!(code, Some(0), "stdout/stderr: {out}"); assert!(out.contains("campaign document valid (intrinsic):")); assert!(out.contains("referential checks skipped (no Aura.toml found up from ")); } #[test] fn campaign_validate_refuses_empty_axis_prose_exit_1() { let dir = temp_cwd("campaign-validate-empty-axis"); let bad = CAMPAIGN_DOC.replacen(r#""values": [8]"#, r#""values": []"#, 1); write_doc(&dir, "bad.campaign.json", &bad); let (out, code) = run_code_in(&dir, &["campaign", "validate", "bad.campaign.json"]); assert_eq!(code, Some(1)); assert!(out.contains("axes.fast: an axis is a non-empty finite set")); assert!(!out.contains("EmptyAxis"), "Debug leak: {out}"); } #[test] fn campaign_introspect_vocabulary_lists_sections() { let (out, code) = run_code(&["campaign", "introspect", "--vocabulary"]); assert_eq!(code, Some(0)); for id in ["std::data", "std::strategy", "std::process_ref", "std::presentation"] { assert!(out.contains(id), "vocabulary misses {id}: {out}"); } } #[test] fn campaign_introspect_unwired_reports_the_spec_example_slots() { let dir = temp_cwd("campaign-introspect-unwired"); let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft", "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] }, "strategies": [ { "ref": { "content_id": "9f3a" }, "axes": { "slow": { "kind": "I64", "values": [] } } } ], "seed": 1, "presentation": { "persist_taps": [], "emit": [] } }"#; write_doc(&dir, "draft.campaign.json", draft); let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]); assert_eq!(code, Some(0)); assert!(out.contains("open slot: process.ref (required, one of: content_id, identity_id)")); assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)")); } /// The campaign twin of `process_introspect_vocabulary_block_and_content_id`'s /// `--content-id` branch: pins that `campaign introspect --content-id` wires /// `parse_valid_campaign` + `campaign_to_json` + `content_id_of` end to end /// (the process branch was covered; this one was not). #[test] fn campaign_introspect_content_id_prints_hash() { let dir = temp_cwd("campaign-introspect-content-id"); write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC); let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--content-id", "c.campaign.json"]); assert_eq!(code, Some(0), "stdout/stderr: {out}"); let line = out.lines().find(|l| l.starts_with("content:")).expect("id line"); assert_eq!(line.len(), "content:".len() + 64); } #[test] fn campaign_register_stores_content_addressed_under_runs_root() { let dir = temp_cwd("campaign-register"); write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC); let (out, code) = run_code_in(&dir, &["campaign", "register", "c.campaign.json"]); assert_eq!(code, Some(0), "stdout/stderr: {out}"); let line = out.lines().find(|l| l.starts_with("registered campaign content:")).expect("line"); let id = line .trim_start_matches("registered campaign content:") .split(' ') .next() .expect("id"); assert!(dir.join("runs").join("campaigns").join(format!("{id}.json")).is_file()); } /// `guard_one_mode` is shared, family-parameterized code (`process` and /// `campaign` both route through it). The process family already pins the /// zero-flags arm; this pins that the campaign family gets its own family /// name threaded into the usage line, not a copy-pasted "process" string. #[test] fn campaign_introspect_no_flag_usage_names_the_campaign_family() { let (out, code) = run_code(&["campaign", "introspect"]); assert_eq!(code, Some(2), "stdout/stderr: {out}"); assert!( out.contains("Usage: aura campaign introspect --vocabulary | --block | --unwired | --content-id "), "stdout/stderr: {out}" ); } /// `guard_one_mode` requires *exactly* one mode: the zero-flags arm is /// pinned elsewhere, but nothing previously exercised the over-selection /// arm (`selected_modes() != 1` is not just `== 0`). Two modes at once must /// refuse with the same usage-exit-2 idiom, not silently pick one. #[test] fn campaign_introspect_two_flags_is_usage_exit_2() { let (out, code) = run_code(&["campaign", "introspect", "--vocabulary", "--block", "std::data"]); assert_eq!(code, Some(2), "stdout/stderr: {out}"); assert!(out.contains("Usage: aura campaign introspect"), "stdout/stderr: {out}"); } /// Register must be a gate, not a passthrough: an invalid document is /// refused with prose (exit 1) and — the property that matters for a /// content-addressed store — no file is ever written under `runs/campaigns/` /// for it. A store that could contain unvalidated documents would make /// every downstream reader re-derive the checks register was supposed to /// have already made. #[test] fn campaign_register_refuses_invalid_document_and_writes_nothing() { let dir = temp_cwd("campaign-register-invalid"); let bad = CAMPAIGN_DOC.replacen(r#""values": [8]"#, r#""values": []"#, 1); write_doc(&dir, "bad.campaign.json", &bad); let (out, code) = run_code_in(&dir, &["campaign", "register", "bad.campaign.json"]); assert_eq!(code, Some(1), "stdout/stderr: {out}"); assert!(out.contains("refusing to register:"), "stdout/stderr: {out}"); assert!(out.contains("axes.fast: an axis is a non-empty finite set"), "stdout/stderr: {out}"); assert!( !dir.join("runs").join("campaigns").exists(), "register must not create a store entry for an invalid document" ); }