1use std::collections::BTreeSet;
27
28use faucet_core::{Value, json};
29
30pub const ROW_KEY: &str = "__fidelity_id";
34
35#[derive(Debug, Clone, Default)]
43pub struct Tolerance {
44 skip: BTreeSet<String>,
46 float_epsilon: Option<f64>,
49 empty_string_is_null: bool,
53 lenient_scalar_kind: bool,
57}
58
59impl Tolerance {
60 pub fn exact() -> Self {
63 Self::default()
64 }
65
66 pub fn skipping(mut self, column: &str) -> Self {
68 self.skip.insert(column.to_string());
69 self
70 }
71
72 pub fn float_epsilon(mut self, eps: f64) -> Self {
74 self.float_epsilon = Some(eps);
75 self
76 }
77
78 pub fn empty_string_becomes_null(mut self) -> Self {
80 self.empty_string_is_null = true;
81 self
82 }
83
84 pub fn lenient_scalar_kind(mut self) -> Self {
86 self.lenient_scalar_kind = true;
87 self
88 }
89}
90
91pub fn corpus() -> Vec<Value> {
97 vec![
98 json!({
99 ROW_KEY: "integers",
100 "i64_max": i64::MAX,
103 "i64_min": i64::MIN,
104 "beyond_f64_exact": 9_007_199_254_740_993i64,
105 "zero": 0,
106 "negative": -1,
107 }),
108 json!({
109 ROW_KEY: "floats",
110 "simple": 1.5,
111 "repeating": 0.1,
114 "very_small": 1e-300,
115 "very_large": 1e300,
116 "negative_zero": -0.0,
118 }),
119 json!({
120 ROW_KEY: "strings",
121 "empty": "",
122 "unicode": "héllo wörld — 日本語 🚰",
123 "quote": "it's",
125 "backslash": "back\\slash",
126 "double_quote": "say \"hi\"",
127 "newline": "line1\nline2",
128 "tab": "a\tb",
129 "padded": " spaced ",
131 }),
132 json!({
133 ROW_KEY: "temporal",
134 "date": "2026-02-29",
135 "timestamp_utc": "2026-09-18T12:34:56Z",
136 "timestamp_offset": "2026-09-18T12:34:56+05:30",
137 "timestamp_micros": "2026-09-18T12:34:56.123456Z",
138 "before_epoch": "1969-07-20T20:17:40Z",
140 }),
141 json!({
142 ROW_KEY: "booleans_and_null",
143 "true_val": true,
144 "false_val": false,
145 "null_val": Value::Null,
146 }),
147 json!({
148 ROW_KEY: "canonical",
149 "decimal": "12345678901234567890.123456789",
153 "decimal_scale": "1.50",
154 "big_integer": "123456789012345678901234567890",
155 "binary": "AAEC/f7/",
156 }),
157 json!({
158 ROW_KEY: "nested",
159 "object": { "a": 1, "b": { "c": [1, 2, 3] } },
160 "array": [1, "two", null, true],
161 "empty_object": {},
162 "empty_array": [],
163 }),
164 ]
165}
166
167pub fn flat_corpus() -> Value {
171 let mut out = serde_json::Map::new();
172 out.insert(ROW_KEY.to_string(), json!("flat"));
173 for rec in corpus() {
174 let Some(obj) = rec.as_object() else { continue };
175 let Some(class) = obj.get(ROW_KEY).and_then(|v| v.as_str()) else {
176 continue;
177 };
178 for (k, v) in obj {
179 if k == ROW_KEY {
180 continue;
181 }
182 out.insert(format!("{class}_{k}"), v.clone());
183 }
184 }
185 Value::Object(out)
186}
187
188pub async fn assert_corpus_read(source: &dyn faucet_core::Source, sent: &[Value], tol: Tolerance) {
192 let landed = source
193 .fetch_all()
194 .await
195 .unwrap_or_else(|e| panic!("reading the corpus back failed: {e}"));
196 assert_round_trip(sent, &landed, tol);
197}
198
199pub async fn assert_corpus_written<F, Fut>(
203 sink: &dyn faucet_core::Sink,
204 sent: &[Value],
205 read_back: F,
206 tol: Tolerance,
207) where
208 F: FnOnce() -> Fut,
209 Fut: std::future::Future<Output = Vec<Value>>,
210{
211 sink.write_batch(sent)
212 .await
213 .unwrap_or_else(|e| panic!("writing the corpus failed: {e}"));
214 sink.flush()
215 .await
216 .unwrap_or_else(|e| panic!("flushing the corpus failed: {e}"));
217 let landed = read_back().await;
218 assert_round_trip(sent, &landed, tol);
219}
220
221#[derive(Debug, Clone, PartialEq)]
223pub struct Mismatch {
224 pub row: String,
226 pub field: String,
228 pub sent: Value,
230 pub landed: Value,
232}
233
234impl std::fmt::Display for Mismatch {
235 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236 write!(
237 f,
238 "row {:?} field {:?}: sent {} but landed {}",
239 self.row, self.field, self.sent, self.landed
240 )
241 }
242}
243
244pub fn diff_round_trip(sent: &[Value], landed: &[Value], tol: &Tolerance) -> Vec<Mismatch> {
250 let key_of = |v: &Value| {
251 v.get(ROW_KEY)
252 .and_then(|k| k.as_str())
253 .unwrap_or("<unkeyed>")
254 .to_string()
255 };
256 let landed_by_key: std::collections::HashMap<String, &Value> =
257 landed.iter().map(|v| (key_of(v), v)).collect();
258
259 let mut out = Vec::new();
260 for s in sent {
261 let row = key_of(s);
262 let Some(l) = landed_by_key.get(&row) else {
263 out.push(Mismatch {
264 row,
265 field: "<row>".into(),
266 sent: s.clone(),
267 landed: Value::Null,
268 });
269 continue;
270 };
271 let Some(sobj) = s.as_object() else { continue };
272 for (field, sent_v) in sobj {
273 if field == ROW_KEY || tol.skip.contains(field) {
274 continue;
275 }
276 let landed_v = l.get(field).cloned().unwrap_or(Value::Null);
277 if !values_agree(sent_v, &landed_v, tol) {
278 out.push(Mismatch {
279 row: row.clone(),
280 field: field.clone(),
281 sent: sent_v.clone(),
282 landed: landed_v,
283 });
284 }
285 }
286 }
287 let sent_keys: BTreeSet<String> = sent.iter().map(key_of).collect();
290 for l in landed {
291 let row = key_of(l);
292 if !sent_keys.contains(&row) {
293 out.push(Mismatch {
294 row,
295 field: "<row>".into(),
296 sent: Value::Null,
297 landed: l.clone(),
298 });
299 }
300 }
301 out
302}
303
304fn values_agree(sent: &Value, landed: &Value, tol: &Tolerance) -> bool {
306 if sent == landed {
307 if let (Some(a), Some(b)) = (sent.as_f64(), landed.as_f64())
310 && a == 0.0
311 && b == 0.0
312 {
313 return a.is_sign_negative() == b.is_sign_negative();
314 }
315 return true;
316 }
317 if tol.empty_string_is_null && sent.as_str() == Some("") && landed.is_null() {
318 return true;
319 }
320 if let (Some(eps), Value::Number(n), Value::Number(m)) = (tol.float_epsilon, sent, landed) {
324 if n.is_i64() || n.is_u64() {
325 return integer_agrees(n, m);
326 }
327 if let (Some(a), Some(b)) = (n.as_f64(), m.as_f64()) {
328 let scale = a.abs().max(b.abs()).max(1.0);
329 return (a - b).abs() <= eps * scale;
330 }
331 }
332 if tol.lenient_scalar_kind
333 && !sent.is_object()
334 && !sent.is_array()
335 && !sent.is_null()
336 && !landed.is_null()
337 {
338 let render = |v: &Value| match v {
339 Value::String(s) => s.clone(),
340 other => other.to_string(),
341 };
342 return render(sent) == render(landed);
343 }
344 false
345}
346
347fn integer_agrees(sent: &serde_json::Number, landed: &serde_json::Number) -> bool {
350 if let (Some(a), Some(b)) = (sent.as_i64(), landed.as_i64()) {
351 return a == b;
352 }
353 if let (Some(a), Some(b)) = (sent.as_u64(), landed.as_u64()) {
354 return a == b;
355 }
356 match (sent.as_i64(), landed.as_f64()) {
357 (Some(a), Some(b)) if !landed.is_i64() && !landed.is_u64() => {
358 a.unsigned_abs() <= 1 << 53 && a as f64 == b
359 }
360 _ => false,
361 }
362}
363
364pub fn assert_round_trip(sent: &[Value], landed: &[Value], tol: Tolerance) {
368 let mismatches = diff_round_trip(sent, landed, &tol);
369 assert!(
370 mismatches.is_empty(),
371 "round-trip fidelity lost on {} field(s):\n {}",
372 mismatches.len(),
373 mismatches
374 .iter()
375 .map(|m| m.to_string())
376 .collect::<Vec<_>>()
377 .join("\n ")
378 );
379}
380
381#[cfg(test)]
382mod tests {
383 #[test]
384 fn integer_agrees_compares_large_unsigned_integers() {
385 let big = |v: u64| serde_json::Number::from(v);
386 assert!(integer_agrees(&big(u64::MAX), &big(u64::MAX)));
387 assert!(!integer_agrees(&big(u64::MAX), &big(u64::MAX - 1)));
388 let f = serde_json::Number::from_f64(1.8e19).unwrap();
390 assert!(!integer_agrees(&big(u64::MAX), &f));
391 }
392 use super::*;
393
394 #[test]
395 fn corpus_rows_are_keyed_and_distinct() {
396 let c = corpus();
397 let keys: BTreeSet<&str> = c
398 .iter()
399 .map(|r| r[ROW_KEY].as_str().expect("every row is keyed"))
400 .collect();
401 assert_eq!(keys.len(), c.len(), "row keys must be unique");
402 assert!(c.len() >= 6, "the corpus must cover every hazard class");
403 }
404
405 #[test]
406 fn an_unchanged_round_trip_has_no_mismatches() {
407 assert_round_trip(&corpus(), &corpus(), Tolerance::exact());
408 }
409
410 #[test]
411 fn rows_are_matched_by_key_not_position() {
412 let mut reordered = corpus();
413 reordered.reverse();
414 assert_round_trip(&corpus(), &reordered, Tolerance::exact());
415 }
416
417 #[test]
418 fn a_missing_row_is_reported() {
419 let landed: Vec<Value> = corpus().into_iter().skip(1).collect();
420 let d = diff_round_trip(&corpus(), &landed, &Tolerance::exact());
421 assert_eq!(d.len(), 1);
422 assert_eq!(d[0].field, "<row>");
423 assert_eq!(d[0].row, "integers");
424 }
425
426 #[test]
427 fn an_invented_row_is_reported() {
428 let mut landed = corpus();
429 landed.push(json!({ ROW_KEY: "ghost", "x": 1 }));
430 let d = diff_round_trip(&corpus(), &landed, &Tolerance::exact());
431 assert_eq!(d.len(), 1, "{d:?}");
432 assert_eq!(d[0].row, "ghost");
433 assert_eq!(d[0].sent, Value::Null, "nothing was sent for this row");
434 }
435
436 #[test]
437 fn a_precision_loss_past_2_pow_53_is_caught() {
438 let mut landed = corpus();
441 landed[0]["beyond_f64_exact"] = json!(9_007_199_254_740_992i64);
442 let d = diff_round_trip(&corpus(), &landed, &Tolerance::exact());
443 assert_eq!(d.len(), 1);
444 assert_eq!(d[0].field, "beyond_f64_exact");
445 }
446
447 #[test]
448 fn negative_zero_losing_its_sign_is_caught() {
449 let mut landed = corpus();
452 landed[1]["negative_zero"] = json!(0.0);
453 let d = diff_round_trip(&corpus(), &landed, &Tolerance::exact());
454 assert_eq!(d.len(), 1, "{d:?}");
455 assert_eq!(d[0].field, "negative_zero");
456 }
457
458 #[test]
459 fn an_empty_string_becoming_null_is_caught_but_tolerable() {
460 let mut landed = corpus();
461 landed[2]["empty"] = Value::Null;
462 assert_eq!(
463 diff_round_trip(&corpus(), &landed, &Tolerance::exact()).len(),
464 1,
465 "exact mode must flag it"
466 );
467 assert!(
468 diff_round_trip(
469 &corpus(),
470 &landed,
471 &Tolerance::exact().empty_string_becomes_null()
472 )
473 .is_empty(),
474 "a destination that cannot store '' distinctly may opt out explicitly"
475 );
476 }
477
478 #[test]
479 fn a_dropped_field_reads_as_null_and_is_caught() {
480 let mut landed = corpus();
481 landed[2]
482 .as_object_mut()
483 .expect("object")
484 .remove("unicode")
485 .expect("field present");
486 let d = diff_round_trip(&corpus(), &landed, &Tolerance::exact());
487 assert_eq!(d.len(), 1);
488 assert_eq!(d[0].landed, Value::Null);
489 }
490
491 #[test]
492 fn skipping_opts_a_column_out_entirely() {
493 let mut landed = corpus();
494 landed[3]["timestamp_offset"] = json!("2026-09-18T07:04:56Z");
495 assert_eq!(
496 diff_round_trip(&corpus(), &landed, &Tolerance::exact()).len(),
497 1
498 );
499 assert!(
500 diff_round_trip(
501 &corpus(),
502 &landed,
503 &Tolerance::exact().skipping("timestamp_offset")
504 )
505 .is_empty()
506 );
507 }
508
509 #[test]
510 fn float_epsilon_accepts_a_reround_but_not_a_real_change() {
511 let mut landed = corpus();
512 landed[1]["repeating"] = json!(0.100000000000000_1);
513 let tol = Tolerance::exact().float_epsilon(1e-12);
514 assert!(diff_round_trip(&corpus(), &landed, &tol).is_empty());
515
516 landed[1]["repeating"] = json!(0.2);
517 assert_eq!(
518 diff_round_trip(&corpus(), &landed, &tol).len(),
519 1,
520 "an epsilon must not excuse a genuinely different value"
521 );
522 }
523
524 #[test]
525 fn float_epsilon_does_not_hide_big_integer_rounding() {
526 let tol = Tolerance::exact().float_epsilon(1e-15);
527 assert!(diff_round_trip(&corpus(), &corpus(), &tol).is_empty());
528 let mut landed = corpus();
529 landed[0]["beyond_f64_exact"] = json!(9_007_199_254_740_992i64);
530 assert_eq!(diff_round_trip(&corpus(), &landed, &tol).len(), 1);
531 let mut landed = corpus();
532 landed[0]["i64_max"] = json!(i64::MAX as f64);
533 assert_eq!(diff_round_trip(&corpus(), &landed, &tol).len(), 1);
534 let mut landed = corpus();
535 landed[0]["i64_max"] = json!(i64::MAX - 1);
536 assert_eq!(diff_round_trip(&corpus(), &landed, &tol).len(), 1);
537 let mut landed = corpus();
539 landed[0]["negative"] = json!(-1.0);
540 assert!(diff_round_trip(&corpus(), &landed, &tol).is_empty());
541 let mut landed = corpus();
543 landed[1]["repeating"] = json!(0.1 + 1e-16);
544 assert!(diff_round_trip(&corpus(), &landed, &tol).is_empty());
545 }
546
547 #[test]
548 fn lenient_scalar_kind_never_turns_null_into_text() {
549 let tol = Tolerance::exact().lenient_scalar_kind();
550 assert!(!values_agree(&Value::Null, &json!("null"), &tol));
551 assert!(!values_agree(&json!("null"), &Value::Null, &tol));
552 }
553
554 #[test]
555 fn lenient_scalar_kind_accepts_a_stringified_number_only() {
556 let mut landed = corpus();
557 landed[0]["zero"] = json!("0");
558 let tol = Tolerance::exact().lenient_scalar_kind();
559 assert!(diff_round_trip(&corpus(), &landed, &tol).is_empty());
560
561 landed[0]["zero"] = json!("1");
562 assert_eq!(diff_round_trip(&corpus(), &landed, &tol).len(), 1);
563 }
564
565 #[test]
566 fn flat_corpus_carries_every_field_with_class_prefixed_names() {
567 let flat = flat_corpus();
568 let obj = flat.as_object().expect("object");
569 assert_eq!(obj[ROW_KEY], json!("flat"));
570 assert_eq!(obj["integers_i64_max"], json!(i64::MAX));
571 assert_eq!(obj["strings_empty"], json!(""));
572 assert_eq!(obj["nested_empty_array"], json!([]));
573 let expected: usize = corpus()
575 .iter()
576 .map(|r| r.as_object().expect("object").len() - 1)
577 .sum();
578 assert_eq!(obj.len(), expected + 1, "plus the row key");
579 }
580
581 struct VecSource(Vec<Value>);
582
583 #[async_trait::async_trait]
584 impl faucet_core::Source for VecSource {
585 async fn fetch_with_context(
586 &self,
587 _ctx: &std::collections::HashMap<String, Value>,
588 ) -> Result<Vec<Value>, faucet_core::FaucetError> {
589 Ok(self.0.clone())
590 }
591 }
592
593 #[derive(Default)]
594 struct VecSink(std::sync::Mutex<Vec<Value>>);
595
596 #[async_trait::async_trait]
597 impl faucet_core::Sink for VecSink {
598 async fn write_batch(&self, records: &[Value]) -> Result<usize, faucet_core::FaucetError> {
599 self.0.lock().unwrap().extend_from_slice(records);
600 Ok(records.len())
601 }
602 }
603
604 #[tokio::test]
605 async fn one_sided_helpers_compare_against_the_corpus() {
606 assert_corpus_read(&VecSource(corpus()), &corpus(), Tolerance::exact()).await;
607 let sink = VecSink::default();
608 assert_corpus_written(
609 &sink,
610 &corpus(),
611 || async { sink.0.lock().unwrap().clone() },
612 Tolerance::exact(),
613 )
614 .await;
615 }
616
617 #[tokio::test]
618 #[should_panic(expected = "round-trip fidelity lost")]
619 async fn a_lossy_source_fails_the_read_helper() {
620 let mut lossy = corpus();
621 lossy[0]["beyond_f64_exact"] = json!(9_007_199_254_740_992i64);
622 assert_corpus_read(&VecSource(lossy), &corpus(), Tolerance::exact()).await;
623 }
624
625 #[test]
626 fn the_corpus_carries_the_canonical_renderings() {
627 let canonical = corpus()
628 .into_iter()
629 .find(|r| r[ROW_KEY] == "canonical")
630 .expect("canonical row");
631 assert_eq!(
632 canonical["decimal"],
633 json!("12345678901234567890.123456789")
634 );
635 assert_eq!(canonical["binary"], json!("AAEC/f7/"));
636 }
637
638 #[test]
639 fn mismatch_display_names_both_sides() {
640 let m = Mismatch {
641 row: "integers".into(),
642 field: "i64_max".into(),
643 sent: json!(1),
644 landed: json!(2),
645 };
646 let s = m.to_string();
647 assert!(
648 s.contains("integers") && s.contains("i64_max") && s.contains('1') && s.contains('2'),
649 "{s}"
650 );
651 }
652}