Files
Aura/crates/aura-cli/tests/graph_construct.rs
T
Brummel 62f6592ef6 feat(cli): surface the composite doc in the graph viewer (#125)
The viewer threads the model's doc through normalizeModel into both
composite view states — appended to INFO.B (collapsed box) and INFO.C
(expanded cluster frame) — and shows the root composite's doc as a muted
header line (#rootdoc span in GRAPH_HEAD, empty when absent; the DOM
population sits in the browser-only block, the same boundary as the
breadcrumb). viewer_tooltip.mjs pins the doc'd cases beside the
byte-exact un-doc'd pins; sample-model.json gains the additive doc key;
e2e drives aura graph over a nested-doc blueprint into the emitted page.

closes #125
2026-07-11 20:18:32 +02:00

1217 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! End-to-end coverage for the `aura graph build` / `aura graph introspect`
//! op-script CLI (#157, §C): drives the built `aura` binary over stdin/argv,
//! the exact surface a data-level author uses.
use std::io::Write;
use std::process::{Command, Stdio};
const BIN: &str = env!("CARGO_BIN_EXE_aura");
/// Run `aura <args>` with `stdin_doc` piped in; return (stdout, stderr, success).
fn run(args: &[&str], stdin_doc: &str) -> (String, String, bool) {
let mut child = Command::new(BIN)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn aura");
child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap();
let out = child.wait_with_output().expect("wait aura");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
}
/// Like `run`, but returns the exact process exit CODE (not just success/failure).
/// The exit-code partition (#175 iteration 2) is a property of the integer, so its
/// tests must assert `Some(1)` vs `Some(2)`, which `run`'s bool cannot express.
fn run_code(args: &[&str], stdin_doc: &str) -> (String, Option<i32>) {
let mut child = Command::new(BIN)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn aura");
child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap();
let out = child.wait_with_output().expect("wait aura");
(String::from_utf8_lossy(&out.stderr).into_owned(), out.status.code())
}
const SIGNAL_DOC: &str = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
/// Every op below applies cleanly (all eager checks pass), but `sub.rhs` is
/// never wired — a 0-cover input slot only the holistic finalize gate can
/// reject. The smallest document that is op-valid yet whole-document-invalid.
const UNCONNECTED_DOC: &str = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast"},
{"op":"add","type":"SMA","name":"slow"},
{"op":"add","type":"Sub"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#;
#[test]
fn graph_build_emits_blueprint_for_a_valid_document() {
let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC);
assert!(ok, "exit success");
assert!(stdout.contains("\"format_version\":1"), "emits the #155 envelope: {stdout}");
assert!(stdout.contains("\"SMA\""), "carries the nodes: {stdout}");
}
/// The canonical blueprint artifact carries no trailing newline: `aura graph build`
/// emits exactly the library `blueprint_to_json` bytes (the form #158 content-addresses),
/// not a display-framed line. Byte-canonical across the CLI and library surfaces (#164).
#[test]
fn graph_build_emit_is_byte_canonical_no_trailing_newline() {
let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC);
assert!(ok, "exit success");
assert!(!stdout.ends_with('\n'), "no trailing newline on the canonical emit: {stdout:?}");
assert!(stdout.contains("\"format_version\":1"), "still the #155 envelope: {stdout}");
}
#[test]
fn graph_build_fails_fast_at_the_offending_op() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit");
assert!(stdout.is_empty(), "no blueprint emitted on error: {stdout}");
assert!(stderr.contains("op 1 (add)"), "names the failing op: {stderr}");
assert!(stderr.contains("Nope"), "names the cause: {stderr}");
}
#[test]
fn graph_introspect_vocabulary_lists_the_node_types() {
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--vocabulary"], "");
assert!(ok, "exit success");
assert!(stdout.contains("SMA"), "lists SMA: {stdout}");
assert!(stdout.contains("Bias"), "lists Bias: {stdout}");
assert!(!stdout.contains("Recorder"), "a sink is not in the zero-arg vocabulary: {stdout}");
}
/// The #160 roster macro's whole point is that `std_vocabulary`'s match arms and
/// `std_vocabulary_types`'s enumerable list can never drift apart — but that
/// guarantee is checked in-process, inside `aura-std`'s own unit tests. This pins
/// the SAME closed-roster count one layer further out, through a code path the
/// roster macro cannot see: `Env::type_ids()` (project std concatenation) and
/// the CLI's println loop, observed across the real process boundary. A bug in
/// that CLI-side plumbing (a dropped or duplicated entry in the concatenation or
/// the print loop) would pass every roster-internal test yet still change what a
/// user actually sees from `aura graph introspect --vocabulary` with no project
/// loaded — this test is the only thing that would catch it.
#[test]
fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() {
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--vocabulary"], "");
assert!(ok, "exit success");
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
28,
"the std-only (no project) vocabulary has exactly the roster's 28 entries: {stdout}"
);
}
#[test]
fn graph_introspect_node_answers_ports_without_a_build() {
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "SMA"], "");
assert!(ok, "exit success");
assert!(stdout.contains("series"), "lists the input port: {stdout}");
assert!(stdout.contains("value"), "lists the output field: {stdout}");
}
#[test]
fn graph_introspect_unwired_reports_open_slots() {
let doc = r#"[
{"op":"add","type":"SMA","name":"fast"},
{"op":"add","type":"Sub"},
{"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#;
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--unwired"], doc);
assert!(ok, "exit success");
assert!(stdout.contains("sub.rhs"), "sub.rhs still open: {stdout}");
assert!(!stdout.contains("sub.lhs"), "sub.lhs is covered: {stdout}");
}
/// Property (holistic finalize path, binary contract): a fault only decidable
/// at end-of-document (a 0-covered interior slot) makes `aura graph build` exit
/// non-zero, route the cause to *stderr* as a `finalize:` fault (NOT attributed
/// to any op index), and emit nothing on stdout. The eager fail-fast path (above)
/// already exercises a per-op fault; this pins the iteration's eager/holistic
/// gate split as the binary's observable exit-code + stream-routing contract,
/// not merely the engine `replay` return value.
#[test]
fn graph_build_reports_a_holistic_fault_at_finalize() {
let (stdout, stderr, ok) = run(&["graph", "build"], UNCONNECTED_DOC);
assert!(!ok, "non-zero exit on a finalize fault");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(stderr.contains("finalize:"), "attributes the fault to finalize, not an op: {stderr}");
assert!(stderr.contains("finalize: slot") && stderr.contains("is unconnected"),
"the holistic fault renders by-identifier, got: {stderr}");
}
/// Property (determinism, domain invariant 1, at the CLI seam): the same op-list
/// document piped to `aura graph build` twice yields byte-identical blueprint
/// JSON on stdout. The #155 canonical form has no run-to-run ordering or
/// formatting jitter — a precondition for content-addressing a blueprint (#158).
/// The non-empty guard rules out two equal-but-empty error paths masquerading as
/// agreement.
#[test]
fn graph_build_is_byte_identical_across_runs() {
let (first, _e1, ok1) = run(&["graph", "build"], SIGNAL_DOC);
let (second, _e2, ok2) = run(&["graph", "build"], SIGNAL_DOC);
assert!(ok1 && ok2, "both runs succeed");
assert!(!first.is_empty(), "non-empty blueprint output");
assert_eq!(first, second, "byte-identical blueprint across runs");
}
/// Property (read-only query, clean rejection): `aura graph introspect --node`
/// with a type outside the closed vocabulary exits non-zero, names the offending
/// type on stderr, and prints no partial port listing on stdout. The introspect
/// surface's only negative-path E2E — it fails cleanly rather than half-answering.
#[test]
fn graph_introspect_node_rejects_an_unknown_type() {
let (stdout, stderr, ok) = run(&["graph", "introspect", "--node", "Bogus"], "");
assert!(!ok, "non-zero exit for an unknown type");
assert!(stdout.is_empty(), "no partial port listing emitted: {stdout}");
assert!(stderr.contains("Bogus"), "names the offending type: {stderr}");
}
/// Property (#162 item 2, bind-mismatch prose at the binary seam): an `aura graph
/// build` whose bind value-kind disagrees with the param's declared kind exits
/// non-zero and writes the cause as prose to stderr — naming the param path and
/// both the expected and actual kind — and never leaks the raw `KindMismatch`
/// Debug struct the pre-#162 `{err:?}` presenter exposed. SMA's `length` is i64,
/// so binding an `F64` is the smallest kind disagreement. Pins the user-facing
/// bind-error ergonomics at the binary, not merely the in-process presenter.
#[test]
fn graph_build_renders_a_bind_kind_mismatch_as_prose() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit on a bad bind");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(stderr.contains("param fast.length expects I64 but got F64"),
"phrases the bind mismatch as prose: {stderr}");
assert!(!stderr.contains("KindMismatch"),
"does not leak the Debug struct name: {stderr}");
}
/// Property (#162 item 3, discoverable bind form at the binary seam): `aura graph
/// introspect --node` annotates each param with its typed-Scalar bind wrapper, so
/// a hand-author learns the `{"<Kind>": <v>}` value form from the CLI alone
/// without reading source. SMA's `length` is i64, so the shown form is the `I64`
/// wrapper.
#[test]
fn graph_introspect_node_shows_the_typed_bind_form() {
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "SMA"], "");
assert!(ok, "exit success");
assert!(stdout.contains("(bind {\"I64\": <v>})"),
"shows the typed-Scalar bind-value form for the i64 param: {stdout}");
}
/// Property (#162 item 1, by-identifier finalize fault at the binary seam, a
/// non-unconnected variant): a fault decidable only at finalize that is NOT a
/// 0-covered slot — here one source role fanning into two slots of differing
/// scalar kind (an `f64` `Sub.lhs` and a `bool` `And.a`); `feed` runs no eager
/// kind check, so the disagreement surfaces only at the holistic finalize gate —
/// renders by *identifier*: `aura graph build` exits non-zero, routes a
/// `finalize:` fault to stderr, and names the offending role ("price") in prose,
/// never a raw machine role index. The companion unconnected-slot test pins one
/// finalize variant; this pins that the by-identifier translation generalises
/// across finalize variants.
#[test]
fn graph_build_renders_role_kind_mismatch_at_finalize_by_identifier() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"Sub","name":"sub"},
{"op":"add","type":"And","name":"conj"},
{"op":"feed","role":"price","into":["sub.lhs","conj.a"]}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit on a finalize fault");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(stderr.contains("finalize:"), "attributes the fault to finalize, not an op: {stderr}");
assert!(stderr.contains("input role price fans into slots of differing kinds"),
"names the offending role by-identifier in prose: {stderr}");
assert!(!stderr.contains("RoleKindMismatch"),
"does not leak the Debug variant name: {stderr}");
}
/// Property (#158 acc 1, the content-id surface): `aura graph introspect --content-id`
/// prints the 64-hex SHA256 of the op-script's canonical blueprint — deterministic across
/// runs and distinct for a structurally different topology. It is the same hash `aura run`
/// stamps as `topology_hash` (both via the one `content_id` primitive over the canonical
/// `blueprint_to_json` bytes), so a data-authored topology has a stable, comparable content
/// id. (Note: an op-script and the Rust `sma_signal` builder produce *different*
/// canonical forms — composite debug-name, an unbound `Bias.scale` — so they are different
/// topologies by the byte definition; content-addressing keys on the canonical form.)
#[test]
fn graph_introspect_content_id_is_deterministic_and_distinguishes() {
let (a, _e, ok) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
assert!(ok, "exit success");
let id = a.trim();
assert_eq!(id.len(), 64, "a 64-hex SHA256 content id: {id:?}");
assert!(id.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {id:?}");
// deterministic: the same op-script yields the same content id.
let (b, _e2, ok2) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
assert!(ok2, "exit success");
assert_eq!(id, b.trim(), "same op-script -> same content id");
// distinguishes: a structurally different topology (slow length 4 -> 5) differs.
let other = SIGNAL_DOC.replace("\"I64\":4", "\"I64\":5");
let (c, _e3, ok3) = run(&["graph", "introspect", "--content-id"], &other);
assert!(ok3, "exit success");
assert_ne!(id, c.trim(), "a different topology -> a different content id");
}
/// Property (negative): `aura graph introspect --content-id` on a malformed op-list exits
/// non-zero with the cause on stderr and prints no content id — it fails cleanly, never a
/// partial/garbage hash.
#[test]
fn graph_introspect_content_id_rejects_a_bad_document() {
let (stdout, stderr, ok) = run(&["graph", "introspect", "--content-id"], r#"[{"op":"add","type":"Nope"}]"#);
assert!(!ok, "non-zero exit on a bad op-list");
assert!(stdout.is_empty(), "no content id emitted on error: {stdout}");
assert!(stderr.contains("Nope"), "names the cause: {stderr}");
}
/// Property (exit-code partition, #175 iteration 2, deviation #8): a malformed
/// op-script piped to `aura graph build` is a RUNTIME failure — bad stdin *content*,
/// exit 1 — NOT the usage code 2. The attribution principle classifies bad
/// piped-stdin data as environment/runtime (the command line was well-formed),
/// distinct from a command-line fault. The sibling `graph_build_fails_fast_at_the_
/// offending_op` pins only `!ok` (non-zero); this pins the exact integer, so a
/// regression that reverted the graph_construct stdin site to the pre-split exit 2
/// fails here.
#[test]
fn graph_build_bad_stdin_content_is_runtime_exit_1() {
let (stderr, code) = run_code(&["graph", "build"], r#"[{"op":"add","type":"Nope"}]"#);
assert_eq!(code, Some(1), "bad stdin content is a runtime failure -> exit 1; stderr: {stderr}");
assert!(stderr.contains("Nope"), "still names the cause: {stderr}");
}
/// Property (exit-code partition, #175 iteration 2): within the SAME `graph
/// introspect` subcommand, an invalid `--node <T>` flag VALUE is a USAGE error (a
/// command-line fault the user must fix in argv) — exit 2 — distinct from bad stdin
/// content on the `--unwired`/`--content-id` branches, which is runtime exit 1. This
/// pins the argv-fault-vs-stdin-fault boundary the attribution principle draws at
/// the exact integer; the sibling `graph_introspect_node_rejects_an_unknown_type`
/// checks only non-zero, and would pass whether the code were 1 or 2.
#[test]
fn graph_introspect_unknown_node_flag_is_usage_exit_2() {
let (stderr, code) = run_code(&["graph", "introspect", "--node", "Bogus"], "");
assert_eq!(code, Some(2), "an invalid --node flag value is a usage error -> exit 2; stderr: {stderr}");
assert!(stderr.contains("Bogus"), "names the bad type: {stderr}");
}
/// Property (#171 acc 1 at the binary seam): `--identity-id` bridges op-scripts
/// that differ only in debug names — the renamed twin keeps the same identity id
/// while its content id moves. Rename via blanket replace: "fast" occurs as the
/// instance name and in every `fast.*` reference, so the replaced document stays
/// internally consistent.
#[test]
fn graph_introspect_identity_id_bridges_renamed_op_scripts() {
let renamed = SIGNAL_DOC.replace("fast", "speedy");
let (ia, _e, ok) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
assert!(ok, "exit success");
let ia = ia.trim().to_string();
assert_eq!(ia.len(), 64, "a 64-hex identity id: {ia:?}");
assert!(ia.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {ia:?}");
let (ib, _e2, ok2) = run(&["graph", "introspect", "--identity-id"], &renamed);
assert!(ok2, "exit success");
assert_eq!(ia, ib.trim(), "renamed twin -> same identity id");
let (ca, _e3, ok3) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
let (cb, _e4, ok4) = run(&["graph", "introspect", "--content-id"], &renamed);
assert!(ok3 && ok4, "exit success");
assert_ne!(ca.trim(), cb.trim(), "renamed twin -> different content ids");
}
/// Property (combinable id flags): `--content-id --identity-id` prints both ids,
/// one per line, content id first — each byte-equal to its single-flag output,
/// and the two differ (SIGNAL_DOC carries debug names the identity form blanks).
#[test]
fn graph_introspect_content_and_identity_id_combine() {
let (both, _e, ok) = run(&["graph", "introspect", "--content-id", "--identity-id"], SIGNAL_DOC);
assert!(ok, "exit success");
let lines: Vec<&str> = both.lines().collect();
assert_eq!(lines.len(), 2, "two lines, one id each: {both:?}");
let (content, _e2, ok2) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
let (identity, _e3, ok3) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
assert!(ok2 && ok3, "exit success");
assert_eq!(lines[0], content.trim(), "content id first");
assert_eq!(lines[1], identity.trim(), "identity id second");
assert_ne!(lines[0], lines[1], "the two ids differ on a debug-named document");
}
/// Property (negative, mirror of the content-id rejection): `--identity-id` on a
/// bad op-list exits non-zero with the cause on stderr and prints no id.
#[test]
fn graph_introspect_identity_id_rejects_a_bad_document() {
let (stdout, stderr, ok) =
run(&["graph", "introspect", "--identity-id"], r#"[{"op":"add","type":"Nope"}]"#);
assert!(!ok, "non-zero exit on a bad op-list");
assert!(stdout.is_empty(), "no identity id emitted on error: {stdout}");
assert!(stderr.contains("Nope"), "names the cause: {stderr}");
}
/// Property (usage gate — the previously untested count!=1 dispatch path):
/// `graph introspect` with no selection flag is a usage fault — exit 2 with the
/// usage line on stderr.
#[test]
fn graph_introspect_no_flag_is_usage_exit_2() {
let (stderr, code) = run_code(&["graph", "introspect"], "");
assert_eq!(code, Some(2), "no selection flag is a usage error -> exit 2; stderr: {stderr}");
assert!(stderr.contains("Usage"), "prints the usage line: {stderr}");
}
/// A fresh, unique working directory for a test that persists content-addressed
/// blueprints under `./runs/` (mirrors `research_docs.rs`'s `temp_cwd`).
fn temp_cwd(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("aura-graph-{}-{}", std::process::id(), name));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
}
/// Run `aura <args>` in `dir` (no stdin); return (stdout, stderr, exit code).
fn run_in(dir: &std::path::Path, args: &[&str]) -> (String, String, Option<i32>) {
let out = Command::new(BIN)
.args(args)
.current_dir(dir)
.output()
.expect("binary runs");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code(),
)
}
/// The absolute path of a blueprint fixture shipped with this test crate.
fn fixture(name: &str) -> String {
format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
}
/// The absolute path of a demo blueprint shipped in the public examples/
/// gallery (#159 copy+prove) — distinct from `fixture()`, which reads the
/// internal tests/fixtures/ copies the emitter builds from.
fn example(name: &str) -> String {
format!("{}/examples/{name}", env!("CARGO_MANIFEST_DIR"))
}
/// The id extracted from a `registered blueprint {id} ({path})` line.
/// Prefix-tolerant across the #194 convention change (the display form dropped
/// the glued `content:` prefix): the id is bare either way.
fn registered_id(stdout: &str) -> String {
stdout
.lines()
.find(|l| l.starts_with("registered blueprint "))
.expect("register line")
.trim_start_matches("registered blueprint ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// Property (#196, the on-ramp): `aura graph register <blueprint.json>` parses
/// the document through the vocabulary, canonicalizes, and stores it content-
/// addressed under `runs/blueprints/<id>.json`, printing the id + store path.
#[test]
fn graph_register_stores_and_prints_content_id() {
let dir = temp_cwd("register");
let (stdout, stderr, code) = run_in(&dir, &["graph", "register", &example("r_sma.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
// #194: the register verb prints the bare id — `registered blueprint <id> (<path>)`.
assert!(!stdout.contains("content:"), "register prints the bare id, no content: prefix: {stdout}");
let id = registered_id(&stdout);
assert_eq!(id.len(), 64, "a 64-hex content id: {id:?}");
assert!(id.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {id:?}");
assert!(
dir.join("runs").join("blueprints").join(format!("{id}.json")).is_file(),
"stored under runs/blueprints/<id>.json: {stdout}"
);
}
/// Register is write-once content-addressed: the same document registers to the
/// same id, exit 0 both times (the idempotent `put_blueprint` contract).
#[test]
fn graph_register_is_idempotent() {
let dir = temp_cwd("register-idempotent");
let bp = example("r_sma.json");
let (out1, err1, code1) = run_in(&dir, &["graph", "register", &bp]);
let (out2, err2, code2) = run_in(&dir, &["graph", "register", &bp]);
assert_eq!(code1, Some(0), "first register: {out1} {err1}");
assert_eq!(code2, Some(0), "second register: {out2} {err2}");
assert_eq!(registered_id(&out1), registered_id(&out2), "same id twice");
}
/// Property (#196, the campaign-axis namespace): `--params <FILE>` prints the
/// RAW composite param space — one `{name}:{kind:?}` line per open param, in
/// lowering order, WITHOUT the harness-wrap prefix `aura sweep --list-axes`
/// shows. The open fixture leaves exactly the two SMA lengths unbound
/// (`bias.scale` is bound in the document).
#[test]
fn graph_params_lists_raw_axis_namespace() {
let dir = temp_cwd("params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_sma_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
}
/// Property (#196, file-mode --content-id): a blueprint FILE's printed content
/// id is exactly the store key `graph register` prints — the file is shape-
/// discriminated from the op-list form and canonicalized by the blueprint rules.
#[test]
fn graph_content_id_accepts_a_blueprint_file() {
let dir = temp_cwd("content-id-file");
let bp = example("r_sma.json");
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
let (id_out, id_err, id_code) = run_in(&dir, &["graph", "introspect", "--content-id", &bp]);
assert_eq!(id_code, Some(0), "content-id: {id_out} {id_err}");
assert_eq!(id_out.trim(), registered_id(&reg_out), "file content id == store key");
}
/// The one-mode guard extends over the new `--params` mode: zero modes and two
/// modes both refuse usage-exit-2, and the usage line names the new mode.
#[test]
fn graph_introspect_mode_guard_still_exits_two_on_zero_or_two_modes() {
let dir = temp_cwd("mode-guard");
let (_out0, err0, code0) = run_in(&dir, &["graph", "introspect"]);
assert_eq!(code0, Some(2), "zero modes is usage exit 2: {err0}");
assert!(err0.contains("--params <FILE|ID>"), "usage line names --params: {err0}");
let (_out2, err2, code2) =
run_in(&dir, &["graph", "introspect", "--vocabulary", "--params", "x.json"]);
assert_eq!(code2, Some(2), "two modes is usage exit 2: {err2}");
assert!(err2.contains("Usage: aura graph introspect"), "prints the usage line: {err2}");
}
/// Property (#196, the register->params-by-id round trip): `--params <ID>`
/// with a registered blueprint's content id resolves through the project
/// store and prints exactly the same raw axis namespace `--params <FILE>`
/// prints for the same document — the advertised `<FILE|ID>` addressing is
/// two paths to the identical answer, not merely two accepted shapes.
#[test]
fn graph_params_by_content_id_matches_params_by_file() {
let dir = temp_cwd("params-by-id");
let bp = example("r_sma_open.json");
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
let id = registered_id(&reg_out);
let (by_id_out, by_id_err, by_id_code) =
run_in(&dir, &["graph", "introspect", "--params", &id]);
assert_eq!(by_id_code, Some(0), "stdout: {by_id_out} stderr: {by_id_err}");
let (by_file_out, by_file_err, by_file_code) =
run_in(&dir, &["graph", "introspect", "--params", &bp]);
assert_eq!(by_file_code, Some(0), "stdout: {by_file_out} stderr: {by_file_err}");
assert_eq!(by_id_out, by_file_out, "by-id and by-file agree on the raw axis namespace");
assert_eq!(by_id_out, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
}
/// #194 (the prefix trap): a `--params <ID>` target may carry the display
/// `content:` prefix copied verbatim from register/introspect output — the CLI
/// strips it and resolves the bare store address, printing exactly the same raw
/// axis namespace the bare id (and the file) print.
#[test]
fn graph_params_tolerates_content_prefix_on_target() {
let dir = temp_cwd("params-content-prefix");
let bp = example("r_sma_open.json");
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
let id = registered_id(&reg_out);
let (pfx_out, pfx_err, pfx_code) =
run_in(&dir, &["graph", "introspect", "--params", &format!("content:{id}")]);
assert_eq!(pfx_code, Some(0), "content:-prefixed --params must resolve: stdout {pfx_out} stderr {pfx_err}");
assert_eq!(pfx_out, "fast.length:I64\nslow.length:I64\n", "same raw axis namespace as the bare id");
}
/// Property (#196, negative): `--params <ID>` with a well-shaped 64-hex id
/// that was never registered refuses with a store-miss message, not a panic
/// or a silent empty namespace — the registry lookup branch of
/// `resolve_blueprint_text` is exercised.
#[test]
fn graph_params_by_unregistered_id_reports_a_store_miss() {
let dir = temp_cwd("params-missing-id");
let bogus_id = "a".repeat(64);
let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", &bogus_id]);
assert_ne!(code, Some(0), "non-zero exit on a store miss: {stdout} {stderr}");
assert!(stdout.is_empty(), "no partial namespace on a miss: {stdout}");
assert!(
stderr.contains(&format!("no blueprint {bogus_id} in the project store")),
"names the miss: {stderr}"
);
}
/// Property (#196, negative): `--params <TARGET>` where TARGET is neither an
/// existing file nor a 64-hex content id refuses naming both readings — the
/// `resolve_blueprint_text` fall-through branch, shared with `campaign run`'s
/// target resolution via `is_content_id`.
#[test]
fn graph_params_target_neither_file_nor_id_is_refused() {
let dir = temp_cwd("params-neither");
let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", "not-a-file-or-id"]);
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
assert!(stdout.is_empty(), "no partial namespace on refusal: {stdout}");
assert!(
stderr.contains("'not-a-file-or-id' is neither a readable .json file nor a 64-hex content id"),
"names both readings: {stderr}"
);
}
/// Property (#196, the op-list branch of file-mode --content-id): a FILE that
/// is a JSON *array* (a construction op-list, not a blueprint envelope) is
/// shape-discriminated correctly and produces the SAME content id as piping
/// the identical document to stdin — `composite_from_any`'s `Array` arm is
/// exercised end-to-end, not just its `Object` sibling.
#[test]
fn graph_content_id_accepts_an_op_list_file() {
let dir = temp_cwd("content-id-op-list-file");
let op_list_path = dir.join("op-list.json");
std::fs::write(&op_list_path, SIGNAL_DOC).expect("write op-list fixture");
let (file_out, file_err, file_code) = run_in(
&dir,
&["graph", "introspect", "--content-id", op_list_path.to_str().unwrap()],
);
assert_eq!(file_code, Some(0), "stdout: {file_out} stderr: {file_err}");
let (stdin_out, _e, stdin_ok) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
assert!(stdin_ok, "stdin variant succeeds");
assert_eq!(file_out.trim(), stdin_out.trim(), "op-list file and stdin agree on content id");
}
/// Property (#196, negative): a FILE whose top-level JSON value is neither an
/// object (blueprint envelope) nor an array (op-list) — e.g. a bare string —
/// is refused, exercising `composite_from_any`'s fall-through arm.
#[test]
fn graph_content_id_rejects_a_file_that_is_neither_object_nor_array() {
let dir = temp_cwd("content-id-neither-shape");
let bad_path = dir.join("bad.json");
std::fs::write(&bad_path, "\"just a string\"").expect("write bad fixture");
let (stdout, stderr, code) = run_in(
&dir,
&["graph", "introspect", "--content-id", bad_path.to_str().unwrap()],
);
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
assert!(stdout.is_empty(), "no content id emitted: {stdout}");
assert!(
stderr.contains("document is neither a blueprint envelope (object) nor an op-list (array)"),
"names the shape refusal: {stderr}"
);
}
/// Property (#196, negative): `register` on a syntactically-valid blueprint
/// envelope that names an unknown node type refuses with the `blueprint_load_prose`
/// `UnknownNodeType` prose (not a panic, not a bare Debug dump) — this refusal
/// path is shared by `register`, `--params`, and file-mode `--content-id` (all
/// route through `blueprint_from_json` -> `blueprint_load_prose`), so exercising
/// it once through `register` closes the whole family.
#[test]
fn graph_register_rejects_an_unknown_node_type_with_prose() {
let dir = temp_cwd("register-unknown-node");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "register", &fixture("unknown_node.json")]);
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
assert!(stdout.is_empty(), "no registration line on refusal: {stdout}");
assert!(
stderr.contains("unknown node type \"Nope\""),
"names the unknown node type: {stderr}"
);
}
/// Property (#202 headline, the on-ramp is shape-invariant): `aura graph register`
/// accepts an op-script document (a JSON array) exactly as it accepts a #155
/// blueprint envelope (a JSON object) — it shape-discriminates the array form,
/// builds it through the same one-build tail, and stores it under the SAME content
/// id it stores that op-script's `graph build` envelope under. The content id is
/// shape-invariant (an op-script and its built envelope hash identically), so the
/// two on-ramp shapes must agree on the store key; `register` must not reject the
/// op-script with the raw serde `invalid type: map, expected u32` message the
/// direct-`blueprint_from_json` path currently leaks.
#[test]
fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
let dir = temp_cwd("register-op-script");
// The op-script form (a JSON array of ops), written to a file.
let op_script = dir.join("op-script.json");
std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
// Its `graph build` envelope (a JSON object), the shape `register` already accepts.
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
assert!(built, "graph build produces the envelope");
let envelope = dir.join("envelope.json");
std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");
// The envelope shape registers today (green sibling); the op-script must too.
let (env_out, env_err, env_code) =
run_in(&dir, &["graph", "register", envelope.to_str().unwrap()]);
assert_eq!(env_code, Some(0), "register envelope: {env_out} {env_err}");
let (op_out, op_err, op_code) =
run_in(&dir, &["graph", "register", op_script.to_str().unwrap()]);
assert_eq!(op_code, Some(0), "register op-script exits 0: {op_out} {op_err}");
assert_eq!(
registered_id(&op_out),
registered_id(&env_out),
"op-script and its built envelope register to the same content id"
);
}
/// Property (#202, the same on-ramp seam at the sibling verb): `aura graph
/// introspect --params` accepts an op-script exactly as it accepts a blueprint
/// envelope — the raw axis namespace it prints for the op-script equals the one it
/// prints for that op-script's built envelope, both exit 0. Currently `--params`
/// rejects the op-script with the same raw serde `invalid type: map, expected u32`
/// message `register` does (both route through the direct `blueprint_from_json`).
#[test]
fn graph_params_accepts_an_op_script_matching_its_envelope() {
let dir = temp_cwd("params-op-script");
let op_script = dir.join("op-script.json");
std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
assert!(built, "graph build produces the envelope");
let envelope = dir.join("envelope.json");
std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");
let (env_out, env_err, env_code) =
run_in(&dir, &["graph", "introspect", "--params", envelope.to_str().unwrap()]);
assert_eq!(env_code, Some(0), "params on envelope: {env_out} {env_err}");
let (op_out, op_err, op_code) =
run_in(&dir, &["graph", "introspect", "--params", op_script.to_str().unwrap()]);
assert_eq!(op_code, Some(0), "params on op-script exits 0: {op_out} {op_err}");
assert_eq!(op_out, env_out, "op-script and envelope agree on the raw axis namespace");
}
/// Property (#226, the render positional closes the #202 on-ramp family):
/// `aura graph <op-script.json>` (the render positional) accepts an op-script
/// document (a JSON array) exactly as it accepts a #155 blueprint envelope (a
/// JSON object). Its two introspect siblings (`register`, `introspect --params`,
/// pinned above) already shape-discriminate via `composite_from_any`, but the
/// render positional still parses via the direct `blueprint_from_json` and so
/// rejects the op-script form — leaking the raw serde `invalid type: map,
/// expected u32` mislabeled as "blueprint document is not valid JSON". The
/// op-script and its own `graph build` envelope canonicalize to the same
/// Composite (the register content-id parity above proves the bytes match), so
/// both must render byte-identically at exit 0.
#[test]
fn graph_render_accepts_an_op_script_matching_its_envelope() {
let dir = temp_cwd("render-op-script");
let op_script = dir.join("op-script.json");
std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
assert!(built, "graph build produces the envelope");
let envelope = dir.join("envelope.json");
std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");
let (env_out, env_err, env_code) = run_in(&dir, &["graph", envelope.to_str().unwrap()]);
assert_eq!(env_code, Some(0), "render envelope: {env_err}");
let (op_out, op_err, op_code) = run_in(&dir, &["graph", op_script.to_str().unwrap()]);
assert_eq!(op_code, Some(0), "render op-script exits 0: {op_err}");
assert!(
!op_err.contains("blueprint document is not valid JSON"),
"must not mislabel a valid op-script as invalid JSON: {op_err}"
);
assert_eq!(op_out, env_out, "op-script and its built envelope render byte-identically");
}
/// Property (#159): the shipped closed example (`examples/r_sma.json`) is
/// genuinely closed, not merely byte-identical to the builder — `graph
/// introspect --params` on it reports zero unbound params. The Cut-1
/// byte-fidelity proof (`shipped_r_sma_examples_are_byte_identical_to_the_builder`,
/// main.rs) only pins the example's bytes to the builder's serialization; it says
/// nothing about whether that serialization is actually closed. This pins the
/// "closed" claim the demo makes to its audience.
#[test]
fn shipped_r_sma_example_is_genuinely_closed() {
let dir = temp_cwd("example-closed-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_sma.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
}
/// Property (#159): the shipped open example (`examples/r_sma_open.json`) is a
/// usable sweep-axis template from its public gallery location, not just a
/// byte-identical artifact — `graph introspect --params` lists exactly the two
/// unbound SMA lengths, in lowering order, matching the raw axis namespace the
/// internal `sma_signal_open.json` fixture prints (#196). The byte-fidelity
/// proof only checks the example's bytes match the builder; this checks the
/// shipped copy is actually introspectable/bindable from where a consumer runs
/// it.
#[test]
fn shipped_r_sma_open_example_lists_its_axis_namespace() {
let dir = temp_cwd("example-open-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_sma_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "fast.length:I64\nslow.length:I64\n",
"the raw open params, in order, from the public gallery copy"
);
}
/// Property (#159 cut 2): the shipped closed example (`examples/r_breakout.json`) is
/// genuinely closed — `graph introspect --params` reports zero unbound params.
#[test]
fn shipped_r_breakout_example_is_genuinely_closed() {
let dir = temp_cwd("r-breakout-example-closed-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
}
/// Property (#159 cut 2): the shipped open example (`examples/r_breakout_open.json`)
/// lists its ONE ganged channel axis (#61: the two rolling windows are structurally
/// one knob).
#[test]
fn shipped_r_breakout_open_example_lists_its_axis_namespace() {
let dir = temp_cwd("r-breakout-example-open-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "channel_length:I64\n",
"the ganged channel knob — one public axis for the two rolling windows (#61)"
);
}
/// Property (#61 + #159 cut 2): binding the shipped open example's ganged
/// `channel_length` axis through the REAL `aura sweep` CLI pipeline (argv ->
/// `parse_axes` -> `compile_with_cells`'s gang expansion) reproduces the exact
/// metrics of running the shipped CLOSED example, whose `channel_hi`/
/// `channel_lo` are separately hardwired to the same value. The equivalence
/// between a gang and its member-bound twin is already pinned at the builder/
/// engine level (aura-engine's `gang_e2e.rs`, and this crate's
/// `r_breakout_example_loaded_runs_identically_to_the_carved_signal`, which
/// calls `run_signal_r` directly); this instead drives the actual public
/// binary end-to-end on the actual shipped files, so a regression in the
/// CLI's own axis-name resolution or grid-point binding (e.g. silently
/// treating the gang as an unbound param, or fanning the value to only one
/// member) would be caught here even if the internal builder-level
/// equivalence still holds.
#[test]
fn shipped_r_breakout_open_gang_axis_matches_the_closed_example() {
let closed_dir = temp_cwd("r-breakout-gang-axis-closed");
let (closed_stdout, closed_stderr, closed_code) = run_in(&closed_dir, &["run", &example("r_breakout.json")]);
assert_eq!(closed_code, Some(0), "closed run stderr: {closed_stderr}");
let closed: serde_json::Value =
serde_json::from_str(closed_stdout.trim()).expect("closed run report parses as JSON");
let sweep_dir = temp_cwd("r-breakout-gang-axis-sweep");
let (sweep_stdout, sweep_stderr, sweep_code) = run_in(
&sweep_dir,
&["sweep", &example("r_breakout_open.json"), "--axis", "r_breakout_signal.channel_length=3"],
);
assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
let lines: Vec<&str> = sweep_stdout.lines().collect();
assert_eq!(lines.len(), 1, "one grid point for a single-value axis: {sweep_stdout}");
let member: serde_json::Value = serde_json::from_str(lines[0]).expect("member line parses as JSON");
assert_eq!(
member["report"]["metrics"], closed["metrics"],
"channel_length=3, bound via the real sweep CLI, must fan out to both channel_hi \
and channel_lo identically to the closed example's hardwired channel=3"
);
}
/// Property (#159 cut 3): the shipped closed example (`examples/r_meanrev.json`) is
/// genuinely closed — `graph introspect --params` reports zero unbound params.
#[test]
fn shipped_r_meanrev_example_is_genuinely_closed() {
let dir = temp_cwd("r-meanrev-example-closed-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
}
/// Property (#159 cut 3): the shipped open example (`examples/r_meanrev_open.json`)
/// lists its raw axis namespace, in lowering (node-add) order.
#[test]
fn shipped_r_meanrev_open_example_lists_its_axis_namespace() {
let dir = temp_cwd("r-meanrev-example-open-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "window:I64\nband.factor:F64\n",
"the ganged EWMA window + the independent band knob, in lowering order"
);
}
/// Property (#61 + #159 cut 3): a sweep over TWO axes at once — the ganged
/// `window` knob (fusing `mean_window.length`/`var_window.length`) alongside
/// the independent, un-ganged `band.factor` knob — binds both correctly
/// through the real CLI grid, matching the shipped closed example's hardwired
/// window=3/band=2.0. This is a distinct risk from the r_breakout gang test
/// (a lone gang axis): a mis-scoped gang-expansion map that shifts sibling
/// param positions could silently mis-bind the co-present un-ganged axis
/// instead (or vice-versa) even though each axis alone still resolves.
#[test]
fn shipped_r_meanrev_open_gang_plus_plain_axis_matches_the_closed_example() {
let closed_dir = temp_cwd("r-meanrev-gang-axis-closed");
let (closed_stdout, closed_stderr, closed_code) = run_in(&closed_dir, &["run", &example("r_meanrev.json")]);
assert_eq!(closed_code, Some(0), "closed run stderr: {closed_stderr}");
let closed: serde_json::Value =
serde_json::from_str(closed_stdout.trim()).expect("closed run report parses as JSON");
let sweep_dir = temp_cwd("r-meanrev-gang-axis-sweep");
let (sweep_stdout, sweep_stderr, sweep_code) = run_in(
&sweep_dir,
&[
"sweep", &example("r_meanrev_open.json"),
"--axis", "r_meanrev_signal.window=3",
"--axis", "r_meanrev_signal.band.factor=2.0",
],
);
assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
let lines: Vec<&str> = sweep_stdout.lines().collect();
assert_eq!(lines.len(), 1, "one grid point for two single-value axes: {sweep_stdout}");
let member: serde_json::Value = serde_json::from_str(lines[0]).expect("member line parses as JSON");
assert_eq!(
member["report"]["metrics"], closed["metrics"],
"window=3 (ganged) + band.factor=2.0 (plain), bound via the real sweep CLI, must \
match the closed example's hardwired window=3/band=2.0"
);
}
/// The op-script surface accepts a gang op and the built blueprint exposes
/// ONE public knob for the fused pair (#61).
#[test]
fn graph_build_accepts_a_gang_op() {
let dir = temp_cwd("graph-build-gang-op");
let ops = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"a"},
{"op":"add","type":"SMA","name":"b"},
{"op":"gang","as":"length","into":["a.length","b.length"]},
{"op":"add","type":"Sub"},
{"op":"feed","role":"price","into":["a.series","b.series"]},
{"op":"connect","from":"a.value","to":"sub.lhs"},
{"op":"connect","from":"b.value","to":"sub.rhs"},
{"op":"expose","from":"sub.value","as":"bias"}
]"#;
let (bp, stderr, ok) = run(&["graph", "build"], ops);
assert!(ok, "graph build: stderr: {stderr}");
// Write the built blueprint to a file, then introspect its raw param space
// (mirrors `graph_content_id_accepts_an_op_list_file`'s file-write plumbing).
let bp_path = dir.join("bp.json");
std::fs::write(&bp_path, &bp).expect("write built blueprint");
let (params, stderr2, code2) =
run_in(&dir, &["graph", "introspect", "--params", bp_path.to_str().unwrap()]);
assert_eq!(code2, Some(0), "stderr: {stderr2}");
assert_eq!(params, "length:I64\n", "one fused public knob, not two members");
}
/// Property (#61, Task 6 wiring): a blueprint built through the op-script `gang`
/// op and then RENDERED via `aura graph <file>` — the real CLI round trip a
/// consumer drives, not a hand-built `Composite` fed straight to `model_to_json`
/// — embeds the fused gang table in the page's inlined `window.AURA_MODEL`. This
/// is the seam that actually connects the construction-layer gang op to the
/// viewer surface: `graph_model.rs`'s `model_carries_gangs_only_for_ganged_scopes`
/// pins the JSON fragment in isolation, and `viewer_gang_param` pins the JS
/// annotation against a hand-written model literal, but neither proves the gang
/// table SURVIVES the build -> file -> render pipeline a consumer actually runs.
#[test]
fn graph_build_gang_op_survives_render_round_trip() {
let dir = temp_cwd("graph-render-gang-round-trip");
let ops = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"a"},
{"op":"add","type":"SMA","name":"b"},
{"op":"gang","as":"length","into":["a.length","b.length"]},
{"op":"add","type":"Sub"},
{"op":"feed","role":"price","into":["a.series","b.series"]},
{"op":"connect","from":"a.value","to":"sub.lhs"},
{"op":"connect","from":"b.value","to":"sub.rhs"},
{"op":"expose","from":"sub.value","as":"bias"}
]"#;
let (bp, stderr, ok) = run(&["graph", "build"], ops);
assert!(ok, "graph build: stderr: {stderr}");
let bp_path = dir.join("bp.json");
std::fs::write(&bp_path, &bp).expect("write built blueprint");
let out = std::process::Command::new(BIN)
.arg("graph")
.arg(&bp_path)
.current_dir(&dir)
.output()
.expect("spawn aura graph <blueprint>");
assert_eq!(
out.status.code(),
Some(0),
"`aura graph <blueprint.json>` exit: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let html = String::from_utf8(out.stdout).expect("utf-8 stdout");
assert!(
html.contains(r#""gangs":[{"name":"length","kind":"I64","members":[["#),
"rendered page does not embed the gang table: {html}"
);
}
/// Property (#125, the render seam a hand-authored blueprint document actually
/// drives): a composite's `doc` field is not merely a `model_to_json` unit-level
/// concept — `aura graph <file>` on a real on-disk blueprint envelope that
/// carries `"doc":"..."` embeds that exact text in the page's inlined
/// `window.AURA_MODEL` JSON. The graph_model.rs golden pins the JSON fragment in
/// isolation; this proves the doc survives the actual file-load -> render CLI
/// pipeline a consumer runs, not just the library function called directly.
#[test]
fn graph_render_embeds_the_hand_authored_composite_doc() {
let out = std::process::Command::new(BIN)
.arg("graph")
.arg(fixture("doc_blueprint.json"))
.output()
.expect("spawn aura graph <blueprint>");
assert_eq!(
out.status.code(),
Some(0),
"render exit: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let html = String::from_utf8(out.stdout).expect("utf-8 stdout");
assert!(
html.contains(r#","doc":"why this graph: single SMA smoke test""#),
"rendered page does not embed the composite doc: {html}"
);
}
/// Property (#125, doc-blind identity at the CLI seam): a blueprint document
/// carrying a `doc` and its doc-less twin are the SAME topology by the identity
/// projection (`--identity-id` agrees) but DIFFERENT documents by the canonical
/// projection (`--content-id` differs) — mirroring the established renamed-twin
/// pattern (`graph_introspect_identity_id_bridges_renamed_op_scripts`) for the
/// new field. `blueprint_serde.rs`'s `doc_round_trips_canonically_and_is_
/// identity_blind` pins this at the library level; this drives the real binary
/// over a real file, the boundary a regression in the CLI's own dispatch could
/// still break even with the library invariant intact.
#[test]
fn graph_doc_field_moves_content_id_but_not_identity_id() {
let doced = fixture("doc_blueprint.json");
let doceless_text = std::fs::read_to_string(&doced)
.expect("read fixture")
.replace(r#""doc":"why this graph: single SMA smoke test","#, "");
let dir = temp_cwd("doc-field-ids");
let doceless = dir.join("doc_blueprint_no_doc.json");
std::fs::write(&doceless, &doceless_text).expect("write doc-less twin");
// `--identity-id` has no FILE slot of its own (main.rs `GraphIntrospectCmd`):
// combine it with `--content-id <FILE>` (both id flags share one build) and
// read the two printed lines, content id first (mirrors
// `graph_introspect_content_and_identity_id_combine`).
let (doced_ids, _e, ok1) =
run_in(&dir, &["graph", "introspect", "--content-id", &doced, "--identity-id"]);
let (doceless_ids, _e2, ok2) = run_in(
&dir,
&["graph", "introspect", "--content-id", doceless.to_str().unwrap(), "--identity-id"],
);
assert_eq!(ok1, Some(0), "doc'd id pair exits 0: {doced_ids}");
assert_eq!(ok2, Some(0), "doc-less id pair exits 0: {doceless_ids}");
let doced_lines: Vec<&str> = doced_ids.lines().collect();
let doceless_lines: Vec<&str> = doceless_ids.lines().collect();
assert_eq!(doced_lines.len(), 2, "content id then identity id: {doced_ids:?}");
assert_eq!(doceless_lines.len(), 2, "content id then identity id: {doceless_ids:?}");
assert_ne!(
doced_lines[0], doceless_lines[0],
"the doc is canonical-byte-bearing (content id moves)"
);
assert_eq!(
doced_lines[1], doceless_lines[1],
"the identity projection is doc-blind (identity id agrees)"
);
}
/// Property (#125 viewer/#3, nested-composite doc through the real render
/// pipeline): the sibling test above only proves a ROOT-level `doc` reaches
/// the rendered page — the viewer threads a composite's doc onto its own
/// tooltip (INFO.B/INFO.C in graph-viewer.js), which needs the doc present on
/// a NESTED composite def, not just the root. `aura graph <file>` over a
/// blueprint whose one node is a nested composite carrying `doc` must embed
/// that exact string in the emitted page's `composites` section of
/// `window.AURA_MODEL` — the composite-def half of `graph_model.rs`'s
/// `doc_fragment`, exercised through the actual file-load -> render CLI
/// binary, not just the library function called directly.
#[test]
fn graph_render_embeds_a_nested_composites_doc() {
let out = std::process::Command::new(BIN)
.arg("graph")
.arg(fixture("nested_doc_blueprint.json"))
.output()
.expect("spawn aura graph <blueprint>");
assert_eq!(
out.status.code(),
Some(0),
"render exit: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let html = String::from_utf8(out.stdout).expect("utf-8 stdout");
assert!(
html.contains(r#","doc":"momentum leg: change over a lookback, gated by the trend sign""#),
"rendered page does not embed the nested composite's doc: {html}"
);
}
/// The `gang` op's arity refusal (fewer than two members) reads as prose at the
/// binary seam, attributed to its op index, never the raw `GangArity` Debug name.
#[test]
fn graph_build_reports_gang_arity_fault_as_prose() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"a"},
{"op":"gang","as":"solo","into":["a.length"]}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit on a gang arity fault");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(
stderr.contains("op 2 (gang): gang `solo`: needs at least two members"),
"names the op + cause as prose: {stderr}"
);
assert!(!stderr.contains("GangArity"), "does not leak the Debug variant name: {stderr}");
}
/// The `gang` op's double-membership refusal (a param already claimed by an
/// earlier gang) reads as prose, naming the offending `node.param`.
#[test]
fn graph_build_reports_already_ganged_fault_as_prose() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"a"},
{"op":"add","type":"SMA","name":"b"},
{"op":"add","type":"SMA","name":"c"},
{"op":"gang","as":"lo","into":["a.length","b.length"]},
{"op":"gang","as":"hi","into":["a.length","c.length"]}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit on an already-ganged fault");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(
stderr.contains("op 5 (gang): gang: `a.length` is already ganged"),
"names the op + doubly-ganged member as prose: {stderr}"
);
assert!(!stderr.contains("AlreadyGanged"), "does not leak the Debug variant name: {stderr}");
}
/// The `gang` op's kind-mismatch refusal (members of differing scalar kind)
/// reads as prose, naming the offending member and both kinds.
#[test]
fn graph_build_reports_gang_kind_mismatch_as_prose() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"a"},
{"op":"add","type":"Bias","name":"b"},
{"op":"gang","as":"g","into":["a.length","b.scale"]}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit on a gang kind mismatch");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(
stderr.contains("op 3 (gang): gang: member `b.scale` is F64, expected I64"),
"names the op + kind mismatch as prose: {stderr}"
);
assert!(!stderr.contains("GangKindMismatch"), "does not leak the Debug variant name: {stderr}");
}
/// Property (corrupt-gang-blueprint, the `gang_fault_prose` seam): `aura graph
/// register` on a hand-corrupted blueprint whose gangs section fails structural
/// validation (a single-member gang) refuses with the `blueprint_load_prose` ->
/// `gang_fault_prose` wording — never the raw `CompileError`/`GangFault` Debug
/// dump the pre-#61 fallback would otherwise leak. Mirrors
/// `graph_register_rejects_an_unknown_node_type_with_prose`'s pattern, one layer
/// further into the load path.
#[test]
fn graph_register_rejects_a_corrupt_gang_table_with_prose() {
let dir = temp_cwd("register-bad-gang");
let (stdout, stderr, code) = run_in(&dir, &["graph", "register", &fixture("bad_gang.json")]);
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
assert!(stdout.is_empty(), "no registration line on refusal: {stdout}");
assert!(
stderr.contains("gang section invalid: gang \"solo\" has fewer than two members"),
"phrases the corrupt gang table as prose: {stderr}"
);
assert!(!stderr.contains("GangFault"), "does not leak the Debug type name: {stderr}");
assert!(!stderr.contains("BadGang"), "does not leak the Debug variant name: {stderr}");
}
/// Property (#61, the `blueprint_serde` canonicalization the CLI content-id
/// path relies on): declaring a gang's members in a different order in the
/// op-script's `into` list must NOT change the built blueprint's content id.
/// `project()`'s member/gang sort (task 3) exists precisely so that the
/// write-once content-addressed store (`graph register`'s idempotency
/// contract) sees two authoring-order variants of the identical graph as one
/// blueprint; were the sort ever dropped, this is the CLI-boundary regression
/// that would silently start minting a second id for the same topology.
#[test]
fn graph_build_gang_member_order_does_not_affect_content_id() {
let forward = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"a"},
{"op":"add","type":"SMA","name":"b"},
{"op":"gang","as":"length","into":["a.length","b.length"]},
{"op":"add","type":"Sub"},
{"op":"feed","role":"price","into":["a.series","b.series"]},
{"op":"connect","from":"a.value","to":"sub.lhs"},
{"op":"connect","from":"b.value","to":"sub.rhs"},
{"op":"expose","from":"sub.value","as":"bias"}
]"#;
let reversed = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"a"},
{"op":"add","type":"SMA","name":"b"},
{"op":"gang","as":"length","into":["b.length","a.length"]},
{"op":"add","type":"Sub"},
{"op":"feed","role":"price","into":["a.series","b.series"]},
{"op":"connect","from":"a.value","to":"sub.lhs"},
{"op":"connect","from":"b.value","to":"sub.rhs"},
{"op":"expose","from":"sub.value","as":"bias"}
]"#;
let (id_forward, e1, ok1) = run(&["graph", "introspect", "--content-id"], forward);
let (id_reversed, e2, ok2) = run(&["graph", "introspect", "--content-id"], reversed);
assert!(ok1, "forward: {e1}");
assert!(ok2, "reversed: {e2}");
assert_eq!(id_forward, id_reversed, "gang member declaration order is content-id-invisible");
}
/// Property (#61, the eager gang gate's bind-then-gang ordering): a member
/// already bound at `add` has left the node's open param schema exactly like
/// any other consumed param, so ganging it reports the generic `UnknownParam`
/// prose — not a gang-specific "already bound" message — proving the gang op
/// resolves each member against the CURRENT open schema (mirroring `try_bind`)
/// rather than against the node's original, pre-bind schema.
#[test]
fn graph_build_gang_on_a_bound_member_reports_unknown_param() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"a","bind":{"length":{"I64":5}}},
{"op":"add","type":"SMA","name":"b"},
{"op":"gang","as":"length","into":["a.length","b.length"]}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit: a bound member cannot be ganged");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(
stderr.contains("op 3 (gang): node a has no param \"length\""),
"the bound member reads as an ordinary unknown param, not a gang-specific fault: {stderr}"
);
}