b18a695531
A single run recorded into `runs/traces/<render-name>/`, so a second run of the same blueprint overwrote the first and could leave a stale tap file beside an index that no longer listed it. The directory is now `runs/traces/<render-name>-<id8>/`, where the id is a digest over the run's own manifest: two runs differing in any identity-bearing input land in two directories, two runs differing in nothing land in one. The digest hashes the manifest with its two provenance fields removed and its parameter vectors merged. - `commit` (the aura binary's build sha) and `project.commit` (the project repo's HEAD plus a `-dirty` marker, re-derived on every invocation) record who built or checked out the code, not what the run was. The latter would have minted a fresh directory for any uncommitted file anywhere in the project worktree — the authoring loop's normal state. - `params` and `defaults` are merged name-sorted before hashing: how a value was supplied is not what the run is, so a no-op `--override` no longer splits one run across two directories. The two vectors are disjoint by construction. `project.dylib_sha256` stays in. It is what the C13 hot-reload comparison varies — an unchanged blueprint against a rebuilt node crate — which the campaign leg structurally cannot express, since its axes vary params, instrument and window, never code. The manifest is now assembled before the tap bind rather than after the run. Every input it needs was already resolved at that point, and the one value both keys the directory and enters the report, so a handle can never name one identity while the record describes another. `aura chart <render-name>` no longer names a directory; it now lists the trace handles beginning with that name instead of refusing blankly. `TraceStoreError::NameTaken` stops prescribing `--trace`, retired in #319. C18, C22, C23 and C27 record the new identity, each superseded clause moved verbatim to its history sidecar; C23 gains its first. closes #311
737 lines
33 KiB
Rust
737 lines
33 KiB
Rust
//! #282: a hand-authored blueprint with a declared tap, run on the single-run
|
|
//! path, records the tapped series; the same blueprint in a sweep does not
|
|
//! (`run_blueprint_member`, the sweep/reduce path, never calls `bind_tap` —
|
|
//! structurally guaranteed by Task 6 leaving it untouched, not re-asserted
|
|
//! here).
|
|
|
|
use std::path::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 this process test (so the run's
|
|
/// `./runs/traces/` never dirties the repo). Unique per test + per process.
|
|
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
|
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-tap-{name}"));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
|
dir
|
|
}
|
|
|
|
/// The exact synthetic price array `r_sma_prices()` (main.rs) feeds the
|
|
/// `RunData::Synthetic` path — duplicated here (not re-exported; a private
|
|
/// CLI helper) so the tapped SMA(2) column can be pinned against an
|
|
/// independently-derived expectation, not a blind capture.
|
|
const R_SMA_PRICES: [f64; 18] = [
|
|
1.0000, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034, 1.0012, 0.9998,
|
|
1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
|
|
];
|
|
|
|
/// The shipped `examples/r_sma.json` blueprint, with one additional declared
|
|
/// tap on node 0 ("fast", `SMA(length=2)`) field 0 — the smallest addition
|
|
/// that names an interior producer output without touching the existing
|
|
/// topology (nodes/edges/input_roles/output all verbatim).
|
|
fn tap_blueprint_json() -> String {
|
|
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
|
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
|
|
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
|
|
v["blueprint"]["taps"] = serde_json::json!([{"name": "fast_tap", "from": {"node": 0, "field": 0}}]);
|
|
serde_json::to_string(&v).expect("re-serialize tapped blueprint")
|
|
}
|
|
|
|
/// The `trace_name` handle a recording run printed on its stdout record line —
|
|
/// the address of the directory its taps landed in. #311: that directory is
|
|
/// `<render-name>-<id8>`, keyed by the run's own identity, so no test may
|
|
/// hard-code it; every trace path in this file is derived from this value.
|
|
fn trace_handle(stdout: &[u8]) -> String {
|
|
let line = String::from_utf8_lossy(stdout);
|
|
let v: serde_json::Value =
|
|
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
|
|
v["trace_name"]
|
|
.as_str()
|
|
.expect("a recording run prints its trace_name")
|
|
.to_string()
|
|
}
|
|
|
|
/// Property: on the single-run path (`aura run <blueprint.json>` over the
|
|
/// built-in synthetic stream, no `--real`), a declared tap is bound and its
|
|
/// per-cycle series is persisted through the trace store — the persisted
|
|
/// `fast_tap.json` carries exactly the SMA(2) values over the known synthetic
|
|
/// price stream (silent until warm at sample 2, one value per cycle after).
|
|
#[test]
|
|
fn single_run_persists_a_declared_tap_series() {
|
|
let cwd = temp_cwd("single-run-persists");
|
|
let bp_path = cwd.join("tapped_r_sma.json");
|
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
|
|
|
let out = Command::new(BIN)
|
|
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn aura run");
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
|
|
let trace_path =
|
|
cwd.join("runs/traces").join(trace_handle(&out.stdout)).join("fast_tap.json");
|
|
let trace_text = std::fs::read_to_string(&trace_path).unwrap_or_else(|e| {
|
|
panic!("expected a persisted tap trace at {}: {e}", trace_path.display())
|
|
});
|
|
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
|
|
|
|
assert_eq!(trace["tap"], "fast_tap");
|
|
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
|
|
|
|
// SMA(2), silent until warm (2 samples): first emission at the 2nd sample
|
|
// (ts=2), value = mean(prices[0], prices[1]); then a rolling mean of the
|
|
// last two prices per subsequent cycle (ts = i+1 for prices[i]).
|
|
let expected: Vec<f64> = (1..R_SMA_PRICES.len())
|
|
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
|
|
.collect();
|
|
let expected_ts: Vec<i64> = (2..=R_SMA_PRICES.len() as i64).collect();
|
|
|
|
let got_ts: Vec<i64> = trace["ts"].as_array().expect("ts array").iter().map(|v| v.as_i64().unwrap()).collect();
|
|
assert_eq!(got_ts, expected_ts, "recorded timestamps");
|
|
|
|
let got: Vec<f64> = trace["columns"][0]
|
|
.as_array()
|
|
.expect("columns[0] array")
|
|
.iter()
|
|
.map(|v| v.as_f64().unwrap())
|
|
.collect();
|
|
assert_eq!(got.len(), expected.len(), "recorded row count");
|
|
for (i, (g, e)) in got.iter().zip(expected.iter()).enumerate() {
|
|
assert!((g - e).abs() < 1e-9, "row {i}: got {g}, expected {e}");
|
|
}
|
|
}
|
|
|
|
/// A tap-free blueprint run (the existing `run_prints_json_and_exits_zero`
|
|
/// shape) leaves no `runs/` directory at all — the tap machinery is fully
|
|
/// inert (no binds, no trace_store write) when a blueprint declares no taps,
|
|
/// matching Task 6's byte-identical guarantee.
|
|
#[test]
|
|
fn tap_free_run_writes_no_trace_store() {
|
|
let cwd = temp_cwd("tap-free-writes-nothing");
|
|
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
|
let out = Command::new(BIN)
|
|
.args(["exec", &bp])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn aura run");
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
assert!(!cwd.join("runs").exists(), "a tap-free run must not create a trace store");
|
|
}
|
|
|
|
/// The shipped `examples/r_sma.json` blueprint with TWO declared taps that
|
|
/// share the same name (`"dup"`), on two distinct producers (node 0 "fast" and
|
|
/// node 1 "slow") — the shape `run_signal_r`'s `seen: BTreeSet` dedup guard
|
|
/// exists for (`FlatGraph::bind_tap` itself keeps no cross-call state per its
|
|
/// own doc comment, so nothing upstream of the CLI catches this).
|
|
fn duplicate_tap_blueprint_json() -> String {
|
|
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
|
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
|
|
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
|
|
v["blueprint"]["taps"] = serde_json::json!([
|
|
{"name": "dup", "from": {"node": 0, "field": 0}},
|
|
{"name": "dup", "from": {"node": 1, "field": 0}},
|
|
]);
|
|
serde_json::to_string(&v).expect("re-serialize duplicate-tap blueprint")
|
|
}
|
|
|
|
/// The shipped `examples/r_sma.json` blueprint with two distinctly named
|
|
/// declared taps: `fast_tap` on node 0 ("fast", SMA(2)) and `slow_tap` on
|
|
/// node 1 ("slow") — the smallest fixture on which an explicit `--tap`
|
|
/// plan and the record-all default can be compared file-by-file (#310).
|
|
fn two_tap_blueprint_json() -> String {
|
|
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
|
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
|
|
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
|
|
v["blueprint"]["taps"] = serde_json::json!([
|
|
{"name": "fast_tap", "from": {"node": 0, "field": 0}},
|
|
{"name": "slow_tap", "from": {"node": 1, "field": 0}},
|
|
]);
|
|
serde_json::to_string(&v).expect("re-serialize two-tap blueprint")
|
|
}
|
|
|
|
/// Property: **a blueprint declaring two taps under the same name is refused
|
|
/// on the single-run path with a named error and exit code 1, before any
|
|
/// trace is persisted** — the CLI-owned `TapBindError::DuplicateBind` dedup
|
|
/// guard (bind_tap itself binds a single tap and keeps no cross-call state,
|
|
/// per its own doc comment) actually fires end-to-end, and the partial first
|
|
/// bind never reaches disk as a half-written trace store.
|
|
#[test]
|
|
fn single_run_refuses_a_duplicate_tap_name_before_persisting_anything() {
|
|
let cwd = temp_cwd("duplicate-tap-name-refused");
|
|
let bp_path = cwd.join("dup_tap_r_sma.json");
|
|
std::fs::write(&bp_path, duplicate_tap_blueprint_json()).expect("write dup-tap blueprint");
|
|
|
|
let out = Command::new(BIN)
|
|
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn aura run");
|
|
|
|
assert!(!out.status.success(), "a duplicate tap name must be refused, not silently bound");
|
|
// C14 class 2: fault in argv-named content (#297)
|
|
assert_eq!(out.status.code(), Some(2));
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(
|
|
stderr.contains("dup") && stderr.contains("more than once"),
|
|
"stderr must name the offending tap and the duplicate-bind fault: {stderr}"
|
|
);
|
|
assert!(!cwd.join("runs").exists(), "a refused run must not persist a half-written trace store");
|
|
}
|
|
|
|
/// The `aura: writing tap traces failed: …` register, pre-run arm: `runs`
|
|
/// exists as a FILE, so the trace store's directory creation
|
|
/// (`begin_run`) fails before the run — exit 1 through the register.
|
|
/// (This register was code-present but untested before #283.)
|
|
#[test]
|
|
fn an_unwritable_store_root_exits_through_the_tap_trace_register() {
|
|
let cwd = temp_cwd("store-root-is-a-file");
|
|
let bp_path = cwd.join("tapped_r_sma.json");
|
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
|
std::fs::write(cwd.join("runs"), b"not a directory").expect("occupy runs as a file");
|
|
|
|
let out = Command::new(BIN)
|
|
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn aura run");
|
|
|
|
assert!(!out.status.success(), "an unwritable store must refuse, not succeed");
|
|
// C14 class 1: environment fault (#297)
|
|
assert_eq!(out.status.code(), Some(1));
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(
|
|
stderr.contains("writing tap traces failed"),
|
|
"the tap-trace register must fire: {stderr}"
|
|
);
|
|
}
|
|
|
|
/// The same register, deferred arm (#283): the run directory pre-exists
|
|
/// read-only, so `begin_run` succeeds (create_dir_all on an existing dir)
|
|
/// but the record consumer's deferred open in `initialize` fails; the run
|
|
/// COMPLETES, the failure surfaces terminally through the register, and no
|
|
/// `index.json` is written (the store's treat-as-absent crash shape).
|
|
///
|
|
/// #311: the run directory's name is the run's own identity and cannot be known
|
|
/// in advance, so the premise is set up by RUNNING first — the identity property
|
|
/// itself guarantees that a second, byte-identical invocation targets the same
|
|
/// directory. Emptying it and dropping its write bit between the two runs
|
|
/// reproduces the original premise exactly (an existing, unwritable run dir)
|
|
/// without hard-coding a name. Making the store ROOT unwritable instead would
|
|
/// move the failure to `begin_run`, i.e. into the pre-run arm its sibling at
|
|
/// `an_unwritable_store_root_exits_through_the_tap_trace_register` already pins.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let cwd = temp_cwd("run-dir-read-only");
|
|
let bp_path = cwd.join("tapped_r_sma.json");
|
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
|
|
|
let exec = || {
|
|
Command::new(BIN)
|
|
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn aura run")
|
|
};
|
|
|
|
let first = exec();
|
|
assert!(first.status.success(), "stderr: {}", String::from_utf8_lossy(&first.stderr));
|
|
let run_dir = cwd.join("runs/traces").join(trace_handle(&first.stdout));
|
|
for entry in std::fs::read_dir(&run_dir).expect("read the run dir") {
|
|
std::fs::remove_file(entry.expect("dir entry").path()).expect("empty the run dir");
|
|
}
|
|
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o555))
|
|
.expect("make run dir read-only");
|
|
|
|
let out = exec();
|
|
|
|
// restore permissions FIRST so the next run's temp_cwd cleanup works.
|
|
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o755))
|
|
.expect("restore run dir permissions");
|
|
|
|
assert!(!out.status.success(), "a failed writer must surface, not pass silently");
|
|
// C14 class 1: environment fault (#297)
|
|
assert_eq!(out.status.code(), Some(1));
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(
|
|
stderr.contains("writing tap traces failed"),
|
|
"the tap-trace register must fire terminally: {stderr}"
|
|
);
|
|
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape");
|
|
assert_eq!(
|
|
std::fs::read_dir(cwd.join("runs/traces")).expect("read the store").count(),
|
|
1,
|
|
"the identical re-run targeted the same directory rather than minting a second"
|
|
);
|
|
}
|
|
|
|
/// #310: `--tap fast_tap=mean` subscribes the declared tap to the `mean`
|
|
/// fold instead of the record-all default — the trace store holds ONE
|
|
/// summary row whose value is the mean of the full SMA(2) series, and
|
|
/// `index.json` lists exactly the subscribed tap.
|
|
#[test]
|
|
fn run_tap_selector_persists_a_fold_summary_row() {
|
|
let cwd = temp_cwd("tap-selector-mean");
|
|
let bp_path = cwd.join("tapped_r_sma.json");
|
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
|
|
|
let out = Command::new(BIN)
|
|
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn aura run");
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
|
|
let run_dir = cwd.join("runs/traces").join(trace_handle(&out.stdout));
|
|
let index_text =
|
|
std::fs::read_to_string(run_dir.join("index.json")).expect("read index.json");
|
|
let index: serde_json::Value = serde_json::from_str(&index_text).expect("parse index.json");
|
|
assert_eq!(index["taps"], serde_json::json!(["fast_tap"]));
|
|
|
|
let trace_text =
|
|
std::fs::read_to_string(run_dir.join("fast_tap.json")).expect("read fold trace");
|
|
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
|
|
assert_eq!(trace["tap"], "fast_tap");
|
|
let rows = trace["columns"][0].as_array().expect("columns[0] array");
|
|
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
|
|
let sma: Vec<f64> = (1..R_SMA_PRICES.len())
|
|
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
|
|
.collect();
|
|
let expected = sma.iter().sum::<f64>() / sma.len() as f64;
|
|
let got = rows[0].as_f64().expect("f64 row");
|
|
assert!((got - expected).abs() < 1e-9, "mean row: got {got}, expected {expected}");
|
|
}
|
|
|
|
/// #310: an all-`record` explicit plan is byte-identical to the no-flag
|
|
/// record-all default — the selector replaces the default without
|
|
/// changing what `record` itself writes (C1 determinism across runs).
|
|
#[test]
|
|
fn run_explicit_record_plan_matches_the_record_all_default() {
|
|
let cwd_a = temp_cwd("record-default");
|
|
let cwd_b = temp_cwd("record-explicit");
|
|
for cwd in [&cwd_a, &cwd_b] {
|
|
std::fs::write(cwd.join("two_tap.json"), two_tap_blueprint_json())
|
|
.expect("write two-tap blueprint");
|
|
}
|
|
let run = |cwd: &std::path::Path, extra: &[&str]| -> std::path::PathBuf {
|
|
let mut args = vec!["exec", "two_tap.json"];
|
|
args.extend_from_slice(extra);
|
|
let out = Command::new(BIN)
|
|
.args(&args)
|
|
.current_dir(cwd)
|
|
.output()
|
|
.expect("spawn aura run");
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
cwd.join("runs/traces").join(trace_handle(&out.stdout))
|
|
};
|
|
let dir_a = run(&cwd_a, &[]);
|
|
let dir_b = run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]);
|
|
// #311: the tap PLAN is not identity-bearing — it selects what is written,
|
|
// not what the run IS — so both invocations mint the same handle.
|
|
assert_eq!(
|
|
dir_a.file_name(),
|
|
dir_b.file_name(),
|
|
"the same blueprint under the same inputs is one identity either way"
|
|
);
|
|
|
|
for file in ["fast_tap.json", "slow_tap.json"] {
|
|
let a = std::fs::read(dir_a.join(file)).expect("default trace");
|
|
let b = std::fs::read(dir_b.join(file)).expect("explicit trace");
|
|
assert_eq!(a, b, "{file} must be byte-identical across default and explicit record plans");
|
|
}
|
|
let taps_of = |dir: &std::path::Path| -> serde_json::Value {
|
|
let text = std::fs::read_to_string(dir.join("index.json")).expect("read index.json");
|
|
serde_json::from_str::<serde_json::Value>(&text).expect("parse index.json")["taps"].clone()
|
|
};
|
|
assert_eq!(taps_of(&dir_a), taps_of(&dir_b));
|
|
}
|
|
|
|
/// #310: an unknown fold label is refused through the registry's
|
|
/// roster-enumerating refusal, before any store write.
|
|
#[test]
|
|
fn run_tap_selector_refuses_an_unknown_label_with_the_roster() {
|
|
let cwd = temp_cwd("tap-selector-unknown-label");
|
|
let bp_path = cwd.join("tapped_r_sma.json");
|
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
|
let out = Command::new(BIN)
|
|
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=medain"])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn aura run");
|
|
assert!(!out.status.success(), "an unknown fold label must refuse");
|
|
// C14 class 2: fault in argv-named content (#297)
|
|
assert_eq!(out.status.code(), Some(2));
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(
|
|
stderr.contains("medain") && stderr.contains("mean"),
|
|
"refusal must name the label and enumerate the roster: {stderr}"
|
|
);
|
|
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
|
|
}
|
|
|
|
/// #310/#333: a selection naming a tap the blueprint does not declare is
|
|
/// refused typed (UndeclaredTap), before any store write, and the refusal
|
|
/// enumerates the blueprint's declared taps (the same way the unknown-fold
|
|
/// refusal enumerates the fold-registry roster) — on the two-tap fixture,
|
|
/// both `fast_tap` and `slow_tap` must be named so the author does not have
|
|
/// to reopen the blueprint JSON.
|
|
#[test]
|
|
fn run_tap_selector_refuses_an_undeclared_tap() {
|
|
let cwd = temp_cwd("tap-selector-undeclared");
|
|
let bp_path = cwd.join("two_tap.json");
|
|
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
|
let out = Command::new(BIN)
|
|
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "nope=mean"])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn aura run");
|
|
assert!(!out.status.success(), "an undeclared tap must refuse");
|
|
// C14 class 2: fault in argv-named content (#297)
|
|
assert_eq!(out.status.code(), Some(2));
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(stderr.contains("nope"), "refusal must name the offending tap: {stderr}");
|
|
assert!(
|
|
stderr.contains("fast_tap") && stderr.contains("slow_tap"),
|
|
"refusal must enumerate the blueprint's declared taps: {stderr}"
|
|
);
|
|
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
|
|
}
|
|
|
|
/// #334: an explicit `--tap` plan that leaves a declared tap unbound emits
|
|
/// the C14 benign skipped-tap note on stderr, per unbound tap, naming it —
|
|
/// exit code stays 0 (this is a note, not a refusal). `fast_tap` (subscribed)
|
|
/// gets no such note; `slow_tap` (left unlisted) does.
|
|
#[test]
|
|
fn run_tap_selector_notes_unbound_declared_taps_on_stderr() {
|
|
let cwd = temp_cwd("tap-selector-unbound-note");
|
|
let bp_path = cwd.join("two_tap.json");
|
|
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
|
|
|
let out = Command::new(BIN)
|
|
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn aura run");
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(
|
|
stderr.contains("aura: note: declared tap \"slow_tap\" unbound this run"),
|
|
"stderr must note the unbound declared tap: {stderr}"
|
|
);
|
|
assert!(
|
|
!stderr.contains("\"fast_tap\" unbound"),
|
|
"the subscribed tap must not be noted as unbound: {stderr}"
|
|
);
|
|
}
|
|
|
|
/// #334: the record-all default (no `--tap` flag) leaves no declared tap
|
|
/// unbound — nothing is skipped, so the note must never appear.
|
|
#[test]
|
|
fn run_with_no_tap_flag_emits_no_unbound_note() {
|
|
let cwd = temp_cwd("tap-selector-default-no-note");
|
|
let bp_path = cwd.join("two_tap.json");
|
|
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
|
|
|
let out = Command::new(BIN)
|
|
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn aura run");
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(
|
|
!stderr.contains("unbound this run"),
|
|
"the record-all default must never note a skipped tap: {stderr}"
|
|
);
|
|
}
|
|
|
|
/// #310: a malformed or duplicate `--tap` is a usage error (exit 2)
|
|
/// before anything runs.
|
|
#[test]
|
|
fn run_tap_selector_refuses_malformed_and_duplicate_pairs() {
|
|
let cwd = temp_cwd("tap-selector-usage");
|
|
let bp_path = cwd.join("tapped_r_sma.json");
|
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
|
for args in [
|
|
vec!["--tap", "fast_tapmean"],
|
|
vec!["--tap", "fast_tap=mean", "--tap", "fast_tap=last"],
|
|
] {
|
|
let mut full = vec!["exec", bp_path.to_str().expect("utf-8 path")];
|
|
full.extend(args);
|
|
let out = Command::new(BIN)
|
|
.args(&full)
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn aura run");
|
|
assert_eq!(out.status.code(), Some(2), "usage errors exit 2");
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(stderr.contains("--tap"), "usage message names the flag: {stderr}");
|
|
assert!(!cwd.join("runs").exists());
|
|
}
|
|
}
|
|
|
|
/// #309: a run that records traces names the handle they landed under, on the
|
|
/// data plane — so a caller can chain `exec` into `chart` without knowing the
|
|
/// blueprint's render name. The tapped fixture's root composite is named
|
|
/// `sma_signal`, which is exactly the directory the existing
|
|
/// `single_run_persists_a_declared_tap_series` reads its trace back from.
|
|
#[test]
|
|
fn recording_run_reports_its_trace_handle_on_stdout() {
|
|
let cwd = temp_cwd("trace-handle-echo");
|
|
let bp_path = cwd.join("tapped_r_sma.json");
|
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
|
|
|
let out = Command::new(BIN)
|
|
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn exec");
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
|
|
let line = String::from_utf8_lossy(&out.stdout);
|
|
let v: serde_json::Value = serde_json::from_str(line.trim()).expect("stdout is one JSON object");
|
|
let handle = v["trace_name"].as_str().expect("trace_name is present and a string");
|
|
// #311: the handle is the run's own IDENTITY, not its render name alone —
|
|
// the render name is the readable prefix, the 8-hex tail the run identity.
|
|
// (The next assertion, that the handle names the directory the traces
|
|
// landed in, is name-agnostic and unchanged.)
|
|
assert!(
|
|
handle.starts_with("sma_signal-"),
|
|
"the handle carries the run's render name as its prefix; got {handle}"
|
|
);
|
|
assert_eq!(
|
|
handle.len(),
|
|
"sma_signal-".len() + 8,
|
|
"the identity suffix is exactly 8 hex chars; got {handle}"
|
|
);
|
|
assert!(
|
|
cwd.join("runs/traces").join(handle).join("fast_tap.json").exists(),
|
|
"the reported handle must name the directory the traces landed in; got {handle}"
|
|
);
|
|
}
|
|
|
|
/// #309: the handle is appended, never interleaved — the report's own key
|
|
/// order and bytes survive the flattening wrapper untouched. This is the first
|
|
/// pin of `serde(flatten)` byte behaviour in this workspace; assert bytes.
|
|
///
|
|
/// #311: the handle's own spelling (`<render-name>-<id8>`) is pinned elsewhere
|
|
/// (`recording_run_reports_its_trace_handle_on_stdout`); this test only pins the
|
|
/// BYTE POSITION — that whatever the handle is, it is the last key, appended
|
|
/// after every other field, never interleaved — so it reads the handle back off
|
|
/// the same line rather than hard-coding its value.
|
|
#[test]
|
|
fn trace_handle_is_appended_as_the_last_key() {
|
|
let cwd = temp_cwd("trace-handle-bytes");
|
|
let bp_path = cwd.join("tapped_r_sma.json");
|
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
|
|
|
let out = Command::new(BIN)
|
|
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn exec");
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
let handle = trace_handle(out.stdout.as_slice());
|
|
|
|
let expected_tail = format!(",\"trace_name\":\"{handle}\"}}");
|
|
assert!(
|
|
line.ends_with(&expected_tail),
|
|
"the handle must be the last key, appended before the closing brace: {line}"
|
|
);
|
|
}
|
|
|
|
/// #309: a tap-free run records nothing, so it reports no handle — the
|
|
/// wrapper must not emit an empty key. (The line's key order and grouping are
|
|
/// pinned separately, in `exec.rs`; this asserts only the absent key.)
|
|
#[test]
|
|
fn tap_free_run_line_carries_no_trace_handle() {
|
|
let cwd = temp_cwd("trace-handle-absent");
|
|
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
|
|
|
let out = Command::new(BIN)
|
|
.args(["exec", &bp])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn exec");
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
assert!(
|
|
!line.contains("trace_name"),
|
|
"a run that recorded nothing must emit no trace_name key at all: {line}"
|
|
);
|
|
}
|
|
|
|
/// #311 headline: two runs of ONE blueprint that differ in an identity-bearing
|
|
/// input (an `--override` value that actually changes the bound parameter,
|
|
/// `fast.length=2` vs `fast.length=3`) land in TWO trace directories — the
|
|
/// first run's tapped series is still on disk, byte-unchanged, after the
|
|
/// second run completes. Before #311, both runs minted `traces/sma_signal/`
|
|
/// and the second overwrote the first, losing the earlier series
|
|
/// unrecoverably.
|
|
#[test]
|
|
fn two_runs_of_one_blueprint_with_different_overrides_keep_both_traces() {
|
|
let cwd = temp_cwd("two-overrides-two-traces");
|
|
let bp_path = cwd.join("tapped_r_sma.json");
|
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
|
|
|
let run = |value: &str| -> String {
|
|
let ov = format!("fast.length={value}");
|
|
let out = Command::new(BIN)
|
|
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--override", &ov])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn exec");
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
trace_handle(&out.stdout)
|
|
};
|
|
|
|
let handle_a = run("2");
|
|
let series_a = std::fs::read(cwd.join("runs/traces").join(&handle_a).join("fast_tap.json"))
|
|
.expect("the first run's tapped series");
|
|
let handle_b = run("3");
|
|
|
|
assert_ne!(
|
|
handle_a, handle_b,
|
|
"two runs differing in an identity-bearing input must land in two directories"
|
|
);
|
|
let series_b = std::fs::read(cwd.join("runs/traces").join(&handle_b).join("fast_tap.json"))
|
|
.expect("the second run's tapped series");
|
|
assert_ne!(series_a, series_b, "SMA(2) and SMA(3) are different series");
|
|
assert_eq!(
|
|
std::fs::read(cwd.join("runs/traces").join(&handle_a).join("fast_tap.json"))
|
|
.expect("the first run's series survives the second run"),
|
|
series_a,
|
|
"the earlier run's trace must survive the later run untouched"
|
|
);
|
|
for handle in [&handle_a, &handle_b] {
|
|
assert!(
|
|
cwd.join("runs/traces").join(handle).join("index.json").is_file(),
|
|
"each run closes its own index: {handle}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// #311 acceptance criterion 2: two runs identical in every identity-bearing
|
|
/// input write ONE directory and print ONE handle. Identity keying separates
|
|
/// runs that differ; it does not mint a directory per invocation.
|
|
#[test]
|
|
fn two_identical_runs_share_one_trace_directory() {
|
|
let cwd = temp_cwd("identical-runs-one-dir");
|
|
let bp_path = cwd.join("tapped_r_sma.json");
|
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
|
|
|
let run = || -> String {
|
|
let out = Command::new(BIN)
|
|
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn exec");
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
trace_handle(&out.stdout)
|
|
};
|
|
|
|
let first = run();
|
|
let second = run();
|
|
assert_eq!(first, second, "same inputs, same identity, same handle");
|
|
assert!(
|
|
first.starts_with("sma_signal-") && first.len() == "sma_signal-".len() + 8,
|
|
"the render name is the readable prefix, the 8-hex tail the identity: {first}"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_dir(cwd.join("runs/traces")).expect("read the store").count(),
|
|
1,
|
|
"an unchanged re-run must not grow the store"
|
|
);
|
|
}
|
|
|
|
/// #311 acceptance criterion 3 — and the pin for the two source-read properties
|
|
/// the `project.commit` exclusion rests on: the field carries a `-dirty` marker,
|
|
/// and it is re-derived on EVERY invocation (`aura-runner::project::project_commit`).
|
|
/// A run whose project worktree is dirty must still mint the handle it minted
|
|
/// with a clean worktree; otherwise editing any file in the project — including
|
|
/// the blueprint being worked on — would turn the store into an unbounded
|
|
/// directory generator.
|
|
#[test]
|
|
fn a_dirty_project_worktree_does_not_change_the_run_handle() {
|
|
let cwd = temp_cwd("provenance-does-not-churn");
|
|
std::fs::write(cwd.join("Aura.toml"), "# identity fixture: data-only project\n")
|
|
.expect("write Aura.toml");
|
|
let bp_path = cwd.join("tapped_r_sma.json");
|
|
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
|
// Ignore the trace store the first `exec()` below creates — otherwise
|
|
// `runs/` itself would be an untracked path and `git status --porcelain`
|
|
// would already report the worktree dirty on the SECOND invocation
|
|
// regardless of `scratch.txt`, making that write inert rather than the
|
|
// thing that actually flips the marker.
|
|
std::fs::write(cwd.join(".gitignore"), "runs/\n").expect("write .gitignore");
|
|
|
|
let git = |args: &[&str]| {
|
|
let out = Command::new("git")
|
|
.args(args)
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("run git");
|
|
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
|
};
|
|
git(&["init"]);
|
|
git(&["config", "user.email", "test@example.com"]);
|
|
git(&["config", "user.name", "test"]);
|
|
git(&["add", "-A"]);
|
|
git(&["commit", "-m", "fixture"]);
|
|
|
|
let exec = || -> (String, String) {
|
|
let out = Command::new(BIN)
|
|
.args(["exec", bp_path.to_str().expect("utf-8 path")])
|
|
.current_dir(&cwd)
|
|
.output()
|
|
.expect("spawn exec");
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
let line = String::from_utf8_lossy(&out.stdout);
|
|
let v: serde_json::Value =
|
|
serde_json::from_str(line.trim()).expect("stdout is one JSON record line");
|
|
let commit = v["manifest"]["project"]["commit"]
|
|
.as_str()
|
|
.expect("a run inside a git-backed project stamps its repo HEAD")
|
|
.to_string();
|
|
(trace_handle(&out.stdout), commit)
|
|
};
|
|
|
|
let (clean_handle, clean_commit) = exec();
|
|
// `runs/` is gitignored (above), so this is the only untracked change —
|
|
// it, and nothing else, is what flips `git status --porcelain` for the
|
|
// second invocation below.
|
|
std::fs::write(cwd.join("scratch.txt"), "an uncommitted edit\n").expect("dirty the worktree");
|
|
let (dirty_handle, dirty_commit) = exec();
|
|
|
|
// The two source-read properties, pinned: the marker exists, and the field
|
|
// is re-derived per invocation rather than captured once.
|
|
assert!(
|
|
!clean_commit.ends_with("-dirty"),
|
|
"a committed worktree stamps a bare HEAD: {clean_commit}"
|
|
);
|
|
assert!(
|
|
dirty_commit.ends_with("-dirty"),
|
|
"an uncommitted file stamps the -dirty marker: {dirty_commit}"
|
|
);
|
|
assert_ne!(clean_commit, dirty_commit, "project.commit is re-derived on every invocation");
|
|
|
|
// ...and yet it is the same run.
|
|
assert_eq!(
|
|
clean_handle, dirty_handle,
|
|
"the project repo's HEAD/dirty state is provenance, not identity"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_dir(cwd.join("runs/traces")).expect("read the store").count(),
|
|
1,
|
|
"one identity, one directory"
|
|
);
|
|
}
|