//! 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 ` 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) { 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(), 29, "the std-only (no project) vocabulary has exactly the roster's 29 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 `{"": }` 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\": })"), "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 ` 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::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-graph-{name}")); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create temp cwd"); dir } /// Run `aura ` in `dir` (no stdin); return (stdout, stderr, exit code). fn run_in(dir: &std::path::Path, args: &[&str]) -> (String, String, Option) { 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 ` parses /// the document through the vocabulary, canonicalizes, and stores it content- /// addressed under `runs/blueprints/.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 ()`. 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/.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 ` 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", &fixture("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(®_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 "), "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 ` /// with a registered blueprint's content id resolves through the project /// store and prints exactly the same raw axis namespace `--params ` /// prints for the same document — the advertised `` 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 = fixture("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(®_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 ` 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 = fixture("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(®_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 ` 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 ` 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 ` (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 open fixture (`tests/fixtures/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 open_r_sma_fixture_lists_its_axis_namespace() { let dir = temp_cwd("example-open-params"); let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", &fixture("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 (#248): the four `_open` blueprint twins were RELOCATED out of the /// public gallery, not deleted — each stem is present under `tests/fixtures/` /// (the internal sweep-axis fixture location) and absent from `examples/` (the /// public, user-facing gallery). This is the acceptance criterion's core claim /// ("`examples/` ships no `_open` variants") stated as a filesystem fact, so a /// future example addition that reintroduces a twin — or a migration that moves /// the reference but forgets the file — fails here even though every /// `include_str!`/path-string call site already resolves. #[test] fn open_twins_are_relocated_from_examples_to_fixtures_not_deleted() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); for stem in ["r_sma", "r_breakout", "r_meanrev", "r_channel"] { let gallery_path = format!("{manifest_dir}/examples/{stem}_open.json"); assert!( !std::path::Path::new(&gallery_path).exists(), "{stem}_open.json must not ship in the public examples/ gallery (#248)" ); let fixture_path = format!("{manifest_dir}/tests/fixtures/{stem}_open.json"); assert!( std::path::Path::new(&fixture_path).exists(), "{stem}_open.json must survive relocated under tests/fixtures/ (#248)" ); } } /// 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 open fixture (`tests/fixtures/r_breakout_open.json`) /// lists its ONE ganged channel axis (#61: the two rolling windows are structurally /// one knob). #[test] fn open_r_breakout_fixture_lists_its_axis_namespace() { let dir = temp_cwd("r-breakout-example-open-params"); let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", &fixture("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 open_r_breakout_fixture_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", &fixture("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 open fixture (`tests/fixtures/r_meanrev_open.json`) /// lists its raw axis namespace, in lowering (node-add) order. #[test] fn open_r_meanrev_fixture_lists_its_axis_namespace() { let dir = temp_cwd("r-meanrev-example-open-params"); let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", &fixture("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 open_r_meanrev_fixture_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", &fixture("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 ` — 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 "); assert_eq!( out.status.code(), Some(0), "`aura graph ` 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 ` 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 "); 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 ` (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 ` 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 "); 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}" ); } /// Like `run_in`, but also pipes `stdin_doc` to the child — the combination /// `aura graph build` needs, since it reads its op-script from stdin AND must /// discover the project from the working directory. Neither `run` (stdin, no /// cwd) nor `run_in` (cwd, no stdin) covers both. fn run_in_stdin( dir: &std::path::Path, args: &[&str], stdin_doc: &str, ) -> (String, String, Option) { let mut child = Command::new(BIN) .args(args) .current_dir(dir) .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.code(), ) } /// Property (#244): the tier-aware unresolved-namespace hint fires on the /// op-script `aura graph build` path exactly as it does on the blueprint LOAD /// path (`project_load.rs::data_only_project_hints_attach_for_namespaced_ids`, /// which this mirrors). Inside a data-only project (a bare `Aura.toml`, no node /// crate attached), an op-script referencing a project-namespaced type /// (`nosuch::Node`) the vocabulary cannot resolve refuses non-zero AND the /// refusal carries the tier hint — "binds no node crate" + the `aura nodes new` /// attach verb — not the bare unknown-type error. Same consumer mistake, same /// diagnostic quality across both entry surfaces. (The refusal/non-zero exit /// already works today; the hint text is the absent behaviour.) #[test] fn graph_build_hints_attach_for_namespaced_ids_in_a_data_only_project() { let dir = temp_cwd("graph-build-dataonly-hint"); std::fs::write(dir.join("Aura.toml"), "").expect("write bare Aura.toml"); // A single op is enough: `replay` fails fast at the unknown-type `add`. let ops = r#"[{"op":"add","type":"nosuch::Node","name":"n"}]"#; let (stdout, stderr, code) = run_in_stdin(&dir, &["graph", "build"], ops); assert_ne!(code, Some(0), "an unresolvable namespaced type refuses: {stderr}"); assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}"); assert!( stderr.contains("binds no node crate"), "the data-only tier hint fires on the graph-build path: {stderr}" ); assert!( stderr.contains("aura nodes new"), "the hint points at the attach verb: {stderr}" ); }