Files
Aura/crates/aura-cli/tests/graph_construct.rs
T
Brummel b8b83cf1c9 feat(0091): byte-canonical blueprint emit; record the equality-surface decision
#164 — tighten the C24 "canonical, versioned" blueprint contract on two corners
the milestone fieldtest surfaced. Both forks were derivable; resolved with
rationale (recorded on #164).

Fork 1 — canonical artifact carries no trailing newline. `blueprint_to_json`
returns the JSON value with no trailing newline (647 bytes for the SMA-cross
fixture); `aura graph build` had framed it with a `println!` (648). The library
serializer is the single canonical source — the form content-addressed topology
identity (#158) hashes — so the CLI is a transport that must not mutate the
bytes: `build_cmd` now `print!`s the canonical bytes verbatim, making the CLI and
library emit paths byte-identical. RED-first: the new E2E
`graph_build_emit_is_byte_canonical_no_trailing_newline` pinned the trailing
newline (FAIL), green after the one-line change. The two cycle-0088 `.out.json`
goldens are re-recorded at 647 bytes. The loader stays lenient (a trailing
newline on input still parses, Tier-1 robustness; the milestone fieldtest's
`mt_4` tolerance check is unaffected).

Fork 2 — the canonical JSON is the blueprint equality/identity surface; no
second in-memory PartialEq/Debug is added. `Composite`/`BlueprintNode` cannot
derive them: `PrimitiveBuilder` (aura-core node.rs) holds a
`build: Box<dyn Fn(&[Cell]) -> Box<dyn Node>>` closure, neither comparable nor
printable. Equality could only be defined over the serialized data, of which
`blueprint_to_json` is already the single source; a separate in-memory equality
notion would be a redundant second source of truth and a drift hazard against
the form #158 hashes. Recorded the decision (discharges acceptance box 2's "or
JSON-string is the intended equality surface" arm) in the C24 ledger; no code
added for it.

Verified: cargo test --workspace green (51 suites, incl. the new RED->green E2E);
cargo clippy --workspace --all-targets -D warnings clean.

closes #164
2026-06-30 15:57:15 +02:00

215 lines
10 KiB
Rust

//! 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(),
)
}
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}");
}
#[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}");
}