|
| 1 | +#![cfg(feature = "mock")] |
| 2 | +//! End-to-end tests for jaq/jq expression evaluation inside running workflows. |
| 3 | +//! |
| 4 | +//! Config strings prefixed with `=` are expressions (see `src/expr.rs`): a simple |
| 5 | +//! dotted path resolves by segment-walk, anything else runs as a jq program over |
| 6 | +//! the evaluation scope `{ item, run }`. These tests exercise expressions in the |
| 7 | +//! nodes that consume them — `transform` (its `set` map), `switch` (its case key), |
| 8 | +//! and a `condition` fed by a computed field — plus a `split_out` → `transform` |
| 9 | +//! combo, and assert the *computed values* land in the run output. |
| 10 | +//! |
| 11 | +//! Gated behind the `mock` feature, so plain `cargo test` skips it while |
| 12 | +//! `cargo test --all-features` runs it. |
| 13 | +
|
| 14 | +use serde_json::{Value, json}; |
| 15 | +use tinyflows::caps::mock::mock_capabilities; |
| 16 | +use tinyflows::compiler::compile; |
| 17 | +use tinyflows::engine::run; |
| 18 | +use tinyflows::model::{Edge, Node, NodeKind, TriggerKind, WorkflowGraph}; |
| 19 | + |
| 20 | +/// Builds a node with the given id, kind, and config. |
| 21 | +fn node(id: &str, kind: NodeKind, config: Value) -> Node { |
| 22 | + Node { |
| 23 | + id: id.to_string(), |
| 24 | + kind, |
| 25 | + type_version: 1, |
| 26 | + name: id.to_string(), |
| 27 | + config, |
| 28 | + ports: vec![], |
| 29 | + position: None, |
| 30 | + } |
| 31 | +} |
| 32 | + |
| 33 | +/// Builds a trigger node with the given firing mode. |
| 34 | +fn trigger(id: &str, kind: TriggerKind) -> Node { |
| 35 | + node(id, NodeKind::Trigger, json!({ "kind": kind })) |
| 36 | +} |
| 37 | + |
| 38 | +/// Builds an edge from `from_node`'s `from_port` into `to_node`'s `main` port. |
| 39 | +fn edge(from_node: &str, from_port: &str, to_node: &str) -> Edge { |
| 40 | + Edge { |
| 41 | + from_node: from_node.to_string(), |
| 42 | + from_port: from_port.to_string(), |
| 43 | + to_node: to_node.to_string(), |
| 44 | + to_port: "main".to_string(), |
| 45 | + } |
| 46 | +} |
| 47 | + |
| 48 | +/// A `transform` whose `set` map uses jq programs — `add`, `length`, `map`, and |
| 49 | +/// array indexing — over the incoming item. Each computed field must appear on |
| 50 | +/// the emitted item with the right value. |
| 51 | +#[tokio::test] |
| 52 | +async fn transform_evaluates_jq_aggregations() { |
| 53 | + let graph = WorkflowGraph { |
| 54 | + name: "jq_transform".to_string(), |
| 55 | + nodes: vec![ |
| 56 | + trigger("start", TriggerKind::Manual), |
| 57 | + node( |
| 58 | + "compute", |
| 59 | + NodeKind::Transform, |
| 60 | + json!({ |
| 61 | + "set": { |
| 62 | + "total": "=.item.nums | add", |
| 63 | + "count": "=.item.nums | length", |
| 64 | + "doubled": "=.item.nums | map(. * 2)", |
| 65 | + "first": "=.item.nums[0]" |
| 66 | + } |
| 67 | + }), |
| 68 | + ), |
| 69 | + ], |
| 70 | + edges: vec![edge("start", "main", "compute")], |
| 71 | + ..Default::default() |
| 72 | + }; |
| 73 | + let compiled = compile(&graph).expect("compile"); |
| 74 | + let outcome = run( |
| 75 | + &compiled, |
| 76 | + json!({ "nums": [1, 2, 3, 4] }), |
| 77 | + &mock_capabilities(), |
| 78 | + ) |
| 79 | + .await |
| 80 | + .expect("run"); |
| 81 | + |
| 82 | + let item = &outcome.output["nodes"]["compute"]["items"][0]["json"]; |
| 83 | + assert_eq!(item["total"], json!(10), "add should sum the array"); |
| 84 | + assert_eq!(item["count"], json!(4), "length should count the elements"); |
| 85 | + assert_eq!( |
| 86 | + item["doubled"], |
| 87 | + json!([2, 4, 6, 8]), |
| 88 | + "map(. * 2) should double each element" |
| 89 | + ); |
| 90 | + assert_eq!(item["first"], json!(1), "index [0] should take the first"); |
| 91 | + // The source field is preserved alongside the computed ones. |
| 92 | + assert_eq!(item["nums"], json!([1, 2, 3, 4])); |
| 93 | +} |
| 94 | + |
| 95 | +/// A `switch` whose case key is a jq program computing a discriminant string. |
| 96 | +/// Only the branch matching the computed port must run. |
| 97 | +#[tokio::test] |
| 98 | +async fn switch_routes_on_jq_discriminant() { |
| 99 | + let graph = WorkflowGraph { |
| 100 | + name: "jq_switch".to_string(), |
| 101 | + nodes: vec![ |
| 102 | + trigger("start", TriggerKind::Manual), |
| 103 | + // priority > 5 => "high", otherwise "low" — a jq-computed case key. |
| 104 | + node( |
| 105 | + "route", |
| 106 | + NodeKind::Switch, |
| 107 | + json!({ "expression": "=if .item.priority > 5 then \"high\" else \"low\" end" }), |
| 108 | + ), |
| 109 | + node("high", NodeKind::OutputParser, Value::Null), |
| 110 | + node("low", NodeKind::OutputParser, Value::Null), |
| 111 | + ], |
| 112 | + edges: vec![ |
| 113 | + edge("start", "main", "route"), |
| 114 | + edge("route", "high", "high"), |
| 115 | + edge("route", "low", "low"), |
| 116 | + ], |
| 117 | + ..Default::default() |
| 118 | + }; |
| 119 | + let compiled = compile(&graph).expect("compile"); |
| 120 | + let outcome = run(&compiled, json!({ "priority": 9 }), &mock_capabilities()) |
| 121 | + .await |
| 122 | + .expect("run"); |
| 123 | + |
| 124 | + assert!( |
| 125 | + !outcome.output["nodes"]["high"]["items"].is_null(), |
| 126 | + "priority 9 should route to the `high` branch" |
| 127 | + ); |
| 128 | + assert!( |
| 129 | + outcome.output["nodes"]["low"].is_null(), |
| 130 | + "the `low` branch should not have run" |
| 131 | + ); |
| 132 | +} |
| 133 | + |
| 134 | +/// A `switch` keyed by a plain `field` discriminant (no expression): the field's |
| 135 | +/// value on the first input item selects the port. |
| 136 | +#[tokio::test] |
| 137 | +async fn switch_routes_on_field_discriminant() { |
| 138 | + let graph = WorkflowGraph { |
| 139 | + name: "field_switch".to_string(), |
| 140 | + nodes: vec![ |
| 141 | + trigger("start", TriggerKind::Manual), |
| 142 | + node("route", NodeKind::Switch, json!({ "field": "kind" })), |
| 143 | + node("alpha", NodeKind::OutputParser, Value::Null), |
| 144 | + node("beta", NodeKind::OutputParser, Value::Null), |
| 145 | + ], |
| 146 | + edges: vec![ |
| 147 | + edge("start", "main", "route"), |
| 148 | + edge("route", "alpha", "alpha"), |
| 149 | + edge("route", "beta", "beta"), |
| 150 | + ], |
| 151 | + ..Default::default() |
| 152 | + }; |
| 153 | + let compiled = compile(&graph).expect("compile"); |
| 154 | + let outcome = run(&compiled, json!({ "kind": "beta" }), &mock_capabilities()) |
| 155 | + .await |
| 156 | + .expect("run"); |
| 157 | + |
| 158 | + assert!( |
| 159 | + !outcome.output["nodes"]["beta"]["items"].is_null(), |
| 160 | + "kind `beta` should route to the `beta` branch" |
| 161 | + ); |
| 162 | + assert!( |
| 163 | + outcome.output["nodes"]["alpha"].is_null(), |
| 164 | + "the `alpha` branch should not have run" |
| 165 | + ); |
| 166 | +} |
| 167 | + |
| 168 | +/// A `condition` driven by a field that a preceding `transform` computed with a |
| 169 | +/// jq comparison. The boolean result selects the `true`/`false` port. |
| 170 | +#[tokio::test] |
| 171 | +async fn condition_driven_by_computed_field() { |
| 172 | + let graph = WorkflowGraph { |
| 173 | + name: "computed_condition".to_string(), |
| 174 | + nodes: vec![ |
| 175 | + trigger("start", TriggerKind::Manual), |
| 176 | + // Compute a boolean `passed` field via a jq comparison expression. |
| 177 | + node( |
| 178 | + "grade", |
| 179 | + NodeKind::Transform, |
| 180 | + json!({ "set": { "passed": "=.item.score >= 50" } }), |
| 181 | + ), |
| 182 | + node("check", NodeKind::Condition, json!({ "field": "passed" })), |
| 183 | + node("accepted", NodeKind::OutputParser, Value::Null), |
| 184 | + node("rejected", NodeKind::OutputParser, Value::Null), |
| 185 | + ], |
| 186 | + edges: vec![ |
| 187 | + edge("start", "main", "grade"), |
| 188 | + edge("grade", "main", "check"), |
| 189 | + edge("check", "true", "accepted"), |
| 190 | + edge("check", "false", "rejected"), |
| 191 | + ], |
| 192 | + ..Default::default() |
| 193 | + }; |
| 194 | + let compiled = compile(&graph).expect("compile"); |
| 195 | + let outcome = run(&compiled, json!({ "score": 73 }), &mock_capabilities()) |
| 196 | + .await |
| 197 | + .expect("run"); |
| 198 | + |
| 199 | + // The transform actually computed the boolean. |
| 200 | + assert_eq!( |
| 201 | + outcome.output["nodes"]["grade"]["items"][0]["json"]["passed"], |
| 202 | + json!(true), |
| 203 | + "score 73 >= 50 should compute `passed = true`" |
| 204 | + ); |
| 205 | + assert!( |
| 206 | + !outcome.output["nodes"]["accepted"]["items"].is_null(), |
| 207 | + "a passing score should route to the `true` branch" |
| 208 | + ); |
| 209 | + assert!( |
| 210 | + outcome.output["nodes"]["rejected"].is_null(), |
| 211 | + "the `false` branch should not have run" |
| 212 | + ); |
| 213 | +} |
| 214 | + |
| 215 | +/// A `split_out` → `transform` combo: fan an array into one item per element, |
| 216 | +/// then compute a field per item with a jq expression. Every emitted item must |
| 217 | +/// carry the per-element computed value. |
| 218 | +#[tokio::test] |
| 219 | +async fn split_out_then_transform_computes_per_item() { |
| 220 | + let graph = WorkflowGraph { |
| 221 | + name: "split_transform".to_string(), |
| 222 | + nodes: vec![ |
| 223 | + trigger("start", TriggerKind::Manual), |
| 224 | + node("fan", NodeKind::SplitOut, json!({ "path": "orders" })), |
| 225 | + // Each fanned item is one order object; compute a `total` per item. |
| 226 | + node( |
| 227 | + "price", |
| 228 | + NodeKind::Transform, |
| 229 | + json!({ "set": { "total": "=.item.qty * .item.unit" } }), |
| 230 | + ), |
| 231 | + ], |
| 232 | + edges: vec![edge("start", "main", "fan"), edge("fan", "main", "price")], |
| 233 | + ..Default::default() |
| 234 | + }; |
| 235 | + let compiled = compile(&graph).expect("compile"); |
| 236 | + let outcome = run( |
| 237 | + &compiled, |
| 238 | + json!({ |
| 239 | + "orders": [ |
| 240 | + { "qty": 2, "unit": 5 }, |
| 241 | + { "qty": 3, "unit": 10 } |
| 242 | + ] |
| 243 | + }), |
| 244 | + &mock_capabilities(), |
| 245 | + ) |
| 246 | + .await |
| 247 | + .expect("run"); |
| 248 | + |
| 249 | + // split_out fanned the two orders into two items. |
| 250 | + let items = outcome.output["nodes"]["price"]["items"] |
| 251 | + .as_array() |
| 252 | + .expect("transform produced an items array"); |
| 253 | + assert_eq!(items.len(), 2, "two orders should fan out into two items"); |
| 254 | + assert_eq!( |
| 255 | + items[0]["json"]["total"], |
| 256 | + json!(10), |
| 257 | + "first order total should be 2 * 5" |
| 258 | + ); |
| 259 | + assert_eq!( |
| 260 | + items[1]["json"]["total"], |
| 261 | + json!(30), |
| 262 | + "second order total should be 3 * 10" |
| 263 | + ); |
| 264 | +} |
0 commit comments