ferridriver_expect/
throw.rs1use std::panic::Location;
8
9use regex::Regex;
10use serde_json::Value;
11
12use crate::asymmetric::json_short;
13use crate::{AssertionFailure, CallerLocation};
14
15#[derive(Debug, Clone, Default)]
17pub struct ThrownError {
18 pub message: String,
21 pub class_name: Option<String>,
23}
24
25#[derive(Debug, Clone)]
26pub enum ThrowMatcher {
27 Any,
28 Substring(String),
29 Regex(Regex),
30 ClassName(String),
31 Object(Value),
33}
34
35pub struct ExpectFn {
36 caught: Option<ThrownError>,
37 is_not: bool,
38 is_soft: bool,
39 message: Option<String>,
40}
41
42#[must_use]
43pub fn expect_fn(caught: Option<ThrownError>) -> ExpectFn {
44 ExpectFn {
45 caught,
46 is_not: false,
47 is_soft: false,
48 message: None,
49 }
50}
51
52impl ExpectFn {
53 #[must_use]
54 pub fn not(mut self) -> Self {
55 self.is_not = !self.is_not;
56 self
57 }
58
59 #[must_use]
60 pub fn soft(mut self) -> Self {
61 self.is_soft = true;
62 self
63 }
64
65 #[must_use]
66 pub fn with_message(mut self, message: impl Into<String>) -> Self {
67 self.message = Some(message.into());
68 self
69 }
70
71 pub fn is_soft(&self) -> bool {
72 self.is_soft
73 }
74
75 #[track_caller]
76 pub fn to_throw(&self, matcher: Option<&ThrowMatcher>) -> Result<(), AssertionFailure> {
77 let location = Location::caller();
78 let pass = match (&self.caught, matcher) {
79 (None, _) => false,
80 (Some(_), None) => true,
81 (Some(err), Some(ThrowMatcher::Any)) => !err.message.is_empty() || err.class_name.is_some(),
82 (Some(err), Some(ThrowMatcher::Substring(s))) => err.message.contains(s.as_str()),
83 (Some(err), Some(ThrowMatcher::Regex(re))) => re.is_match(&err.message),
84 (Some(err), Some(ThrowMatcher::ClassName(name))) => {
85 err.class_name.as_deref() == Some(name.as_str()) || err.message.contains(name.as_str())
86 },
87 (Some(err), Some(ThrowMatcher::Object(subset))) => {
88 if let Some(obj) = subset.as_object() {
89 let mut ok = true;
90 if let Some(Value::String(msg_expected)) = obj.get("message") {
91 ok &= err.message.contains(msg_expected.as_str());
92 }
93 if let Some(Value::String(name_expected)) = obj.get("name") {
94 ok &= err.class_name.as_deref() == Some(name_expected.as_str());
95 }
96 ok
97 } else {
98 false
99 }
100 },
101 };
102 let pass = if self.is_not { !pass } else { pass };
103 if pass {
104 return Ok(());
105 }
106 let expected_desc = match matcher {
107 None | Some(ThrowMatcher::Any) => "function to throw".to_string(),
108 Some(ThrowMatcher::Substring(s)) => format!("throw containing {s:?}"),
109 Some(ThrowMatcher::Regex(re)) => format!("throw matching /{}/", re.as_str()),
110 Some(ThrowMatcher::ClassName(n)) => format!("throw of {n}"),
111 Some(ThrowMatcher::Object(o)) => format!("throw matching {}", json_short(o)),
112 };
113 let received_desc = match &self.caught {
114 None => "function returned without throwing".to_string(),
115 Some(err) => match &err.class_name {
116 Some(n) => format!("{n}: {}", err.message),
117 None => err.message.clone(),
118 },
119 };
120 let not = if self.is_not { ".not" } else { "" };
121 let prefix = self.message.as_ref().map(|m| format!("{m}: ")).unwrap_or_default();
122 let title = format!("{prefix}expect(fn){not}.toThrow() failed");
123 let body = format!("Expected: {expected_desc}\nReceived: {received_desc}");
124 Err(AssertionFailure::new(title, Some(body)).with_location(CallerLocation::from_std(location)))
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 fn err(r: Result<(), AssertionFailure>) {
133 assert!(r.is_err(), "expected err");
134 }
135
136 #[test]
137 fn to_throw_substring_and_classname() {
138 let caught = Some(ThrownError {
139 message: "boom: out of range".into(),
140 class_name: Some("RangeError".into()),
141 });
142 expect_fn(caught.clone())
143 .to_throw(Some(&ThrowMatcher::Substring("out of range".into())))
144 .unwrap();
145 err(expect_fn(caught.clone()).to_throw(Some(&ThrowMatcher::Substring("nope".into()))));
146 expect_fn(caught.clone())
147 .to_throw(Some(&ThrowMatcher::ClassName("RangeError".into())))
148 .unwrap();
149 expect_fn(caught).to_throw(None).unwrap();
150 err(expect_fn(None).to_throw(None));
151 }
152
153 #[test]
154 fn not_inverts_throw() {
155 err(
156 expect_fn(Some(ThrownError {
157 message: "boom".into(),
158 class_name: None,
159 }))
160 .not()
161 .to_throw(None),
162 );
163 expect_fn(None).not().to_throw(None).unwrap();
164 }
165}