Files
Aura/crates/aura-cli/tests/graph_construct.rs
T
Brummel 57f401f2ab refactor(std): std_vocabulary_roster! macro — one source for the vocabulary roster
The three hand-kept copies of the 22-key zero-arg roster in
aura-std/vocabulary.rs (std_vocabulary match arms, std_vocabulary_types
list, the test's inline array) collapse into one private declarative
macro invoked once with the "TypeId" => Type pairs — the resolver and
the enumerable list now agree by construction (the #160 failure mode:
a node resolvable but silently absent from graph introspect
--vocabulary, or vice versa). Byte-preserving: same fn signatures, same
22 ids, same order; lib.rs re-export and every consumer untouched; the
vocabulary stays a closed compiled-in set (invariant 9 / C24 — a
compile-time expansion, no registry). Adding a zero-arg node is now one
roster line plus the conscious count-pin bumps.

Tests: the two unit tests reshaped (round-trip iterates the generated
list with PrimitiveBuilder::label() as the independent oracle; the shape
test keeps the count pin as deliberate friction). The E2E phase added
one cross-boundary pin beyond the plan — kept:
graph_introspect_vocabulary_lists_exactly_the_closed_roster_count checks
the same count through Env::type_ids() and the CLI print loop across the
real process boundary, which no roster-internal test can see.

Accepted residual (documented at the roster site, recorded on #160):
a new zero-arg node never rostered at all stays unguarded — no
enumeration of zero-arg builders exists — and fails safe in both
directions (clean UnknownNodeType on load; merely absent from
--vocabulary).

Verification: cargo build clean; cargo test --workspace 885 passed /
0 failed (884 baseline + 1 new e2e); clippy --all-targets -D warnings
clean; cargo doc --no-deps 0 warnings.

closes #160, refs #180
2026-07-02 21:56:07 +02:00

381 lines
20 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(),
22,
"the std-only (no project) vocabulary has exactly the roster's 22 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}");
}