Skip to content

Commit b6cd839

Browse files
committed
Merge branch 'tf-wt-6'
2 parents d861809 + c016c86 commit b6cd839

5 files changed

Lines changed: 1058 additions & 0 deletions

File tree

tests/expressions_e2e.rs

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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

Comments
 (0)