8e5d14b2bb
Plan 0060 Tasks 4-5 (#106): wire `aura sweep|walkforward --real` end to end. - A shared RealWindowGrammar accumulator holds the `--real <SYMBOL> [--from <ms>] [--to <ms>]` grammar (flag-repeat strictness, empty-symbol + window-without-real rejection) for both parsers, mirroring parse_real_args. - parse_sweep_args grows the DataChoice 4th element; parse_walkforward_args is new (no --strategy axis). The sweep + walkforward dispatch arms become rest-matched, build the DataSource via from_choice (build-or-refuse on un-vetted symbol / absent data), and run. USAGE documents the --real tails. mc stays exact-matched, so `aura mc --real` falls through to usage + exit(2) — Fork A: MC excluded (its seed varies a synthetic price-walk, undefined over real data). - Gated integration tests (skip on no local data): sweep --real EURUSD persists 4 portable member dirs over real bars and charts; walkforward --real EURUSD persists one oos<ns> dir per rolling window; same real sweep twice is byte-identical (C1). Un-gated: un-vetted symbol, window-without-real, and mc --real each refuse with exit 2. - Remove the now-redundant `#[allow(dead_code)]` from the Task-1 provider: every const, both enums, and every method are live once the wiring lands. Verified end-to-end over real EURUSD data: sweep --real -> 4 members; walkforward --real over 2024 -> 9 rolling OOS windows; all refusals exit 2. Full workspace test + clippy -D warnings green. closes #106
1202 lines
59 KiB
Rust
1202 lines
59 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 run_real_unspecced_symbol_refuses_with_exit_2() {
|
||
// The instrument_spec lookup precedes any data access, so an un-specced symbol
|
||
// refuses with NO local data required (CI-safe). "NONEXISTENT" is not in the
|
||
// vetted table.
|
||
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(2), "expected exit 2");
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no vetted pip/instrument spec for symbol 'NONEXISTENT'"),
|
||
"expected the per-instrument-pip refusal message, 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(2)` 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_unspecced_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(2), "expected exit 2: {:?}", 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 being un-specced, not on
|
||
/// `--real` being taken at all. A *vetted* symbol (`EURUSD`, in the instrument
|
||
/// table) gets PAST the `instrument_spec` 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 vetted pip/instrument spec" 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_vetted_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 vetted pip/instrument spec"),
|
||
"a vetted symbol 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 2).
|
||
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(2) => assert!(
|
||
stderr.contains("no local data for symbol 'EURUSD'"),
|
||
"exit 2 for a vetted symbol must be the no-data path, got: {stderr}"
|
||
),
|
||
other => panic!("unexpected exit for a vetted real run: {other:?}; stderr: {stderr}"),
|
||
}
|
||
}
|
||
|
||
/// Property: a vetted 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 un-specced→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 2), not the pip-refusal, asserted above.
|
||
#[test]
|
||
fn run_real_vetted_index_pip_reaches_the_emitted_manifest() {
|
||
// The vetted 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 vetted symbol with no local data takes the
|
||
// distinct no-data path (exit 2, "no local data"), never the pip-refusal.
|
||
if out.status.code() == Some(2) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data for symbol 'GER40'"),
|
||
"exit 2 for vetted 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}"
|
||
);
|
||
}
|
||
|
||
#[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 `<tap>.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 <SYM>`, 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: vetted GER40 with no local data takes the
|
||
// distinct no-data path (exit 2), never the pip-refusal, and writes no traces.
|
||
if out.status.code() == Some(2) {
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(
|
||
stderr.contains("no local data for symbol 'GER40'"),
|
||
"exit 2 for vetted 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("<!doctype html>"), "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 usage error (stderr + exit 2), 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(2), "missing run must exit 2");
|
||
assert!(!missing.status.success());
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: the `--panels` flag is an honest CLI dispatch axis — `chart <name>`
|
||
/// serves the overlay page and `chart <name> --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);
|
||
}
|
||
|
||
#[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_family_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 now a family member: the assigned `family_id` plus the nested
|
||
// RunReport (C18/C21 lineage). The first run assigns `sweep-0`; the nested
|
||
// report is serialized via `serde_json`, so the manifest's fields lead with
|
||
// `broker` (the commit value is the volatile git HEAD, pinned by params below).
|
||
assert!(first.starts_with("{\"family_id\":\"sweep-0\",\"report\":{\"manifest\":{"), "got: {stdout}");
|
||
assert!(
|
||
first.contains("\"params\":[[\"signals.trend.fast.length\",{\"I64\":2}],[\"signals.trend.slow.length\",{\"I64\":4}],[\"signals.momentum.fast.length\",{\"I64\":2}],[\"signals.momentum.slow.length\",{\"I64\":4}],[\"signals.momentum.signal.length\",{\"I64\":3}],[\"signals.blend.weights[0]\",{\"F64\":1.0}],[\"signals.blend.weights[1]\",{\"F64\":1.0}],[\"exposure.scale\",{\"F64\":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);
|
||
}
|
||
|
||
/// Property: `aura mc` runs the built-in Monte-Carlo family end-to-end through the
|
||
/// binary — it prints one member line per seed (carrying the assigned `family_id`)
|
||
/// plus one `mc_aggregate` line, exits 0, and persists the draws as a discoverable
|
||
/// `MonteCarlo` family (C12 axis 4 / C18/C21). Driven through the built binary so
|
||
/// it pins the observable CLI contract, not just the in-crate render helper.
|
||
#[test]
|
||
fn mc_runs_persists_a_monte_carlo_family_and_lists_it() {
|
||
let cwd = temp_cwd("mc-family");
|
||
let out = Command::new(BIN).arg("mc").current_dir(&cwd).output().expect("spawn aura mc");
|
||
assert!(out.status.success(), "mc exit: {:?}", out.status);
|
||
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let lines: Vec<&str> = stdout.lines().collect();
|
||
// three built-in seeds -> three member lines + one aggregate line.
|
||
assert_eq!(lines.len(), 4, "mc stdout: {stdout:?}");
|
||
for line in &lines[..3] {
|
||
assert!(line.contains("\"family_id\":\"mc-0\""), "member missing family_id: {line}");
|
||
assert!(line.contains("\"seed\":"), "member missing seed: {line}");
|
||
}
|
||
assert!(lines[3].contains("\"mc_aggregate\":"), "missing aggregate line: {}", lines[3]);
|
||
|
||
// the run persisted the draws as a discoverable MonteCarlo family.
|
||
let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families");
|
||
assert!(fams.status.success(), "families exit: {:?}", fams.status);
|
||
let fams_out = String::from_utf8(fams.stdout).expect("utf-8");
|
||
assert_eq!(fams_out.lines().count(), 1, "families: {fams_out:?}");
|
||
assert!(fams_out.contains("\"family_id\":\"mc-0\""), "families: {fams_out:?}");
|
||
assert!(fams_out.contains("\"kind\":\"MonteCarlo\""), "families: {fams_out:?}");
|
||
assert!(fams_out.contains("\"members\":3"), "families: {fams_out:?}");
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
#[test]
|
||
fn runs_families_list_and_per_family_rank_across_invocations() {
|
||
let cwd = temp_cwd("runs-flow");
|
||
// two sweeps accumulate two families (sweep-0, sweep-1) over time, each a
|
||
// related set of 4 records (C18/C21 lineage), in the sibling family store.
|
||
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 families` shows both stored families in first-seen order, each with
|
||
// its kind + member count.
|
||
let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families");
|
||
assert!(fams.status.success(), "families exit: {:?}", fams.status);
|
||
let fams_out = String::from_utf8(fams.stdout).expect("utf-8");
|
||
let fam_lines: Vec<&str> = fams_out.lines().collect();
|
||
assert_eq!(fam_lines.len(), 2, "families: {fams_out:?}");
|
||
assert!(fam_lines[0].contains("\"family_id\":\"sweep-0\""), "fam0: {fams_out:?}");
|
||
assert!(fam_lines[1].contains("\"family_id\":\"sweep-1\""), "fam1: {fams_out:?}");
|
||
assert!(fam_lines.iter().all(|l| l.contains("\"members\":4")), "members: {fams_out:?}");
|
||
|
||
// `runs family sweep-0 rank total_pips`: the family's 4 members, highest
|
||
// total_pips first.
|
||
let rank = Command::new(BIN)
|
||
.args(["runs", "family", "sweep-0", "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(), 4, "rank: {rank_out:?}");
|
||
assert!(rank_out.lines().all(|l| l.starts_with("{\"manifest\":{")), "rank shape: {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:?}");
|
||
|
||
// an unknown metric is a usage error
|
||
let bogus = Command::new(BIN)
|
||
.args(["runs", "family", "sweep-0", "rank", "bogus"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn bogus");
|
||
assert_eq!(bogus.status.code(), Some(2), "bogus exit: {:?}", bogus.status);
|
||
|
||
// an unknown family id is an empty family: zero lines, exit 0.
|
||
let unknown = Command::new(BIN)
|
||
.args(["runs", "family", "nope-9"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn unknown");
|
||
assert!(unknown.status.success(), "unknown family exit: {:?}", unknown.status);
|
||
assert_eq!(unknown.stdout.len(), 0, "unknown family stdout: {:?}", unknown.stdout);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
#[test]
|
||
fn runs_list_and_rank_are_retired_and_exit_two() {
|
||
// `aura runs list` / `runs rank <metric>` were retired (#73): families
|
||
// (sweep/mc/walkforward, C21) subsume standalone over-time comparison. The
|
||
// dropped subcommands now fall through to the strict-arg usage error (exit 2),
|
||
// before any registry access.
|
||
let list = Command::new(BIN).args(["runs", "list"]).output().expect("spawn list");
|
||
assert_eq!(list.status.code(), Some(2), "retired `runs list` must exit 2: {:?}", list.status);
|
||
let rank = Command::new(BIN).args(["runs", "rank", "total_pips"]).output().expect("spawn rank");
|
||
assert_eq!(rank.status.code(), Some(2), "retired `runs rank` must exit 2: {:?}", rank.status);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
/// Property: `aura mc --trace <name>` persists one standalone, round-tripping
|
||
/// run-dir per draw seed (the built-in MC family draws seeds 1,2,3), and a plain
|
||
/// `aura mc` (no --trace) writes no trace tree (opt-in; byte-unchanged path).
|
||
#[test]
|
||
fn mc_trace_persists_a_member_dir_per_seed() {
|
||
let cwd = temp_cwd("mc-trace");
|
||
|
||
// opt-in OFF: plain `aura mc` persists no per-member trace dirs.
|
||
let plain = Command::new(BIN).arg("mc").current_dir(&cwd).output().expect("spawn aura mc");
|
||
assert!(plain.status.success(), "plain mc exit: {:?}", plain.status);
|
||
assert!(!cwd.join("runs/traces").exists(), "plain mc must write no trace files");
|
||
|
||
// opt-in ON: one standalone run-dir per draw seed.
|
||
let traced = Command::new(BIN)
|
||
.args(["mc", "--trace", "mc1"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura mc --trace");
|
||
assert!(traced.status.success(), "traced mc exit: {:?}", traced.status);
|
||
for seed in [1, 2, 3] {
|
||
let dir = cwd.join(format!("runs/traces/mc1/seed{seed}"));
|
||
assert!(dir.join("index.json").exists(), "seed{seed} index.json missing");
|
||
assert!(dir.join("equity.json").exists(), "seed{seed} equity tap missing");
|
||
assert!(dir.join("exposure.json").exists(), "seed{seed} exposure tap missing");
|
||
// the persisted equity tap is columnar SoA (C7): kind tag + ts/column parity.
|
||
let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json");
|
||
assert!(equity.contains("\"kinds\":[\"F64\"]"), "seed{seed} F64 tag: {equity}");
|
||
let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count();
|
||
let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count();
|
||
assert_eq!(col_len, ts_len, "seed{seed} SoA parity: ts={ts_len} col={col_len}; {equity}");
|
||
}
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: `aura sweep --trace <name>` persists one standalone, round-tripping
|
||
/// run-dir per grid point. The built-in grid is fast∈{2,3} × slow∈{4,5} = 4
|
||
/// points, content-keyed by the varying axes (signals.trend.fast.length /
|
||
/// signals.trend.slow.length); a plain `aura sweep` writes nothing.
|
||
#[test]
|
||
fn sweep_trace_persists_a_member_dir_per_grid_point() {
|
||
let cwd = temp_cwd("sweep-trace");
|
||
|
||
let plain = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn aura sweep");
|
||
assert!(plain.status.success(), "plain sweep exit: {:?}", plain.status);
|
||
assert!(!cwd.join("runs/traces").exists(), "plain sweep must write no trace files");
|
||
|
||
let traced = Command::new(BIN)
|
||
.args(["sweep", "--trace", "swp"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --trace");
|
||
assert!(traced.status.success(), "traced sweep exit: {:?}", traced.status);
|
||
for key in [
|
||
"signals.trend.fast.length-2_signals.trend.slow.length-4",
|
||
"signals.trend.fast.length-2_signals.trend.slow.length-5",
|
||
"signals.trend.fast.length-3_signals.trend.slow.length-4",
|
||
"signals.trend.fast.length-3_signals.trend.slow.length-5",
|
||
] {
|
||
let dir = cwd.join(format!("runs/traces/swp/{key}"));
|
||
assert!(dir.join("index.json").exists(), "{key} index.json missing");
|
||
assert!(dir.join("equity.json").exists(), "{key} equity tap missing");
|
||
assert!(dir.join("exposure.json").exists(), "{key} exposure tap missing");
|
||
let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json");
|
||
assert!(equity.contains("\"kinds\":[\"F64\"]"), "{key} F64 tag: {equity}");
|
||
let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count();
|
||
let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count();
|
||
assert_eq!(col_len, ts_len, "{key} SoA parity: ts={ts_len} col={col_len}; {equity}");
|
||
}
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property: `aura walkforward --trace <name>` persists one standalone,
|
||
/// round-tripping run-dir per OOS window (the built-in roll = 3 windows), each
|
||
/// keyed `oos<ns>`; a plain `aura walkforward` writes nothing.
|
||
#[test]
|
||
fn walkforward_trace_persists_a_member_dir_per_oos_window() {
|
||
let cwd = temp_cwd("wf-trace");
|
||
|
||
let plain = Command::new(BIN).arg("walkforward").current_dir(&cwd).output().expect("spawn aura walkforward");
|
||
assert!(plain.status.success(), "plain walkforward exit: {:?}", plain.status);
|
||
assert!(!cwd.join("runs/traces").exists(), "plain walkforward must write no trace files");
|
||
|
||
let traced = Command::new(BIN)
|
||
.args(["walkforward", "--trace", "wf1"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura walkforward --trace");
|
||
assert!(traced.status.success(), "traced walkforward exit: {:?}", traced.status);
|
||
|
||
let base = cwd.join("runs/traces/wf1");
|
||
let mut members: Vec<String> = std::fs::read_dir(&base)
|
||
.expect("read wf1 trace dir")
|
||
.map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned())
|
||
.collect();
|
||
members.sort();
|
||
assert_eq!(members.len(), 3, "built-in roll = 3 OOS windows -> 3 member dirs; got {members:?}");
|
||
for key in &members {
|
||
assert!(key.starts_with("oos"), "member key must be oos<ns>: {key}");
|
||
let dir = base.join(key);
|
||
assert!(dir.join("index.json").exists(), "{key} index.json missing");
|
||
let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json");
|
||
assert!(equity.contains("\"kinds\":[\"F64\"]"), "{key} F64 tag: {equity}");
|
||
let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count();
|
||
let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count();
|
||
assert_eq!(col_len, ts_len, "{key} SoA parity: ts={ts_len} col={col_len}; {equity}");
|
||
}
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (C1 / Fork-C content-derived keys): `aura sweep --trace` is
|
||
/// reproducible — re-running it produces the SAME member-dir set and
|
||
/// BYTE-IDENTICAL tap files. The whole reason member keys are derived from the
|
||
/// grid point's content (not a runtime ordinal counter) is that members run in
|
||
/// parallel across threads (C1: parallelism *across* sims); a thread-race in the
|
||
/// keying or the persisted bytes would silently regress determinism. A runtime
|
||
/// counter would shuffle which member lands in which content-keyed dir run-to-run; this
|
||
/// test fails loudly if that drift is ever reintroduced.
|
||
#[test]
|
||
fn sweep_trace_is_byte_deterministic_across_runs() {
|
||
let collect = |tag: &str| -> std::collections::BTreeMap<String, String> {
|
||
let cwd = temp_cwd(tag);
|
||
let out = Command::new(BIN)
|
||
.args(["sweep", "--trace", "det"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --trace");
|
||
assert!(out.status.success(), "{tag} sweep exit: {:?}", out.status);
|
||
|
||
// Map each member dir's tap files to their bytes (member_key/file -> content).
|
||
let base = cwd.join("runs/traces/det");
|
||
let mut taps = std::collections::BTreeMap::new();
|
||
for member in std::fs::read_dir(&base).expect("read det trace dir") {
|
||
let member = member.expect("dir entry").file_name().to_string_lossy().into_owned();
|
||
for file in ["index.json", "equity.json", "exposure.json"] {
|
||
let body = std::fs::read_to_string(base.join(&member).join(file))
|
||
.unwrap_or_else(|e| panic!("{tag} read {member}/{file}: {e}"));
|
||
taps.insert(format!("{member}/{file}"), body);
|
||
}
|
||
}
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
taps
|
||
};
|
||
|
||
let first = collect("sweep-det-1");
|
||
let second = collect("sweep-det-2");
|
||
|
||
// Same member-dir set, run-to-run (content-keyed, not order-keyed).
|
||
let first_keys: Vec<&String> = first.keys().collect();
|
||
let second_keys: Vec<&String> = second.keys().collect();
|
||
assert_eq!(first_keys, second_keys, "member-dir set drifted across runs");
|
||
|
||
// Every tap byte-identical across the two runs (the index.json git-commit
|
||
// field is build-identity, constant within one build, so it compares equal
|
||
// here too — this pins run-to-run determinism, C1).
|
||
assert_eq!(first, second, "a traced member's bytes drifted across two runs (C1 violation)");
|
||
}
|
||
|
||
/// Property (#105 generic key, the bool-param proof): `aura sweep --strategy
|
||
/// momentum --trace <name>` sweeps the momentum strategy — a totally different
|
||
/// param surface incl. a bool — and persists one portable member dir per grid
|
||
/// point. Every member-dir name is filesystem-conformant (`[A-Za-z0-9._-]`), the
|
||
/// bool axis shows up (both `longonly.enabled-true` and `-false`), and one member
|
||
/// is chartable.
|
||
#[test]
|
||
fn momentum_sweep_trace_persists_portable_member_dirs_with_the_bool() {
|
||
let cwd = temp_cwd("momentum-trace");
|
||
|
||
let traced = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "momentum", "--trace", "mom"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --strategy momentum --trace");
|
||
assert!(
|
||
traced.status.success(),
|
||
"momentum sweep exit: {:?}; stderr: {}",
|
||
traced.status,
|
||
String::from_utf8_lossy(&traced.stderr)
|
||
);
|
||
|
||
let base = cwd.join("runs/traces/mom");
|
||
let members: Vec<String> = std::fs::read_dir(&base)
|
||
.expect("read mom trace dir")
|
||
.map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned())
|
||
.collect();
|
||
assert_eq!(members.len(), 8, "2x2x2 momentum grid = 8 member dirs; got {members:?}");
|
||
for m in &members {
|
||
assert!(
|
||
m.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')),
|
||
"non-portable member dir name: {m}"
|
||
);
|
||
}
|
||
assert!(members.iter().any(|m| m.contains("longonly.enabled-true")), "missing bool=true: {members:?}");
|
||
assert!(members.iter().any(|m| m.contains("longonly.enabled-false")), "missing bool=false: {members:?}");
|
||
|
||
// one member is chartable (the bool sits inside the dir name passed through).
|
||
let one = &members[0];
|
||
let chart = Command::new(BIN)
|
||
.args(["chart", &format!("mom/{one}")])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura chart mom/<member>");
|
||
assert!(chart.status.success(), "chart exit: {:?}", chart.status);
|
||
assert!(
|
||
String::from_utf8_lossy(&chart.stdout).contains("uPlot"),
|
||
"expected a self-contained uPlot page"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#105, the `--strategy` selector at the stdout boundary): `aura sweep
|
||
/// --strategy momentum` SWITCHES the swept surface — it prints exactly the
|
||
/// 8-point momentum family (vs the default SMA arm's 4 points), and every line is
|
||
/// a `RunReport` over the momentum param surface, carrying the bool axis as a
|
||
/// TYPED `{"Bool":...}` param (both `true` and `false` appear across the grid).
|
||
/// The disk-trace test pins the member-dir names; this pins the binary's actual
|
||
/// stdout. A selector miswiring (`momentum`→`SmaCross`) would leave that test
|
||
/// green — the dirs would still differ — while silently printing SMA reports
|
||
/// here; nothing else catches it. Asserts on structure + the typed bool token,
|
||
/// never the volatile commit value or the metric floats, so it stays
|
||
/// deterministic. Run in a fresh temp cwd so the family-store ordinal is stable.
|
||
#[test]
|
||
fn sweep_strategy_momentum_prints_eight_typed_bool_member_lines() {
|
||
let cwd = temp_cwd("sweep-strategy-momentum");
|
||
let out = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "momentum"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --strategy momentum");
|
||
assert!(
|
||
out.status.success(),
|
||
"momentum sweep exit: {:?}; stderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
|
||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
let lines: Vec<&str> = stdout.lines().collect();
|
||
// 2x2x2 momentum grid = 8 member lines (vs the default SMA arm's 4): the
|
||
// selector switched the swept surface, observable at stdout.
|
||
assert_eq!(lines.len(), 8, "momentum sweep must print 8 family lines: {stdout:?}");
|
||
|
||
// Every line is a family member: assigned id + a nested RunReport over the
|
||
// MOMENTUM param surface (the SMA arm carries `signals.trend.*`, never
|
||
// `ema.length`/`longonly.enabled`).
|
||
for line in &lines {
|
||
assert!(line.starts_with("{\"family_id\":\"sweep-0\",\"report\":{\"manifest\":{"), "got: {line}");
|
||
assert!(line.contains("[\"ema.length\","), "momentum surface missing ema.length: {line}");
|
||
assert!(!line.contains("signals.trend.fast.length"), "must not be the SMA surface: {line}");
|
||
}
|
||
|
||
// The bool axis is carried as a TYPED `{"Bool":...}` param, both values
|
||
// present across the 8-point grid — the bool-param proof at the stdout edge.
|
||
assert!(
|
||
stdout.contains("[\"longonly.enabled\",{\"Bool\":true}]"),
|
||
"missing typed Bool=true param: {stdout}"
|
||
);
|
||
assert!(
|
||
stdout.contains("[\"longonly.enabled\",{\"Bool\":false}]"),
|
||
"missing typed Bool=false param: {stdout}"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
/// Property (#105, the bool actually gates — observable on disk): the
|
||
/// `longonly.enabled` param is genuinely WIRED into the momentum harness, not a
|
||
/// no-op knob. For one grid point's two bool members (same `ema.length` +
|
||
/// `exposure.scale`, differing only in the bool), the `enabled=true` member
|
||
/// records a long-only exposure stream — every recorded value `>= 0` — while the
|
||
/// otherwise-identical `enabled=false` member records at least one NEGATIVE
|
||
/// exposure over the same deterministic showcase stream. This pins the gate's
|
||
/// effect on the persisted C7 exposure tap end-to-end: the LongOnly unit test
|
||
/// proves `max(x,0)` in isolation, the dir-name test proves the bool reaches the
|
||
/// key, but only this proves the bool changes the OBSERVED run output. A
|
||
/// regression that dropped the gate from the wiring (passing exposure straight to
|
||
/// the broker) would make the two members' exposure taps identical — this fails
|
||
/// loudly. Asserts on the sign (>=0 / <0), never exact floats, so it is
|
||
/// deterministic across builds.
|
||
#[test]
|
||
fn momentum_longonly_true_clamps_exposure_nonnegative_false_keeps_negatives() {
|
||
let cwd = temp_cwd("momentum-longonly-gate");
|
||
let traced = Command::new(BIN)
|
||
.args(["sweep", "--strategy", "momentum", "--trace", "gate"])
|
||
.current_dir(&cwd)
|
||
.output()
|
||
.expect("spawn aura sweep --strategy momentum --trace");
|
||
assert!(
|
||
traced.status.success(),
|
||
"momentum --trace exit: {:?}; stderr: {}",
|
||
traced.status,
|
||
String::from_utf8_lossy(&traced.stderr)
|
||
);
|
||
|
||
// One grid point, its two bool members (identical but for the bool).
|
||
let base = cwd.join("runs/traces/gate");
|
||
let enabled = base.join("ema.length-5_exposure.scale-1_longonly.enabled-true");
|
||
let disabled = base.join("ema.length-5_exposure.scale-1_longonly.enabled-false");
|
||
|
||
let exposures = |dir: &std::path::Path| -> Vec<f64> {
|
||
let body = std::fs::read_to_string(dir.join("exposure.json")).expect("read exposure.json");
|
||
// the single f64 column's comma-separated values (substring parse, no serde).
|
||
json_array_body(&body, "\"columns\":[[")
|
||
.split(',')
|
||
.map(|t| t.trim().parse::<f64>().expect("exposure value is an f64"))
|
||
.collect()
|
||
};
|
||
|
||
let enabled_vals = exposures(&enabled);
|
||
let disabled_vals = exposures(&disabled);
|
||
|
||
// enabled=true is a long-only gate: NO recorded exposure is negative.
|
||
assert!(
|
||
enabled_vals.iter().all(|&x| x >= 0.0),
|
||
"long-only (enabled=true) must clamp short exposure to >= 0; got: {enabled_vals:?}"
|
||
);
|
||
// enabled=false passes through: at least one negative survives over the same
|
||
// showcase stream — proof the bool genuinely changes the run, not a no-op.
|
||
assert!(
|
||
disabled_vals.iter().any(|&x| x < 0.0),
|
||
"pass-through (enabled=false) must keep a short exposure over this stream; got: {disabled_vals:?}"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&cwd);
|
||
}
|
||
|
||
// --- Real-data family path (#106, plan 0060 Task 5) -------------------------
|
||
//
|
||
// These drive `aura sweep|walkforward --real <SYMBOL>` end to end over the local
|
||
// data-server archive. The gated tests need the Pepperstone M1 archive present;
|
||
// they skip (print + pass) on a data-less machine, the project's
|
||
// skip-on-no-data convention. The pip-refusal test is NOT gated: the un-specced
|
||
// symbol is rejected before any data access.
|
||
|
||
// EURUSD 2024-06 (UTC inclusive ms): a bounded vetted real window.
|
||
const EURUSD_JUN2024_FROM_MS: i64 = 1_717_200_000_000;
|
||
const EURUSD_JUN2024_TO_MS: i64 = 1_719_791_999_999;
|
||
|
||
// EURUSD 2024 full year (UTC inclusive ms): 2024-01-01 00:00:00.000 ..
|
||
// 2024-12-31 23:59:59.999 — a span wide enough for several 90/30/30-day
|
||
// walk-forward windows.
|
||
const EURUSD_2024_FROM_MS: i64 = 1_704_067_200_000;
|
||
const EURUSD_2024_TO_MS: i64 = 1_735_689_599_999;
|
||
|
||
fn local_data_present() -> bool {
|
||
std::path::Path::new(data_server::DEFAULT_DATA_PATH).is_dir()
|
||
}
|
||
|
||
/// Property: `aura sweep --real EURUSD` runs the whole built-in grid over REAL M1
|
||
/// close bars (not the synthetic VecSource), persisting one filesystem-portable
|
||
/// member dir per grid point — the four-point SMA grid lands as four
|
||
/// `[A-Za-z0-9._-]` dirs under `runs/traces/<name>/`, and one of them charts as a
|
||
/// self-contained uPlot page. This pins the real source actually flowing through
|
||
/// the family builders (Tasks 2-4) at the built-binary boundary, gated on local
|
||
/// data so it never fails on a data-less machine.
|
||
#[test]
|
||
fn sweep_real_persists_portable_member_dirs_over_real_bars() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let dir = temp_cwd("sweep_real");
|
||
let out = std::process::Command::new(BIN)
|
||
.args(["sweep", "--real", "EURUSD",
|
||
"--from", &EURUSD_JUN2024_FROM_MS.to_string(),
|
||
"--to", &EURUSD_JUN2024_TO_MS.to_string(),
|
||
"--trace", "swpr"])
|
||
.current_dir(&dir).output().unwrap();
|
||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
let members: Vec<_> = std::fs::read_dir(dir.join("runs/traces/swpr")).unwrap()
|
||
.map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
|
||
assert_eq!(members.len(), 4, "four sweep members: {members:?}");
|
||
assert!(members.iter().all(|k| k.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))),
|
||
"all keys filesystem-portable: {members:?}");
|
||
// one member charts as a uPlot page
|
||
let key = &members[0];
|
||
let chart = std::process::Command::new(BIN)
|
||
.args(["chart", &format!("swpr/{key}")]).current_dir(&dir).output().unwrap();
|
||
assert!(chart.status.success(), "stderr: {}", String::from_utf8_lossy(&chart.stderr));
|
||
assert!(String::from_utf8_lossy(&chart.stdout).contains("uPlot"));
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property: `aura walkforward --real EURUSD` over a full year of real bars rolls
|
||
/// the real-data window roller (90/30/30-day, ns-stamped) into SEVERAL OOS
|
||
/// windows, persisting one `oos<ns>`-keyed member dir per window. Pins that the
|
||
/// windowed real path draws its span from the probed `--from..--to` (not the
|
||
/// synthetic showcase span) and that the roller produces a multi-window family
|
||
/// over real data, gated on the local archive.
|
||
#[test]
|
||
fn walkforward_real_persists_one_oos_member_per_window() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let dir = temp_cwd("wf_real");
|
||
// a full year so 90/30/30-day rolling fits several windows
|
||
let out = std::process::Command::new(BIN)
|
||
.args(["walkforward", "--real", "EURUSD",
|
||
"--from", &EURUSD_2024_FROM_MS.to_string(),
|
||
"--to", &EURUSD_2024_TO_MS.to_string(),
|
||
"--trace", "wfr"])
|
||
.current_dir(&dir).output().unwrap();
|
||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||
let members: Vec<_> = std::fs::read_dir(dir.join("runs/traces/wfr")).unwrap()
|
||
.map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
|
||
assert!(members.iter().all(|k| k.starts_with("oos")), "oos<ns> member dirs: {members:?}");
|
||
assert!(members.len() >= 2, "several OOS windows over a year: {members:?}");
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property: the per-instrument pip refusal fires for `aura sweep --real
|
||
/// <unspecced>` exactly as it does for `aura run --real` — an un-vetted symbol
|
||
/// (`AAPL.US`, absent from the instrument table) is rejected with exit 2 and the
|
||
/// "no vetted pip" message BEFORE any data access. NOT gated: the refusal
|
||
/// precedes the archive entirely, so it is CI-safe and runs on every machine.
|
||
#[test]
|
||
fn sweep_real_unspecced_symbol_refuses_with_exit_2() {
|
||
// not gated: the pip refusal happens before any data access.
|
||
let dir = temp_cwd("sweep_real_refuse");
|
||
let out = std::process::Command::new(BIN)
|
||
.args(["sweep", "--real", "AAPL.US"]).current_dir(&dir).output().unwrap();
|
||
assert_eq!(out.status.code(), Some(2));
|
||
assert!(String::from_utf8_lossy(&out.stderr).contains("no vetted pip"));
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (C1): the same real sweep run twice is byte-identical — re-running
|
||
/// `aura sweep --real EURUSD` over the same bounded window produces an identical
|
||
/// report body. The whole determinism contract rests on a real backtest being
|
||
/// reproducible; a thread-race in the real source ingestion or member fold would
|
||
/// surface as a drift here. Gated on the local archive.
|
||
#[test]
|
||
fn sweep_real_is_byte_deterministic_across_runs() {
|
||
if !local_data_present() {
|
||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||
return;
|
||
}
|
||
let run = || {
|
||
// a fresh wiped cwd per call -> an empty registry each run, so the
|
||
// `family_id` ordinal is `sweep-0` for both; the raw stdout bodies are
|
||
// directly comparable with no ordinal normalisation needed.
|
||
let dir = temp_cwd("sweep_real_det");
|
||
let o = std::process::Command::new(BIN)
|
||
.args(["sweep", "--real", "EURUSD",
|
||
"--from", &EURUSD_JUN2024_FROM_MS.to_string(),
|
||
"--to", &EURUSD_JUN2024_TO_MS.to_string()])
|
||
.current_dir(&dir).output().unwrap();
|
||
let body = String::from_utf8_lossy(&o.stdout).into_owned();
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
body
|
||
};
|
||
assert_eq!(run(), run(), "same real sweep twice must be byte-identical (C1)");
|
||
}
|
||
|
||
/// Property (spec-0060 MC exclusion): `aura mc` admits NO `--real` flag. The
|
||
/// spec carves MC out of the real-data path — its seed varies a *synthetic*
|
||
/// price-walk realization, undefined over real bars (one realization -> identical
|
||
/// members). The observable contract is that `aura mc --real EURUSD` is REJECTED
|
||
/// (usage on stderr, exit 2) at the binary boundary, never silently accepted as a
|
||
/// real run. This pins the exclusion: if an `["mc", rest @ ..]` arm ever wired
|
||
/// `--real` into MC, this test fails loudly. NOT gated — the refusal precedes any
|
||
/// data access, so it is CI-safe on every machine.
|
||
#[test]
|
||
fn mc_rejects_real_flag_with_usage_exit_2() {
|
||
let dir = temp_cwd("mc_no_real");
|
||
let out = Command::new(BIN)
|
||
.args(["mc", "--real", "EURUSD"])
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn aura mc --real");
|
||
assert_eq!(out.status.code(), Some(2), "mc --real must exit 2; status: {:?}", out.status);
|
||
assert!(out.stdout.is_empty(), "mc --real must not emit a run on stdout: {:?}", out.stdout);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains("usage"), "mc --real stderr must carry usage: {stderr:?}");
|
||
// the exclusion is total: no `runs/` registry write either.
|
||
assert!(!dir.join("runs").exists(), "mc --real must not start a real run");
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Property (window-flags-require-`--real`, at the binary boundary): a family
|
||
/// command given a `--from`/`--to` window WITHOUT `--real` is a usage error —
|
||
/// there is no synthetic window knob, so `aura sweep --from 100` and
|
||
/// `aura walkforward --to 200` exit 2 (usage on stderr), never a silent synthetic
|
||
/// run over the whole built-in stream. The pure grammar is unit-tested in
|
||
/// `main.rs`; this pins that the `Err` arm actually wires through to `exit(2)` at
|
||
/// the dispatch for BOTH family commands. NOT gated — the refusal is pure
|
||
/// argument parsing, before any data access.
|
||
#[test]
|
||
fn family_window_flag_without_real_refuses_with_usage_exit_2() {
|
||
// (subcommand args, the subcommand token the usage message must name)
|
||
for (args, cmd) in [
|
||
(&["sweep", "--from", "100"][..], "sweep"),
|
||
(&["sweep", "--to", "200"][..], "sweep"),
|
||
(&["walkforward", "--from", "100"][..], "walkforward"),
|
||
(&["walkforward", "--to", "200"][..], "walkforward"),
|
||
] {
|
||
let dir = temp_cwd("family_window_no_real");
|
||
let out = Command::new(BIN)
|
||
.args(args)
|
||
.current_dir(&dir)
|
||
.output()
|
||
.unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}"));
|
||
assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status);
|
||
assert!(out.stdout.is_empty(), "`aura {args:?}` must emit no run on stdout: {:?}", out.stdout);
|
||
// the refusal carries the subcommand's usage, which names `--real` — the
|
||
// flag the window needs and was given without.
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stderr.contains(cmd), "`aura {args:?}` stderr must name `{cmd}`: {stderr:?}");
|
||
assert!(stderr.contains("--real"), "`aura {args:?}` stderr must name `--real`: {stderr:?}");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
}
|