1use crate::pipeline_test::runner::CaseRun;
5use crate::pipeline_test::spec::{Expectation, MatchMode};
6use serde_json::Value;
7
8const MAX_DIFF_PATHS: usize = 8;
11
12pub 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
73pub 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
96fn 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 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
150fn 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
210fn 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 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 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 assert!(
403 evaluate(
404 &expect,
405 &run(vec![], vec![], Some("contract violation: v1"))
406 )
407 .is_empty()
408 );
409 let failures = evaluate(&expect, &run(vec![], vec![], Some("boom")));
411 assert!(
412 failures[0].contains("expected message containing 'contract'"),
413 "{failures:?}"
414 );
415 let failures = evaluate(&expect, &run(vec![], vec![], None));
417 assert!(failures[0].contains("but it succeeded"), "{failures:?}");
418 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 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 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 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}