1use serde_json::Value;
8
9use crate::client::VictauriClient;
10use crate::error::TestError;
11
12#[derive(Debug)]
14pub struct CoverageReport {
15 pub total_commands: usize,
17 pub tested_commands: usize,
19 pub coverage_percentage: f64,
21 pub untested: Vec<String>,
23 pub most_called: Vec<CommandCalls>,
25}
26
27#[derive(Debug)]
29pub struct CommandCalls {
30 pub name: String,
32 pub calls: usize,
34}
35
36impl CoverageReport {
37 #[must_use]
39 pub fn meets_threshold(&self, threshold_percent: f64) -> bool {
40 self.coverage_percentage >= threshold_percent
41 }
42
43 #[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
73pub 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(®istry);
86 let called: Vec<String> = extract_ipc_commands(&ipc_log);
87
88 build_report(®istered, &called)
89}
90
91pub 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 {
182 0.0
183 } else {
184 (tested as f64 / total_commands as f64) * 100.0
185 };
186
187 Ok(CoverageReport {
188 total_commands,
189 tested_commands: tested,
190 coverage_percentage,
191 untested,
192 most_called,
193 })
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn build_report_full_coverage() {
202 let registered = vec!["cmd_a".to_string(), "cmd_b".to_string()];
203 let called = vec![
204 "cmd_a".to_string(),
205 "cmd_b".to_string(),
206 "cmd_a".to_string(),
207 ];
208
209 let report = build_report(®istered, &called).unwrap();
210 assert_eq!(report.total_commands, 2);
211 assert_eq!(report.tested_commands, 2);
212 assert_eq!(report.coverage_percentage, 100.0);
213 assert!(report.untested.is_empty());
214 assert_eq!(report.most_called[0].name, "cmd_a");
215 assert_eq!(report.most_called[0].calls, 2);
216 }
217
218 #[test]
219 fn build_report_partial_coverage() {
220 let registered = vec![
221 "cmd_a".to_string(),
222 "cmd_b".to_string(),
223 "cmd_c".to_string(),
224 ];
225 let called = vec!["cmd_a".to_string()];
226
227 let report = build_report(®istered, &called).unwrap();
228 assert_eq!(report.tested_commands, 1);
229 assert!((report.coverage_percentage - 33.333).abs() < 0.01);
230 assert_eq!(report.untested.len(), 2);
231 assert!(report.untested.contains(&"cmd_b".to_string()));
232 assert!(report.untested.contains(&"cmd_c".to_string()));
233 }
234
235 #[test]
236 fn build_report_no_commands() {
237 let report = build_report(&[], &[]).unwrap();
239 assert_eq!(report.coverage_percentage, 0.0);
240 assert_eq!(report.total_commands, 0);
241 }
242
243 #[test]
244 fn build_report_strips_plugin_prefix() {
245 let registered = vec!["save_data".to_string()];
246 let called = vec!["plugin:myapp|save_data".to_string()];
247
248 let report = build_report(®istered, &called).unwrap();
249 assert_eq!(report.tested_commands, 1);
250 assert_eq!(report.coverage_percentage, 100.0);
251 }
252
253 #[test]
254 fn meets_threshold_boundary() {
255 let report = CoverageReport {
256 total_commands: 10,
257 tested_commands: 8,
258 coverage_percentage: 80.0,
259 untested: vec!["a".to_string(), "b".to_string()],
260 most_called: vec![],
261 };
262 assert!(report.meets_threshold(80.0));
263 assert!(!report.meets_threshold(80.1));
264 }
265
266 #[test]
267 fn summary_formatting() {
268 let report = CoverageReport {
269 total_commands: 3,
270 tested_commands: 1,
271 coverage_percentage: 33.3,
272 untested: vec!["cmd_b".to_string(), "cmd_c".to_string()],
273 most_called: vec![CommandCalls {
274 name: "cmd_a".to_string(),
275 calls: 5,
276 }],
277 };
278 let summary = report.to_summary();
279
280 assert!(summary.contains("33.3%"));
281 assert!(summary.contains("1/3"));
282 assert!(summary.contains("cmd_a"));
283 assert!(summary.contains("cmd_b"));
284 assert!(summary.contains("Untested (2)"));
285 }
286
287 #[test]
288 fn extract_command_names_from_array() {
289 let registry = serde_json::json!([
290 {"name": "cmd_a", "description": "A"},
291 {"name": "cmd_b", "description": "B"}
292 ]);
293 let names = extract_command_names(®istry);
294 assert_eq!(names, vec!["cmd_a", "cmd_b"]);
295 }
296
297 #[test]
298 fn extract_command_names_from_commands_field() {
299 let registry = serde_json::json!({
300 "commands": [
301 {"name": "cmd_x"},
302 {"name": "cmd_y"}
303 ]
304 });
305 let names = extract_command_names(®istry);
306 assert_eq!(names, vec!["cmd_x", "cmd_y"]);
307 }
308
309 #[test]
310 fn extract_ipc_commands_from_log() {
311 let log = serde_json::json!([
312 {"command": "greet", "status": "ok"},
313 {"command": "save", "status": "ok"}
314 ]);
315 let cmds = extract_ipc_commands(&log);
316 assert_eq!(cmds, vec!["greet", "save"]);
317 }
318
319 #[test]
320 fn meets_threshold_exact_boundary() {
321 let report = build_report(&["a".to_string(), "b".to_string()], &["a".to_string()]).unwrap();
322 assert!(report.meets_threshold(50.0));
324 assert!(!report.meets_threshold(50.1));
325 }
326
327 #[test]
328 fn summary_includes_all_sections() {
329 let report = build_report(
330 &[
331 "cmd_a".to_string(),
332 "cmd_b".to_string(),
333 "cmd_c".to_string(),
334 ],
335 &["cmd_a".to_string(), "cmd_a".to_string()],
336 )
337 .unwrap();
338 let summary = report.to_summary();
339 assert!(summary.contains("IPC Coverage:"));
340 assert!(summary.contains("Most called:"));
341 assert!(summary.contains("Untested"));
342 assert!(summary.contains("cmd_a"));
343 assert!(summary.contains("cmd_b"));
344 assert!(summary.contains("cmd_c"));
345 }
346
347 #[test]
348 fn extract_command_names_empty_object() {
349 let registry = serde_json::json!({});
350 let names = extract_command_names(®istry);
351 assert!(names.is_empty());
352 }
353
354 #[test]
355 fn extract_command_names_null_input() {
356 let registry = serde_json::json!(null);
357 let names = extract_command_names(®istry);
358 assert!(names.is_empty());
359 }
360
361 #[test]
362 fn extract_ipc_commands_empty_array() {
363 let log = serde_json::json!([]);
364 let cmds = extract_ipc_commands(&log);
365 assert!(cmds.is_empty());
366 }
367
368 #[test]
369 fn extract_ipc_commands_non_array() {
370 let log = serde_json::json!("not an array");
371 let cmds = extract_ipc_commands(&log);
372 assert!(cmds.is_empty());
373 }
374}