Skip to main content
Glama
snapshot_compare.rs25.9 kB
//! Snapshot comparison engine for regression testing. //! //! Compares Terminal State Tree snapshots to detect changes in element detection. use std::collections::HashMap; use serde::{Deserialize, Serialize}; use terminal_mcp_core::{Bounds, Element}; use crate::detection::DetectedElement; /// Describes a change to a single element. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum ElementChange { /// Element was added (not in expected) Added, /// Element was removed (was in expected) Removed, /// Element type changed TypeChanged { /// Expected element type expected: String, /// Actual element type actual: String, }, /// Element bounds changed BoundsChanged { /// Expected bounds expected: Bounds, /// Actual bounds actual: Bounds, }, /// Element content changed (label, items, etc.) ContentChanged { /// Field name that changed field: String, /// Expected field value expected: String, /// Actual field value actual: String, }, /// Element confidence level changed ConfidenceChanged { /// Expected confidence level expected: String, /// Actual confidence level actual: String, }, } /// Difference for a single element. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ElementDiff { /// Reference ID of the element (if available) pub ref_id: Option<String>, /// Element type pub element_type: String, /// Location of the element pub bounds: Option<Bounds>, /// List of changes pub changes: Vec<ElementChange>, } /// Result of comparing two snapshots. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SnapshotDiff { /// Elements that were added (in actual but not expected) pub added: Vec<ElementDiff>, /// Elements that were removed (in expected but not actual) pub removed: Vec<ElementDiff>, /// Elements that were modified pub modified: Vec<ElementDiff>, /// Summary statistics pub stats: DiffStats, } /// Summary statistics for a snapshot diff. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct DiffStats { /// Number of elements in expected snapshot pub expected_count: usize, /// Number of elements in actual snapshot pub actual_count: usize, /// Number of elements added pub added_count: usize, /// Number of elements removed pub removed_count: usize, /// Number of elements modified pub modified_count: usize, /// Number of elements unchanged pub unchanged_count: usize, } impl SnapshotDiff { /// Check if snapshots are identical. pub fn is_match(&self) -> bool { self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty() } /// Get total number of differences. pub fn diff_count(&self) -> usize { self.added.len() + self.removed.len() + self.modified.len() } /// Generate a human-readable summary. pub fn summary(&self) -> String { if self.is_match() { return "Snapshots match".to_string(); } let mut parts = Vec::new(); if !self.added.is_empty() { parts.push(format!("{} added", self.added.len())); } if !self.removed.is_empty() { parts.push(format!("{} removed", self.removed.len())); } if !self.modified.is_empty() { parts.push(format!("{} modified", self.modified.len())); } format!("Differences: {}", parts.join(", ")) } /// Generate HTML report of the diff. pub fn to_html(&self) -> String { let mut html = String::new(); html.push_str("<!DOCTYPE html>\n<html>\n<head>\n"); html.push_str("<title>Snapshot Diff Report</title>\n"); html.push_str("<style>\n"); html.push_str("body { font-family: monospace; margin: 20px; }\n"); html.push_str(".added { background-color: #d4edda; }\n"); html.push_str(".removed { background-color: #f8d7da; }\n"); html.push_str(".modified { background-color: #fff3cd; }\n"); html.push_str(".section { margin: 20px 0; padding: 10px; border: 1px solid #ddd; }\n"); html.push_str("h2 { margin-top: 0; }\n"); html.push_str("table { border-collapse: collapse; width: 100%; }\n"); html.push_str("th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }\n"); html.push_str("th { background-color: #f5f5f5; }\n"); html.push_str("</style>\n</head>\n<body>\n"); html.push_str("<h1>Snapshot Diff Report</h1>\n"); html.push_str(&format!( "<p><strong>Status:</strong> {}</p>\n", self.summary() )); // Stats html.push_str("<div class=\"section\">\n"); html.push_str("<h2>Statistics</h2>\n"); html.push_str("<table>\n"); html.push_str(&format!( "<tr><td>Expected elements</td><td>{}</td></tr>\n", self.stats.expected_count )); html.push_str(&format!( "<tr><td>Actual elements</td><td>{}</td></tr>\n", self.stats.actual_count )); html.push_str(&format!( "<tr><td>Added</td><td>{}</td></tr>\n", self.stats.added_count )); html.push_str(&format!( "<tr><td>Removed</td><td>{}</td></tr>\n", self.stats.removed_count )); html.push_str(&format!( "<tr><td>Modified</td><td>{}</td></tr>\n", self.stats.modified_count )); html.push_str(&format!( "<tr><td>Unchanged</td><td>{}</td></tr>\n", self.stats.unchanged_count )); html.push_str("</table>\n</div>\n"); // Added elements if !self.added.is_empty() { html.push_str("<div class=\"section added\">\n"); html.push_str("<h2>Added Elements</h2>\n"); html.push_str(&Self::elements_table(&self.added)); html.push_str("</div>\n"); } // Removed elements if !self.removed.is_empty() { html.push_str("<div class=\"section removed\">\n"); html.push_str("<h2>Removed Elements</h2>\n"); html.push_str(&Self::elements_table(&self.removed)); html.push_str("</div>\n"); } // Modified elements if !self.modified.is_empty() { html.push_str("<div class=\"section modified\">\n"); html.push_str("<h2>Modified Elements</h2>\n"); for diff in &self.modified { html.push_str(&format!( "<h3>{} ({})</h3>\n", diff.element_type, diff.ref_id.as_deref().unwrap_or("unknown") )); html.push_str("<ul>\n"); for change in &diff.changes { html.push_str(&format!("<li>{:?}</li>\n", change)); } html.push_str("</ul>\n"); } html.push_str("</div>\n"); } html.push_str("</body>\n</html>\n"); html } fn elements_table(elements: &[ElementDiff]) -> String { let mut html = String::new(); html.push_str("<table>\n"); html.push_str("<tr><th>Type</th><th>Ref ID</th><th>Bounds</th></tr>\n"); for elem in elements { let bounds_str = elem .bounds .as_ref() .map(|b| format!("({},{}) {}x{}", b.row, b.col, b.width, b.height)) .unwrap_or_else(|| "N/A".to_string()); html.push_str(&format!( "<tr><td>{}</td><td>{}</td><td>{}</td></tr>\n", elem.element_type, elem.ref_id.as_deref().unwrap_or("N/A"), bounds_str )); } html.push_str("</table>\n"); html } } /// Snapshot matcher for comparing detection results. #[derive(Default)] pub struct SnapshotMatcher { /// Tolerance for bounds comparison (in cells) pub bounds_tolerance: u16, /// Whether to compare confidence levels pub compare_confidence: bool, /// Whether to compare ref_ids (usually false for golden comparison) pub compare_ref_ids: bool, } impl SnapshotMatcher { /// Create a new snapshot matcher with default settings. pub fn new() -> Self { Self::default() } /// Set bounds tolerance for comparison. pub fn with_bounds_tolerance(mut self, tolerance: u16) -> Self { self.bounds_tolerance = tolerance; self } /// Enable confidence level comparison. pub fn with_confidence_comparison(mut self, enabled: bool) -> Self { self.compare_confidence = enabled; self } /// Compare two lists of detected elements. pub fn compare( &self, expected: &[DetectedElement], actual: &[DetectedElement], ) -> SnapshotDiff { let mut diff = SnapshotDiff::default(); diff.stats.expected_count = expected.len(); diff.stats.actual_count = actual.len(); // Build maps by element type and approximate position let _expected_map = self.build_element_map(expected); let actual_map = self.build_element_map(actual); // Track which elements have been matched let mut matched_actual: Vec<bool> = vec![false; actual.len()]; // Compare expected elements for exp in expected.iter() { let exp_key = self.element_key(exp); if let Some(actual_indices) = actual_map.get(&exp_key) { // Try to find a matching element let mut found_match = false; for &act_idx in actual_indices { if matched_actual[act_idx] { continue; } let act = &actual[act_idx]; let changes = self.compare_elements(exp, act); if changes.is_empty() { // Perfect match matched_actual[act_idx] = true; found_match = true; diff.stats.unchanged_count += 1; break; } else if self.is_same_element(exp, act) { // Same element but with changes matched_actual[act_idx] = true; found_match = true; diff.modified.push(ElementDiff { ref_id: self.get_ref_id(&exp.element), element_type: self.element_type_name(&exp.element), bounds: Some(exp.bounds), changes, }); diff.stats.modified_count += 1; break; } } if !found_match { // Element was removed diff.removed.push(ElementDiff { ref_id: self.get_ref_id(&exp.element), element_type: self.element_type_name(&exp.element), bounds: Some(exp.bounds), changes: vec![ElementChange::Removed], }); diff.stats.removed_count += 1; } } else { // Element type not found at all - removed diff.removed.push(ElementDiff { ref_id: self.get_ref_id(&exp.element), element_type: self.element_type_name(&exp.element), bounds: Some(exp.bounds), changes: vec![ElementChange::Removed], }); diff.stats.removed_count += 1; } } // Find added elements (in actual but not matched) for (i, act) in actual.iter().enumerate() { if !matched_actual[i] { diff.added.push(ElementDiff { ref_id: self.get_ref_id(&act.element), element_type: self.element_type_name(&act.element), bounds: Some(act.bounds), changes: vec![ElementChange::Added], }); diff.stats.added_count += 1; } } diff } /// Build a map of elements by type for faster lookup. fn build_element_map(&self, elements: &[DetectedElement]) -> HashMap<String, Vec<usize>> { let mut map: HashMap<String, Vec<usize>> = HashMap::new(); for (i, elem) in elements.iter().enumerate() { let key = self.element_key(elem); map.entry(key).or_default().push(i); } map } /// Generate a key for element matching (type name). fn element_key(&self, elem: &DetectedElement) -> String { self.element_type_name(&elem.element) } /// Get element type name as string. fn element_type_name(&self, elem: &Element) -> String { match elem { Element::Button { .. } => "button".to_string(), Element::Checkbox { .. } => "checkbox".to_string(), Element::Input { .. } => "input".to_string(), Element::Menu { .. } => "menu".to_string(), Element::ProgressBar { .. } => "progress_bar".to_string(), Element::StatusBar { .. } => "status_bar".to_string(), Element::Table { .. } => "table".to_string(), Element::Border { .. } => "border".to_string(), Element::Text { .. } => "text".to_string(), } } /// Get ref_id from element if available. fn get_ref_id(&self, elem: &Element) -> Option<String> { match elem { Element::Button { ref_id, .. } => Some(ref_id.clone()), Element::Checkbox { ref_id, .. } => Some(ref_id.clone()), Element::Input { ref_id, .. } => Some(ref_id.clone()), Element::Menu { ref_id, .. } => Some(ref_id.clone()), Element::ProgressBar { ref_id, .. } => Some(ref_id.clone()), Element::StatusBar { ref_id, .. } => Some(ref_id.clone()), Element::Table { ref_id, .. } => Some(ref_id.clone()), Element::Border { ref_id, .. } => Some(ref_id.clone()), Element::Text { ref_id, .. } => Some(ref_id.clone()), } } /// Check if two elements are the same (based on position and type). fn is_same_element(&self, a: &DetectedElement, b: &DetectedElement) -> bool { if self.element_type_name(&a.element) != self.element_type_name(&b.element) { return false; } // Check bounds with tolerance self.bounds_match(&a.bounds, &b.bounds) } /// Check if bounds match within tolerance. fn bounds_match(&self, a: &Bounds, b: &Bounds) -> bool { let row_diff = (a.row as i32 - b.row as i32).unsigned_abs() as u16; let col_diff = (a.col as i32 - b.col as i32).unsigned_abs() as u16; row_diff <= self.bounds_tolerance && col_diff <= self.bounds_tolerance } /// Compare two elements and return list of changes. fn compare_elements( &self, expected: &DetectedElement, actual: &DetectedElement, ) -> Vec<ElementChange> { let mut changes = Vec::new(); // Compare bounds if expected.bounds != actual.bounds && self.bounds_tolerance == 0 { changes.push(ElementChange::BoundsChanged { expected: expected.bounds, actual: actual.bounds, }); } // Compare element-specific content self.compare_element_content(&expected.element, &actual.element, &mut changes); // Compare confidence if enabled if self.compare_confidence && expected.confidence != actual.confidence { changes.push(ElementChange::ConfidenceChanged { expected: format!("{:?}", expected.confidence), actual: format!("{:?}", actual.confidence), }); } changes } /// Compare element-specific content. fn compare_element_content( &self, expected: &Element, actual: &Element, changes: &mut Vec<ElementChange>, ) { match (expected, actual) { ( Element::Button { label: exp_label, .. }, Element::Button { label: act_label, .. }, ) => { if exp_label != act_label { changes.push(ElementChange::ContentChanged { field: "label".to_string(), expected: exp_label.clone(), actual: act_label.clone(), }); } } ( Element::Checkbox { label: exp_label, checked: exp_checked, .. }, Element::Checkbox { label: act_label, checked: act_checked, .. }, ) => { if exp_label != act_label { changes.push(ElementChange::ContentChanged { field: "label".to_string(), expected: exp_label.clone(), actual: act_label.clone(), }); } if exp_checked != act_checked { changes.push(ElementChange::ContentChanged { field: "checked".to_string(), expected: exp_checked.to_string(), actual: act_checked.to_string(), }); } } ( Element::Input { value: exp_value, cursor_pos: exp_cursor, .. }, Element::Input { value: act_value, cursor_pos: act_cursor, .. }, ) => { if exp_value != act_value { changes.push(ElementChange::ContentChanged { field: "value".to_string(), expected: exp_value.clone(), actual: act_value.clone(), }); } if exp_cursor != act_cursor { changes.push(ElementChange::ContentChanged { field: "cursor_pos".to_string(), expected: exp_cursor.to_string(), actual: act_cursor.to_string(), }); } } ( Element::Menu { items: exp_items, selected: exp_selected, .. }, Element::Menu { items: act_items, selected: act_selected, .. }, ) => { if exp_items != act_items { changes.push(ElementChange::ContentChanged { field: "items".to_string(), expected: format!("{:?}", exp_items), actual: format!("{:?}", act_items), }); } if exp_selected != act_selected { changes.push(ElementChange::ContentChanged { field: "selected".to_string(), expected: exp_selected.to_string(), actual: act_selected.to_string(), }); } } ( Element::ProgressBar { percent: exp_pct, .. }, Element::ProgressBar { percent: act_pct, .. }, ) => { if exp_pct != act_pct { changes.push(ElementChange::ContentChanged { field: "percent".to_string(), expected: exp_pct.to_string(), actual: act_pct.to_string(), }); } } ( Element::Table { headers: exp_headers, rows: exp_rows, .. }, Element::Table { headers: act_headers, rows: act_rows, .. }, ) => { if exp_headers != act_headers { changes.push(ElementChange::ContentChanged { field: "headers".to_string(), expected: format!("{:?}", exp_headers), actual: format!("{:?}", act_headers), }); } if exp_rows.len() != act_rows.len() { changes.push(ElementChange::ContentChanged { field: "row_count".to_string(), expected: exp_rows.len().to_string(), actual: act_rows.len().to_string(), }); } } _ => { // Different element types - this shouldn't happen if is_same_element works correctly if std::mem::discriminant(expected) != std::mem::discriminant(actual) { changes.push(ElementChange::TypeChanged { expected: self.element_type_name(expected), actual: self.element_type_name(actual), }); } } } } } #[cfg(test)] mod tests { use super::*; use crate::detection::Confidence; fn create_button(label: &str, row: u16, col: u16) -> DetectedElement { DetectedElement { element: Element::Button { ref_id: format!("button_{}", label), bounds: Bounds::new(col, row, label.len() as u16 + 4, 1), label: label.to_string(), }, bounds: Bounds::new(col, row, label.len() as u16 + 4, 1), confidence: Confidence::High, } } fn create_checkbox(label: &str, checked: bool, row: u16, col: u16) -> DetectedElement { DetectedElement { element: Element::Checkbox { ref_id: format!("checkbox_{}", label), bounds: Bounds::new(col, row, label.len() as u16 + 4, 1), label: label.to_string(), checked, }, bounds: Bounds::new(col, row, label.len() as u16 + 4, 1), confidence: Confidence::High, } } #[test] fn test_identical_snapshots() { let expected = vec![create_button("OK", 5, 10), create_button("Cancel", 5, 20)]; let actual = expected.clone(); let matcher = SnapshotMatcher::new(); let diff = matcher.compare(&expected, &actual); assert!(diff.is_match()); assert_eq!(diff.stats.unchanged_count, 2); } #[test] fn test_added_element() { let expected = vec![create_button("OK", 5, 10)]; let actual = vec![create_button("OK", 5, 10), create_button("Cancel", 5, 20)]; let matcher = SnapshotMatcher::new(); let diff = matcher.compare(&expected, &actual); assert!(!diff.is_match()); assert_eq!(diff.added.len(), 1); assert_eq!(diff.added[0].element_type, "button"); } #[test] fn test_removed_element() { let expected = vec![create_button("OK", 5, 10), create_button("Cancel", 5, 20)]; let actual = vec![create_button("OK", 5, 10)]; let matcher = SnapshotMatcher::new(); let diff = matcher.compare(&expected, &actual); assert!(!diff.is_match()); assert_eq!(diff.removed.len(), 1); assert_eq!(diff.removed[0].element_type, "button"); } #[test] fn test_modified_element() { let expected = vec![create_checkbox("Option", false, 5, 10)]; let actual = vec![create_checkbox("Option", true, 5, 10)]; let matcher = SnapshotMatcher::new(); let diff = matcher.compare(&expected, &actual); assert!(!diff.is_match()); assert_eq!(diff.modified.len(), 1); assert!(diff.modified[0].changes.iter().any( |c| matches!(c, ElementChange::ContentChanged { field, .. } if field == "checked") )); } #[test] fn test_bounds_tolerance() { let expected = vec![create_button("OK", 5, 10)]; let actual = vec![create_button("OK", 6, 11)]; // Slightly different position // Without tolerance - should detect as modified let matcher = SnapshotMatcher::new(); let diff = matcher.compare(&expected, &actual); assert!(!diff.is_match()); // With tolerance - should match let matcher = SnapshotMatcher::new().with_bounds_tolerance(2); let diff = matcher.compare(&expected, &actual); assert!(diff.is_match()); } #[test] fn test_html_report() { let expected = vec![create_button("OK", 5, 10)]; let actual = vec![create_button("OK", 5, 10), create_button("Cancel", 5, 20)]; let matcher = SnapshotMatcher::new(); let diff = matcher.compare(&expected, &actual); let html = diff.to_html(); assert!(html.contains("Snapshot Diff Report")); assert!(html.contains("Added Elements")); assert!(html.contains("button")); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aybelatchane/mcp-server-terminal'

If you have feedback or need assistance with the MCP directory API, please join our Discord server