1use std::panic::Location;
4
5use regex::Regex;
6use serde_json::Value;
7
8use crate::asymmetric::{deep_equal, float_bit_eq, json_short, match_object};
9use crate::diff::json_diff;
10use crate::{AssertionFailure, CallerLocation};
11
12#[derive(Debug, Clone)]
19pub enum StringOrRegex {
20 String(String),
21 Regex(Regex),
22}
23
24impl StringOrRegex {
25 pub fn matches(&self, actual: &str) -> bool {
26 match self {
27 Self::String(expected) => actual == expected,
28 Self::Regex(re) => re.is_match(actual),
29 }
30 }
31
32 pub fn description(&self) -> String {
33 match self {
34 Self::String(s) => format!("\"{s}\""),
35 Self::Regex(re) => format!("/{}/", re.as_str()),
36 }
37 }
38}
39
40impl From<&str> for StringOrRegex {
41 fn from(s: &str) -> Self {
42 Self::String(s.to_string())
43 }
44}
45
46impl From<String> for StringOrRegex {
47 fn from(s: String) -> Self {
48 Self::String(s)
49 }
50}
51
52impl From<Regex> for StringOrRegex {
53 fn from(re: Regex) -> Self {
54 Self::Regex(re)
55 }
56}
57
58pub struct ExpectValue {
61 actual: Value,
62 is_not: bool,
63 is_soft: bool,
64 message: Option<String>,
65}
66
67#[must_use]
69pub fn expect_value(actual: Value) -> ExpectValue {
70 ExpectValue {
71 actual,
72 is_not: false,
73 is_soft: false,
74 message: None,
75 }
76}
77
78impl ExpectValue {
79 #[must_use]
80 pub fn not(mut self) -> Self {
81 self.is_not = !self.is_not;
82 self
83 }
84
85 #[must_use]
86 pub fn soft(mut self) -> Self {
87 self.is_soft = true;
88 self
89 }
90
91 #[must_use]
92 pub fn with_message(mut self, message: impl Into<String>) -> Self {
93 self.message = Some(message.into());
94 self
95 }
96
97 pub fn is_soft(&self) -> bool {
98 self.is_soft
99 }
100
101 pub fn actual(&self) -> &Value {
102 &self.actual
103 }
104
105 fn fail(
106 &self,
107 method: &str,
108 expected: impl Into<String>,
109 received: impl Into<String>,
110 rich_diff: Option<String>,
111 location: Option<&'static Location<'static>>,
112 ) -> AssertionFailure {
113 let expected = expected.into();
114 let received = received.into();
115 let not = if self.is_not { ".not" } else { "" };
116 let prefix = self.message.as_ref().map(|m| format!("{m}: ")).unwrap_or_default();
117 let message = format!("{prefix}expect(value){not}.{method}() failed");
124 let summary_diff = format!("Expected: {expected}\nReceived: {received}");
125 let body = match rich_diff {
126 Some(d) => format!("{summary_diff}\n\nDiff:\n{d}"),
127 None => summary_diff,
128 };
129 let mut failure = AssertionFailure::new(message, Some(body));
130 if let Some(loc) = location {
131 failure = failure.with_location(CallerLocation::from_std(loc));
132 }
133 failure
134 }
135
136 #[track_caller]
137 fn check(
138 &self,
139 pass: bool,
140 method: &str,
141 expected: impl Into<String>,
142 received: impl Into<String>,
143 ) -> Result<(), AssertionFailure> {
144 let pass = if self.is_not { !pass } else { pass };
145 if pass {
146 Ok(())
147 } else {
148 Err(self.fail(method, expected, received, None, Some(Location::caller())))
149 }
150 }
151
152 #[track_caller]
153 fn check_with_diff(
154 &self,
155 pass: bool,
156 method: &str,
157 expected: impl Into<String>,
158 received: impl Into<String>,
159 diff: String,
160 ) -> Result<(), AssertionFailure> {
161 let pass = if self.is_not { !pass } else { pass };
162 if pass {
163 Ok(())
164 } else {
165 Err(self.fail(method, expected, received, Some(diff), Some(Location::caller())))
166 }
167 }
168
169 #[track_caller]
174 pub fn to_be(&self, expected: &Value) -> Result<(), AssertionFailure> {
175 if self.actual.is_object() || self.actual.is_array() || expected.is_object() || expected.is_array() {
176 return Err(self.fail(
177 "toBe",
178 format!("primitive equal to {}", json_short(expected)),
179 format!("{} (use toEqual for objects/arrays)", json_short(&self.actual)),
180 None,
181 Some(Location::caller()),
182 ));
183 }
184 let pass = deep_equal(&self.actual, expected);
185 self.check(pass, "toBe", json_short(expected), json_short(&self.actual))
186 }
187
188 #[track_caller]
191 pub fn to_equal(&self, expected: &Value) -> Result<(), AssertionFailure> {
192 let pass = deep_equal(&self.actual, expected);
193 let diff = if pass {
194 None
195 } else {
196 Some(json_diff(expected, &self.actual))
197 };
198 match diff {
199 Some(d) => self.check_with_diff(pass, "toEqual", json_short(expected), json_short(&self.actual), d),
200 None => self.check(pass, "toEqual", json_short(expected), json_short(&self.actual)),
201 }
202 }
203
204 #[track_caller]
207 pub fn to_strict_equal(&self, expected: &Value) -> Result<(), AssertionFailure> {
208 let pass = deep_equal(&self.actual, expected);
209 let diff = if pass {
210 None
211 } else {
212 Some(json_diff(expected, &self.actual))
213 };
214 match diff {
215 Some(d) => self.check_with_diff(pass, "toStrictEqual", json_short(expected), json_short(&self.actual), d),
216 None => self.check(pass, "toStrictEqual", json_short(expected), json_short(&self.actual)),
217 }
218 }
219
220 #[track_caller]
223 pub fn to_be_null(&self) -> Result<(), AssertionFailure> {
224 self.check(self.actual.is_null(), "toBeNull", "null", json_short(&self.actual))
225 }
226
227 #[track_caller]
230 pub fn to_be_undefined(&self) -> Result<(), AssertionFailure> {
231 self.check(
232 self.actual.is_null(),
233 "toBeUndefined",
234 "undefined",
235 json_short(&self.actual),
236 )
237 }
238
239 #[track_caller]
240 pub fn to_be_defined(&self) -> Result<(), AssertionFailure> {
241 self.check(
242 !self.actual.is_null(),
243 "toBeDefined",
244 "defined value",
245 json_short(&self.actual),
246 )
247 }
248
249 #[track_caller]
252 pub fn to_be_truthy(&self) -> Result<(), AssertionFailure> {
253 self.check(
254 is_truthy(&self.actual),
255 "toBeTruthy",
256 "truthy",
257 json_short(&self.actual),
258 )
259 }
260
261 #[track_caller]
262 pub fn to_be_falsy(&self) -> Result<(), AssertionFailure> {
263 self.check(!is_truthy(&self.actual), "toBeFalsy", "falsy", json_short(&self.actual))
264 }
265
266 #[track_caller]
269 pub fn to_be_nan(&self) -> Result<(), AssertionFailure> {
270 let pass = self.actual.as_f64().is_some_and(f64::is_nan);
271 self.check(pass, "toBeNaN", "NaN", json_short(&self.actual))
272 }
273
274 #[track_caller]
275 pub fn to_be_close_to(&self, expected: f64, digits: Option<u8>) -> Result<(), AssertionFailure> {
276 let digits = digits.unwrap_or(2);
277 let actual = self.actual.as_f64().ok_or_else(|| {
278 self.fail(
279 "toBeCloseTo",
280 format!("number close to {expected}"),
281 json_short(&self.actual),
282 None,
283 Some(Location::caller()),
284 )
285 })?;
286 let pass = close_enough_within(actual, expected, digits);
287 self.check(
288 pass,
289 "toBeCloseTo",
290 format!("{expected} (±{digits} decimal places)"),
291 format!("{actual}"),
292 )
293 }
294
295 #[track_caller]
296 pub fn to_be_greater_than(&self, expected: f64) -> Result<(), AssertionFailure> {
297 let actual = self.numeric_or_fail("toBeGreaterThan", expected)?;
298 self.check(
299 actual > expected,
300 "toBeGreaterThan",
301 format!("> {expected}"),
302 format!("{actual}"),
303 )
304 }
305
306 #[track_caller]
307 pub fn to_be_greater_than_or_equal(&self, expected: f64) -> Result<(), AssertionFailure> {
308 let actual = self.numeric_or_fail("toBeGreaterThanOrEqual", expected)?;
309 self.check(
310 actual >= expected,
311 "toBeGreaterThanOrEqual",
312 format!(">= {expected}"),
313 format!("{actual}"),
314 )
315 }
316
317 #[track_caller]
318 pub fn to_be_less_than(&self, expected: f64) -> Result<(), AssertionFailure> {
319 let actual = self.numeric_or_fail("toBeLessThan", expected)?;
320 self.check(
321 actual < expected,
322 "toBeLessThan",
323 format!("< {expected}"),
324 format!("{actual}"),
325 )
326 }
327
328 pub fn to_be_less_than_or_equal(&self, expected: f64) -> Result<(), AssertionFailure> {
329 let actual = self.numeric_or_fail("toBeLessThanOrEqual", expected)?;
330 self.check(
331 actual <= expected,
332 "toBeLessThanOrEqual",
333 format!("<= {expected}"),
334 format!("{actual}"),
335 )
336 }
337
338 #[track_caller]
339 fn numeric_or_fail(&self, method: &str, expected: f64) -> Result<f64, AssertionFailure> {
340 let loc = Location::caller();
341 self.actual.as_f64().ok_or_else(|| {
342 self.fail(
343 method,
344 format!("{expected}"),
345 format!("non-numeric {}", json_short(&self.actual)),
346 None,
347 Some(loc),
348 )
349 })
350 }
351
352 #[track_caller]
356 pub fn to_contain(&self, expected: &Value) -> Result<(), AssertionFailure> {
357 let pass = match (&self.actual, expected) {
358 (Value::Array(arr), exp) => arr.iter().any(|v| primitive_strict_equal(v, exp)),
359 (Value::String(s), Value::String(needle)) => s.contains(needle.as_str()),
360 _ => false,
361 };
362 self.check(
363 pass,
364 "toContain",
365 format!("containing {}", json_short(expected)),
366 json_short(&self.actual),
367 )
368 }
369
370 #[track_caller]
372 pub fn to_contain_equal(&self, expected: &Value) -> Result<(), AssertionFailure> {
373 let pass = match &self.actual {
374 Value::Array(arr) => arr.iter().any(|v| deep_equal(v, expected)),
375 _ => false,
376 };
377 if pass {
378 self.check(
379 pass,
380 "toContainEqual",
381 format!("containing equal {}", json_short(expected)),
382 json_short(&self.actual),
383 )
384 } else {
385 let diff = json_diff(expected, &self.actual);
386 self.check_with_diff(
387 pass,
388 "toContainEqual",
389 format!("containing equal {}", json_short(expected)),
390 json_short(&self.actual),
391 diff,
392 )
393 }
394 }
395
396 #[track_caller]
397 pub fn to_have_length(&self, expected: usize) -> Result<(), AssertionFailure> {
398 let actual_len = match &self.actual {
399 Value::Array(a) => Some(a.len()),
400 Value::String(s) => Some(s.chars().count()),
401 _ => None,
402 };
403 match actual_len {
404 Some(len) => self.check(
405 len == expected,
406 "toHaveLength",
407 format!("length {expected}"),
408 format!("length {len}"),
409 ),
410 None => Err(self.fail(
411 "toHaveLength",
412 format!("length {expected}"),
413 format!("value without .length: {}", json_short(&self.actual)),
414 None,
415 Some(Location::caller()),
416 )),
417 }
418 }
419
420 #[track_caller]
423 pub fn to_have_property(&self, path: &Value, expected: Option<&Value>) -> Result<(), AssertionFailure> {
424 let loc = Location::caller();
425 let segments =
426 parse_property_path(path).map_err(|e| self.fail("toHaveProperty", e, json_short(path), None, Some(loc)))?;
427 let descended = descend(&self.actual, &segments);
428 let pass = match (descended, expected) {
429 (Some(_), None) => true,
430 (Some(val), Some(exp)) => deep_equal(val, exp),
431 (None, _) => false,
432 };
433 let desc = format!(
434 "property {} {}",
435 path_describe(&segments),
436 expected.map(|v| format!("= {}", json_short(v))).unwrap_or_default()
437 );
438 let received = match descend(&self.actual, &segments) {
439 Some(v) => format!("= {}", json_short(v)),
440 None => "(missing)".to_string(),
441 };
442 self.check(pass, "toHaveProperty", desc, received)
443 }
444
445 #[track_caller]
449 pub fn to_match(&self, pattern: &StringOrRegex) -> Result<(), AssertionFailure> {
450 let actual = match self.actual.as_str() {
451 Some(s) => s,
452 None => {
453 return Err(self.fail(
454 "toMatch",
455 format!("matching {}", pattern.description()),
456 format!("non-string {}", json_short(&self.actual)),
457 None,
458 Some(Location::caller()),
459 ));
460 },
461 };
462 let pass = match pattern {
463 StringOrRegex::String(needle) => actual.contains(needle.as_str()),
464 StringOrRegex::Regex(re) => re.is_match(actual),
465 };
466 self.check(pass, "toMatch", pattern.description(), format!("{actual:?}"))
467 }
468
469 #[track_caller]
470 pub fn to_match_object(&self, subset: &Value) -> Result<(), AssertionFailure> {
471 let pass = match_object(&self.actual, subset);
472 if pass {
473 self.check(pass, "toMatchObject", json_short(subset), json_short(&self.actual))
474 } else {
475 let diff = json_diff(subset, &self.actual);
476 self.check_with_diff(
477 pass,
478 "toMatchObject",
479 json_short(subset),
480 json_short(&self.actual),
481 diff,
482 )
483 }
484 }
485
486 #[track_caller]
491 pub fn to_be_instance_of(&self, ctor_name: &str, actual_ctor_name: Option<&str>) -> Result<(), AssertionFailure> {
492 let actual_name = actual_ctor_name.unwrap_or_else(|| infer_builtin_ctor(&self.actual));
493 let pass = actual_name == ctor_name;
494 self.check(
495 pass,
496 "toBeInstanceOf",
497 format!("instance of {ctor_name}"),
498 format!("instance of {actual_name}"),
499 )
500 }
501}
502
503fn close_enough_within(a: f64, b: f64, digits: u8) -> bool {
504 if !a.is_finite() || !b.is_finite() {
505 return float_bit_eq(a, b);
506 }
507 let tol = 10f64.powi(-i32::from(digits)) / 2.0;
508 (a - b).abs() < tol
509}
510
511fn is_truthy(v: &Value) -> bool {
512 match v {
513 Value::Null => false,
514 Value::Bool(b) => *b,
515 Value::Number(n) => n
516 .as_f64()
517 .is_some_and(|f| !float_bit_eq(f, 0.0) && !float_bit_eq(f, -0.0) && !f.is_nan()),
518 Value::String(s) => !s.is_empty(),
519 Value::Array(_) | Value::Object(_) => true,
520 }
521}
522
523fn primitive_strict_equal(a: &Value, b: &Value) -> bool {
524 match (a, b) {
525 (Value::Null, Value::Null) => true,
526 (Value::Bool(x), Value::Bool(y)) => x == y,
527 (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
528 (Some(xf), Some(yf)) => float_bit_eq(xf, yf),
529 _ => false,
530 },
531 (Value::String(x), Value::String(y)) => x == y,
532 _ => deep_equal(a, b),
533 }
534}
535
536#[derive(Debug, Clone)]
537enum PropSegment {
538 Key(String),
539 Index(usize),
540}
541
542fn parse_property_path(path: &Value) -> Result<Vec<PropSegment>, String> {
543 match path {
544 Value::String(s) => Ok(s.split('.').map(|seg| PropSegment::Key(seg.to_string())).collect()),
545 Value::Array(arr) => arr
546 .iter()
547 .map(|seg| match seg {
548 Value::String(s) => Ok(PropSegment::Key(s.clone())),
549 Value::Number(n) => n
550 .as_u64()
551 .map(|i| PropSegment::Index(i as usize))
552 .ok_or_else(|| "property path index must be a non-negative integer".to_string()),
553 other => Err(format!(
554 "property path segment must be string or integer; got {}",
555 json_short(other)
556 )),
557 })
558 .collect(),
559 _ => Err("property path must be a string or array".into()),
560 }
561}
562
563fn descend<'a>(v: &'a Value, segments: &[PropSegment]) -> Option<&'a Value> {
564 let mut cur = v;
565 for seg in segments {
566 cur = match (cur, seg) {
567 (Value::Object(map), PropSegment::Key(k)) => map.get(k)?,
568 (Value::Array(arr), PropSegment::Index(i)) => arr.get(*i)?,
569 (Value::Array(arr), PropSegment::Key(k)) => arr.get(k.parse::<usize>().ok()?)?,
570 _ => return None,
571 };
572 }
573 Some(cur)
574}
575
576fn path_describe(segments: &[PropSegment]) -> String {
577 let parts: Vec<String> = segments
578 .iter()
579 .map(|s| match s {
580 PropSegment::Key(k) => k.clone(),
581 PropSegment::Index(i) => format!("[{i}]"),
582 })
583 .collect();
584 parts.join(".")
585}
586
587fn infer_builtin_ctor(v: &Value) -> &'static str {
588 match v {
589 Value::Null => "Null",
590 Value::Bool(_) => "Boolean",
591 Value::Number(_) => "Number",
592 Value::String(_) => "String",
593 Value::Array(_) => "Array",
594 Value::Object(_) => "Object",
595 }
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601 use crate::asymmetric::ASYM_TAG_KEY;
602 use serde_json::json;
603
604 fn ok(r: Result<(), AssertionFailure>) {
605 if let Err(e) = r {
606 panic!("expected ok, got: {}", e.message);
607 }
608 }
609 fn err(r: Result<(), AssertionFailure>) {
610 assert!(r.is_err(), "expected err");
611 }
612
613 #[test]
614 fn to_be_primitive_only() {
615 ok(expect_value(json!(1)).to_be(&json!(1)));
616 err(expect_value(json!(1)).to_be(&json!(2)));
617 err(expect_value(json!([1])).to_be(&json!([1])));
618 }
619
620 #[test]
621 fn to_equal_failure_carries_diff_and_location() {
622 let actual = json!({"id": 1, "name": "Alice", "tags": ["admin", "user"]});
623 let expected = json!({"id": 2, "name": "Alice", "tags": ["admin"]});
624 let err = expect_value(actual)
625 .to_equal(&expected)
626 .expect_err("toEqual should fail");
627 let diff = err.diff.as_deref().unwrap_or("");
629 assert!(diff.contains('-'), "diff missing '-' line: {diff}");
630 assert!(diff.contains('+'), "diff missing '+' line: {diff}");
631 assert!(diff.contains("\"id\""), "diff lacks pretty-JSON key context: {diff}");
632 let loc = err.location.expect("location captured");
634 assert!(loc.file.contains("value.rs"), "location file: {}", loc.file);
635 assert!(loc.line > 0);
636 }
637
638 #[test]
639 fn to_equal_recurses() {
640 ok(expect_value(json!({"a": [1, 2]})).to_equal(&json!({"a": [1, 2]})));
641 err(expect_value(json!({"a": [1, 2]})).to_equal(&json!({"a": [1, 3]})));
642 }
643
644 #[test]
645 fn to_equal_with_asymmetric() {
646 let exp = json!({"id": {ASYM_TAG_KEY: "any", "name": "Number"}});
647 ok(expect_value(json!({"id": 7})).to_equal(&exp));
648 err(expect_value(json!({"id": "x"})).to_equal(&exp));
649 }
650
651 #[test]
652 fn not_inverts() {
653 ok(expect_value(json!(1)).not().to_be(&json!(2)));
654 err(expect_value(json!(1)).not().to_be(&json!(1)));
655 }
656
657 #[test]
658 fn to_contain_array_and_string() {
659 ok(expect_value(json!([1, 2, 3])).to_contain(&json!(2)));
660 err(expect_value(json!([1, 2, 3])).to_contain(&json!(4)));
661 ok(expect_value(json!("hello world")).to_contain(&json!("world")));
662 }
663
664 #[test]
665 fn to_contain_equal_deep() {
666 ok(expect_value(json!([{"id": 1}, {"id": 2}])).to_contain_equal(&json!({"id": 2})));
667 err(expect_value(json!([{"id": 1}, {"id": 2}])).to_contain_equal(&json!({"id": 3})));
668 }
669
670 #[test]
671 fn to_have_length_works() {
672 ok(expect_value(json!([1, 2, 3])).to_have_length(3));
673 ok(expect_value(json!("abcd")).to_have_length(4));
674 err(expect_value(json!(42)).to_have_length(1));
675 }
676
677 #[test]
678 fn to_have_property_dot_path() {
679 ok(expect_value(json!({"a": {"b": 1}})).to_have_property(&json!("a.b"), None));
680 ok(expect_value(json!({"a": {"b": 1}})).to_have_property(&json!("a.b"), Some(&json!(1))));
681 err(expect_value(json!({"a": {"b": 1}})).to_have_property(&json!("a.c"), None));
682 }
683
684 #[test]
685 fn to_have_property_array_path_with_index() {
686 ok(expect_value(json!({"arr": [10, 20]})).to_have_property(&json!(["arr", 1]), Some(&json!(20))));
687 }
688
689 #[test]
690 fn to_match_string_substring() {
691 ok(expect_value(json!("hello")).to_match(&StringOrRegex::String("ello".into())));
692 ok(expect_value(json!("hello")).to_match(&StringOrRegex::Regex(Regex::new("^h.+o$").unwrap())));
693 err(expect_value(json!("hello")).to_match(&StringOrRegex::String("bye".into())));
694 }
695
696 #[test]
697 fn to_match_object_subset() {
698 ok(expect_value(json!({"a": 1, "b": 2})).to_match_object(&json!({"a": 1})));
699 err(expect_value(json!({"a": 1, "b": 2})).to_match_object(&json!({"a": 2})));
700 }
701
702 #[test]
703 fn close_to_default_two_digits() {
704 ok(expect_value(json!(0.1 + 0.2)).to_be_close_to(0.3, None));
705 err(expect_value(json!(0.1 + 0.2)).to_be_close_to(0.4, None));
706 }
707
708 #[test]
709 fn truthy_and_falsy() {
710 ok(expect_value(json!(1)).to_be_truthy());
711 ok(expect_value(json!("")).to_be_falsy());
712 ok(expect_value(json!(0)).to_be_falsy());
713 ok(expect_value(Value::Null).to_be_falsy());
714 }
715
716 #[test]
717 fn to_be_instance_of_builtins() {
718 ok(expect_value(json!([1])).to_be_instance_of("Array", None));
719 ok(expect_value(json!("x")).to_be_instance_of("String", None));
720 err(expect_value(json!(1)).to_be_instance_of("String", None));
721 }
722
723 #[test]
724 fn greater_less_than() {
725 ok(expect_value(json!(5)).to_be_greater_than(3.0));
726 err(expect_value(json!(3)).to_be_greater_than(3.0));
727 ok(expect_value(json!(3)).to_be_greater_than_or_equal(3.0));
728 ok(expect_value(json!(2)).to_be_less_than(3.0));
729 ok(expect_value(json!(3)).to_be_less_than_or_equal(3.0));
730 }
731}