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 {
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(®istered, &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(®istered, &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(®istered, &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(®istry);
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(®istry);
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 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(®istry);
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(®istry);
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}