Skip to main content

victauri_test/
assertions.rs

1use serde_json::Value;
2
3use crate::VictauriClient;
4use crate::error::TestError;
5
6/// Fluent assertion builder for full-stack Tauri test verification.
7///
8/// Collects multiple checks (DOM, IPC, network, state) and reports all
9/// failures together rather than stopping at the first one.
10///
11/// # Example
12///
13/// ```rust,ignore
14/// let report = client.verify()
15///     .has_text("Hello, World!")
16///     .has_no_text("Error")
17///     .ipc_was_called("greet")
18///     .ipc_was_called_with("greet", json!({"name": "World"}))
19///     .ipc_was_not_called("delete_account")
20///     .no_console_errors()
21///     .run()
22///     .await
23///     .unwrap();
24///
25/// report.assert_all_passed();
26/// ```
27pub struct VerifyBuilder<'a> {
28    client: &'a mut VictauriClient,
29    checks: Vec<Check>,
30}
31
32enum Check {
33    HasText(String),
34    HasNoText(String),
35    IpcWasCalled(String),
36    IpcWasCalledWith(String, Value),
37    IpcWasNotCalled(String),
38    NetworkRequest {
39        method: Option<String>,
40        url_contains: String,
41    },
42    NoNetworkRequest {
43        url_contains: String,
44    },
45    NoConsoleErrors,
46    StateMatches {
47        frontend_expr: String,
48        backend_state: Value,
49    },
50    IpcHealthy,
51    NoGhostCommands,
52    CoverageAbove(f64),
53}
54
55/// A single check result — pass or fail with context.
56#[derive(Debug, Clone)]
57pub struct CheckResult {
58    /// Human-readable description of what was checked.
59    pub description: String,
60    /// Whether the check passed.
61    pub passed: bool,
62    /// Failure detail (empty if passed).
63    pub detail: String,
64}
65
66/// Collection of check results from a `verify()` run.
67#[derive(Debug)]
68pub struct VerifyReport {
69    /// Individual check results in order.
70    pub results: Vec<CheckResult>,
71}
72
73impl VerifyReport {
74    /// Returns true if all checks passed.
75    #[must_use]
76    pub fn all_passed(&self) -> bool {
77        self.results.iter().all(|r| r.passed)
78    }
79
80    /// Returns only the failed checks.
81    #[must_use]
82    pub fn failures(&self) -> Vec<&CheckResult> {
83        self.results.iter().filter(|r| !r.passed).collect()
84    }
85
86    /// Converts this report to a `JUnit` XML [`JunitReport`](crate::reporting::JunitReport).
87    #[must_use]
88    pub fn to_junit(
89        &self,
90        suite_name: &str,
91        duration: std::time::Duration,
92    ) -> crate::reporting::JunitReport {
93        crate::reporting::JunitReport::from_verify_report(self, suite_name, duration)
94    }
95
96    /// Panics with a formatted report if any check failed.
97    ///
98    /// # Panics
99    ///
100    /// Panics if any check in the report did not pass.
101    pub fn assert_all_passed(&self) {
102        if self.all_passed() {
103            return;
104        }
105        let failures: Vec<String> = self
106            .failures()
107            .iter()
108            .enumerate()
109            .map(|(i, f)| format!("  {}. {} — {}", i + 1, f.description, f.detail))
110            .collect();
111        panic!(
112            "verify() failed ({}/{} checks passed):\n{}",
113            self.results.len() - failures.len(),
114            self.results.len(),
115            failures.join("\n")
116        );
117    }
118}
119
120impl<'a> VerifyBuilder<'a> {
121    pub(crate) fn new(client: &'a mut VictauriClient) -> Self {
122        Self {
123            client,
124            checks: Vec::new(),
125        }
126    }
127
128    /// Assert that the page currently contains the given text.
129    #[must_use]
130    pub fn has_text(mut self, text: &str) -> Self {
131        self.checks.push(Check::HasText(text.to_string()));
132        self
133    }
134
135    /// Assert that the page does NOT contain the given text.
136    #[must_use]
137    pub fn has_no_text(mut self, text: &str) -> Self {
138        self.checks.push(Check::HasNoText(text.to_string()));
139        self
140    }
141
142    /// Assert that an IPC command was called at least once.
143    #[must_use]
144    pub fn ipc_was_called(mut self, command: &str) -> Self {
145        self.checks.push(Check::IpcWasCalled(command.to_string()));
146        self
147    }
148
149    /// Assert that an IPC command was called with specific arguments.
150    #[must_use]
151    pub fn ipc_was_called_with(mut self, command: &str, args: Value) -> Self {
152        self.checks
153            .push(Check::IpcWasCalledWith(command.to_string(), args));
154        self
155    }
156
157    /// Assert that an IPC command was never called.
158    #[must_use]
159    pub fn ipc_was_not_called(mut self, command: &str) -> Self {
160        self.checks
161            .push(Check::IpcWasNotCalled(command.to_string()));
162        self
163    }
164
165    /// Assert a network request was made matching the given URL substring.
166    #[must_use]
167    pub fn network_request(mut self, method: Option<&str>, url_contains: &str) -> Self {
168        self.checks.push(Check::NetworkRequest {
169            method: method.map(String::from),
170            url_contains: url_contains.to_string(),
171        });
172        self
173    }
174
175    /// Assert NO network request was made matching the given URL substring.
176    #[must_use]
177    pub fn no_network_request(mut self, url_contains: &str) -> Self {
178        self.checks.push(Check::NoNetworkRequest {
179            url_contains: url_contains.to_string(),
180        });
181        self
182    }
183
184    /// Assert that no console errors were logged.
185    #[must_use]
186    pub fn no_console_errors(mut self) -> Self {
187        self.checks.push(Check::NoConsoleErrors);
188        self
189    }
190
191    /// Assert that frontend state matches backend state.
192    #[must_use]
193    pub fn state_matches(mut self, frontend_expr: &str, backend_state: Value) -> Self {
194        self.checks.push(Check::StateMatches {
195            frontend_expr: frontend_expr.to_string(),
196            backend_state,
197        });
198        self
199    }
200
201    /// Assert that IPC integrity is healthy (no stale/errored calls).
202    #[must_use]
203    pub fn ipc_healthy(mut self) -> Self {
204        self.checks.push(Check::IpcHealthy);
205        self
206    }
207
208    /// Assert that there are no ghost commands.
209    #[must_use]
210    pub fn no_ghost_commands(mut self) -> Self {
211        self.checks.push(Check::NoGhostCommands);
212        self
213    }
214
215    /// Assert that IPC command coverage meets the given threshold percentage.
216    ///
217    /// Queries the command registry and IPC log to compute how many registered
218    /// commands have been exercised, then checks whether the coverage percentage
219    /// is at or above `threshold`.
220    #[must_use]
221    pub fn coverage_above(mut self, threshold: f64) -> Self {
222        self.checks.push(Check::CoverageAbove(threshold));
223        self
224    }
225
226    /// Execute all queued checks and return the report.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`TestError`] only on transport/connection failures.
231    /// Check failures are reported in the [`VerifyReport`], not as errors.
232    pub async fn run(self) -> Result<VerifyReport, TestError> {
233        let client = self.client;
234        let mut results = Vec::with_capacity(self.checks.len());
235
236        for check in self.checks {
237            let result = run_check(client, &check).await?;
238            results.push(result);
239        }
240
241        Ok(VerifyReport { results })
242    }
243}
244
245async fn run_check(client: &mut VictauriClient, check: &Check) -> Result<CheckResult, TestError> {
246    match check {
247        Check::HasText(text) => {
248            let snap = client.dom_snapshot().await?;
249            let snap_str = serde_json::to_string(&snap).unwrap_or_default();
250            let found = snap_str.contains(text.as_str());
251            Ok(CheckResult {
252                description: format!("page contains \"{text}\""),
253                passed: found,
254                detail: if found {
255                    String::new()
256                } else {
257                    format!("text \"{text}\" not found in DOM")
258                },
259            })
260        }
261        Check::HasNoText(text) => {
262            let snap = client.dom_snapshot().await?;
263            let snap_str = serde_json::to_string(&snap).unwrap_or_default();
264            let found = snap_str.contains(text.as_str());
265            Ok(CheckResult {
266                description: format!("page does NOT contain \"{text}\""),
267                passed: !found,
268                detail: if found {
269                    format!("text \"{text}\" was found in DOM but shouldn't be")
270                } else {
271                    String::new()
272                },
273            })
274        }
275        Check::IpcWasCalled(command) => {
276            let log = client.get_ipc_log(None).await?;
277            let found = ipc_log_contains_command(&log, command);
278            Ok(CheckResult {
279                description: format!("IPC command \"{command}\" was called"),
280                passed: found,
281                detail: if found {
282                    String::new()
283                } else {
284                    format!("command \"{command}\" not found in IPC log")
285                },
286            })
287        }
288        Check::IpcWasCalledWith(command, expected_args) => {
289            let log = client.get_ipc_log(None).await?;
290            let (found, actual_args) = ipc_log_find_with_args(&log, command, expected_args);
291            Ok(CheckResult {
292                description: format!("IPC \"{command}\" called with {expected_args}"),
293                passed: found,
294                detail: if found {
295                    String::new()
296                } else if let Some(actual) = actual_args {
297                    format!("command called but with args: {actual}")
298                } else {
299                    format!("command \"{command}\" not found in IPC log")
300                },
301            })
302        }
303        Check::IpcWasNotCalled(command) => {
304            let log = client.get_ipc_log(None).await?;
305            let found = ipc_log_contains_command(&log, command);
306            Ok(CheckResult {
307                description: format!("IPC command \"{command}\" was NOT called"),
308                passed: !found,
309                detail: if found {
310                    format!("command \"{command}\" WAS called but shouldn't have been")
311                } else {
312                    String::new()
313                },
314            })
315        }
316        Check::NetworkRequest {
317            method,
318            url_contains,
319        } => {
320            let log = client.logs("network", None).await?;
321            let found = network_log_matches(&log, method.as_deref(), url_contains);
322            let desc = match method {
323                Some(m) => format!("network {m} request to \"*{url_contains}*\""),
324                None => format!("network request to \"*{url_contains}*\""),
325            };
326            Ok(CheckResult {
327                description: desc,
328                passed: found,
329                detail: if found {
330                    String::new()
331                } else {
332                    "no matching network request found".to_string()
333                },
334            })
335        }
336        Check::NoNetworkRequest { url_contains } => {
337            let log = client.logs("network", None).await?;
338            let found = network_log_matches(&log, None, url_contains);
339            Ok(CheckResult {
340                description: format!("NO network request to \"*{url_contains}*\""),
341                passed: !found,
342                detail: if found {
343                    format!("found network request matching \"{url_contains}\" but shouldn't have")
344                } else {
345                    String::new()
346                },
347            })
348        }
349        Check::NoConsoleErrors => {
350            let log = client.logs("console", None).await?;
351            let errors = console_log_errors(&log);
352            Ok(CheckResult {
353                description: "no console errors".to_string(),
354                passed: errors.is_empty(),
355                detail: if errors.is_empty() {
356                    String::new()
357                } else {
358                    format!("{} error(s): {}", errors.len(), errors.join("; "))
359                },
360            })
361        }
362        Check::StateMatches {
363            frontend_expr,
364            backend_state,
365        } => {
366            let result = client
367                .verify_state(frontend_expr, backend_state.clone())
368                .await?;
369            let passed = result
370                .get("passed")
371                .and_then(Value::as_bool)
372                .unwrap_or(false);
373            Ok(CheckResult {
374                description: format!("state matches ({frontend_expr})"),
375                passed,
376                detail: if passed {
377                    String::new()
378                } else {
379                    let divs = result.get("divergences").cloned().unwrap_or(Value::Null);
380                    format!("divergences: {divs}")
381                },
382            })
383        }
384        Check::IpcHealthy => {
385            let result = client.check_ipc_integrity().await?;
386            let healthy = result
387                .get("healthy")
388                .and_then(Value::as_bool)
389                .unwrap_or(false);
390            Ok(CheckResult {
391                description: "IPC integrity healthy".to_string(),
392                passed: healthy,
393                detail: if healthy {
394                    String::new()
395                } else {
396                    serde_json::to_string(&result).unwrap_or_default()
397                },
398            })
399        }
400        Check::NoGhostCommands => {
401            let result = client.detect_ghost_commands().await?;
402            let ghosts = result
403                .get("frontend_only")
404                .or_else(|| result.get("ghost_commands"))
405                .and_then(Value::as_array)
406                .map_or(0, Vec::len);
407            // `frontend_only` is only a real bug list when the registry mirrors the
408            // app's full command set. Without that (reliability none/low), the entries
409            // are real-but-uninstrumented commands — failing here would punish every app
410            // that doesn't use #[inspectable]. Only treat as ghosts when reliability is high.
411            let reliability = result
412                .get("reliability")
413                .and_then(Value::as_str)
414                .unwrap_or("unknown");
415            let counts_as_ghosts = ghosts > 0 && reliability == "high";
416            Ok(CheckResult {
417                description: "no ghost commands".to_string(),
418                passed: !counts_as_ghosts,
419                detail: if counts_as_ghosts {
420                    format!("{ghosts} ghost command(s) found (reliability: high)")
421                } else if ghosts > 0 {
422                    format!(
423                        "{ghosts} frontend-only command(s) found but registry is \
424                         incomplete (reliability: {reliability}) — not treated as ghosts"
425                    )
426                } else {
427                    String::new()
428                },
429            })
430        }
431        Check::CoverageAbove(threshold) => {
432            let report = crate::coverage::coverage_report(client).await?;
433            let passed = report.meets_threshold(*threshold);
434            Ok(CheckResult {
435                description: format!(
436                    "IPC coverage >= {threshold:.1}% (actual: {:.1}%)",
437                    report.coverage_percentage
438                ),
439                passed,
440                detail: if passed {
441                    String::new()
442                } else {
443                    format!(
444                        "coverage {:.1}% is below threshold {threshold:.1}%",
445                        report.coverage_percentage
446                    )
447                },
448            })
449        }
450    }
451}
452
453fn ipc_log_contains_command(log: &Value, command: &str) -> bool {
454    if let Some(arr) = log.as_array() {
455        return arr.iter().any(|entry| {
456            entry
457                .get("command")
458                .and_then(Value::as_str)
459                .is_some_and(|c| c == command)
460        });
461    }
462    if let Some(entries) = log.get("entries").and_then(Value::as_array) {
463        return entries.iter().any(|entry| {
464            entry
465                .get("command")
466                .and_then(Value::as_str)
467                .is_some_and(|c| c == command)
468        });
469    }
470    false
471}
472
473fn ipc_log_find_with_args(
474    log: &Value,
475    command: &str,
476    expected_args: &Value,
477) -> (bool, Option<Value>) {
478    let entries = if let Some(arr) = log.as_array() {
479        arr.clone()
480    } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
481        entries.clone()
482    } else {
483        return (false, None);
484    };
485
486    let mut last_args = None;
487    for entry in &entries {
488        let cmd = entry.get("command").and_then(Value::as_str).unwrap_or("");
489        if cmd != command {
490            continue;
491        }
492        let args = entry.get("args").or_else(|| entry.get("request_body"));
493        if let Some(args) = args {
494            if args_match(args, expected_args) {
495                return (true, None);
496            }
497            last_args = Some(args.clone());
498        } else if expected_args.is_null()
499            || expected_args == &Value::Object(serde_json::Map::default())
500        {
501            return (true, None);
502        }
503    }
504    (false, last_args)
505}
506
507fn args_match(actual: &Value, expected: &Value) -> bool {
508    match expected {
509        Value::Object(exp_map) => {
510            let Some(actual_map) = actual.as_object() else {
511                return false;
512            };
513            exp_map
514                .iter()
515                .all(|(k, v)| actual_map.get(k).is_some_and(|av| av == v))
516        }
517        _ => actual == expected,
518    }
519}
520
521fn network_log_matches(log: &Value, method: Option<&str>, url_contains: &str) -> bool {
522    let entries = if let Some(arr) = log.as_array() {
523        arr.as_slice()
524    } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
525        entries.as_slice()
526    } else {
527        return false;
528    };
529
530    entries.iter().any(|entry| {
531        let url = entry.get("url").and_then(Value::as_str).unwrap_or("");
532        if !url.contains(url_contains) {
533            return false;
534        }
535        if let Some(m) = method {
536            let req_method = entry.get("method").and_then(Value::as_str).unwrap_or("");
537            return req_method.eq_ignore_ascii_case(m);
538        }
539        true
540    })
541}
542
543fn console_log_errors(log: &Value) -> Vec<String> {
544    let entries = if let Some(arr) = log.as_array() {
545        arr.as_slice()
546    } else if let Some(entries) = log.get("entries").and_then(Value::as_array) {
547        entries.as_slice()
548    } else {
549        return Vec::new();
550    };
551
552    entries
553        .iter()
554        .filter_map(|entry| {
555            let level = entry.get("level").and_then(Value::as_str).unwrap_or("");
556            if level == "error" {
557                let msg = entry
558                    .get("message")
559                    .and_then(Value::as_str)
560                    .unwrap_or("(no message)")
561                    .to_string();
562                Some(msg)
563            } else {
564                None
565            }
566        })
567        .collect()
568}
569
570// ── Standalone IPC assertion functions ──────────────────────────────────────
571
572/// Assert that a specific IPC command was called at least once.
573///
574/// # Panics
575///
576/// Panics if the command was not found in the IPC log.
577///
578/// # Examples
579///
580/// ```
581/// use serde_json::json;
582///
583/// let log = json!([{"command": "save_settings", "args": {"theme": "dark"}}]);
584/// victauri_test::assert_ipc_called(&log, "save_settings");
585/// ```
586pub fn assert_ipc_called(log: &Value, command: &str) {
587    assert!(
588        ipc_log_contains_command(log, command),
589        "expected IPC command \"{command}\" to have been called, but it was not found in the log"
590    );
591}
592
593/// Assert that a specific IPC command was called with the given arguments.
594///
595/// Uses partial matching — the expected args only need to be a subset of actual args.
596///
597/// # Panics
598///
599/// Panics if the command was not called with matching arguments.
600///
601/// # Examples
602///
603/// ```
604/// use serde_json::json;
605///
606/// let log = json!([{"command": "save_settings", "args": {"theme": "dark", "lang": "en"}}]);
607/// victauri_test::assert_ipc_called_with(&log, "save_settings", &json!({"theme": "dark"}));
608/// ```
609pub fn assert_ipc_called_with(log: &Value, command: &str, expected_args: &Value) {
610    let (found, actual_args) = ipc_log_find_with_args(log, command, expected_args);
611    if !found {
612        if let Some(actual) = actual_args {
613            panic!(
614                "IPC command \"{command}\" was called but with different args:\n  expected: {expected_args}\n  actual:   {actual}"
615            );
616        } else {
617            panic!("IPC command \"{command}\" was never called (expected args: {expected_args})");
618        }
619    }
620}
621
622/// Assert that a specific IPC command was NOT called.
623///
624/// # Panics
625///
626/// Panics if the command was found in the IPC log.
627///
628/// # Examples
629///
630/// ```
631/// use serde_json::json;
632///
633/// let log = json!([{"command": "save_settings", "args": {}}]);
634/// victauri_test::assert_ipc_not_called(&log, "delete_account");
635/// ```
636pub fn assert_ipc_not_called(log: &Value, command: &str) {
637    assert!(
638        !ipc_log_contains_command(log, command),
639        "expected IPC command \"{command}\" to NOT have been called, but it was"
640    );
641}
642
643#[cfg(test)]
644mod tests {
645    use serde_json::json;
646
647    use super::*;
648
649    #[test]
650    fn ipc_contains_finds_command_in_array() {
651        let log = json!([
652            {"command": "greet", "args": {"name": "World"}},
653            {"command": "save_settings", "args": {"theme": "dark"}}
654        ]);
655        assert!(ipc_log_contains_command(&log, "greet"));
656        assert!(ipc_log_contains_command(&log, "save_settings"));
657        assert!(!ipc_log_contains_command(&log, "delete_account"));
658    }
659
660    #[test]
661    fn ipc_contains_finds_command_in_entries_object() {
662        let log = json!({"entries": [{"command": "fetch_data"}]});
663        assert!(ipc_log_contains_command(&log, "fetch_data"));
664        assert!(!ipc_log_contains_command(&log, "nope"));
665    }
666
667    #[test]
668    fn args_match_partial_object() {
669        let actual = json!({"theme": "dark", "lang": "en", "notifications": true});
670        let expected = json!({"theme": "dark"});
671        assert!(args_match(&actual, &expected));
672    }
673
674    #[test]
675    fn args_match_full_object() {
676        let actual = json!({"theme": "dark"});
677        let expected = json!({"theme": "dark"});
678        assert!(args_match(&actual, &expected));
679    }
680
681    #[test]
682    fn args_match_fails_on_mismatch() {
683        let actual = json!({"theme": "light"});
684        let expected = json!({"theme": "dark"});
685        assert!(!args_match(&actual, &expected));
686    }
687
688    #[test]
689    fn args_match_scalar() {
690        assert!(args_match(&json!("hello"), &json!("hello")));
691        assert!(!args_match(&json!("hello"), &json!("world")));
692    }
693
694    #[test]
695    fn ipc_find_with_args_partial_match() {
696        let log = json!([
697            {"command": "save", "args": {"theme": "dark", "lang": "en"}}
698        ]);
699        let (found, _) = ipc_log_find_with_args(&log, "save", &json!({"theme": "dark"}));
700        assert!(found);
701    }
702
703    #[test]
704    fn ipc_find_with_args_no_match_returns_actual() {
705        let log = json!([
706            {"command": "save", "args": {"theme": "light"}}
707        ]);
708        let (found, actual) = ipc_log_find_with_args(&log, "save", &json!({"theme": "dark"}));
709        assert!(!found);
710        assert_eq!(actual, Some(json!({"theme": "light"})));
711    }
712
713    #[test]
714    fn ipc_find_with_args_command_not_found() {
715        let log = json!([{"command": "other", "args": {}}]);
716        let (found, actual) = ipc_log_find_with_args(&log, "save", &json!({"theme": "dark"}));
717        assert!(!found);
718        assert_eq!(actual, None);
719    }
720
721    #[test]
722    fn network_log_matches_url() {
723        let log = json!([
724            {"url": "http://api.example.com/users", "method": "GET", "status": 200},
725            {"url": "http://api.example.com/settings", "method": "POST", "status": 201}
726        ]);
727        assert!(network_log_matches(&log, None, "/users"));
728        assert!(network_log_matches(&log, Some("POST"), "/settings"));
729        assert!(!network_log_matches(&log, Some("DELETE"), "/settings"));
730        assert!(!network_log_matches(&log, None, "/nonexistent"));
731    }
732
733    #[test]
734    fn console_errors_filters_by_level() {
735        let log = json!([
736            {"level": "log", "message": "info msg"},
737            {"level": "error", "message": "something broke"},
738            {"level": "warn", "message": "careful"},
739            {"level": "error", "message": "another error"}
740        ]);
741        let errors = console_log_errors(&log);
742        assert_eq!(errors.len(), 2);
743        assert_eq!(errors[0], "something broke");
744        assert_eq!(errors[1], "another error");
745    }
746
747    #[test]
748    fn console_errors_empty_for_no_errors() {
749        let log = json!([{"level": "log", "message": "all good"}]);
750        assert!(console_log_errors(&log).is_empty());
751    }
752
753    #[test]
754    fn assert_ipc_called_passes() {
755        let log = json!([{"command": "greet", "args": {"name": "World"}}]);
756        assert_ipc_called(&log, "greet");
757    }
758
759    #[test]
760    #[should_panic(expected = "was not found in the log")]
761    fn assert_ipc_called_fails() {
762        let log = json!([{"command": "greet", "args": {}}]);
763        assert_ipc_called(&log, "nonexistent");
764    }
765
766    #[test]
767    fn assert_ipc_called_with_passes() {
768        let log = json!([{"command": "save", "args": {"theme": "dark", "extra": true}}]);
769        assert_ipc_called_with(&log, "save", &json!({"theme": "dark"}));
770    }
771
772    #[test]
773    #[should_panic(expected = "different args")]
774    fn assert_ipc_called_with_fails_wrong_args() {
775        let log = json!([{"command": "save", "args": {"theme": "light"}}]);
776        assert_ipc_called_with(&log, "save", &json!({"theme": "dark"}));
777    }
778
779    #[test]
780    fn assert_ipc_not_called_passes() {
781        let log = json!([{"command": "greet", "args": {}}]);
782        assert_ipc_not_called(&log, "delete_everything");
783    }
784
785    #[test]
786    #[should_panic(expected = "NOT have been called")]
787    fn assert_ipc_not_called_fails() {
788        let log = json!([{"command": "greet", "args": {}}]);
789        assert_ipc_not_called(&log, "greet");
790    }
791
792    #[test]
793    fn verify_report_all_passed() {
794        let report = VerifyReport {
795            results: vec![
796                CheckResult {
797                    description: "check1".into(),
798                    passed: true,
799                    detail: String::new(),
800                },
801                CheckResult {
802                    description: "check2".into(),
803                    passed: true,
804                    detail: String::new(),
805                },
806            ],
807        };
808        assert!(report.all_passed());
809        assert!(report.failures().is_empty());
810    }
811
812    #[test]
813    fn verify_report_with_failures() {
814        let report = VerifyReport {
815            results: vec![
816                CheckResult {
817                    description: "pass".into(),
818                    passed: true,
819                    detail: String::new(),
820                },
821                CheckResult {
822                    description: "fail".into(),
823                    passed: false,
824                    detail: "something wrong".into(),
825                },
826            ],
827        };
828        assert!(!report.all_passed());
829        assert_eq!(report.failures().len(), 1);
830        assert_eq!(report.failures()[0].description, "fail");
831    }
832
833    #[test]
834    #[should_panic(expected = "verify() failed")]
835    fn verify_report_assert_panics_on_failure() {
836        let report = VerifyReport {
837            results: vec![CheckResult {
838                description: "bad".into(),
839                passed: false,
840                detail: "it broke".into(),
841            }],
842        };
843        report.assert_all_passed();
844    }
845}