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 super::ui_map::{UiElement, UiMap};
7use std::collections::HashMap;
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 =
68            (before.page_signals.scroll_position - after.page_signals.scroll_position).abs()
69                > scroll_threshold;
70
71        Self {
72            added_elements: added,
73            removed_elements: removed,
74            changed_elements: changed,
75            url_changed: before.url != after.url,
76            scroll_changed,
77            signals_changed: before.page_signals.to_hash_string()
78                != after.page_signals.to_hash_string(),
79        }
80    }
81
82    /// Build a map from stable identity → element reference.
83    /// Uses ax_ref if available, falls back to el_N ID.
84    fn build_identity_map<'a>(elements: &'a [UiElement]) -> HashMap<String, &'a UiElement> {
85        elements
86            .iter()
87            .map(|e| {
88                let key = e.ax_ref.as_deref().unwrap_or(&e.id).to_string();
89                (key, e)
90            })
91            .collect()
92    }
93
94    fn detect_changes(
95        id: &str,
96        before: &UiElement,
97        after: &UiElement,
98        changes: &mut Vec<ElementChange>,
99    ) {
100        if before.name != after.name {
101            changes.push(ElementChange {
102                element_id: id.to_string(),
103                field: ChangeField::Name,
104                old_value: before.name.clone().unwrap_or_default(),
105                new_value: after.name.clone().unwrap_or_default(),
106            });
107        }
108        if before.value != after.value {
109            changes.push(ElementChange {
110                element_id: id.to_string(),
111                field: ChangeField::Value,
112                old_value: before.value.clone().unwrap_or_default(),
113                new_value: after.value.clone().unwrap_or_default(),
114            });
115        }
116        if before.states.focused != after.states.focused {
117            changes.push(ElementChange {
118                element_id: id.to_string(),
119                field: ChangeField::Focused,
120                old_value: before.states.focused.to_string(),
121                new_value: after.states.focused.to_string(),
122            });
123        }
124        if before.states.enabled != after.states.enabled {
125            changes.push(ElementChange {
126                element_id: id.to_string(),
127                field: ChangeField::Enabled,
128                old_value: before.states.enabled.to_string(),
129                new_value: after.states.enabled.to_string(),
130            });
131        }
132    }
133
134    /// Check if anything changed.
135    pub fn has_changes(&self) -> bool {
136        !self.added_elements.is_empty()
137            || !self.removed_elements.is_empty()
138            || !self.changed_elements.is_empty()
139            || self.url_changed
140            || self.scroll_changed
141            || self.signals_changed
142    }
143
144    /// Human-readable summary.
145    pub fn summary(&self) -> String {
146        let mut parts = Vec::new();
147        if self.url_changed {
148            parts.push("URL changed".to_string());
149        }
150        if !self.added_elements.is_empty() {
151            parts.push(format!("{} elements added", self.added_elements.len()));
152        }
153        if !self.removed_elements.is_empty() {
154            parts.push(format!("{} elements removed", self.removed_elements.len()));
155        }
156        if !self.changed_elements.is_empty() {
157            parts.push(format!("{} elements changed", self.changed_elements.len()));
158        }
159        if self.scroll_changed {
160            parts.push("scroll position changed".to_string());
161        }
162        if parts.is_empty() {
163            "No changes".to_string()
164        } else {
165            parts.join(", ")
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::models::{Bounds, Viewport};
174    use crate::perception::ui_map::*;
175
176    fn make_element(id: &str, ax_ref: &str, name: &str, enabled: bool) -> UiElement {
177        UiElement {
178            id: id.to_string(),
179            role: UiRole::Button,
180            name: Some(name.to_string()),
181            value: None,
182            bounds: Bounds::new(0.0, 0.0, 80.0, 30.0),
183            states: if enabled {
184                UiState::enabled()
185            } else {
186                UiState::disabled()
187            },
188            confidence: 0.95,
189            source: ElementSource::AccessibilityTree,
190            icon_type: None,
191            children: vec![],
192            ax_ref: Some(ax_ref.to_string()),
193        }
194    }
195
196    fn make_ui_map(elements: Vec<UiElement>) -> UiMap {
197        let vp = Viewport {
198            width: 1280,
199            height: 720,
200            device_pixel_ratio: 2.0,
201        };
202        UiMap::new(
203            "https://example.com".into(),
204            elements,
205            vec![],
206            PageSignals::default(),
207            vp,
208            String::new(),
209        )
210    }
211
212    #[test]
213    fn test_identical_maps() {
214        let map = make_ui_map(vec![make_element("el_0", "ax_1", "Submit", true)]);
215        let diff = UiMapDiff::compute(&map, &map);
216        assert!(!diff.has_changes());
217    }
218
219    #[test]
220    fn test_element_added() {
221        let before = make_ui_map(vec![make_element("el_0", "ax_1", "Submit", true)]);
222        let after = make_ui_map(vec![
223            make_element("el_0", "ax_1", "Submit", true),
224            make_element("el_1", "ax_2", "Cancel", true),
225        ]);
226        let diff = UiMapDiff::compute(&before, &after);
227        assert_eq!(diff.added_elements.len(), 1);
228        assert!(diff.added_elements.contains(&"ax_2".to_string()));
229        assert!(diff.removed_elements.is_empty());
230    }
231
232    #[test]
233    fn test_element_removed() {
234        let before = make_ui_map(vec![
235            make_element("el_0", "ax_1", "Submit", true),
236            make_element("el_1", "ax_2", "Cancel", true),
237        ]);
238        let after = make_ui_map(vec![make_element("el_0", "ax_1", "Submit", true)]);
239        let diff = UiMapDiff::compute(&before, &after);
240        assert!(diff.added_elements.is_empty());
241        assert_eq!(diff.removed_elements.len(), 1);
242        assert!(diff.removed_elements.contains(&"ax_2".to_string()));
243    }
244
245    #[test]
246    fn test_element_changed() {
247        let before = make_ui_map(vec![make_element("el_0", "ax_1", "Submit", true)]);
248        let after = make_ui_map(vec![make_element("el_0", "ax_1", "Confirm", true)]);
249        let diff = UiMapDiff::compute(&before, &after);
250        assert!(diff.added_elements.is_empty());
251        assert!(diff.removed_elements.is_empty());
252        assert_eq!(diff.changed_elements.len(), 1);
253        assert_eq!(diff.changed_elements[0].field, ChangeField::Name);
254    }
255
256    #[test]
257    fn test_id_reflow_stable_with_ax_ref() {
258        // Even if el_N IDs shift, ax_ref provides stable identity
259        let before = make_ui_map(vec![
260            make_element("el_0", "ax_1", "Submit", true),
261            make_element("el_1", "ax_2", "Cancel", true),
262        ]);
263        // IDs shifted: el_0 is now ax_2 (Cancel), el_1 is ax_1 (Submit)
264        let after = make_ui_map(vec![
265            make_element("el_0", "ax_2", "Cancel", true),
266            make_element("el_1", "ax_1", "Submit", true),
267        ]);
268        let diff = UiMapDiff::compute(&before, &after);
269        // No real changes — same elements, just reordered
270        assert!(diff.added_elements.is_empty());
271        assert!(diff.removed_elements.is_empty());
272        assert!(diff.changed_elements.is_empty());
273    }
274
275    #[test]
276    fn test_url_changed() {
277        let vp = Viewport {
278            width: 1280,
279            height: 720,
280            device_pixel_ratio: 2.0,
281        };
282        let before = UiMap::new(
283            "https://a.com".into(),
284            vec![],
285            vec![],
286            PageSignals::default(),
287            vp,
288            String::new(),
289        );
290        let after = UiMap::new(
291            "https://b.com".into(),
292            vec![],
293            vec![],
294            PageSignals::default(),
295            vp,
296            String::new(),
297        );
298        let diff = UiMapDiff::compute(&before, &after);
299        assert!(diff.url_changed);
300        assert!(diff.has_changes());
301    }
302}