feat(engine): composite doc field — authoring, serde, identity, model emit (refs #125)
Composite gains doc: Option<String> (the prose twin of name, a C23 debug symbol): with_doc/doc() on Composite, a doc knob on GraphBuilder, a Tier-1 additive-optional CompositeData field (no format-version bump; absent-field documents keep their exact bytes), identity-stripped like the name. The graph model emits an optional trailing doc fragment in both scopes; json_str is hardened for free text (\n/\t/\r named, control chars as \u00XX — the doc is the first multi-line value through it). e2e: a hand-authored doc'd blueprint renders with the doc embedded; the doc moves the content id but never the identity id. refs #125
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
{"format_version":1,"blueprint":{"name":"sig","doc":"why this graph: single SMA smoke test","nodes":[{"primitive":{"type":"SMA","name":"fast"}}]}}
|
||||||
@@ -967,6 +967,79 @@ fn graph_build_gang_op_survives_render_round_trip() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The `gang` op's arity refusal (fewer than two members) reads as prose at the
|
/// 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.
|
/// binary seam, attributed to its op index, never the raw `GangArity` Debug name.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ pub struct GangMember {
|
|||||||
/// re-exports one interior `(node, field)` under a boundary name).
|
/// re-exports one interior `(node, field)` under a boundary name).
|
||||||
pub struct Composite {
|
pub struct Composite {
|
||||||
name: String,
|
name: String,
|
||||||
|
doc: Option<String>,
|
||||||
nodes: Vec<BlueprintNode>,
|
nodes: Vec<BlueprintNode>,
|
||||||
edges: Vec<Edge>,
|
edges: Vec<Edge>,
|
||||||
input_roles: Vec<Role>,
|
input_roles: Vec<Role>,
|
||||||
@@ -188,13 +189,24 @@ impl Composite {
|
|||||||
input_roles: Vec<Role>,
|
input_roles: Vec<Role>,
|
||||||
output: Vec<OutField>,
|
output: Vec<OutField>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self { name: name.into(), nodes, edges, input_roles, output, gangs: Vec::new() }
|
Self { name: name.into(), doc: None, nodes, edges, input_roles, output, gangs: Vec::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The authored render name (cluster title, #13). Non-load-bearing.
|
/// The authored render name (cluster title, #13). Non-load-bearing.
|
||||||
pub fn name(&self) -> &str {
|
pub fn name(&self) -> &str {
|
||||||
&self.name
|
&self.name
|
||||||
}
|
}
|
||||||
|
/// Attach the authored rationale — the prose twin of the `name` beside it
|
||||||
|
/// (a C23 debug symbol: render/tooltip surface only, stripped from the
|
||||||
|
/// identity projection, never reaching the flat graph). Fluent (#125).
|
||||||
|
pub fn with_doc(mut self, doc: impl Into<String>) -> Self {
|
||||||
|
self.doc = Some(doc.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
/// The authored rationale, if any. Non-load-bearing (C23, #125).
|
||||||
|
pub fn doc(&self) -> Option<&str> {
|
||||||
|
self.doc.as_deref()
|
||||||
|
}
|
||||||
/// The interior blueprint items (read-only graph-as-data, C9).
|
/// The interior blueprint items (read-only graph-as-data, C9).
|
||||||
pub fn nodes(&self) -> &[BlueprintNode] {
|
pub fn nodes(&self) -> &[BlueprintNode] {
|
||||||
&self.nodes
|
&self.nodes
|
||||||
@@ -1040,8 +1052,9 @@ fn inline_composite(
|
|||||||
// cell `point: &[Cell]` arg drives `lower_items` below; the flat graph stays
|
// cell `point: &[Cell]` arg drives `lower_items` below; the flat graph stays
|
||||||
// wired by raw index.
|
// wired by raw index.
|
||||||
// `gangs` is an authoring-time gate only (checked at `with_gangs`); it has
|
// `gangs` is an authoring-time gate only (checked at `with_gangs`); it has
|
||||||
// no runtime representation in the flat graph, same as `name`.
|
// no runtime representation in the flat graph, same as `name`. `doc` is
|
||||||
let Composite { name: _, nodes, edges, input_roles, output, gangs: _ } = c;
|
// the prose twin of `name` (#125) and dissolves alongside it.
|
||||||
|
let Composite { name: _, doc: _, nodes, edges, input_roles, output, gangs: _ } = c;
|
||||||
let item_count = nodes.len();
|
let item_count = nodes.len();
|
||||||
|
|
||||||
// recursively lower interior items, then rewrite interior edges through them
|
// recursively lower interior items, then rewrite interior edges through them
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ pub struct BlueprintDoc {
|
|||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
pub struct CompositeData {
|
pub struct CompositeData {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// The authored rationale (the prose twin of `name`; a C23 debug symbol,
|
||||||
|
/// #125). Tier-1 additive-optional: omitted when absent, defaulted on
|
||||||
|
/// load — no `BLUEPRINT_FORMAT_VERSION` bump, and absent-field documents
|
||||||
|
/// keep their exact pre-change bytes (content ids untouched).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
|
pub doc: Option<String>,
|
||||||
pub nodes: Vec<NodeData>,
|
pub nodes: Vec<NodeData>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub edges: Vec<Edge>,
|
pub edges: Vec<Edge>,
|
||||||
@@ -83,6 +89,7 @@ fn project(c: &Composite) -> CompositeData {
|
|||||||
|
|
||||||
CompositeData {
|
CompositeData {
|
||||||
name: c.name().to_string(),
|
name: c.name().to_string(),
|
||||||
|
doc: c.doc().map(str::to_string),
|
||||||
nodes: c.nodes().iter().map(project_node).collect(),
|
nodes: c.nodes().iter().map(project_node).collect(),
|
||||||
edges: c.edges().to_vec(),
|
edges: c.edges().to_vec(),
|
||||||
input_roles: c.input_roles().to_vec(),
|
input_roles: c.input_roles().to_vec(),
|
||||||
@@ -142,6 +149,7 @@ pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError>
|
|||||||
|
|
||||||
fn strip_debug_symbols(b: &mut CompositeData) {
|
fn strip_debug_symbols(b: &mut CompositeData) {
|
||||||
b.name = String::new();
|
b.name = String::new();
|
||||||
|
b.doc = None; // the prose twin of the name (identity-blind, #171/#125)
|
||||||
for node in &mut b.nodes {
|
for node in &mut b.nodes {
|
||||||
match node {
|
match node {
|
||||||
NodeData::Primitive(p) => {
|
NodeData::Primitive(p) => {
|
||||||
@@ -218,9 +226,13 @@ fn reconstruct(
|
|||||||
NodeData::Composite(c) => BlueprintNode::Composite(reconstruct(c, resolve)?),
|
NodeData::Composite(c) => BlueprintNode::Composite(reconstruct(c, resolve)?),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Composite::new(d.name.clone(), nodes, d.edges.clone(), d.input_roles.clone(), d.output.clone())
|
let composite = Composite::new(d.name.clone(), nodes, d.edges.clone(), d.input_roles.clone(), d.output.clone())
|
||||||
.with_gangs(d.gangs.clone())
|
.with_gangs(d.gangs.clone())
|
||||||
.map_err(LoadError::Gang)
|
.map_err(LoadError::Gang)?;
|
||||||
|
Ok(match &d.doc {
|
||||||
|
Some(doc) => composite.with_doc(doc.clone()),
|
||||||
|
None => composite,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a blueprint from canonical JSON, resolving each primitive's type identity
|
/// Load a blueprint from canonical JSON, resolving each primitive's type identity
|
||||||
@@ -336,6 +348,30 @@ mod tests {
|
|||||||
assert_eq!(json, golden);
|
assert_eq!(json, golden);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #125: a doc'd composite round-trips byte-stably, the doc is
|
||||||
|
/// canonical-byte-bearing (content-id-affecting), absent doc emits no key
|
||||||
|
/// (pre-change bytes untouched), and the identity projection strips it
|
||||||
|
/// exactly like the name it sits beside.
|
||||||
|
#[test]
|
||||||
|
fn doc_round_trips_canonically_and_is_identity_blind() {
|
||||||
|
let plain = crate::test_fixtures::sink_free_sma_cross_signal();
|
||||||
|
let doced = crate::test_fixtures::sink_free_sma_cross_signal()
|
||||||
|
.with_doc("why this graph: the spread detects trend turns");
|
||||||
|
let plain_json = blueprint_to_json(&plain).expect("serializes");
|
||||||
|
let doced_json = blueprint_to_json(&doced).expect("serializes");
|
||||||
|
assert_ne!(plain_json, doced_json, "the doc is canonical-byte-bearing");
|
||||||
|
assert!(doced_json.contains("why this graph"), "doc serialized: {doced_json}");
|
||||||
|
assert!(!plain_json.contains("\"doc\""), "absent doc emits no key: {plain_json}");
|
||||||
|
let loaded = blueprint_from_json(&doced_json, &|t| aura_std::std_vocabulary(t)).expect("loads");
|
||||||
|
assert_eq!(loaded.doc(), Some("why this graph: the spread detects trend turns"));
|
||||||
|
assert_eq!(doced_json, blueprint_to_json(&loaded).expect("re-serializes"), "round-trip byte-stable");
|
||||||
|
assert_eq!(
|
||||||
|
blueprint_identity_json(&doced).expect("identity"),
|
||||||
|
blueprint_identity_json(&plain).expect("identity"),
|
||||||
|
"identity form is doc-blind"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_node_type_fails_named() {
|
fn unknown_node_type_fails_named() {
|
||||||
// a valid envelope naming a type outside the vocabulary -> clean, named error
|
// a valid envelope naming a type outside the vocabulary -> clean, named error
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ pub enum BuildError {
|
|||||||
/// and port/field names. See the module docs.
|
/// and port/field names. See the module docs.
|
||||||
pub struct GraphBuilder {
|
pub struct GraphBuilder {
|
||||||
name: String,
|
name: String,
|
||||||
|
doc: Option<String>,
|
||||||
nodes: Vec<BlueprintNode>,
|
nodes: Vec<BlueprintNode>,
|
||||||
schemas: Vec<NodeSchema>,
|
schemas: Vec<NodeSchema>,
|
||||||
edges: Vec<(OutPort, InPort)>,
|
edges: Vec<(OutPort, InPort)>,
|
||||||
@@ -99,6 +100,7 @@ impl GraphBuilder {
|
|||||||
pub fn new(name: impl Into<String>) -> Self {
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
|
doc: None,
|
||||||
nodes: Vec::new(),
|
nodes: Vec::new(),
|
||||||
schemas: Vec::new(),
|
schemas: Vec::new(),
|
||||||
edges: Vec::new(),
|
edges: Vec::new(),
|
||||||
@@ -152,6 +154,12 @@ impl GraphBuilder {
|
|||||||
self.gangs.push((name.to_string(), members.into_iter().collect()));
|
self.gangs.push((name.to_string(), members.into_iter().collect()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach the authored rationale for the composite under construction
|
||||||
|
/// (the prose twin of the builder's name; a C23 debug symbol, #125).
|
||||||
|
pub fn doc(&mut self, doc: impl Into<String>) {
|
||||||
|
self.doc = Some(doc.into());
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve every accumulated name to an index and assemble the [`Composite`].
|
/// Resolve every accumulated name to an index and assemble the [`Composite`].
|
||||||
pub fn build(self) -> Result<Composite, BuildError> {
|
pub fn build(self) -> Result<Composite, BuildError> {
|
||||||
let mut edges = Vec::with_capacity(self.edges.len());
|
let mut edges = Vec::with_capacity(self.edges.len());
|
||||||
@@ -222,7 +230,10 @@ impl GraphBuilder {
|
|||||||
let kind = kind.unwrap_or(ScalarKind::F64);
|
let kind = kind.unwrap_or(ScalarKind::F64);
|
||||||
gangs.push(Gang { name: name.clone(), kind, members });
|
gangs.push(Gang { name: name.clone(), kind, members });
|
||||||
}
|
}
|
||||||
let composite = Composite::new(self.name, self.nodes, edges, roles, output);
|
let mut composite = Composite::new(self.name, self.nodes, edges, roles, output);
|
||||||
|
if let Some(doc) = self.doc {
|
||||||
|
composite = composite.with_doc(doc);
|
||||||
|
}
|
||||||
match composite.with_gangs(gangs) {
|
match composite.with_gangs(gangs) {
|
||||||
Ok(c) => Ok(c),
|
Ok(c) => Ok(c),
|
||||||
Err(CompileError::BadGang(f)) => Err(BuildError::BadGang(f)),
|
Err(CompileError::BadGang(f)) => Err(BuildError::BadGang(f)),
|
||||||
@@ -340,6 +351,25 @@ mod tests {
|
|||||||
assert_eq!(built.output(), hand.output());
|
assert_eq!(built.output(), hand.output());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #125: the builder's doc knob threads into the built composite; absent
|
||||||
|
/// knob leaves the field empty.
|
||||||
|
#[test]
|
||||||
|
fn builder_doc_threads_into_the_composite() {
|
||||||
|
let build = |with_doc: bool| {
|
||||||
|
let mut g = GraphBuilder::new("sma_cross");
|
||||||
|
if with_doc {
|
||||||
|
g.doc("fast/slow spread rationale");
|
||||||
|
}
|
||||||
|
let fast = g.add(Sma::builder().named("fast"));
|
||||||
|
let price = g.input_role("price");
|
||||||
|
g.feed(price, [fast.input("series")]);
|
||||||
|
g.expose(fast.output("value"), "out");
|
||||||
|
g.build().expect("resolves")
|
||||||
|
};
|
||||||
|
assert_eq!(build(true).doc(), Some("fast/slow spread rationale"));
|
||||||
|
assert_eq!(build(false).doc(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builder_harness_compiles_identically_to_hand_wired() {
|
fn builder_harness_compiles_identically_to_hand_wired() {
|
||||||
use crate::test_fixtures::composite_sma_cross_harness;
|
use crate::test_fixtures::composite_sma_cross_harness;
|
||||||
|
|||||||
@@ -42,8 +42,10 @@ fn firing_str(f: Firing) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A JSON string literal: wrap in quotes, escape `"` and `\` (the minimal set
|
/// A JSON string literal: wrap in quotes, escape `"`, `\`, and control
|
||||||
/// sufficient here — model names are author-controlled identifiers).
|
/// characters (`\n`/`\t`/`\r` named, the rest as `\u00XX`) — the composite
|
||||||
|
/// `doc` (#125) is free text and the first multi-line value through here;
|
||||||
|
/// identifiers pass through unchanged.
|
||||||
fn json_str(s: &str) -> String {
|
fn json_str(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len() + 2);
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
out.push('"');
|
out.push('"');
|
||||||
@@ -51,6 +53,10 @@ fn json_str(s: &str) -> String {
|
|||||||
match c {
|
match c {
|
||||||
'"' => out.push_str("\\\""),
|
'"' => out.push_str("\\\""),
|
||||||
'\\' => out.push_str("\\\\"),
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||||
_ => out.push(c),
|
_ => out.push(c),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,14 +196,22 @@ fn gangs_fragment(c: &Composite) -> String {
|
|||||||
format!(r#","gangs":[{items}]"#)
|
format!(r#","gangs":[{items}]"#)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The optional trailing `,"doc":"…"` fragment (#125): emitted LAST in a
|
||||||
|
/// scope/def object so no prefix-pinned consumer (starts_with/grep tests,
|
||||||
|
/// the `window.AURA_MODEL = {"root":` page pin) moves; empty when absent.
|
||||||
|
fn doc_fragment(doc: Option<&str>) -> String {
|
||||||
|
doc.map(|d| format!(r#","doc":{}"#, json_str(d))).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// One scope as `{ "nodes": {…}, "edges": [...] }`.
|
/// One scope as `{ "nodes": {…}, "edges": [...] }`.
|
||||||
fn scope_json(c: &Composite, is_root: bool) -> String {
|
fn scope_json(c: &Composite, is_root: bool) -> String {
|
||||||
let (nodes, edges) = scope_parts(c, is_root);
|
let (nodes, edges) = scope_parts(c, is_root);
|
||||||
format!(
|
format!(
|
||||||
r#"{{"nodes":{{{}}},"edges":[{}]{}}}"#,
|
r#"{{"nodes":{{{}}},"edges":[{}]{}{}}}"#,
|
||||||
nodes.join(","),
|
nodes.join(","),
|
||||||
edges.join(","),
|
edges.join(","),
|
||||||
gangs_fragment(c),
|
gangs_fragment(c),
|
||||||
|
doc_fragment(if is_root { c.doc() } else { None }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,10 +254,11 @@ fn composite_def_json(c: &Composite) -> String {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
format!(
|
format!(
|
||||||
r#"{{"inputs":[{inputs}],"outputs":[{outputs}],"nodes":{{{}}},"edges":[{}]{}}}"#,
|
r#"{{"inputs":[{inputs}],"outputs":[{outputs}],"nodes":{{{}}},"edges":[{}]{}{}}}"#,
|
||||||
nodes.join(","),
|
nodes.join(","),
|
||||||
edges.join(","),
|
edges.join(","),
|
||||||
gangs_fragment(c),
|
gangs_fragment(c),
|
||||||
|
doc_fragment(c.doc()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +374,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
||||||
)
|
)
|
||||||
|
.with_doc("fast/slow SMA spread; the Sub leg is the crossing signal")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A small, stable root harness exercising the four model elements: a bound
|
/// A small, stable root harness exercising the four model elements: a bound
|
||||||
@@ -388,6 +404,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
vec![], // output: the root ends in a sink, no re-export
|
vec![], // output: the root ends in a sink, no re-export
|
||||||
)
|
)
|
||||||
|
.with_doc("sample harness: price feeds sma_cross; its spread becomes a recorded bias")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A composite with exactly two f64 input roles (each firing `Any`): two `Sma`
|
/// A composite with exactly two f64 input roles (each firing `Any`): two `Sma`
|
||||||
@@ -593,10 +610,27 @@ mod tests {
|
|||||||
// Re-capture if an intended model-shape change lands: temporarily add a
|
// Re-capture if an intended model-shape change lands: temporarily add a
|
||||||
// `println!("{}", model_to_json(&sample_root()))` test, run with
|
// `println!("{}", model_to_json(&sample_root()))` test, run with
|
||||||
// `-- --nocapture`, and paste the fresh bytes below.
|
// `-- --nocapture`, and paste the fresh bytes below.
|
||||||
let expected = r##"{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Bias","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["bias","f64"]]}},"2":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["src_price.o0","0.i0"]]},"composites":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["out","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]}}}"##;
|
let expected = r##"{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Bias","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["bias","f64"]]}},"2":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["src_price.o0","0.i0"]],"doc":"sample harness: price feeds sma_cross; its spread becomes a recorded bias"},"composites":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["out","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]],"doc":"fast/slow SMA spread; the Sub leg is the crossing signal"}}}"##;
|
||||||
assert_eq!(model_to_json(&sample_root()), expected);
|
assert_eq!(model_to_json(&sample_root()), expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #125: the doc is free text — the first multi-line value through
|
||||||
|
/// `json_str`. Control characters must emit as valid JSON escapes and the
|
||||||
|
/// whole def must stay machine-parseable.
|
||||||
|
#[test]
|
||||||
|
fn doc_with_control_characters_emits_valid_json() {
|
||||||
|
let c = sma_cross().with_doc("line1\nline2\ttabbed\u{1}ctl");
|
||||||
|
let def = composite_def_json(&c);
|
||||||
|
assert!(
|
||||||
|
def.contains(r#","doc":"line1\nline2\ttabbed\u0001ctl""#),
|
||||||
|
"control chars escape: {def}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_str::<serde_json::Value>(&def).is_ok(),
|
||||||
|
"the def with a multi-line doc parses as JSON: {def}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -- Task 6: structural assertions + mis-wire-differs + read-only ------------
|
// -- Task 6: structural assertions + mis-wire-differs + read-only ------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user