Skip to main content

victauri_test/
coverage.rs

1//! IPC coverage tracking — measures which Tauri commands are exercised by tests.
2//!
3//! Compares the set of registered commands (from the registry) against the set
4//! of IPC calls observed during a test session. Reports tested vs. untested
5//! commands, call counts, and coverage percentage.
6
7use serde_json::Value;
8
9use crate::client::VictauriClient;
10use crate::error::TestError;
11
12/// IPC coverage report showing which commands were exercised.
13#[derive(Debug)]
14pub struct CoverageReport {
15    /// Total number of registered commands.
16    pub total_commands: usize,
17    /// Number of commands invoked at least once.
18    pub tested_commands: usize,
19    /// Coverage percentage (0.0 to 100.0).
20    pub coverage_percentage: f64,
21    /// Commands that were never invoked during the session.
22    pub untested: Vec<String>,
23    /// Commands sorted by invocation count (descending).
24    pub most_called: Vec<CommandCalls>,
25}
26
27/// A command name with its invocation count.
28#[derive(Debug)]
29pub struct CommandCalls {
30    /// Name of the Tauri command.
31    pub name: String,
32    /// Number of times invoked during the session.
33    pub calls: usize,
34}
35
36impl CoverageReport {
37    /// Returns true if coverage meets or exceeds the given threshold.
38    #[must_use]
39    pub fn meets_threshold(&self, threshold_percent: f64) -> bool {
40        self.coverage_percentage >= threshold_percent
41    }
42
43    /// Formats the report for human-readable output.
44    #[must_use]
45    pub fn to_summary(&self) -> String {
46        let mut out = String::with_capacity(512);
47        out.push_str(&format!(
48            "IPC Coverage: {:.1}% ({}/{} commands tested)\n",
49            self.coverage_percentage, self.tested_commands, self.total_commands
50        ));
51
52        if !self.most_called.is_empty() {
53            out.push_str("\nMost called:\n");
54            for cmd in self.most_called.iter().take(10) {
55                out.push_str(&format!("  {:>4}x  {}\n", cmd.calls, cmd.name));
56            }
57        }
58
59        if !self.untested.is_empty() {
60            out.push_str(&format!("\nUntested ({}):\n", self.untested.len()));
61            for name in self.untested.iter().take(20) {
62                out.push_str(&format!("  - {name}\n"));
63            }
64            if self.untested.len() > 20 {
65                out.push_str(&format!("  ... and {} more\n", self.untested.len() - 20));
66            }
67        }
68
69        out
70    }
71}
72
73/// Builds a coverage report by comparing the command registry against IPC call logs.
74///
75/// Queries the running app for its command registry and IPC call history,
76/// then computes which commands were exercised and which remain untested.
77///
78/// # Errors
79///
80/// Returns errors from the underlying MCP tool calls.
81pub async fn coverage_report(client: &mut VictauriClient) -> Result<CoverageReport, TestError> {
82    let registry = client.get_registry().await?;
83    let ipc_log = client.get_ipc_log(None).await?;
84
85    let registered: Vec<String> = extract_command_names(&registry);
86    let called: Vec<String> = extract_ipc_commands(&ipc_log);
87
88    build_report(&registered, &called)
89}
90
91/// Asserts that IPC coverage meets the given threshold, panicking with a
92/// detailed report if it does not.
93///
94/// # Errors
95///
96/// Returns errors from the underlying MCP tool calls.
97///
98/// # Panics
99///
100/// Panics if coverage falls below `threshold_percent`.
101pub async fn assert_coverage_above(
102    client: &mut VictauriClient,
103    threshold_percent: f64,
104) -> Result<(), TestError> {
105    let report = coverage_report(client).await?;
106    assert!(
107        report.meets_threshold(threshold_percent),
108        "IPC coverage {:.1}% is below threshold {:.1}%\n{}",
109        report.coverage_percentage,
110        threshold_percent,
111        report.to_summary()
112    );
113    Ok(())
114}
115
116fn extract_command_names(registry: &Value) -> Vec<String> {
117    if let Some(arr) = registry.as_array() {
118        arr.iter()
119            .filter_map(|v| v.get("name").and_then(Value::as_str).map(String::from))
120            .collect()
121    } else if let Some(commands) = registry.get("commands").and_then(Value::as_array) {
122        commands
123            .iter()
124            .filter_map(|v| v.get("name").and_then(Value::as_str).map(String::from))
125            .collect()
126    } else {
127        Vec::new()
128    }
129}
130
131fn extract_ipc_commands(ipc_log: &Value) -> Vec<String> {
132    if let Some(arr) = ipc_log.as_array() {
133        arr.iter()
134            .filter_map(|v| v.get("command").and_then(Value::as_str).map(String::from))
135            .collect()
136    } else {
137        Vec::new()
138    }
139}
140
141fn build_report(registered: &[String], called: &[String]) -> Result<CoverageReport, TestError> {
142    let mut call_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
143    for cmd in called {
144        let name = cmd
145            .strip_prefix("plugin:")
146            .and_then(|s| s.split('|').nth(1))
147            .unwrap_or(cmd);
148        *call_counts.entry(name).or_default() += 1;
149    }
150
151    let total_commands = registered.len();
152    let mut tested = 0;
153    let mut untested = Vec::new();
154    let mut most_called: Vec<CommandCalls> = Vec::new();
155
156    for name in registered {
157        let clean = name
158            .strip_prefix("plugin:")
159            .and_then(|s| s.split('|').nth(1))
160            .unwrap_or(name);
161        if let Some(&count) = call_counts.get(clean) {
162            tested += 1;
163            most_called.push(CommandCalls {
164                name: clean.to_string(),
165                calls: count,
166            });
167        } else {
168            untested.push(clean.to_string());
169        }
170    }
171
172    most_called.sort_by_key(|c| std::cmp::Reverse(c.calls));
173    untested.sort();
174
175    let coverage_percentage = if total_commands == 0 {
176        100.0
177    } else {
178        (tested as f64 / total_commands as f64) * 100.0
179    };
180
181    Ok(CoverageReport {
182        total_commands,
183        tested_commands: tested,
184        coverage_percentage,
185        untested,
186        most_called,
187    })
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn build_report_full_coverage() {
196        let registered = vec!["cmd_a".to_string(), "cmd_b".to_string()];
197        let called = vec![
198            "cmd_a".to_string(),
199            "cmd_b".to_string(),
200            "cmd_a".to_string(),
201        ];
202
203        let report = build_report(&registered, &called).unwrap();
204        assert_eq!(report.total_commands, 2);
205        assert_eq!(report.tested_commands, 2);
206        assert_eq!(report.coverage_percentage, 100.0);
207        assert!(report.untested.is_empty());
208        assert_eq!(report.most_called[0].name, "cmd_a");
209        assert_eq!(report.most_called[0].calls, 2);
210    }
211
212    #[test]
213    fn build_report_partial_coverage() {
214        let registered = vec![
215            "cmd_a".to_string(),
216            "cmd_b".to_string(),
217            "cmd_c".to_string(),
218        ];
219        let called = vec!["cmd_a".to_string()];
220
221        let report = build_report(&registered, &called).unwrap();
222        assert_eq!(report.tested_commands, 1);
223        assert!((report.coverage_percentage - 33.333).abs() < 0.01);
224        assert_eq!(report.untested.len(), 2);
225        assert!(report.untested.contains(&"cmd_b".to_string()));
226        assert!(report.untested.contains(&"cmd_c".to_string()));
227    }
228
229    #[test]
230    fn build_report_no_commands() {
231        let report = build_report(&[], &[]).unwrap();
232        assert_eq!(report.coverage_percentage, 100.0);
233        assert_eq!(report.total_commands, 0);
234    }
235
236    #[test]
237    fn build_report_strips_plugin_prefix() {
238        let registered = vec!["save_data".to_string()];
239        let called = vec!["plugin:myapp|save_data".to_string()];
240
241        let report = build_report(&registered, &called).unwrap();
242        assert_eq!(report.tested_commands, 1);
243        assert_eq!(report.coverage_percentage, 100.0);
244    }
245
246    #[test]
247    fn meets_threshold_boundary() {
248        let report = CoverageReport {
249            total_commands: 10,
250            tested_commands: 8,
251            coverage_percentage: 80.0,
252            untested: vec!["a".to_string(), "b".to_string()],
253            most_called: vec![],
254        };
255        assert!(report.meets_threshold(80.0));
256        assert!(!report.meets_threshold(80.1));
257    }
258
259    #[test]
260    fn summary_formatting() {
261        let report = CoverageReport {
262            total_commands: 3,
263            tested_commands: 1,
264            coverage_percentage: 33.3,
265            untested: vec!["cmd_b".to_string(), "cmd_c".to_string()],
266            most_called: vec![CommandCalls {
267                name: "cmd_a".to_string(),
268                calls: 5,
269            }],
270        };
271        let summary = report.to_summary();
272
273        assert!(summary.contains("33.3%"));
274        assert!(summary.contains("1/3"));
275        assert!(summary.contains("cmd_a"));
276        assert!(summary.contains("cmd_b"));
277        assert!(summary.contains("Untested (2)"));
278    }
279
280    #[test]
281    fn extract_command_names_from_array() {
282        let registry = serde_json::json!([
283            {"name": "cmd_a", "description": "A"},
284            {"name": "cmd_b", "description": "B"}
285        ]);
286        let names = extract_command_names(&registry);
287        assert_eq!(names, vec!["cmd_a", "cmd_b"]);
288    }
289
290    #[test]
291    fn extract_command_names_from_commands_field() {
292        let registry = serde_json::json!({
293            "commands": [
294                {"name": "cmd_x"},
295                {"name": "cmd_y"}
296            ]
297        });
298        let names = extract_command_names(&registry);
299        assert_eq!(names, vec!["cmd_x", "cmd_y"]);
300    }
301
302    #[test]
303    fn extract_ipc_commands_from_log() {
304        let log = serde_json::json!([
305            {"command": "greet", "status": "ok"},
306            {"command": "save", "status": "ok"}
307        ]);
308        let cmds = extract_ipc_commands(&log);
309        assert_eq!(cmds, vec!["greet", "save"]);
310    }
311
312    #[test]
313    fn meets_threshold_exact_boundary() {
314        let report = build_report(&["a".to_string(), "b".to_string()], &["a".to_string()]).unwrap();
315        // 1 out of 2 = 50.0%
316        assert!(report.meets_threshold(50.0));
317        assert!(!report.meets_threshold(50.1));
318    }
319
320    #[test]
321    fn summary_includes_all_sections() {
322        let report = build_report(
323            &[
324                "cmd_a".to_string(),
325                "cmd_b".to_string(),
326                "cmd_c".to_string(),
327            ],
328            &["cmd_a".to_string(), "cmd_a".to_string()],
329        )
330        .unwrap();
331        let summary = report.to_summary();
332        assert!(summary.contains("IPC Coverage:"));
333        assert!(summary.contains("Most called:"));
334        assert!(summary.contains("Untested"));
335        assert!(summary.contains("cmd_a"));
336        assert!(summary.contains("cmd_b"));
337        assert!(summary.contains("cmd_c"));
338    }
339
340    #[test]
341    fn extract_command_names_empty_object() {
342        let registry = serde_json::json!({});
343        let names = extract_command_names(&registry);
344        assert!(names.is_empty());
345    }
346
347    #[test]
348    fn extract_command_names_null_input() {
349        let registry = serde_json::json!(null);
350        let names = extract_command_names(&registry);
351        assert!(names.is_empty());
352    }
353
354    #[test]
355    fn extract_ipc_commands_empty_array() {
356        let log = serde_json::json!([]);
357        let cmds = extract_ipc_commands(&log);
358        assert!(cmds.is_empty());
359    }
360
361    #[test]
362    fn extract_ipc_commands_non_array() {
363        let log = serde_json::json!("not an array");
364        let cmds = extract_ipc_commands(&log);
365        assert!(cmds.is_empty());
366    }
367}