Skip to main content

faucet_conformance/
fidelity.rs

1//! Shared round-trip fidelity corpus (#651 Category F).
2//!
3//! The promise faucet makes is not "the rows arrive" but "the rows arrive
4//! *unchanged*". Those are different claims, and the second is the one that
5//! breaks quietly: an `i64` past 2^53 that went through a JSON number, a
6//! timestamp that lost its offset, a `""` that became `NULL`, a `0.0` that came
7//! back `-0.0`. None of that fails a run — it lands, and the report is green.
8//!
9//! So every source↔sink pair wants the same test: seed a corpus that contains
10//! exactly the values known to break, move it through a real pipeline, and
11//! assert the landed rows are *equal* to what went in. Writing that per pair
12//! invites each one to quietly pick an easier corpus. This module owns the
13//! corpus and the comparison instead, so a pair's test is the pipeline wiring
14//! and nothing else.
15//!
16//! ```no_run
17//! # use faucet_conformance::fidelity;
18//! # async fn example(records: Vec<faucet_core::Value>) {
19//! // Landed rows come back from the destination however that sink reads back.
20//! let landed: Vec<faucet_core::Value> = read_back_somehow().await;
21//! fidelity::assert_round_trip(&fidelity::corpus(), &landed, fidelity::Tolerance::exact());
22//! # }
23//! # async fn read_back_somehow() -> Vec<faucet_core::Value> { vec![] }
24//! ```
25
26use std::collections::BTreeSet;
27
28use faucet_core::{Value, json};
29
30/// Key every corpus record carries, so a destination that reorders rows can
31/// still be compared. Named with the reserved `__` prefix so it cannot collide
32/// with a realistic column.
33pub const ROW_KEY: &str = "__fidelity_id";
34
35/// What a destination is *allowed* to change, because its type system genuinely
36/// cannot represent the input.
37///
38/// Every allowance is a real, documented limitation of some destination — not a
39/// convenience. A pair that needs one is saying "this column cannot survive
40/// here", which is worth stating explicitly in that pair's test rather than
41/// hiding inside a loose comparison.
42#[derive(Debug, Clone, Default)]
43pub struct Tolerance {
44    /// Columns to skip entirely (the destination has no representation at all).
45    skip: BTreeSet<String>,
46    /// Compare floats within this relative epsilon instead of bit-exactly.
47    /// `None` means bit-exact.
48    float_epsilon: Option<f64>,
49    /// Treat a landed `null` as equal to an input empty string — for the
50    /// destinations (Oracle-family, some warehouse loaders) that cannot store a
51    /// zero-length string distinctly from NULL.
52    empty_string_is_null: bool,
53    /// Accept a landed number where a string was sent, and vice versa, as long
54    /// as the *rendered* values agree — for schemaless destinations that coerce
55    /// on read.
56    lenient_scalar_kind: bool,
57}
58
59impl Tolerance {
60    /// No allowances: every value must land byte- and type-identical. Start
61    /// here, and widen only with a comment naming the destination limitation.
62    pub fn exact() -> Self {
63        Self::default()
64    }
65
66    /// Skip a column this destination cannot represent.
67    pub fn skipping(mut self, column: &str) -> Self {
68        self.skip.insert(column.to_string());
69        self
70    }
71
72    /// Compare floats within a relative epsilon.
73    pub fn float_epsilon(mut self, eps: f64) -> Self {
74        self.float_epsilon = Some(eps);
75        self
76    }
77
78    /// Accept `null` where an empty string was sent.
79    pub fn empty_string_becomes_null(mut self) -> Self {
80        self.empty_string_is_null = true;
81        self
82    }
83
84    /// Accept a scalar whose JSON kind changed but whose rendering did not.
85    pub fn lenient_scalar_kind(mut self) -> Self {
86        self.lenient_scalar_kind = true;
87        self
88    }
89}
90
91/// The corpus: one record per hazard class, each keyed by [`ROW_KEY`].
92///
93/// Every field here exists because it has broken a real pipeline somewhere.
94/// Add to it when a fidelity bug is found — that is what stops the same class
95/// of bug reappearing in the next connector.
96pub fn corpus() -> Vec<Value> {
97    vec![
98        json!({
99            ROW_KEY: "integers",
100            // Past 2^53, so anything that round-trips through an f64 (a JS
101            // number, an unguarded JSON parse) corrupts these silently.
102            "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            // Not representable in binary: a destination that re-renders via a
112            // short decimal form changes it.
113            "repeating": 0.1,
114            "very_small": 1e-300,
115            "very_large": 1e300,
116            // Distinct from 0.0 by sign bit; many round-trips lose it.
117            "negative_zero": -0.0,
118        }),
119        json!({
120            ROW_KEY: "strings",
121            "empty": "",
122            "unicode": "héllo wörld — 日本語 🚰",
123            // The three characters that break naive SQL literal escaping.
124            "quote": "it's",
125            "backslash": "back\\slash",
126            "double_quote": "say \"hi\"",
127            "newline": "line1\nline2",
128            "tab": "a\tb",
129            // Leading/trailing whitespace a CHAR column would pad or trim.
130            "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            // Pre-epoch, which unsigned or unix-seconds encodings mangle.
139            "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            // The canonical renderings of `faucet_core::fidelity`: an exact
150            // decimal and a big integer are strings, binary is base64. A
151            // connector that parses them back through a float corrupts them.
152            "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
167/// The corpus as a flat single record, for destinations that take one wide row
168/// rather than one row per hazard class. Field names are prefixed with the
169/// class so they stay unique.
170pub 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
188/// Read every record `source` produces and assert it is the corpus — the
189/// one-sided source form, after the test seeded the backend with [`corpus`]
190/// (or, for a destination with one wide row, [`flat_corpus`]).
191pub 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
199/// Write `sent` through `sink` (one page, then flush), read the destination
200/// back with `read_back`, and assert the corpus survived — the one-sided sink
201/// form.
202pub 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/// One field that did not survive the trip.
222#[derive(Debug, Clone, PartialEq)]
223pub struct Mismatch {
224    /// [`ROW_KEY`] of the record.
225    pub row: String,
226    /// Field name, or `"<row>"` when the whole record is missing or extra.
227    pub field: String,
228    /// What was sent.
229    pub sent: Value,
230    /// What landed (`Null` when absent).
231    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
244/// Compare landed records against what was sent, returning every mismatch.
245///
246/// Rows are matched by [`ROW_KEY`], not by position, so a destination free to
247/// reorder is not penalised for it. Pure — the assertion wrapper is
248/// [`assert_round_trip`].
249pub 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    // A row the destination invented is a fidelity failure too — a duplicate
288    // from a bad retry shows up here rather than being silently tolerated.
289    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
304/// Whether two values agree under `tol`.
305fn values_agree(sent: &Value, landed: &Value, tol: &Tolerance) -> bool {
306    if sent == landed {
307        // `==` on two JSON numbers does not separate 0.0 from -0.0, so the sign
308        // bit is checked explicitly before accepting equality.
309        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    // The epsilon is for floats only. An integer is compared exactly: going
321    // through an f64 would hide the precision loss past 2^53 the corpus's
322    // integer row exists to catch.
323    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
347/// Whether `landed` holds exactly the integer `sent`: the same integer, or a
348/// float that is that whole number and within the range an f64 holds exactly.
349fn 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
364/// Assert every value survived the round trip, panicking with every mismatch
365/// listed rather than only the first — a type bug usually hits a whole class of
366/// columns, and seeing all of them is what identifies the cause.
367pub 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        // Past i64, a float landing is never an exact match.
389        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        // The bug this corpus exists for: the value survives as an f64 but is
439        // no longer the integer that was sent.
440        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        // `json!(-0.0) == json!(0.0)` is true, so a naive comparison misses
450        // this entirely.
451        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        // A small whole number read back as a float is still the same value.
538        let mut landed = corpus();
539        landed[0]["negative"] = json!(-1.0);
540        assert!(diff_round_trip(&corpus(), &landed, &tol).is_empty());
541        // Floats keep their tolerance.
542        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        // Every non-key field of every row is present exactly once.
574        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}