623d304b7f
The op-script gains its tenth verb end-to-end: OpDoc::Use carries a tagged ref
({"content_id": id-or-unique-prefix} | {"name": label}); DTO conversion resolves
it (label sidecar / #302 prefix semantics / verbatim id), fetches, deserializes
eagerly (a vocab/parse failure fails fast naming the id prefix — review
finding, no swallowed .ok()), re-walks the C29 doc gate over the fetched
composite (a doc-less store entry cannot enter a NEW composition; existing
reads stay valid — no retroactive invalidation), echoes every resolution as
the existing benign marker (aura: note: use "<instance>": <ref> -> <id>,
stderr; stdout stays clean payload), and hands the session a pure cached
lookup closure.
graph register <file> --name <label> records the label and echoes repoints
(label "x" -> <new> (was <old>)); graph introspect --registered lists
label / 12-char id prefix / root doc line — the discovery surface for what
use can reference. Refusals are by-identifier, name their rule, and enumerate
the label set (empty store reads as prose); all op-list content faults are
runtime exit 1 per the pinned #175 attribution, argv misuse stays exit 2.
Ten new/extended e2e tests cover label round-trip + repoint, echo discipline,
unknown-label enumeration, the C29 refusal shape, end-to-end sweep axes
through a spliced instance (graph.<instance>.<node>.<param>), the open-pattern
build flow, and the standalone-run bootstrap refusal (asserts the existing
Debug-form fault; an index->name rendering at the run boundary is recorded
residue, not silently claimed). OP_REFERENCE and its help pin move 9 -> 10.
Empirical note for the record: aura run's bias arm re-roots the loaded signal
via wrap_r, so the standalone-run refusal for an open pattern is exercised on
the bare-tap measurement path; the bias path surfaces openness as a role-
binding refusal instead (pre-existing shape, untouched).
closes #317
1869 lines
94 KiB
Rust
1869 lines
94 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(),
|
||
)
|
||
}
|
||
|
||
/// 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"}
|
||
]"#;
|
||
|
||
/// SIGNAL_DOC with the C29 doc op — the register-reaching variant (a store
|
||
/// write requires a described composite; build-only tests keep SIGNAL_DOC).
|
||
const SIGNAL_DOC_DESCRIBED: &str = r#"[
|
||
{"op":"doc","text":"fast/slow SMA difference clamped into a directional bias"},
|
||
{"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"}
|
||
]"#;
|
||
|
||
/// SIGNAL_DOC with a `doc` op whose text merely restates the op-script
|
||
/// composite's name ("graph", the literal `replay("graph", ..)` gives every
|
||
/// CLI-built op-script) -- the RestatesName arm of the C29 shape gate,
|
||
/// reached via the op-script route rather than a builder-authored blueprint.
|
||
const SIGNAL_DOC_NAME_RESTATED: &str = r#"[
|
||
{"op":"doc","text":"Graph"},
|
||
{"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}");
|
||
}
|
||
|
||
/// C29 (#316): the op-script `doc` op's text lands verbatim in the built
|
||
/// blueprint's `doc` field -- `graph build` is a pure translation from ops to
|
||
/// the canonical envelope, so a doc op is not consumed or reworded on the way
|
||
/// through, only threaded (the shape gate is a separate, register-time check;
|
||
/// see `graph_register_refuses_an_op_script_doc_that_restates_the_composite_name`).
|
||
#[test]
|
||
fn graph_build_emits_the_op_script_doc_verbatim_into_the_blueprint() {
|
||
let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
|
||
assert!(ok, "exit success");
|
||
assert!(
|
||
stdout.contains(r#""doc":"fast/slow SMA difference clamped into a directional bias""#),
|
||
"the doc op's exact text appears in the blueprint's doc field: {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(),
|
||
33,
|
||
"the std-only (no project) vocabulary has exactly the roster's 33 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 (#326): an op-list element carrying an unknown/typo'd key (e.g.
|
||
/// `params` where the schema expects `bind`) is refused at parse — not
|
||
/// silently dropped. Previously the unrecognized key vanished, the field it
|
||
/// meant to set kept its default, and the wrong graph built with zero
|
||
/// signal. Same content-fault family as `graph_build_bad_stdin_content_is_
|
||
/// runtime_exit_1` (both fire from the same top-level deserialize), so the
|
||
/// exit class matches: exit 1, stderr names the offending key.
|
||
#[test]
|
||
fn graph_build_rejects_an_unknown_op_field() {
|
||
let doc = r#"[{"op":"add","type":"Const","params":{"value":{"F64":1.0}}}]"#;
|
||
let (stderr, code) = run_code(&["graph", "build"], doc);
|
||
assert_eq!(code, Some(1), "unknown op field is a content fault -> exit 1; stderr: {stderr}");
|
||
assert!(stderr.contains("params"), "names the unknown key: {stderr}");
|
||
}
|
||
|
||
/// Property (exit-code partition, #175 iteration 2): within the SAME `graph
|
||
/// introspect` subcommand, an invalid `--node <T>` flag VALUE is a USAGE error (a
|
||
/// command-line fault the user must fix in argv) — exit 2 — distinct from bad stdin
|
||
/// content on the `--unwired`/`--content-id` branches, which is runtime exit 1. This
|
||
/// pins the argv-fault-vs-stdin-fault boundary the attribution principle draws at
|
||
/// the exact integer; the sibling `graph_introspect_node_rejects_an_unknown_type`
|
||
/// checks only non-zero, and would pass whether the code were 1 or 2.
|
||
#[test]
|
||
fn graph_introspect_unknown_node_flag_is_usage_exit_2() {
|
||
let (stderr, code) = run_code(&["graph", "introspect", "--node", "Bogus"], "");
|
||
assert_eq!(code, Some(2), "an invalid --node flag value is a usage error -> exit 2; stderr: {stderr}");
|
||
assert!(stderr.contains("Bogus"), "names the bad type: {stderr}");
|
||
}
|
||
|
||
/// Property (#171 acc 1 at the binary seam): `--identity-id` bridges op-scripts
|
||
/// that differ only in debug names — the renamed twin keeps the same identity id
|
||
/// while its content id moves. Rename via blanket replace: "fast" occurs as the
|
||
/// instance name and in every `fast.*` reference, so the replaced document stays
|
||
/// internally consistent.
|
||
#[test]
|
||
fn graph_introspect_identity_id_bridges_renamed_op_scripts() {
|
||
let renamed = SIGNAL_DOC.replace("fast", "speedy");
|
||
let (ia, _e, ok) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
|
||
assert!(ok, "exit success");
|
||
let ia = ia.trim().to_string();
|
||
assert_eq!(ia.len(), 64, "a 64-hex identity id: {ia:?}");
|
||
assert!(ia.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {ia:?}");
|
||
let (ib, _e2, ok2) = run(&["graph", "introspect", "--identity-id"], &renamed);
|
||
assert!(ok2, "exit success");
|
||
assert_eq!(ia, ib.trim(), "renamed twin -> same identity id");
|
||
let (ca, _e3, ok3) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
|
||
let (cb, _e4, ok4) = run(&["graph", "introspect", "--content-id"], &renamed);
|
||
assert!(ok3 && ok4, "exit success");
|
||
assert_ne!(ca.trim(), cb.trim(), "renamed twin -> different content ids");
|
||
}
|
||
|
||
/// Property (combinable id flags): `--content-id --identity-id` prints both ids,
|
||
/// one per line, content id first — each byte-equal to its single-flag output,
|
||
/// and the two differ (SIGNAL_DOC carries debug names the identity form blanks).
|
||
#[test]
|
||
fn graph_introspect_content_and_identity_id_combine() {
|
||
let (both, _e, ok) = run(&["graph", "introspect", "--content-id", "--identity-id"], SIGNAL_DOC);
|
||
assert!(ok, "exit success");
|
||
let lines: Vec<&str> = both.lines().collect();
|
||
assert_eq!(lines.len(), 2, "two lines, one id each: {both:?}");
|
||
let (content, _e2, ok2) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
|
||
let (identity, _e3, ok3) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
|
||
assert!(ok2 && ok3, "exit success");
|
||
assert_eq!(lines[0], content.trim(), "content id first");
|
||
assert_eq!(lines[1], identity.trim(), "identity id second");
|
||
assert_ne!(lines[0], lines[1], "the two ids differ on a debug-named document");
|
||
}
|
||
|
||
/// Property (negative, mirror of the content-id rejection): `--identity-id` on a
|
||
/// bad op-list exits non-zero with the cause on stderr and prints no id.
|
||
#[test]
|
||
fn graph_introspect_identity_id_rejects_a_bad_document() {
|
||
let (stdout, stderr, ok) =
|
||
run(&["graph", "introspect", "--identity-id"], r#"[{"op":"add","type":"Nope"}]"#);
|
||
assert!(!ok, "non-zero exit on a bad op-list");
|
||
assert!(stdout.is_empty(), "no identity id emitted on error: {stdout}");
|
||
assert!(stderr.contains("Nope"), "names the cause: {stderr}");
|
||
}
|
||
|
||
/// Property (usage gate — the previously untested count!=1 dispatch path):
|
||
/// `graph introspect` with no selection flag is a usage fault — exit 2 with the
|
||
/// usage line on stderr.
|
||
#[test]
|
||
fn graph_introspect_no_flag_is_usage_exit_2() {
|
||
let (stderr, code) = run_code(&["graph", "introspect"], "");
|
||
assert_eq!(code, Some(2), "no selection flag is a usage error -> exit 2; stderr: {stderr}");
|
||
assert!(stderr.contains("Usage"), "prints the usage line: {stderr}");
|
||
}
|
||
|
||
/// A fresh, unique working directory for a test that persists content-addressed
|
||
/// blueprints under `./runs/` (mirrors `research_docs.rs`'s `temp_cwd`).
|
||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||
let dir = std::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 <args>` in `dir` (no stdin); return (stdout, stderr, exit code).
|
||
fn run_in(dir: &std::path::Path, args: &[&str]) -> (String, String, Option<i32>) {
|
||
let out = Command::new(BIN)
|
||
.args(args)
|
||
.current_dir(dir)
|
||
.output()
|
||
.expect("binary runs");
|
||
(
|
||
String::from_utf8_lossy(&out.stdout).into_owned(),
|
||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||
out.status.code(),
|
||
)
|
||
}
|
||
|
||
/// Run `aura <args>` in `dir` with `stdin_doc` piped in; return (stderr, exit
|
||
/// code) — the `run_code`/`run_in` cross, for a `use`-seam fault that needs
|
||
/// BOTH a project cwd (the label sidecar lives under `<cwd>/runs/`) and a
|
||
/// piped op-list.
|
||
fn run_code_in(dir: &std::path::Path, args: &[&str], stdin_doc: &str) -> (String, Option<i32>) {
|
||
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.stderr).into_owned(), out.status.code())
|
||
}
|
||
|
||
/// The absolute path of a blueprint fixture shipped with this test crate.
|
||
fn fixture(name: &str) -> String {
|
||
format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
|
||
}
|
||
|
||
/// The absolute path of a demo blueprint shipped in the public examples/
|
||
/// gallery (#159 copy+prove) — distinct from `fixture()`, which reads the
|
||
/// internal tests/fixtures/ copies the emitter builds from.
|
||
fn example(name: &str) -> String {
|
||
format!("{}/examples/{name}", env!("CARGO_MANIFEST_DIR"))
|
||
}
|
||
|
||
/// The id extracted from a `registered blueprint {id} ({path})` line.
|
||
/// Prefix-tolerant across the #194 convention change (the display form dropped
|
||
/// the glued `content:` prefix): the id is bare either way.
|
||
fn registered_id(stdout: &str) -> String {
|
||
stdout
|
||
.lines()
|
||
.find(|l| l.starts_with("registered blueprint "))
|
||
.expect("register line")
|
||
.trim_start_matches("registered blueprint ")
|
||
.split(' ')
|
||
.next()
|
||
.expect("id")
|
||
.trim_start_matches("content:")
|
||
.to_string()
|
||
}
|
||
|
||
/// Property (#196, the on-ramp): `aura graph register <blueprint.json>` parses
|
||
/// the document through the vocabulary, canonicalizes, and stores it content-
|
||
/// addressed under `runs/blueprints/<id>.json`, printing the id + store path.
|
||
#[test]
|
||
fn graph_register_stores_and_prints_content_id() {
|
||
let dir = temp_cwd("register");
|
||
let (stdout, stderr, code) = run_in(&dir, &["graph", "register", &example("r_sma.json")]);
|
||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||
// #194: the register verb prints the bare id — `registered blueprint <id> (<path>)`.
|
||
assert!(!stdout.contains("content:"), "register prints the bare id, no content: prefix: {stdout}");
|
||
let id = registered_id(&stdout);
|
||
assert_eq!(id.len(), 64, "a 64-hex content id: {id:?}");
|
||
assert!(id.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {id:?}");
|
||
assert!(
|
||
dir.join("runs").join("blueprints").join(format!("{id}.json")).is_file(),
|
||
"stored under runs/blueprints/<id>.json: {stdout}"
|
||
);
|
||
}
|
||
|
||
/// Register is write-once content-addressed: the same document registers to the
|
||
/// same id, exit 0 both times (the idempotent `put_blueprint` contract).
|
||
#[test]
|
||
fn graph_register_is_idempotent() {
|
||
let dir = temp_cwd("register-idempotent");
|
||
let bp = example("r_sma.json");
|
||
let (out1, err1, code1) = run_in(&dir, &["graph", "register", &bp]);
|
||
let (out2, err2, code2) = run_in(&dir, &["graph", "register", &bp]);
|
||
assert_eq!(code1, Some(0), "first register: {out1} {err1}");
|
||
assert_eq!(code2, Some(0), "second register: {out2} {err2}");
|
||
assert_eq!(registered_id(&out1), registered_id(&out2), "same id twice");
|
||
}
|
||
|
||
/// C29 register seam end-to-end (#316): a doc-less root composite refuses at
|
||
/// `aura graph register` naming the composite and the rule (exit 1, C14); a
|
||
/// doc-less named nested composite refuses naming the nested name; the
|
||
/// described shipped example registers. The gate lives at the store write
|
||
/// every register path shares — this pins its verb surface.
|
||
#[test]
|
||
fn register_refuses_undescribed_composites_end_to_end() {
|
||
let dir = temp_cwd("register-doc-gate");
|
||
// doc-less root: the shipped example minus its doc member
|
||
let text = std::fs::read_to_string(example("r_sma.json")).expect("read example");
|
||
let mut v: serde_json::Value = serde_json::from_str(&text).expect("example parses");
|
||
v["blueprint"].as_object_mut().expect("blueprint object").remove("doc");
|
||
let docless = dir.join("docless.json");
|
||
std::fs::write(&docless, serde_json::to_string(&v).unwrap()).expect("write fixture");
|
||
let (out, err, code) = run_in(&dir, &["graph", "register", docless.to_str().unwrap()]);
|
||
assert_eq!(code, Some(1), "doc-less root refuses: {out} {err}");
|
||
assert!(
|
||
err.contains("composite `sma_signal` carries no doc"),
|
||
"stderr names the composite and the rule: {err}"
|
||
);
|
||
assert!(err.contains("C29"), "stderr cites the contract: {err}");
|
||
// doc-less named nested composite inside a described root
|
||
v["blueprint"]["doc"] = serde_json::Value::String("a described root".into());
|
||
v["blueprint"]["nodes"]
|
||
.as_array_mut()
|
||
.expect("nodes array")
|
||
.push(serde_json::json!({"composite": {"name": "inner", "nodes": []}}));
|
||
let nested = dir.join("nested.json");
|
||
std::fs::write(&nested, serde_json::to_string(&v).unwrap()).expect("write fixture");
|
||
let (out, err, code) = run_in(&dir, &["graph", "register", nested.to_str().unwrap()]);
|
||
assert_eq!(code, Some(1), "doc-less nested refuses: {out} {err}");
|
||
assert!(
|
||
err.contains("composite `inner` carries no doc"),
|
||
"stderr names the nested composite: {err}"
|
||
);
|
||
// the described shipped example registers
|
||
let (out, err, code) = run_in(&dir, &["graph", "register", &example("r_sma.json")]);
|
||
assert_eq!(code, Some(0), "described example registers: {out} {err}");
|
||
assert!(out.starts_with("registered blueprint "), "prints the register line: {out}");
|
||
}
|
||
|
||
/// Property (#196, the campaign-axis namespace): `--params <FILE>` prints the
|
||
/// RAW composite param space — one `{name}:{kind:?}` line per open param, in
|
||
/// lowering order, WITHOUT the harness-wrap prefix `aura sweep --list-axes`
|
||
/// shows. The open fixture leaves exactly the two SMA lengths unbound
|
||
/// (`bias.scale` is bound in the document).
|
||
#[test]
|
||
fn graph_params_lists_raw_axis_namespace() {
|
||
let dir = temp_cwd("params");
|
||
let (stdout, stderr, code) =
|
||
run_in(&dir, &["graph", "introspect", "--params", &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 <FILE|ID>"), "usage line names --params: {err0}");
|
||
let (_out2, err2, code2) =
|
||
run_in(&dir, &["graph", "introspect", "--vocabulary", "--params", "x.json"]);
|
||
assert_eq!(code2, Some(2), "two modes is usage exit 2: {err2}");
|
||
assert!(err2.contains("Usage: aura graph introspect"), "prints the usage line: {err2}");
|
||
}
|
||
|
||
/// Property (#196, the register->params-by-id round trip): `--params <ID>`
|
||
/// with a registered blueprint's content id resolves through the project
|
||
/// store and prints exactly the same raw axis namespace `--params <FILE>`
|
||
/// prints for the same document — the advertised `<FILE|ID>` addressing is
|
||
/// two paths to the identical answer, not merely two accepted shapes.
|
||
#[test]
|
||
fn graph_params_by_content_id_matches_params_by_file() {
|
||
let dir = temp_cwd("params-by-id");
|
||
let bp = 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 <ID>` target may carry the display
|
||
/// `content:` prefix copied verbatim from register/introspect output — the CLI
|
||
/// strips it and resolves the bare store address, printing exactly the same raw
|
||
/// axis namespace the bare id (and the file) print.
|
||
#[test]
|
||
fn graph_params_tolerates_content_prefix_on_target() {
|
||
let dir = temp_cwd("params-content-prefix");
|
||
let bp = 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 <ID>` with a well-shaped 64-hex id
|
||
/// that was never registered refuses with a store-miss message, not a panic
|
||
/// or a silent empty namespace — the registry lookup branch of
|
||
/// `resolve_blueprint_text` is exercised.
|
||
#[test]
|
||
fn graph_params_by_unregistered_id_reports_a_store_miss() {
|
||
let dir = temp_cwd("params-missing-id");
|
||
let bogus_id = "a".repeat(64);
|
||
let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", &bogus_id]);
|
||
assert_ne!(code, Some(0), "non-zero exit on a store miss: {stdout} {stderr}");
|
||
assert!(stdout.is_empty(), "no partial namespace on a miss: {stdout}");
|
||
assert!(
|
||
stderr.contains(&format!("no blueprint {bogus_id} in the project store")),
|
||
"names the miss: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// Property (#196, negative): `--params <TARGET>` where TARGET is neither an
|
||
/// existing file nor a 64-hex content id refuses naming both readings — the
|
||
/// `resolve_blueprint_text` fall-through branch, shared with `campaign run`'s
|
||
/// target resolution via `is_content_id`.
|
||
#[test]
|
||
fn graph_params_target_neither_file_nor_id_is_refused() {
|
||
let dir = temp_cwd("params-neither");
|
||
let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", "not-a-file-or-id"]);
|
||
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
|
||
assert!(stdout.is_empty(), "no partial namespace on refusal: {stdout}");
|
||
assert!(
|
||
stderr.contains("'not-a-file-or-id' is neither a readable .json file nor a 64-hex content id"),
|
||
"names both readings: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// Property (#196, the op-list branch of file-mode --content-id): a FILE that
|
||
/// is a JSON *array* (a construction op-list, not a blueprint envelope) is
|
||
/// shape-discriminated correctly and produces the SAME content id as piping
|
||
/// the identical document to stdin — `composite_from_any`'s `Array` arm is
|
||
/// exercised end-to-end, not just its `Object` sibling.
|
||
#[test]
|
||
fn graph_content_id_accepts_an_op_list_file() {
|
||
let dir = temp_cwd("content-id-op-list-file");
|
||
let op_list_path = dir.join("op-list.json");
|
||
std::fs::write(&op_list_path, SIGNAL_DOC).expect("write op-list fixture");
|
||
let (file_out, file_err, file_code) = run_in(
|
||
&dir,
|
||
&["graph", "introspect", "--content-id", op_list_path.to_str().unwrap()],
|
||
);
|
||
assert_eq!(file_code, Some(0), "stdout: {file_out} stderr: {file_err}");
|
||
let (stdin_out, _e, stdin_ok) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
|
||
assert!(stdin_ok, "stdin variant succeeds");
|
||
assert_eq!(file_out.trim(), stdin_out.trim(), "op-list file and stdin agree on content id");
|
||
}
|
||
|
||
/// Property (#196, negative): a FILE whose top-level JSON value is neither an
|
||
/// object (blueprint envelope) nor an array (op-list) — e.g. a bare string —
|
||
/// is refused, exercising `composite_from_any`'s fall-through arm.
|
||
#[test]
|
||
fn graph_content_id_rejects_a_file_that_is_neither_object_nor_array() {
|
||
let dir = temp_cwd("content-id-neither-shape");
|
||
let bad_path = dir.join("bad.json");
|
||
std::fs::write(&bad_path, "\"just a string\"").expect("write bad fixture");
|
||
let (stdout, stderr, code) = run_in(
|
||
&dir,
|
||
&["graph", "introspect", "--content-id", bad_path.to_str().unwrap()],
|
||
);
|
||
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
|
||
assert!(stdout.is_empty(), "no content id emitted: {stdout}");
|
||
assert!(
|
||
stderr.contains("document is neither a blueprint envelope (object) nor an op-list (array)"),
|
||
"names the shape refusal: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// Property (#196, negative): `register` on a syntactically-valid blueprint
|
||
/// envelope that names an unknown node type refuses with the `blueprint_load_prose`
|
||
/// `UnknownNodeType` prose (not a panic, not a bare Debug dump) — this refusal
|
||
/// path is shared by `register`, `--params`, and file-mode `--content-id` (all
|
||
/// route through `blueprint_from_json` -> `blueprint_load_prose`), so exercising
|
||
/// it once through `register` closes the whole family.
|
||
#[test]
|
||
fn graph_register_rejects_an_unknown_node_type_with_prose() {
|
||
let dir = temp_cwd("register-unknown-node");
|
||
let (stdout, stderr, code) =
|
||
run_in(&dir, &["graph", "register", &fixture("unknown_node.json")]);
|
||
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
|
||
assert!(stdout.is_empty(), "no registration line on refusal: {stdout}");
|
||
assert!(
|
||
stderr.contains("unknown node type \"Nope\""),
|
||
"names the unknown node type: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// Property (#202 headline, the on-ramp is shape-invariant): `aura graph register`
|
||
/// accepts an op-script document (a JSON array) exactly as it accepts a #155
|
||
/// blueprint envelope (a JSON object) — it shape-discriminates the array form,
|
||
/// builds it through the same one-build tail, and stores it under the SAME content
|
||
/// id it stores that op-script's `graph build` envelope under. The content id is
|
||
/// shape-invariant (an op-script and its built envelope hash identically), so the
|
||
/// two on-ramp shapes must agree on the store key; `register` must not reject the
|
||
/// op-script with the raw serde `invalid type: map, expected u32` message the
|
||
/// direct-`blueprint_from_json` path currently leaks.
|
||
#[test]
|
||
fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
|
||
let dir = temp_cwd("register-op-script");
|
||
// The op-script form (a JSON array of ops), written to a file. Register
|
||
// requires a described composite (C29), so the described twin is used here.
|
||
let op_script = dir.join("op-script.json");
|
||
std::fs::write(&op_script, SIGNAL_DOC_DESCRIBED).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_DESCRIBED);
|
||
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"
|
||
);
|
||
|
||
// C29: the doc-less op-script form refuses at register like any other
|
||
// doc-less composite entering the store.
|
||
let docless_script = dir.join("docless-script.json");
|
||
std::fs::write(&docless_script, SIGNAL_DOC).expect("write op-script fixture");
|
||
let (dl_out, dl_err, dl_code) =
|
||
run_in(&dir, &["graph", "register", docless_script.to_str().unwrap()]);
|
||
assert_eq!(dl_code, Some(1), "doc-less op-script refuses: {dl_out} {dl_err}");
|
||
assert!(dl_err.contains("carries no doc"), "stderr names the rule: {dl_err}");
|
||
assert!(dl_err.contains("C29"), "stderr cites the contract: {dl_err}");
|
||
}
|
||
|
||
/// C29: an op-script's `doc` op is not a bare presence checkbox -- its text
|
||
/// goes through the same shape gate a builder-authored doc does. A doc that
|
||
/// merely restates the composite's name (here "graph", every CLI op-script's
|
||
/// literal name) refuses at register exactly like the builder-authored
|
||
/// RestatesName case already pinned at the registry-unit layer, but reached
|
||
/// end-to-end through the op-script CLI seam this time.
|
||
#[test]
|
||
fn graph_register_refuses_an_op_script_doc_that_restates_the_composite_name() {
|
||
let dir = temp_cwd("register-op-script-restated-doc");
|
||
let restated_script = dir.join("restated-doc-script.json");
|
||
std::fs::write(&restated_script, SIGNAL_DOC_NAME_RESTATED).expect("write op-script fixture");
|
||
let (out, err, code) = run_in(&dir, &["graph", "register", restated_script.to_str().unwrap()]);
|
||
assert_eq!(code, Some(1), "name-restating doc refuses: {out} {err}");
|
||
assert!(err.contains("merely restates"), "stderr names the rule: {err}");
|
||
assert!(err.contains("C29"), "stderr cites the contract: {err}");
|
||
}
|
||
|
||
/// Property (#202, the same on-ramp seam at the sibling verb): `aura graph
|
||
/// introspect --params` accepts an op-script exactly as it accepts a blueprint
|
||
/// envelope — the raw axis namespace it prints for the op-script equals the one it
|
||
/// prints for that op-script's built envelope, both exit 0. Currently `--params`
|
||
/// rejects the op-script with the same raw serde `invalid type: map, expected u32`
|
||
/// message `register` does (both route through the direct `blueprint_from_json`).
|
||
#[test]
|
||
fn graph_params_accepts_an_op_script_matching_its_envelope() {
|
||
let dir = temp_cwd("params-op-script");
|
||
let op_script = dir.join("op-script.json");
|
||
std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
|
||
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
|
||
assert!(built, "graph build produces the envelope");
|
||
let envelope = dir.join("envelope.json");
|
||
std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");
|
||
|
||
let (env_out, env_err, env_code) =
|
||
run_in(&dir, &["graph", "introspect", "--params", envelope.to_str().unwrap()]);
|
||
assert_eq!(env_code, Some(0), "params on envelope: {env_out} {env_err}");
|
||
let (op_out, op_err, op_code) =
|
||
run_in(&dir, &["graph", "introspect", "--params", op_script.to_str().unwrap()]);
|
||
assert_eq!(op_code, Some(0), "params on op-script exits 0: {op_out} {op_err}");
|
||
assert_eq!(op_out, env_out, "op-script and envelope agree on the raw axis namespace");
|
||
}
|
||
|
||
/// Property (#226, the render positional closes the #202 on-ramp family):
|
||
/// `aura graph <op-script.json>` (the render positional) accepts an op-script
|
||
/// document (a JSON array) exactly as it accepts a #155 blueprint envelope (a
|
||
/// JSON object). Its two introspect siblings (`register`, `introspect --params`,
|
||
/// pinned above) already shape-discriminate via `composite_from_any`, but the
|
||
/// render positional still parses via the direct `blueprint_from_json` and so
|
||
/// rejects the op-script form — leaking the raw serde `invalid type: map,
|
||
/// expected u32` mislabeled as "blueprint document is not valid JSON". The
|
||
/// op-script and its own `graph build` envelope canonicalize to the same
|
||
/// Composite (the register content-id parity above proves the bytes match), so
|
||
/// both must render byte-identically at exit 0.
|
||
#[test]
|
||
fn graph_render_accepts_an_op_script_matching_its_envelope() {
|
||
let dir = temp_cwd("render-op-script");
|
||
let op_script = dir.join("op-script.json");
|
||
std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
|
||
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
|
||
assert!(built, "graph build produces the envelope");
|
||
let envelope = dir.join("envelope.json");
|
||
std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");
|
||
|
||
let (env_out, env_err, env_code) = run_in(&dir, &["graph", envelope.to_str().unwrap()]);
|
||
assert_eq!(env_code, Some(0), "render envelope: {env_err}");
|
||
let (op_out, op_err, op_code) = run_in(&dir, &["graph", op_script.to_str().unwrap()]);
|
||
assert_eq!(op_code, Some(0), "render op-script exits 0: {op_err}");
|
||
assert!(
|
||
!op_err.contains("blueprint document is not valid JSON"),
|
||
"must not mislabel a valid op-script as invalid JSON: {op_err}"
|
||
);
|
||
assert_eq!(op_out, env_out, "op-script and its built envelope render byte-identically");
|
||
}
|
||
|
||
/// Property (#159): the shipped closed example (`examples/r_sma.json`) is
|
||
/// genuinely closed, not merely byte-identical to the builder — `graph
|
||
/// introspect --params` on it reports zero unbound params. The Cut-1
|
||
/// byte-fidelity proof (`shipped_r_sma_examples_are_byte_identical_to_the_builder`,
|
||
/// main.rs) only pins the example's bytes to the builder's serialization; it says
|
||
/// nothing about whether that serialization is actually closed. This pins the
|
||
/// "closed" claim the demo makes to its audience.
|
||
#[test]
|
||
fn shipped_r_sma_example_is_genuinely_closed() {
|
||
let dir = temp_cwd("example-closed-params");
|
||
let (stdout, stderr, code) =
|
||
run_in(&dir, &["graph", "introspect", "--params", &example("r_sma.json")]);
|
||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
|
||
}
|
||
|
||
/// Property (#159): the 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 <file>` — the real CLI round trip a
|
||
/// consumer drives, not a hand-built `Composite` fed straight to `model_to_json`
|
||
/// — embeds the fused gang table in the page's inlined `window.AURA_MODEL`. This
|
||
/// is the seam that actually connects the construction-layer gang op to the
|
||
/// viewer surface: `graph_model.rs`'s `model_carries_gangs_only_for_ganged_scopes`
|
||
/// pins the JSON fragment in isolation, and `viewer_gang_param` pins the JS
|
||
/// annotation against a hand-written model literal, but neither proves the gang
|
||
/// table SURVIVES the build -> file -> render pipeline a consumer actually runs.
|
||
#[test]
|
||
fn graph_build_gang_op_survives_render_round_trip() {
|
||
let dir = temp_cwd("graph-render-gang-round-trip");
|
||
let ops = r#"[
|
||
{"op":"source","role":"price","kind":"F64"},
|
||
{"op":"add","type":"SMA","name":"a"},
|
||
{"op":"add","type":"SMA","name":"b"},
|
||
{"op":"gang","as":"length","into":["a.length","b.length"]},
|
||
{"op":"add","type":"Sub"},
|
||
{"op":"feed","role":"price","into":["a.series","b.series"]},
|
||
{"op":"connect","from":"a.value","to":"sub.lhs"},
|
||
{"op":"connect","from":"b.value","to":"sub.rhs"},
|
||
{"op":"expose","from":"sub.value","as":"bias"}
|
||
]"#;
|
||
let (bp, stderr, ok) = run(&["graph", "build"], ops);
|
||
assert!(ok, "graph build: stderr: {stderr}");
|
||
let bp_path = dir.join("bp.json");
|
||
std::fs::write(&bp_path, &bp).expect("write built blueprint");
|
||
|
||
let out = std::process::Command::new(BIN)
|
||
.arg("graph")
|
||
.arg(&bp_path)
|
||
.current_dir(&dir)
|
||
.output()
|
||
.expect("spawn aura graph <blueprint>");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"`aura graph <blueprint.json>` exit: {:?}\nstderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let html = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert!(
|
||
html.contains(r#""gangs":[{"name":"length","kind":"I64","members":[["#),
|
||
"rendered page does not embed the gang table: {html}"
|
||
);
|
||
}
|
||
|
||
/// Property (#125, the render seam a hand-authored blueprint document actually
|
||
/// drives): a composite's `doc` field is not merely a `model_to_json` unit-level
|
||
/// concept — `aura graph <file>` on a real on-disk blueprint envelope that
|
||
/// carries `"doc":"..."` embeds that exact text in the page's inlined
|
||
/// `window.AURA_MODEL` JSON. The graph_model.rs golden pins the JSON fragment in
|
||
/// isolation; this proves the doc survives the actual file-load -> render CLI
|
||
/// pipeline a consumer runs, not just the library function called directly.
|
||
#[test]
|
||
fn graph_render_embeds_the_hand_authored_composite_doc() {
|
||
let out = std::process::Command::new(BIN)
|
||
.arg("graph")
|
||
.arg(fixture("doc_blueprint.json"))
|
||
.output()
|
||
.expect("spawn aura graph <blueprint>");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"render exit: {:?}\nstderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let html = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert!(
|
||
html.contains(r#","doc":"why this graph: single SMA smoke test""#),
|
||
"rendered page does not embed the composite doc: {html}"
|
||
);
|
||
}
|
||
|
||
/// Property (#125, doc-blind identity at the CLI seam): a blueprint document
|
||
/// carrying a `doc` and its doc-less twin are the SAME topology by the identity
|
||
/// projection (`--identity-id` agrees) but DIFFERENT documents by the canonical
|
||
/// projection (`--content-id` differs) — mirroring the established renamed-twin
|
||
/// pattern (`graph_introspect_identity_id_bridges_renamed_op_scripts`) for the
|
||
/// new field. `blueprint_serde.rs`'s `doc_round_trips_canonically_and_is_
|
||
/// identity_blind` pins this at the library level; this drives the real binary
|
||
/// over a real file, the boundary a regression in the CLI's own dispatch could
|
||
/// still break even with the library invariant intact.
|
||
#[test]
|
||
fn graph_doc_field_moves_content_id_but_not_identity_id() {
|
||
let doced = fixture("doc_blueprint.json");
|
||
let doceless_text = std::fs::read_to_string(&doced)
|
||
.expect("read fixture")
|
||
.replace(r#""doc":"why this graph: single SMA smoke test","#, "");
|
||
let dir = temp_cwd("doc-field-ids");
|
||
let doceless = dir.join("doc_blueprint_no_doc.json");
|
||
std::fs::write(&doceless, &doceless_text).expect("write doc-less twin");
|
||
|
||
// `--identity-id` has no FILE slot of its own (main.rs `GraphIntrospectCmd`):
|
||
// combine it with `--content-id <FILE>` (both id flags share one build) and
|
||
// read the two printed lines, content id first (mirrors
|
||
// `graph_introspect_content_and_identity_id_combine`).
|
||
let (doced_ids, _e, ok1) =
|
||
run_in(&dir, &["graph", "introspect", "--content-id", &doced, "--identity-id"]);
|
||
let (doceless_ids, _e2, ok2) = run_in(
|
||
&dir,
|
||
&["graph", "introspect", "--content-id", doceless.to_str().unwrap(), "--identity-id"],
|
||
);
|
||
assert_eq!(ok1, Some(0), "doc'd id pair exits 0: {doced_ids}");
|
||
assert_eq!(ok2, Some(0), "doc-less id pair exits 0: {doceless_ids}");
|
||
let doced_lines: Vec<&str> = doced_ids.lines().collect();
|
||
let doceless_lines: Vec<&str> = doceless_ids.lines().collect();
|
||
assert_eq!(doced_lines.len(), 2, "content id then identity id: {doced_ids:?}");
|
||
assert_eq!(doceless_lines.len(), 2, "content id then identity id: {doceless_ids:?}");
|
||
assert_ne!(
|
||
doced_lines[0], doceless_lines[0],
|
||
"the doc is canonical-byte-bearing (content id moves)"
|
||
);
|
||
assert_eq!(
|
||
doced_lines[1], doceless_lines[1],
|
||
"the identity projection is doc-blind (identity id agrees)"
|
||
);
|
||
}
|
||
|
||
/// Property (#125 viewer/#3, nested-composite doc through the real render
|
||
/// pipeline): the sibling test above only proves a ROOT-level `doc` reaches
|
||
/// the rendered page — the viewer threads a composite's doc onto its own
|
||
/// tooltip (INFO.B/INFO.C in graph-viewer.js), which needs the doc present on
|
||
/// a NESTED composite def, not just the root. `aura graph <file>` over a
|
||
/// blueprint whose one node is a nested composite carrying `doc` must embed
|
||
/// that exact string in the emitted page's `composites` section of
|
||
/// `window.AURA_MODEL` — the composite-def half of `graph_model.rs`'s
|
||
/// `doc_fragment`, exercised through the actual file-load -> render CLI
|
||
/// binary, not just the library function called directly.
|
||
#[test]
|
||
fn graph_render_embeds_a_nested_composites_doc() {
|
||
let out = std::process::Command::new(BIN)
|
||
.arg("graph")
|
||
.arg(fixture("nested_doc_blueprint.json"))
|
||
.output()
|
||
.expect("spawn aura graph <blueprint>");
|
||
assert_eq!(
|
||
out.status.code(),
|
||
Some(0),
|
||
"render exit: {:?}\nstderr: {}",
|
||
out.status,
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
let html = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||
assert!(
|
||
html.contains(r#","doc":"momentum leg: change over a lookback, gated by the trend sign""#),
|
||
"rendered page does not embed the nested composite's doc: {html}"
|
||
);
|
||
}
|
||
|
||
/// The `gang` op's arity refusal (fewer than two members) reads as prose at the
|
||
/// binary seam, attributed to its op index, never the raw `GangArity` Debug name.
|
||
#[test]
|
||
fn graph_build_reports_gang_arity_fault_as_prose() {
|
||
let doc = r#"[
|
||
{"op":"source","role":"price","kind":"F64"},
|
||
{"op":"add","type":"SMA","name":"a"},
|
||
{"op":"gang","as":"solo","into":["a.length"]}
|
||
]"#;
|
||
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
|
||
assert!(!ok, "non-zero exit on a gang arity fault");
|
||
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
|
||
assert!(
|
||
stderr.contains("op 2 (gang): gang `solo`: needs at least two members"),
|
||
"names the op + cause as prose: {stderr}"
|
||
);
|
||
assert!(!stderr.contains("GangArity"), "does not leak the Debug variant name: {stderr}");
|
||
}
|
||
|
||
/// The `gang` op's double-membership refusal (a param already claimed by an
|
||
/// earlier gang) reads as prose, naming the offending `node.param`.
|
||
#[test]
|
||
fn graph_build_reports_already_ganged_fault_as_prose() {
|
||
let doc = r#"[
|
||
{"op":"source","role":"price","kind":"F64"},
|
||
{"op":"add","type":"SMA","name":"a"},
|
||
{"op":"add","type":"SMA","name":"b"},
|
||
{"op":"add","type":"SMA","name":"c"},
|
||
{"op":"gang","as":"lo","into":["a.length","b.length"]},
|
||
{"op":"gang","as":"hi","into":["a.length","c.length"]}
|
||
]"#;
|
||
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
|
||
assert!(!ok, "non-zero exit on an already-ganged fault");
|
||
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
|
||
assert!(
|
||
stderr.contains("op 5 (gang): gang: `a.length` is already ganged"),
|
||
"names the op + doubly-ganged member as prose: {stderr}"
|
||
);
|
||
assert!(!stderr.contains("AlreadyGanged"), "does not leak the Debug variant name: {stderr}");
|
||
}
|
||
|
||
/// The `gang` op's kind-mismatch refusal (members of differing scalar kind)
|
||
/// reads as prose, naming the offending member and both kinds.
|
||
#[test]
|
||
fn graph_build_reports_gang_kind_mismatch_as_prose() {
|
||
let doc = r#"[
|
||
{"op":"source","role":"price","kind":"F64"},
|
||
{"op":"add","type":"SMA","name":"a"},
|
||
{"op":"add","type":"Bias","name":"b"},
|
||
{"op":"gang","as":"g","into":["a.length","b.scale"]}
|
||
]"#;
|
||
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
|
||
assert!(!ok, "non-zero exit on a gang kind mismatch");
|
||
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
|
||
assert!(
|
||
stderr.contains("op 3 (gang): gang: member `b.scale` is F64, expected I64"),
|
||
"names the op + kind mismatch as prose: {stderr}"
|
||
);
|
||
assert!(!stderr.contains("GangKindMismatch"), "does not leak the Debug variant name: {stderr}");
|
||
}
|
||
|
||
/// The `doc` op's duplicate refusal (a second meaning line in one op-script)
|
||
/// reads as prose at the binary seam, attributed to the second `doc` op's
|
||
/// index -- never the raw `DuplicateDoc` Debug variant name.
|
||
#[test]
|
||
fn graph_build_reports_duplicate_doc_fault_as_prose() {
|
||
let doc = r#"[
|
||
{"op":"source","role":"price","kind":"F64"},
|
||
{"op":"doc","text":"first"},
|
||
{"op":"doc","text":"second"}
|
||
]"#;
|
||
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
|
||
assert!(!ok, "non-zero exit on a duplicate doc fault");
|
||
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
|
||
assert!(
|
||
stderr.contains("op 2 (doc): a doc op may appear at most once"),
|
||
"names the op + cause as prose: {stderr}"
|
||
);
|
||
assert!(!stderr.contains("DuplicateDoc"), "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<i32>) {
|
||
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}"
|
||
);
|
||
}
|
||
|
||
/// Property (#284): the `tap` op-script op, driven through the real `aura
|
||
/// graph build` process (not an in-crate call to `build_from_str`), resolves
|
||
/// a name-addressed interior wire (`"fast.value"`, session node 0 / output
|
||
/// field 0) into the exact same `{node, field}` shape a raw-index-authored
|
||
/// blueprint would carry — the emitted blueprint's `taps` array names the
|
||
/// wire by resolved index. This is the process-boundary counterpart of
|
||
/// `construction.rs`'s `tap_op_resolves_and_appends` (in-crate call): it pins
|
||
/// that the CLI's serde front end (`OpDoc::Tap` → `Op::Tap`) actually reaches
|
||
/// the engine and back out to stdout across a real subprocess, the surface a
|
||
/// data-level author drives.
|
||
#[test]
|
||
fn graph_build_emits_a_tap_op_declared_wire() {
|
||
let ops = 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":"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":"tap","from":"fast.value","as":"fast_ma"},
|
||
{"op":"expose","from":"sub.value","as":"bias"}
|
||
]"#;
|
||
let (stdout, stderr, ok) = run(&["graph", "build"], ops);
|
||
assert!(ok, "graph build: stderr: {stderr}");
|
||
assert!(
|
||
stdout.contains(r#""taps":[{"name":"fast_ma","from":{"node":0,"field":0}}]"#),
|
||
"the tap resolves to node 0 (fast), field 0 (value): {stdout}"
|
||
);
|
||
}
|
||
|
||
/// Property (#284): a blueprint authored through the name-addressed `tap` op
|
||
/// — built by the real `aura graph build` process, never hand-edited by raw
|
||
/// node index — is not merely well-formed JSON: `aura run` actually binds and
|
||
/// persists its declared tap, exactly as a raw-index-authored tap does
|
||
/// (`tap_recording.rs`'s coverage of the underlying bind/persist mechanism).
|
||
/// This is the whole point of #284 (removing the raw-index miscount class):
|
||
/// the op-authored artifact must round-trip through the full build -> run ->
|
||
/// persisted-trace pipeline, not just parse. Taps `"slow.value"` (SMA(4),
|
||
/// deliberately not the node-0 `"fast"` port the sibling build-only test
|
||
/// checks) so this test cannot pass by accident on the wrong node.
|
||
#[test]
|
||
fn tap_authored_via_op_script_runs_and_persists_the_series() {
|
||
const PRICES: [f64; 18] = [
|
||
1.0000, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034, 1.0012, 0.9998,
|
||
1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
|
||
];
|
||
let ops = 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","name":"bias","bind":{"scale":{"F64":0.5}}},
|
||
{"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":"tap","from":"slow.value","as":"slow_ma"},
|
||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||
]"#;
|
||
let (blueprint_json, stderr, ok) = run(&["graph", "build"], ops);
|
||
assert!(ok, "graph build: stderr: {stderr}");
|
||
assert!(blueprint_json.contains("\"taps\""), "blueprint carries the tap: {blueprint_json}");
|
||
|
||
let dir = temp_cwd("tap-op-run-roundtrip");
|
||
let bp_path = dir.join("tapped.json");
|
||
std::fs::write(&bp_path, &blueprint_json).expect("write built blueprint");
|
||
|
||
let (_stdout, stderr2, code2) = run_in(&dir, &["run", bp_path.to_str().unwrap()]);
|
||
assert_eq!(code2, Some(0), "aura run over the op-authored tap: {stderr2}");
|
||
|
||
// `aura graph build` always names the root composite "graph" (`composite_from_str`),
|
||
// so the trace store's run-name directory is fixed.
|
||
let trace_path = dir.join("runs/traces/graph/slow_ma.json");
|
||
let trace_text = std::fs::read_to_string(&trace_path)
|
||
.unwrap_or_else(|e| panic!("expected a persisted tap trace at {}: {e}", trace_path.display()));
|
||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
|
||
|
||
assert_eq!(trace["tap"], "slow_ma");
|
||
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
|
||
|
||
// SMA(4), silent until warm (4 samples): first emission at the 4th sample
|
||
// (ts=4), a rolling mean of the last four prices per subsequent cycle.
|
||
let expected: Vec<f64> = (3..PRICES.len())
|
||
.map(|i| PRICES[i - 3..=i].iter().sum::<f64>() / 4.0)
|
||
.collect();
|
||
let expected_ts: Vec<i64> = (4..=PRICES.len() as i64).collect();
|
||
|
||
let got_ts: Vec<i64> =
|
||
trace["ts"].as_array().expect("ts array").iter().map(|v| v.as_i64().unwrap()).collect();
|
||
assert_eq!(got_ts, expected_ts, "recorded timestamps");
|
||
|
||
let got: Vec<f64> = trace["columns"][0]
|
||
.as_array()
|
||
.expect("columns[0] array")
|
||
.iter()
|
||
.map(|v| v.as_f64().unwrap())
|
||
.collect();
|
||
assert_eq!(got.len(), expected.len(), "recorded row count");
|
||
for (i, (g, e)) in got.iter().zip(expected.iter()).enumerate() {
|
||
assert!((g - e).abs() < 1e-9, "row {i}: got {g}, expected {e}");
|
||
}
|
||
}
|
||
|
||
// ---- `use` / label sidecar / open patterns (#317) ------------------------
|
||
|
||
/// A small reusable pattern: an open `x` input role feeds an `SMA`, whose
|
||
/// value re-exports as `out` — the fixture every `use`-seam e2e test below
|
||
/// registers under a label. `sma.length` stays UNBOUND: the pattern's own
|
||
/// open param, so a consumer's `--list-axes` sees the bare (unbound) form.
|
||
const OPEN_PATTERN_DOC: &str = r#"[
|
||
{"op":"doc","text":"a reusable smoothing pattern"},
|
||
{"op":"input","role":"x"},
|
||
{"op":"add","type":"SMA","name":"sma"},
|
||
{"op":"feed","role":"x","into":["sma.series"]},
|
||
{"op":"expose","from":"sma.value","as":"out"}
|
||
]"#;
|
||
|
||
/// Build `doc` via `aura graph build` in `dir` and write the resulting
|
||
/// envelope to `dir/<stem>.bp.json`, returning its path.
|
||
fn build_envelope_in(dir: &std::path::Path, doc: &str, stem: &str) -> std::path::PathBuf {
|
||
let (bytes, _e, ok) = run(&["graph", "build"], doc);
|
||
assert!(ok, "the fixture document builds: {doc}");
|
||
let path = dir.join(format!("{stem}.bp.json"));
|
||
std::fs::write(&path, &bytes).expect("write built envelope");
|
||
path
|
||
}
|
||
|
||
/// `graph register --name` (#317): the label sidecar echo on first
|
||
/// registration, and the repoint echo (`(was <old-id>)`) on a second
|
||
/// registration of DIFFERENT bytes under the SAME label.
|
||
#[test]
|
||
fn graph_register_name_labels_and_repoints() {
|
||
let dir = temp_cwd("register-name");
|
||
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
|
||
let (out1, err1, code1) =
|
||
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
|
||
assert_eq!(code1, Some(0), "first register --name: {out1} {err1}");
|
||
let id1 = registered_id(&out1);
|
||
assert!(
|
||
out1.lines().any(|l| l == format!("label \"smooth\" -> {id1}")),
|
||
"the plain label echo (no repoint on a fresh label): {out1}"
|
||
);
|
||
|
||
// Different bytes (a bound sma.length), same label -> a repoint.
|
||
let doc2 = OPEN_PATTERN_DOC.replace(
|
||
r#"{"op":"add","type":"SMA","name":"sma"}"#,
|
||
r#"{"op":"add","type":"SMA","name":"sma","bind":{"length":{"I64":7}}}"#,
|
||
);
|
||
let bp2 = build_envelope_in(&dir, &doc2, "pattern2");
|
||
let (out2, err2, code2) =
|
||
run_in(&dir, &["graph", "register", bp2.to_str().unwrap(), "--name", "smooth"]);
|
||
assert_eq!(code2, Some(0), "second register --name: {out2} {err2}");
|
||
let id2 = registered_id(&out2);
|
||
assert_ne!(id1, id2, "the two registrations are distinct content");
|
||
assert!(
|
||
out2.lines().any(|l| l == format!("label \"smooth\" -> {id2} (was {id1})")),
|
||
"the repoint echo names both ids: {out2}"
|
||
);
|
||
}
|
||
|
||
/// `use` resolves a registry label (#317): the resolution echo lands on
|
||
/// STDERR (`aura: note: use "<instance>": <label> -> <full id>`), stdout
|
||
/// stays clean payload — the existing e2e convention this cycle extends.
|
||
#[test]
|
||
fn graph_build_use_resolves_a_label_and_echoes_the_id() {
|
||
let dir = temp_cwd("use-resolves-label");
|
||
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
|
||
let (reg_out, reg_err, reg_code) =
|
||
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
|
||
assert_eq!(reg_code, Some(0), "register --name: {reg_out} {reg_err}");
|
||
let id = registered_id(®_out);
|
||
|
||
let consumer = r#"[
|
||
{"op":"source","role":"price","kind":"F64"},
|
||
{"op":"use","ref":{"name":"smooth"},"name":"trend"},
|
||
{"op":"feed","role":"price","into":["trend.x"]},
|
||
{"op":"expose","from":"trend.out","as":"bias"}
|
||
]"#;
|
||
let mut child = Command::new(BIN)
|
||
.args(["graph", "build"])
|
||
.current_dir(&dir)
|
||
.stdin(Stdio::piped())
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.spawn()
|
||
.expect("spawn aura graph build");
|
||
child.stdin.take().unwrap().write_all(consumer.as_bytes()).unwrap();
|
||
let out = child.wait_with_output().expect("wait aura graph build");
|
||
assert!(out.status.success(), "build succeeds: {}", String::from_utf8_lossy(&out.stderr));
|
||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert_eq!(
|
||
stderr.trim(),
|
||
format!("aura: note: use \"trend\": smooth -> {id}"),
|
||
"the resolution echo names the instance, the label, and the full id: {stderr}"
|
||
);
|
||
assert!(stdout.starts_with('{'), "stdout stays clean blueprint payload: {stdout}");
|
||
assert!(!stdout.contains("aura: note:"), "the note never leaks onto stdout: {stdout}");
|
||
assert!(
|
||
stdout.contains(&format!("\"doc\":\"{}\"", "a reusable smoothing pattern")),
|
||
"the spliced instance's own doc survives inline: {stdout}"
|
||
);
|
||
}
|
||
|
||
/// An unknown `use` label enumerates every registered label (C29's "name the
|
||
/// closed set" idiom) and refuses at exit 1 (op-list content fault, #175).
|
||
#[test]
|
||
fn graph_build_use_unknown_label_enumerates_labels_exit_1() {
|
||
let dir = temp_cwd("use-unknown-label");
|
||
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
|
||
let (reg_out, reg_err, reg_code) =
|
||
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
|
||
assert_eq!(reg_code, Some(0), "register --name: {reg_out} {reg_err}");
|
||
|
||
let consumer = r#"[
|
||
{"op":"source","role":"price","kind":"F64"},
|
||
{"op":"use","ref":{"name":"nope"},"name":"trend"},
|
||
{"op":"feed","role":"price","into":["trend.x"]},
|
||
{"op":"expose","from":"trend.out","as":"bias"}
|
||
]"#;
|
||
let out = std::process::Command::new(BIN)
|
||
.args(["graph", "build"])
|
||
.current_dir(&dir)
|
||
.stdin(Stdio::piped())
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.spawn()
|
||
.and_then(|mut child| {
|
||
use std::io::Write;
|
||
child.stdin.take().unwrap().write_all(consumer.as_bytes())?;
|
||
child.wait_with_output()
|
||
})
|
||
.expect("run aura graph build");
|
||
assert_eq!(out.status.code(), Some(1), "unknown label is a content fault -> exit 1");
|
||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||
assert!(stdout.is_empty(), "no blueprint emitted on refusal: {stdout}");
|
||
assert!(stderr.contains("op 1 (use \"trend\")"), "names the op by index and instance: {stderr}");
|
||
assert!(stderr.contains("no registered blueprint labeled \"nope\""), "names the miss: {stderr}");
|
||
assert!(stderr.contains("registered labels: smooth"), "enumerates the closed set: {stderr}");
|
||
}
|
||
|
||
/// The empty-store form of the unknown-label refusal: no labels to
|
||
/// enumerate reads as prose, not a bare empty list.
|
||
#[test]
|
||
fn graph_build_use_unknown_label_empty_store_reads_as_prose() {
|
||
let dir = temp_cwd("use-unknown-label-empty-store");
|
||
let consumer = r#"[
|
||
{"op":"source","role":"price","kind":"F64"},
|
||
{"op":"use","ref":{"name":"nope"},"name":"trend"},
|
||
{"op":"feed","role":"price","into":["trend.x"]},
|
||
{"op":"expose","from":"trend.out","as":"bias"}
|
||
]"#;
|
||
let (stderr, code) = run_code_in(&dir, &["graph", "build"], consumer);
|
||
assert_eq!(code, Some(1), "still a content fault -> exit 1");
|
||
assert!(
|
||
stderr.contains("no registered blueprint labeled \"nope\" — no labels registered"),
|
||
"the empty-store form reads as prose: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// C29 at the `use` seam (#317): a doc-less registered blueprint refuses
|
||
/// entering a NEW composition, naming the ref's id-prefix, the failing
|
||
/// (here root, "nested" from the consumer's view) composite, and the rule —
|
||
/// exit 1. The doc-less entry reaches the store via the RAW file write
|
||
/// existing pre-C29 fixtures use (`register_refuses_undescribed_composites_
|
||
/// end_to_end`'s technique): `graph register` itself always C29-gates, so a
|
||
/// doc-less entry can only reach the store by writing the file directly.
|
||
#[test]
|
||
fn graph_build_use_docless_source_refuses_with_the_c29_shape_exit_1() {
|
||
let dir = temp_cwd("use-docless-source");
|
||
// A doc-less pattern: same shape as OPEN_PATTERN_DOC minus the `doc` op.
|
||
let docless = r#"[
|
||
{"op":"input","role":"x"},
|
||
{"op":"add","type":"SMA","name":"sma","bind":{"length":{"I64":3}}},
|
||
{"op":"feed","role":"x","into":["sma.series"]},
|
||
{"op":"expose","from":"sma.value","as":"out"}
|
||
]"#;
|
||
let (envelope, _e, built) = run(&["graph", "build"], docless);
|
||
assert!(built, "the doc-less pattern still builds (C29 gates register/use, not build)");
|
||
let (id_out, _e2, id_ok) = run(&["graph", "introspect", "--content-id"], docless);
|
||
assert!(id_ok, "content-id computes without a doc");
|
||
let id = id_out.trim().to_string();
|
||
let store_dir = dir.join("runs").join("blueprints");
|
||
std::fs::create_dir_all(&store_dir).expect("create store dir");
|
||
std::fs::write(store_dir.join(format!("{id}.json")), &envelope).expect("raw store write");
|
||
|
||
let consumer = format!(
|
||
r#"[
|
||
{{"op":"source","role":"price","kind":"F64"}},
|
||
{{"op":"use","ref":{{"content_id":"{id}"}},"name":"gate"}},
|
||
{{"op":"feed","role":"price","into":["gate.x"]}},
|
||
{{"op":"expose","from":"gate.out","as":"bias"}}
|
||
]"#
|
||
);
|
||
let (stderr, code) = run_code_in(&dir, &["graph", "build"], &consumer);
|
||
assert_eq!(code, Some(1), "a doc-less fetched composite refuses -> exit 1: {stderr}");
|
||
assert!(stderr.contains("op 1 (use \"gate\")"), "names the op by index and instance: {stderr}");
|
||
assert!(stderr.contains(&id[..12]), "names the ref's id-prefix: {stderr}");
|
||
assert!(stderr.contains("fails the description gate"), "names the rule: {stderr}");
|
||
assert!(
|
||
stderr.contains("has no description") && stderr.contains("re-register it with a \"doc\" op"),
|
||
"carries the re-register hint: {stderr}"
|
||
);
|
||
assert!(stderr.contains("C29"), "cites the contract: {stderr}");
|
||
}
|
||
|
||
/// The worked acceptance flow's last leg (spec §Concrete code shapes): a
|
||
/// pattern registered with an open param splices under an instance, and
|
||
/// `aura sweep --list-axes` on the CONSUMER shows the path-qualified bare
|
||
/// (unbound) axis `graph.<instance>.<node>.<param>:<kind>` — the existing
|
||
/// nested-composite param-prefix discipline, reached through `use` this
|
||
/// time, not a Rust-authored nested composite.
|
||
#[test]
|
||
fn graph_build_use_end_to_end_axes() {
|
||
let dir = temp_cwd("use-end-to-end-axes");
|
||
let bp = build_envelope_in(&dir, OPEN_PATTERN_DOC, "pattern");
|
||
let (reg_out, reg_err, reg_code) =
|
||
run_in(&dir, &["graph", "register", bp.to_str().unwrap(), "--name", "smooth"]);
|
||
assert_eq!(reg_code, Some(0), "register --name: {reg_out} {reg_err}");
|
||
|
||
let consumer = r#"[
|
||
{"op":"source","role":"price","kind":"F64"},
|
||
{"op":"use","ref":{"name":"smooth"},"name":"trend"},
|
||
{"op":"feed","role":"price","into":["trend.x"]},
|
||
{"op":"expose","from":"trend.out","as":"bias"}
|
||
]"#;
|
||
let consumer_path = dir.join("consumer.ops.json");
|
||
std::fs::write(&consumer_path, consumer).expect("write consumer fixture");
|
||
let build = std::process::Command::new(BIN)
|
||
.args(["graph", "build"])
|
||
.current_dir(&dir)
|
||
.stdin(Stdio::piped())
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.spawn()
|
||
.and_then(|mut child| {
|
||
use std::io::Write;
|
||
child.stdin.take().unwrap().write_all(consumer.as_bytes())?;
|
||
child.wait_with_output()
|
||
})
|
||
.expect("run aura graph build");
|
||
assert!(build.status.success(), "consumer builds: {}", String::from_utf8_lossy(&build.stderr));
|
||
let consumer_bp = dir.join("consumer.bp.json");
|
||
std::fs::write(&consumer_bp, &build.stdout).expect("write consumer envelope");
|
||
|
||
let (axes_out, axes_err, axes_code) =
|
||
run_in(&dir, &["sweep", consumer_bp.to_str().unwrap(), "--list-axes"]);
|
||
assert_eq!(axes_code, Some(0), "list-axes: {axes_out} {axes_err}");
|
||
assert_eq!(
|
||
axes_out, "graph.trend.sma.length:I64\n",
|
||
"the spliced instance's open param surfaces path-qualified, bare (unbound) form"
|
||
);
|
||
}
|
||
|
||
/// Open patterns (#317 §"Open patterns", acceptance criterion 6): an
|
||
/// `input`-role op-script — no bound root role at all — builds cleanly:
|
||
/// `finish()` no longer gates root-role boundness, only `compile`/bootstrap
|
||
/// do. The agree flow's first half (register is exercised by the other
|
||
/// `use`-seam tests above; this pins the build step alone).
|
||
#[test]
|
||
fn graph_build_accepts_an_open_input_pattern() {
|
||
let (stdout, stderr, ok) = run(&["graph", "build"], OPEN_PATTERN_DOC);
|
||
assert!(ok, "an input-role pattern builds: {stderr}");
|
||
assert!(stdout.contains("\"input_roles\""), "carries the open root role: {stdout}");
|
||
assert!(!stdout.contains("\"source\""), "the role carries no bound kind (open, #317): {stdout}");
|
||
}
|
||
|
||
/// Open patterns, the OTHER half: running an open blueprint standalone
|
||
/// still refuses — the runnability gate moved nowhere, only `finish()`
|
||
/// stopped pre-empting it (#317). `aura run`'s `bias`-output arm re-roots
|
||
/// through `wrap_r` (an existing, unrelated nesting mechanism unaffected by
|
||
/// this cycle), so a bare-tap (no-`bias`) pattern is used here: its
|
||
/// `run_measurement` path compiles the signal directly, hitting
|
||
/// `CompileError::UnboundRootRole` at bootstrap — via the EXISTING `{e:?}`
|
||
/// Debug rendering (a raw index, not a role name); a name mapping there is
|
||
/// left for a follow-up (out of this cycle's scope, per the plan's own
|
||
/// escape hatch), so this pins the existing form.
|
||
#[test]
|
||
fn running_an_open_blueprint_refuses_at_bootstrap() {
|
||
let doc = r#"[
|
||
{"op":"input","role":"price"},
|
||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||
{"op":"feed","role":"price","into":["fast.series"]},
|
||
{"op":"tap","from":"fast.value","as":"fast_ma"}
|
||
]"#;
|
||
let dir = temp_cwd("open-pattern-run-refuses");
|
||
let (build_out, _e, built) = run(&["graph", "build"], doc);
|
||
assert!(built, "an open (Input) root role finishes without root-role boundness (#317)");
|
||
let bp = dir.join("open.bp.json");
|
||
std::fs::write(&bp, &build_out).expect("write built blueprint");
|
||
let (stdout, stderr, code) = run_in(&dir, &["run", bp.to_str().unwrap()]);
|
||
assert_eq!(code, Some(1), "an open blueprint refuses standalone at bootstrap, not finish: {stdout} {stderr}");
|
||
assert!(stdout.is_empty(), "no report emitted on a bootstrap refusal: {stdout}");
|
||
assert!(
|
||
stderr.contains("UnboundRootRole"),
|
||
"the runnability gate (compile), unchanged, names the fault: {stderr}"
|
||
);
|
||
}
|