Skip to main content

car_browser/perception/
diff.rs

1//! UI state change detection between UiMap snapshots.
2//!
3//! Uses `ax_ref` (the stable AX node ID) for element identity, not the
4//! positional `el_N` IDs which shift when elements are added/removed.
5
6use std::collections::HashMap;
7use super::ui_map::{UiElement, UiMap};
8
9/// Differences between two UiMap snapshots.
10#[derive(Debug, Clone)]
11pub struct UiMapDiff {
12    pub added_elements: Vec<String>,
13    pub removed_elements: Vec<String>,
14    pub changed_elements: Vec<ElementChange>,
15    pub url_changed: bool,
16    pub scroll_changed: bool,
17    pub signals_changed: bool,
18}
19
20#[derive(Debug, Clone)]
21pub struct ElementChange {
22    pub element_id: String,
23    pub field: ChangeField,
24    pub old_value: String,
25    pub new_value: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ChangeField {
30    Name,
31    Value,
32    Focused,
33    Enabled,
34    Checked,
35}
36
37impl UiMapDiff {
38    /// Compute diff between two UiMap snapshots.
39    ///
40    /// Uses `ax_ref` as stable identity when available, falls back to
41    /// `el_N` ID for elements without AX refs.
42    pub fn compute(before: &UiMap, after: &UiMap) -> Self {
43        // Build identity maps: stable_id → element
44        let before_map = Self::build_identity_map(&before.elements);
45        let after_map = Self::build_identity_map(&after.elements);
46
47        let added: Vec<String> = after_map
48            .keys()
49            .filter(|k| !before_map.contains_key(*k))
50            .cloned()
51            .collect();
52
53        let removed: Vec<String> = before_map
54            .keys()
55            .filter(|k| !after_map.contains_key(*k))
56            .cloned()
57            .collect();
58
59        let mut changed = Vec::new();
60        for (id, after_el) in &after_map {
61            if let Some(before_el) = before_map.get(id) {
62                Self::detect_changes(id, before_el, after_el, &mut changed);
63            }
64        }
65
66        let scroll_threshold = 0.05;
67        let scroll_changed = (before.page_signals.scroll_position
68            - after.page_signals.scroll_position)
69            .abs()
70            > scroll_threshold;
71
72        Self {
73            added_elements: added,
74            removed_elements: removed,
75            changed_elements: changed,
76            url_changed: before.url != after.url,
77            scroll_changed,
78            signals_changed: before.page_signals.to_hash_string()
79                != after.page_signals.to_hash_string(),
80        }
81    }
82
83    /// Build a map from stable identity → element reference.
84    /// Uses ax_ref if available, falls back to el_N ID.
85    fn build_identity_map<'a>(elements: &'a [UiElement]) -> HashMap<String, &'a UiElement> {
86        elements
87            .iter()
88            .map(|e| {
89                let key = e
90                    .ax_ref
91                    .as_deref()
92                    .unwrap_or(&e.id)
93                    .to_string();
94                (key, e)
95            })
96            .collect()
97    }
98
99    fn detect_changes(
100        id: &str,
101        before: &UiElement,
102        after: &UiElement,
103        changes: &mut Vec<ElementChange>,
104    ) {
105        if before.name != after.name {
106            changes.push(ElementChange {
107                element_id: id.to_string(),
108                field: ChangeField::Name,
109                old_value: before.name.clone().unwrap_or_default(),
110                new_value: after.name.clone().unwrap_or_default(),
111            });
112        }
113        if before.value != after.value {
114            changes.push(ElementChange {
115                element_id: id.to_string(),
116                field: ChangeField::Value,
117                old_value: before.value.clone().unwrap_or_default(),
118                new_value: after.value.clone().unwrap_or_default(),
119            });
120        }
121        if before.states.focused != after.states.focused {
122            changes.push(ElementChange {
123                element_id: id.to_string(),
124                field: ChangeField::Focused,
125                old_value: before.states.focused.to_string(),
126                new_value: after.states.focused.to_string(),
127            });
128        }
129        if before.states.enabled != after.states.enabled {
130            changes.push(ElementChange {
131                element_id: id.to_string(),
132                field: ChangeField::Enabled,
133                old_value: before.states.enabled.to_string(),
134                new_value: after.states.enabled.to_string(),
135            });
136        }
137    }
138
139    /// Check if anything changed.
140    pub fn has_changes(&self) -> bool {
141        !self.added_elements.is_empty()
142            || !self.removed_elements.is_empty()
143            || !self.changed_elements.is_empty()
144            || self.url_changed
145            || self.scroll_changed
146            || self.signals_changed
147    }
148
149    /// Human-readable summary.
150    pub fn summary(&self) -> String {
151        let mut parts = Vec::new();
152        if self.url_changed {
153            parts.push("URL changed".to_string());
154        }
155        if !self.added_elements.is_empty() {
156            parts.push(format!("{} elements added", self.added_elements.len()));
157        }
158        if !self.removed_elements.is_empty() {
159            parts.push(format!("{} elements removed", self.removed_elements.len()));
160        }
161        if !self.changed_elements.is_empty() {
162            parts.push(format!("{} elements changed", self.changed_elements.len()));
163        }
164        if self.scroll_changed {
165            parts.push("scroll position changed".to_string());
166        }
167        if parts.is_empty() {
168            "No changes".to_string()
169        } else {
170            parts.join(", ")
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::models::{Bounds, Viewport};
179    use crate::perception::ui_map::*;
180
181    fn make_element(id: &str, ax_ref: &str, name: &str, enabled: bool) -> UiElement {
182        UiElement {
183            id: id.to_string(),
184            role: UiRole::Button,
185            name: Some(name.to_string()),
186            value: None,
187            bounds: Bounds::new(0.0, 0.0, 80.0, 30.0),
188            states: if enabled { UiState::enabled() } else { UiState::disabled() },
189            confidence: 0.95,
190            source: ElementSource::AccessibilityTree,
191            icon_type: None,
192            children: vec![],
193            ax_ref: Some(ax_ref.to_string()),
194        }
195    }
196
197    fn make_ui_map(elements: Vec<UiElement>) -> UiMap {
198        let vp = Viewport { width: 1280, height: 720, device_pixel_ratio: 2.0 };
199        UiMap::new("https://example.com".into(), elements, vec![], PageSignals::default(), vp, String::new())
200    }
201
202    #[test]
203    fn test_identical_maps() {
204        let map = make_ui_map(vec![make_element("el_0", "ax_1", "Submit", true)]);
205        let diff = UiMapDiff::compute(&map, &map);
206        assert!(!diff.has_changes());
207    }
208
209    #[test]
210    fn test_element_added() {
211        let before = make_ui_map(vec![make_element("el_0", "ax_1", "Submit", true)]);
212        let after = make_ui_map(vec![
213            make_element("el_0", "ax_1", "Submit", true),
214            make_element("el_1", "ax_2", "Cancel", true),
215        ]);
216        let diff = UiMapDiff::compute(&before, &after);
217        assert_eq!(diff.added_elements.len(), 1);
218        assert!(diff.added_elements.contains(&"ax_2".to_string()));
219        assert!(diff.removed_elements.is_empty());
220    }
221
222    #[test]
223    fn test_element_removed() {
224        let before = make_ui_map(vec![
225            make_element("el_0", "ax_1", "Submit", true),
226            make_element("el_1", "ax_2", "Cancel", true),
227        ]);
228        let after = make_ui_map(vec![make_element("el_0", "ax_1", "Submit", true)]);
229        let diff = UiMapDiff::compute(&before, &after);
230        assert!(diff.added_elements.is_empty());
231        assert_eq!(diff.removed_elements.len(), 1);
232        assert!(diff.removed_elements.contains(&"ax_2".to_string()));
233    }
234
235    #[test]
236    fn test_element_changed() {
237        let before = make_ui_map(vec![make_element("el_0", "ax_1", "Submit", true)]);
238        let after = make_ui_map(vec![make_element("el_0", "ax_1", "Confirm", true)]);
239        let diff = UiMapDiff::compute(&before, &after);
240        assert!(diff.added_elements.is_empty());
241        assert!(diff.removed_elements.is_empty());
242        assert_eq!(diff.changed_elements.len(), 1);
243        assert_eq!(diff.changed_elements[0].field, ChangeField::Name);
244    }
245
246    #[test]
247    fn test_id_reflow_stable_with_ax_ref() {
248        // Even if el_N IDs shift, ax_ref provides stable identity
249        let before = make_ui_map(vec![
250            make_element("el_0", "ax_1", "Submit", true),
251            make_element("el_1", "ax_2", "Cancel", true),
252        ]);
253        // IDs shifted: el_0 is now ax_2 (Cancel), el_1 is ax_1 (Submit)
254        let after = make_ui_map(vec![
255            make_element("el_0", "ax_2", "Cancel", true),
256            make_element("el_1", "ax_1", "Submit", true),
257        ]);
258        let diff = UiMapDiff::compute(&before, &after);
259        // No real changes — same elements, just reordered
260        assert!(diff.added_elements.is_empty());
261        assert!(diff.removed_elements.is_empty());
262        assert!(diff.changed_elements.is_empty());
263    }
264
265    #[test]
266    fn test_url_changed() {
267        let vp = Viewport { width: 1280, height: 720, device_pixel_ratio: 2.0 };
268        let before = UiMap::new("https://a.com".into(), vec![], vec![], PageSignals::default(), vp, String::new());
269        let after = UiMap::new("https://b.com".into(), vec![], vec![], PageSignals::default(), vp, String::new());
270        let diff = UiMapDiff::compute(&before, &after);
271        assert!(diff.url_changed);
272        assert!(diff.has_changes());
273    }
274}