Skip to main content

faucet_cli/pipeline_test/
diff.rs

1//! Expectation evaluation for `faucet test` — pure record matching + a
2//! structured, path-based diff for failure messages.
3
4use crate::pipeline_test::runner::CaseRun;
5use crate::pipeline_test::spec::{Expectation, MatchMode};
6use serde_json::Value;
7
8/// Max differing paths listed per record mismatch, so a wholly-different
9/// record doesn't flood the report.
10const MAX_DIFF_PATHS: usize = 8;
11
12/// Evaluate an expectation against a finished run. Returns one message per
13/// failed assertion; an empty vec means the case passed.
14pub fn evaluate(expect: &Expectation, run: &CaseRun) -> Vec<String> {
15    let mut failures = Vec::new();
16
17    match (&expect.error, &run.error) {
18        (Some(want), Some(got)) => {
19            if !got.contains(want.as_str()) {
20                failures.push(format!(
21                    "error: expected message containing '{want}', got: {got}"
22                ));
23            }
24        }
25        (Some(want), None) => {
26            failures.push(format!(
27                "error: expected the run to fail with '{want}', but it succeeded"
28            ));
29        }
30        (None, Some(got)) => {
31            failures.push(format!("run failed unexpectedly: {got}"));
32        }
33        (None, None) => {}
34    }
35
36    if let Some(expected) = &expect.records {
37        failures.extend(match_records(
38            "records",
39            expected,
40            &run.written,
41            expect.match_mode,
42            expect.unordered,
43        ));
44    }
45    if let Some(expected) = &expect.dlq {
46        failures.extend(match_records(
47            "dlq",
48            expected,
49            &run.dlq_payloads,
50            expect.match_mode,
51            expect.unordered,
52        ));
53    }
54    if let Some(want) = expect.records_written
55        && want != run.records_written
56    {
57        failures.push(format!(
58            "records_written: expected {want}, got {}",
59            run.records_written
60        ));
61    }
62    if let Some(want) = expect.dlq_count
63        && want != run.dlq_payloads.len()
64    {
65        failures.push(format!(
66            "dlq_count: expected {want}, got {}",
67            run.dlq_payloads.len()
68        ));
69    }
70    failures
71}
72
73/// True when `actual` satisfies `expected` under `mode`.
74///
75/// `Exact` is deep equality. `Subset` lets actual objects carry extra fields
76/// at any depth; arrays still require equal length with per-element matching.
77pub fn value_matches(expected: &Value, actual: &Value, mode: MatchMode) -> bool {
78    match mode {
79        MatchMode::Exact => expected == actual,
80        MatchMode::Subset => subset_matches(expected, actual),
81    }
82}
83
84fn subset_matches(expected: &Value, actual: &Value) -> bool {
85    match (expected, actual) {
86        (Value::Object(e), Value::Object(a)) => e
87            .iter()
88            .all(|(k, ev)| a.get(k).is_some_and(|av| subset_matches(ev, av))),
89        (Value::Array(e), Value::Array(a)) => {
90            e.len() == a.len() && e.iter().zip(a).all(|(ev, av)| subset_matches(ev, av))
91        }
92        _ => expected == actual,
93    }
94}
95
96/// Match an expected record list against the actual list, producing failure
97/// messages. Ordered mode compares index-by-index; unordered mode greedily
98/// pairs each expected record with the first unclaimed matching actual.
99fn match_records(
100    label: &str,
101    expected: &[Value],
102    actual: &[Value],
103    mode: MatchMode,
104    unordered: bool,
105) -> Vec<String> {
106    let mut failures = Vec::new();
107    if expected.len() != actual.len() {
108        failures.push(format!(
109            "{label}: expected {} record(s), got {}",
110            expected.len(),
111            actual.len()
112        ));
113    }
114    if unordered {
115        let mut claimed = vec![false; actual.len()];
116        for (i, exp) in expected.iter().enumerate() {
117            let hit = actual
118                .iter()
119                .enumerate()
120                .find(|(j, act)| !claimed[*j] && value_matches(exp, act, mode));
121            match hit {
122                Some((j, _)) => claimed[j] = true,
123                None => failures.push(format!(
124                    "{label}[{i}]: no unmatched actual record equals {}",
125                    compact(exp)
126                )),
127            }
128        }
129    } else {
130        for (i, (exp, act)) in expected.iter().zip(actual).enumerate() {
131            if !value_matches(exp, act, mode) {
132                let mut paths = Vec::new();
133                diff_paths(exp, act, mode, &format!("{label}[{i}]"), &mut paths);
134                if paths.is_empty() {
135                    // Shape mismatch with no leaf-level detail (shouldn't
136                    // happen, but never report a bare "mismatch").
137                    paths.push(format!(
138                        "{label}[{i}]: expected {}, got {}",
139                        compact(exp),
140                        compact(act)
141                    ));
142                }
143                failures.extend(paths);
144            }
145        }
146    }
147    failures
148}
149
150/// Collect up to [`MAX_DIFF_PATHS`] `path: expected X, got Y` lines for two
151/// mismatching values.
152fn diff_paths(
153    expected: &Value,
154    actual: &Value,
155    mode: MatchMode,
156    path: &str,
157    out: &mut Vec<String>,
158) {
159    if out.len() >= MAX_DIFF_PATHS {
160        return;
161    }
162    match (expected, actual) {
163        (Value::Object(e), Value::Object(a)) => {
164            for (k, ev) in e {
165                match a.get(k) {
166                    Some(av) => diff_paths(ev, av, mode, &format!("{path}.{k}"), out),
167                    None => {
168                        if out.len() < MAX_DIFF_PATHS {
169                            out.push(format!(
170                                "{path}.{k}: expected {}, field missing",
171                                compact(ev)
172                            ));
173                        }
174                    }
175                }
176            }
177            if mode == MatchMode::Exact {
178                for k in a.keys() {
179                    if !e.contains_key(k) && out.len() < MAX_DIFF_PATHS {
180                        out.push(format!("{path}.{k}: unexpected field {}", compact(&a[k])));
181                    }
182                }
183            }
184        }
185        (Value::Array(e), Value::Array(a)) => {
186            if e.len() != a.len() {
187                out.push(format!(
188                    "{path}: expected array of {}, got {}",
189                    e.len(),
190                    a.len()
191                ));
192                return;
193            }
194            for (i, (ev, av)) in e.iter().zip(a).enumerate() {
195                diff_paths(ev, av, mode, &format!("{path}[{i}]"), out);
196            }
197        }
198        (e, a) => {
199            if !value_matches(e, a, mode) {
200                out.push(format!(
201                    "{path}: expected {}, got {}",
202                    compact(e),
203                    compact(a)
204                ));
205            }
206        }
207    }
208}
209
210/// Compact single-line JSON, truncated so one huge record can't flood a line.
211fn compact(v: &Value) -> String {
212    const MAX: usize = 120;
213    let s = v.to_string();
214    if s.chars().count() > MAX {
215        let cut: String = s.chars().take(MAX).collect();
216        format!("{cut}…")
217    } else {
218        s
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use serde_json::json;
226
227    fn run(written: Vec<Value>, dlq: Vec<Value>, error: Option<&str>) -> CaseRun {
228        CaseRun {
229            records_written: written.len(),
230            written,
231            dlq_payloads: dlq,
232            error: error.map(str::to_string),
233        }
234    }
235
236    #[test]
237    fn passing_exact_records() {
238        let expect = Expectation {
239            records: Some(vec![json!({"a": 1})]),
240            ..Default::default()
241        };
242        assert!(evaluate(&expect, &run(vec![json!({"a": 1})], vec![], None)).is_empty());
243    }
244
245    #[test]
246    fn mismatch_reports_field_path() {
247        let expect = Expectation {
248            records: Some(vec![json!({"a": 1, "b": {"c": "x"}})]),
249            ..Default::default()
250        };
251        let failures = evaluate(
252            &expect,
253            &run(vec![json!({"a": 1, "b": {"c": "y"}})], vec![], None),
254        );
255        assert_eq!(failures.len(), 1);
256        assert!(failures[0].contains("records[0].b.c"), "{failures:?}");
257        assert!(failures[0].contains("\"x\""), "{failures:?}");
258        assert!(failures[0].contains("\"y\""), "{failures:?}");
259    }
260
261    #[test]
262    fn exact_mode_flags_unexpected_and_missing_fields() {
263        let expect = Expectation {
264            records: Some(vec![json!({"a": 1})]),
265            ..Default::default()
266        };
267        let failures = evaluate(
268            &expect,
269            &run(vec![json!({"a": 1, "extra": 2})], vec![], None),
270        );
271        assert!(
272            failures.iter().any(|f| f.contains("unexpected field")),
273            "{failures:?}"
274        );
275
276        let expect = Expectation {
277            records: Some(vec![json!({"a": 1, "missing": 3})]),
278            ..Default::default()
279        };
280        let failures = evaluate(&expect, &run(vec![json!({"a": 1})], vec![], None));
281        assert!(
282            failures.iter().any(|f| f.contains("field missing")),
283            "{failures:?}"
284        );
285    }
286
287    #[test]
288    fn subset_mode_allows_extra_actual_fields() {
289        let expect = Expectation {
290            records: Some(vec![json!({"a": 1, "n": {"x": true}})]),
291            match_mode: MatchMode::Subset,
292            ..Default::default()
293        };
294        let actual = vec![json!({"a": 1, "n": {"x": true, "y": 0}, "extra": "ok"})];
295        assert!(evaluate(&expect, &run(actual, vec![], None)).is_empty());
296        // …but a wrong value still fails.
297        let failures = evaluate(
298            &expect,
299            &run(vec![json!({"a": 2, "n": {"x": true}})], vec![], None),
300        );
301        assert!(
302            failures.iter().any(|f| f.contains("records[0].a")),
303            "{failures:?}"
304        );
305    }
306
307    #[test]
308    fn subset_arrays_require_same_length() {
309        assert!(value_matches(
310            &json!({"tags": [1, 2]}),
311            &json!({"tags": [1, 2], "e": 3}),
312            MatchMode::Subset
313        ));
314        assert!(!value_matches(
315            &json!({"tags": [1]}),
316            &json!({"tags": [1, 2]}),
317            MatchMode::Subset
318        ));
319    }
320
321    #[test]
322    fn unordered_matches_as_multiset() {
323        let expect = Expectation {
324            records: Some(vec![json!({"a": 2}), json!({"a": 1})]),
325            unordered: true,
326            ..Default::default()
327        };
328        assert!(
329            evaluate(
330                &expect,
331                &run(vec![json!({"a": 1}), json!({"a": 2})], vec![], None)
332            )
333            .is_empty()
334        );
335        // Duplicates are counted: two expected {a:1} need two actuals.
336        let expect = Expectation {
337            records: Some(vec![json!({"a": 1}), json!({"a": 1})]),
338            unordered: true,
339            ..Default::default()
340        };
341        let failures = evaluate(
342            &expect,
343            &run(vec![json!({"a": 1}), json!({"a": 2})], vec![], None),
344        );
345        assert!(
346            failures.iter().any(|f| f.contains("no unmatched actual")),
347            "{failures:?}"
348        );
349    }
350
351    #[test]
352    fn length_mismatch_reported_once_with_counts() {
353        let expect = Expectation {
354            records: Some(vec![json!({"a": 1})]),
355            ..Default::default()
356        };
357        let failures = evaluate(&expect, &run(vec![], vec![], None));
358        assert_eq!(failures, vec!["records: expected 1 record(s), got 0"]);
359    }
360
361    #[test]
362    fn counts_and_dlq_assertions() {
363        let expect = Expectation {
364            records_written: Some(2),
365            dlq_count: Some(1),
366            dlq: Some(vec![json!({"bad": true})]),
367            ..Default::default()
368        };
369        let ok = run(
370            vec![json!({"a": 1}), json!({"a": 2})],
371            vec![json!({"bad": true})],
372            None,
373        );
374        assert!(evaluate(&expect, &ok).is_empty());
375
376        let wrong = run(vec![json!({"a": 1})], vec![], None);
377        let failures = evaluate(&expect, &wrong);
378        assert!(
379            failures
380                .iter()
381                .any(|f| f.contains("records_written: expected 2, got 1"))
382        );
383        assert!(
384            failures
385                .iter()
386                .any(|f| f.contains("dlq_count: expected 1, got 0"))
387        );
388        assert!(
389            failures
390                .iter()
391                .any(|f| f.contains("dlq: expected 1 record(s), got 0"))
392        );
393    }
394
395    #[test]
396    fn error_expectations() {
397        let expect = Expectation {
398            error: Some("contract".into()),
399            ..Default::default()
400        };
401        // Expected failure present and matching → pass.
402        assert!(
403            evaluate(
404                &expect,
405                &run(vec![], vec![], Some("contract violation: v1"))
406            )
407            .is_empty()
408        );
409        // Failure with a different message → fail.
410        let failures = evaluate(&expect, &run(vec![], vec![], Some("boom")));
411        assert!(
412            failures[0].contains("expected message containing 'contract'"),
413            "{failures:?}"
414        );
415        // Success when a failure was demanded → fail.
416        let failures = evaluate(&expect, &run(vec![], vec![], None));
417        assert!(failures[0].contains("but it succeeded"), "{failures:?}");
418        // Unexpected failure without `error:` → fail.
419        let expect = Expectation {
420            records_written: Some(0),
421            ..Default::default()
422        };
423        let failures = evaluate(&expect, &run(vec![], vec![], Some("boom")));
424        assert!(
425            failures[0].contains("run failed unexpectedly"),
426            "{failures:?}"
427        );
428    }
429
430    #[test]
431    fn diff_path_cap_and_truncation() {
432        // 20 differing fields → capped at MAX_DIFF_PATHS messages.
433        let mut e = serde_json::Map::new();
434        let mut a = serde_json::Map::new();
435        for i in 0..20 {
436            e.insert(format!("k{i:02}"), json!(1));
437            a.insert(format!("k{i:02}"), json!(2));
438        }
439        let expect = Expectation {
440            records: Some(vec![Value::Object(e)]),
441            ..Default::default()
442        };
443        let failures = evaluate(&expect, &run(vec![Value::Object(a)], vec![], None));
444        assert_eq!(failures.len(), MAX_DIFF_PATHS);
445
446        // A giant string value is truncated with an ellipsis.
447        let long = "x".repeat(500);
448        let expect = Expectation {
449            records: Some(vec![json!({"v": long})]),
450            ..Default::default()
451        };
452        let failures = evaluate(&expect, &run(vec![json!({"v": "short"})], vec![], None));
453        assert!(failures[0].contains('…'), "{failures:?}");
454    }
455
456    #[test]
457    fn array_length_and_scalar_type_mismatches() {
458        let expect = Expectation {
459            records: Some(vec![json!({"t": [1, 2, 3]})]),
460            ..Default::default()
461        };
462        let failures = evaluate(&expect, &run(vec![json!({"t": [1]})], vec![], None));
463        assert!(
464            failures[0].contains("expected array of 3, got 1"),
465            "{failures:?}"
466        );
467
468        // Whole-record shape mismatch (object vs scalar) still yields a message.
469        let expect = Expectation {
470            records: Some(vec![json!("scalar")]),
471            ..Default::default()
472        };
473        let failures = evaluate(&expect, &run(vec![json!({"a": 1})], vec![], None));
474        assert!(!failures.is_empty());
475    }
476}