1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub struct Point {
10 pub x: u32,
12 pub y: u32,
14}
15
16impl Point {
17 #[must_use]
19 pub const fn new(x: u32, y: u32) -> Self {
20 Self { x, y }
21 }
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub struct Region {
27 pub x: u32,
29 pub y: u32,
31 pub width: u32,
33 pub height: u32,
35}
36
37impl Region {
38 #[must_use]
40 pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
41 Self {
42 x,
43 y,
44 width,
45 height,
46 }
47 }
48
49 #[must_use]
51 pub fn contains(&self, point: Point) -> bool {
52 point.x >= self.x
53 && point.x < self.x + self.width
54 && point.y >= self.y
55 && point.y < self.y + self.height
56 }
57
58 #[must_use]
60 pub fn area(&self) -> u64 {
61 u64::from(self.width) * u64::from(self.height)
62 }
63}
64
65#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
67pub struct GridConfig {
68 pub screen_width: u32,
70 pub screen_height: u32,
72 pub grid_cols: u32,
74 pub grid_rows: u32,
76}
77
78impl GridConfig {
79 #[must_use]
81 pub fn cell_width(&self) -> u32 {
82 self.screen_width / self.grid_cols
83 }
84
85 #[must_use]
87 pub fn cell_height(&self) -> u32 {
88 self.screen_height / self.grid_rows
89 }
90
91 #[must_use]
93 pub fn point_to_cell(&self, point: Point) -> (u32, u32) {
94 let col = (point.x / self.cell_width()).min(self.grid_cols - 1);
95 let row = (point.y / self.cell_height()).min(self.grid_rows - 1);
96 (col, row)
97 }
98
99 #[must_use]
101 pub fn cell_to_region(&self, col: u32, row: u32) -> Region {
102 Region::new(
103 col * self.cell_width(),
104 row * self.cell_height(),
105 self.cell_width(),
106 self.cell_height(),
107 )
108 }
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113pub struct CoverageCell {
114 pub hit_count: u64,
116 pub coverage: f32,
118}
119
120impl CoverageCell {
121 #[must_use]
123 pub fn is_covered(&self) -> bool {
124 self.hit_count > 0
125 }
126}
127
128#[derive(Debug, Clone)]
130pub struct PixelCoverageTracker {
131 config: GridConfig,
132 cells: Vec<Vec<CoverageCell>>,
133 threshold: f32,
134 total_interactions: u64,
135}
136
137impl PixelCoverageTracker {
138 #[must_use]
140 pub fn new(width: u32, height: u32, grid_cols: u32, grid_rows: u32) -> Self {
141 let config = GridConfig {
142 screen_width: width,
143 screen_height: height,
144 grid_cols,
145 grid_rows,
146 };
147
148 let cells = (0..grid_rows)
149 .map(|_| (0..grid_cols).map(|_| CoverageCell::default()).collect())
150 .collect();
151
152 Self {
153 config,
154 cells,
155 threshold: 0.8,
156 total_interactions: 0,
157 }
158 }
159
160 #[must_use]
162 pub fn builder() -> PixelCoverageTrackerBuilder {
163 PixelCoverageTrackerBuilder::default()
164 }
165
166 #[must_use]
168 pub fn resolution(&self) -> (u32, u32) {
169 (self.config.screen_width, self.config.screen_height)
170 }
171
172 #[must_use]
174 pub fn grid_size(&self) -> (u32, u32) {
175 (self.config.grid_cols, self.config.grid_rows)
176 }
177
178 #[must_use]
180 pub fn threshold(&self) -> f32 {
181 self.threshold
182 }
183
184 #[must_use]
186 pub fn grid_config(&self) -> &GridConfig {
187 &self.config
188 }
189
190 pub fn record_interaction(&mut self, point: Point) {
192 let (col, row) = self.config.point_to_cell(point);
193 if let Some(row_cells) = self.cells.get_mut(row as usize) {
194 if let Some(cell) = row_cells.get_mut(col as usize) {
195 cell.hit_count += 1;
196 cell.coverage = 1.0;
197 self.total_interactions += 1;
198 }
199 }
200 }
201
202 pub fn record_region(&mut self, region: Region) {
204 let start_col = region.x / self.config.cell_width();
205 let start_row = region.y / self.config.cell_height();
206 let end_col =
207 ((region.x + region.width) / self.config.cell_width()).min(self.config.grid_cols - 1);
208 let end_row =
209 ((region.y + region.height) / self.config.cell_height()).min(self.config.grid_rows - 1);
210
211 for row in start_row..=end_row {
212 for col in start_col..=end_col {
213 if let Some(row_cells) = self.cells.get_mut(row as usize) {
214 if let Some(cell) = row_cells.get_mut(col as usize) {
215 cell.hit_count += 1;
216 cell.coverage = 1.0;
217 }
218 }
219 }
220 }
221 self.total_interactions += 1;
222 }
223
224 pub fn record_element(&mut self, _id: &str, bounds: Region) {
226 self.record_region(bounds);
227 }
228
229 #[must_use]
231 pub fn generate_report(&self) -> PixelCoverageReport {
232 let total_cells = self.config.grid_cols * self.config.grid_rows;
233 let covered_cells = self
234 .cells
235 .iter()
236 .flat_map(|row| row.iter())
237 .filter(|cell| cell.is_covered())
238 .count() as u32;
239
240 let overall_coverage = if total_cells > 0 {
241 covered_cells as f32 / total_cells as f32
242 } else {
243 0.0
244 };
245
246 let min_coverage = self
247 .cells
248 .iter()
249 .flat_map(|row| row.iter())
250 .map(|c| c.coverage)
251 .fold(f32::MAX, f32::min);
252
253 let max_coverage = self
254 .cells
255 .iter()
256 .flat_map(|row| row.iter())
257 .map(|c| c.coverage)
258 .fold(0.0_f32, f32::max);
259
260 PixelCoverageReport {
261 grid_width: self.config.grid_cols,
262 grid_height: self.config.grid_rows,
263 overall_coverage,
264 covered_cells,
265 total_cells,
266 min_coverage: if !min_coverage.is_finite() || min_coverage > 1.0 {
267 0.0
268 } else {
269 min_coverage
270 },
271 max_coverage,
272 total_interactions: self.total_interactions,
273 meets_threshold: overall_coverage >= self.threshold,
274 uncovered_regions: self.find_uncovered_regions(),
275 }
276 }
277
278 #[must_use]
280 pub fn uncovered_regions(&self) -> Vec<Region> {
281 self.find_uncovered_regions()
282 }
283
284 fn find_uncovered_regions(&self) -> Vec<Region> {
286 let mut regions = Vec::new();
287
288 for (row_idx, row) in self.cells.iter().enumerate() {
289 for (col_idx, cell) in row.iter().enumerate() {
290 if !cell.is_covered() {
291 let region = self.config.cell_to_region(col_idx as u32, row_idx as u32);
293 regions.push(region);
294 }
295 }
296 }
297
298 regions
299 }
300
301 #[must_use]
303 pub fn cells(&self) -> &Vec<Vec<CoverageCell>> {
304 &self.cells
305 }
306
307 #[must_use]
309 pub fn terminal_heatmap(&self) -> super::heatmap::TerminalHeatmap {
310 super::heatmap::TerminalHeatmap::from_tracker(self)
311 }
312
313 #[must_use]
315 pub fn png_heatmap(&self, width: u32, height: u32) -> super::heatmap::PngHeatmap {
316 super::heatmap::PngHeatmap::new(width, height)
317 }
318
319 #[cfg(feature = "media")]
321 pub fn export_png(&self, width: u32, height: u32) -> Result<Vec<u8>, std::io::Error> {
322 self.png_heatmap(width, height).export(&self.cells)
323 }
324
325 #[cfg(feature = "media")]
327 pub fn export_png_to_file(
328 &self,
329 width: u32,
330 height: u32,
331 path: &std::path::Path,
332 ) -> Result<(), std::io::Error> {
333 self.png_heatmap(width, height)
334 .export_to_file(&self.cells, path)
335 }
336}
337
338#[derive(Debug, Clone)]
340pub struct PixelCoverageTrackerBuilder {
341 width: u32,
342 height: u32,
343 grid_cols: u32,
344 grid_rows: u32,
345 threshold: f32,
346}
347
348impl Default for PixelCoverageTrackerBuilder {
349 fn default() -> Self {
350 Self {
351 width: 1920,
352 height: 1080,
353 grid_cols: 64,
354 grid_rows: 36,
355 threshold: 0.8,
356 }
357 }
358}
359
360impl PixelCoverageTrackerBuilder {
361 #[must_use]
363 pub fn resolution(mut self, width: u32, height: u32) -> Self {
364 self.width = width;
365 self.height = height;
366 self
367 }
368
369 #[must_use]
371 pub fn grid_size(mut self, cols: u32, rows: u32) -> Self {
372 self.grid_cols = cols;
373 self.grid_rows = rows;
374 self
375 }
376
377 #[must_use]
379 pub fn threshold(mut self, threshold: f32) -> Self {
380 self.threshold = threshold;
381 self
382 }
383
384 #[must_use]
386 pub fn build(self) -> PixelCoverageTracker {
387 let mut tracker =
388 PixelCoverageTracker::new(self.width, self.height, self.grid_cols, self.grid_rows);
389 tracker.threshold = self.threshold;
390 tracker
391 }
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct PixelCoverageReport {
397 pub grid_width: u32,
399 pub grid_height: u32,
401 pub overall_coverage: f32,
403 pub covered_cells: u32,
405 pub total_cells: u32,
407 pub min_coverage: f32,
409 pub max_coverage: f32,
411 pub total_interactions: u64,
413 pub meets_threshold: bool,
415 pub uncovered_regions: Vec<Region>,
417}
418
419impl Default for PixelCoverageReport {
420 fn default() -> Self {
421 Self {
422 grid_width: 0,
423 grid_height: 0,
424 overall_coverage: 0.0,
425 covered_cells: 0,
426 total_cells: 0,
427 min_coverage: 0.0,
428 max_coverage: 0.0,
429 total_interactions: 0,
430 meets_threshold: false,
431 uncovered_regions: Vec::new(),
432 }
433 }
434}
435
436impl PixelCoverageReport {
437 #[must_use]
439 pub fn percent(&self) -> f32 {
440 self.overall_coverage * 100.0
441 }
442
443 #[must_use]
445 pub fn is_complete(&self) -> bool {
446 self.covered_cells == self.total_cells
447 }
448}
449
450#[derive(Debug, Clone, Default, Serialize, Deserialize)]
452pub struct LineCoverageReport {
453 pub element_coverage: f32,
455 pub screen_coverage: f32,
457 pub journey_coverage: f32,
459 pub total_elements: usize,
461 pub covered_elements: usize,
463}
464
465impl LineCoverageReport {
466 #[must_use]
468 pub fn new(
469 element_coverage: f32,
470 screen_coverage: f32,
471 journey_coverage: f32,
472 total_elements: usize,
473 covered_elements: usize,
474 ) -> Self {
475 Self {
476 element_coverage,
477 screen_coverage,
478 journey_coverage,
479 total_elements,
480 covered_elements,
481 }
482 }
483
484 #[must_use]
486 pub fn average(&self) -> f32 {
487 (self.element_coverage + self.screen_coverage + self.journey_coverage) / 3.0
488 }
489}
490
491#[derive(Debug, Clone, Default, Serialize, Deserialize)]
493pub struct CombinedCoverageReport {
494 pub line_coverage: LineCoverageReport,
496 pub pixel_coverage: PixelCoverageReport,
498 pub overall_score: f32,
500 pub meets_threshold: bool,
502 pub line_weight: f32,
504 pub pixel_weight: f32,
506}
507
508impl CombinedCoverageReport {
509 pub const DEFAULT_LINE_WEIGHT: f32 = 0.5;
511 pub const DEFAULT_PIXEL_WEIGHT: f32 = 0.5;
513 pub const DEFAULT_THRESHOLD: f32 = 0.8;
515
516 #[must_use]
518 pub fn from_parts(line: LineCoverageReport, pixel: PixelCoverageReport) -> Self {
519 Self::from_parts_weighted(
520 line,
521 pixel,
522 Self::DEFAULT_LINE_WEIGHT,
523 Self::DEFAULT_PIXEL_WEIGHT,
524 )
525 }
526
527 #[must_use]
529 pub fn from_parts_weighted(
530 line: LineCoverageReport,
531 pixel: PixelCoverageReport,
532 line_weight: f32,
533 pixel_weight: f32,
534 ) -> Self {
535 let line_score = line.element_coverage;
536 let pixel_score = pixel.overall_coverage;
537 let overall_score = line_score * line_weight + pixel_score * pixel_weight;
538
539 Self {
540 line_coverage: line,
541 pixel_coverage: pixel,
542 overall_score,
543 meets_threshold: overall_score >= Self::DEFAULT_THRESHOLD,
544 line_weight,
545 pixel_weight,
546 }
547 }
548
549 #[must_use]
551 pub fn with_threshold(mut self, threshold: f32) -> Self {
552 self.meets_threshold = self.overall_score >= threshold;
553 self
554 }
555
556 #[must_use]
558 pub fn line_percent(&self) -> f32 {
559 self.line_coverage.element_coverage * 100.0
560 }
561
562 #[must_use]
564 pub fn pixel_percent(&self) -> f32 {
565 self.pixel_coverage.overall_coverage * 100.0
566 }
567
568 #[must_use]
570 pub fn overall_percent(&self) -> f32 {
571 self.overall_score * 100.0
572 }
573
574 #[must_use]
576 pub fn summary(&self) -> String {
577 format!(
578 "Combined Coverage Report\n\
579 ========================\n\
580 Line Coverage: {:.1}% ({}/{} elements)\n\
581 Pixel Coverage: {:.1}% ({}/{} cells)\n\
582 Overall Score: {:.1}%\n\
583 Threshold Met: {}\n",
584 self.line_percent(),
585 self.line_coverage.covered_elements,
586 self.line_coverage.total_elements,
587 self.pixel_percent(),
588 self.pixel_coverage.covered_cells,
589 self.pixel_coverage.total_cells,
590 self.overall_percent(),
591 if self.meets_threshold { "✓" } else { "✗" }
592 )
593 }
594}
595
596#[cfg(test)]
597#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
598mod tests {
599 use super::*;
600
601 #[test]
602 fn test_grid_config_cell_dimensions() {
603 let config = GridConfig {
604 screen_width: 1920,
605 screen_height: 1080,
606 grid_cols: 64,
607 grid_rows: 36,
608 };
609
610 assert_eq!(config.cell_width(), 30);
611 assert_eq!(config.cell_height(), 30);
612 }
613
614 #[test]
615 fn test_grid_config_point_to_cell() {
616 let config = GridConfig {
617 screen_width: 100,
618 screen_height: 100,
619 grid_cols: 10,
620 grid_rows: 10,
621 };
622
623 assert_eq!(config.point_to_cell(Point::new(0, 0)), (0, 0));
624 assert_eq!(config.point_to_cell(Point::new(15, 25)), (1, 2));
625 assert_eq!(config.point_to_cell(Point::new(99, 99)), (9, 9));
626 }
627
628 #[test]
629 fn test_coverage_cell_default() {
630 let cell = CoverageCell::default();
631 assert_eq!(cell.hit_count, 0);
632 assert!(!cell.is_covered());
633 }
634
635 #[test]
636 fn test_builder_defaults() {
637 let builder = PixelCoverageTrackerBuilder::default();
638 assert_eq!(builder.width, 1920);
639 assert_eq!(builder.height, 1080);
640 assert_eq!(builder.grid_cols, 64);
641 assert_eq!(builder.grid_rows, 36);
642 }
643
644 #[test]
645 fn test_report_percent() {
646 let report = PixelCoverageReport {
647 grid_width: 10,
648 grid_height: 10,
649 overall_coverage: 0.75,
650 covered_cells: 75,
651 total_cells: 100,
652 min_coverage: 0.0,
653 max_coverage: 1.0,
654 total_interactions: 100,
655 meets_threshold: false,
656 uncovered_regions: vec![],
657 };
658
659 assert!((report.percent() - 75.0).abs() < f32::EPSILON);
660 }
661
662 #[test]
663 fn test_report_is_complete() {
664 let complete = PixelCoverageReport {
665 grid_width: 10,
666 grid_height: 10,
667 overall_coverage: 1.0,
668 covered_cells: 100,
669 total_cells: 100,
670 min_coverage: 1.0,
671 max_coverage: 1.0,
672 total_interactions: 100,
673 meets_threshold: true,
674 uncovered_regions: vec![],
675 };
676
677 assert!(complete.is_complete());
678
679 let incomplete = PixelCoverageReport {
680 covered_cells: 99,
681 total_cells: 100,
682 ..complete
683 };
684
685 assert!(!incomplete.is_complete());
686 }
687
688 #[test]
689 fn test_region_area() {
690 let region = Region::new(0, 0, 100, 50);
691 assert_eq!(region.area(), 5000);
692 }
693
694 #[test]
699 fn h0_combined_01_from_parts() {
700 let line_report = LineCoverageReport::new(0.90, 1.0, 0.80, 22, 20);
701 let pixel_report = PixelCoverageReport {
702 overall_coverage: 0.85,
703 ..Default::default()
704 };
705
706 let combined = CombinedCoverageReport::from_parts(line_report, pixel_report);
707
708 assert!((combined.overall_score - 0.875).abs() < 0.01);
710 assert!(combined.meets_threshold);
711 }
712
713 #[test]
714 fn h0_combined_02_custom_weights() {
715 let line_report = LineCoverageReport::new(1.0, 1.0, 1.0, 10, 10);
716 let pixel_report = PixelCoverageReport {
717 overall_coverage: 0.0,
718 ..Default::default()
719 };
720
721 let combined =
723 CombinedCoverageReport::from_parts_weighted(line_report, pixel_report, 1.0, 0.0);
724
725 assert!((combined.overall_score - 1.0).abs() < 0.01);
726 }
727
728 #[test]
729 fn h0_combined_03_threshold() {
730 let line_report = LineCoverageReport::new(0.5, 0.5, 0.5, 10, 5);
731 let pixel_report = PixelCoverageReport {
732 overall_coverage: 0.5,
733 ..Default::default()
734 };
735
736 let combined = CombinedCoverageReport::from_parts(line_report, pixel_report);
737
738 assert!(!combined.meets_threshold); assert!((combined.overall_score - 0.5).abs() < 0.01);
740
741 let relaxed = combined.with_threshold(0.4);
742 assert!(relaxed.meets_threshold);
743 }
744
745 #[test]
746 fn h0_combined_04_summary() {
747 let line_report = LineCoverageReport::new(0.90, 1.0, 0.80, 22, 20);
748 let pixel_report = PixelCoverageReport {
749 overall_coverage: 0.85,
750 covered_cells: 85,
751 total_cells: 100,
752 ..Default::default()
753 };
754
755 let combined = CombinedCoverageReport::from_parts(line_report, pixel_report);
756 let summary = combined.summary();
757
758 assert!(summary.contains("Line Coverage"));
759 assert!(summary.contains("Pixel Coverage"));
760 assert!(summary.contains("Overall Score"));
761 assert!(summary.contains("✓"));
762 }
763
764 #[test]
765 fn h0_combined_05_line_report_average() {
766 let report = LineCoverageReport::new(0.9, 0.6, 0.9, 10, 8);
767 assert!((report.average() - 0.8).abs() < 0.01);
768 }
769
770 #[test]
771 fn h0_combined_06_percentages() {
772 let line_report = LineCoverageReport::new(0.90, 1.0, 0.80, 22, 20);
773 let pixel_report = PixelCoverageReport {
774 overall_coverage: 0.85,
775 ..Default::default()
776 };
777
778 let combined = CombinedCoverageReport::from_parts(line_report, pixel_report);
779
780 assert!((combined.line_percent() - 90.0).abs() < 0.01);
781 assert!((combined.pixel_percent() - 85.0).abs() < 0.01);
782 assert!((combined.overall_percent() - 87.5).abs() < 0.01);
783 }
784
785 #[test]
786 fn h0_combined_07_default() {
787 let combined = CombinedCoverageReport::default();
788 assert_eq!(combined.overall_score, 0.0);
789 assert!(!combined.meets_threshold);
790 }
791
792 #[test]
797 fn h0_tracker_png_export() {
798 let mut tracker = PixelCoverageTracker::new(100, 100, 10, 10);
799 tracker.record_region(Region::new(0, 0, 50, 50));
800
801 let png = tracker.export_png(200, 200).unwrap();
802 assert!(!png.is_empty());
803 assert_eq!(&png[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
805 }
806
807 #[test]
808 fn h0_tracker_png_heatmap() {
809 let tracker = PixelCoverageTracker::new(100, 100, 10, 10);
810 let heatmap = tracker.png_heatmap(200, 200);
811 let png = heatmap.export(tracker.cells()).unwrap();
812 assert!(!png.is_empty());
813 }
814}