car-browser 0.7.0

Browser automation and perception pipeline for Common Agent Runtime
Documentation
//! UI state change detection between UiMap snapshots.
//!
//! Uses `ax_ref` (the stable AX node ID) for element identity, not the
//! positional `el_N` IDs which shift when elements are added/removed.

use std::collections::HashMap;
use super::ui_map::{UiElement, UiMap};

/// Differences between two UiMap snapshots.
#[derive(Debug, Clone)]
pub struct UiMapDiff {
    pub added_elements: Vec<String>,
    pub removed_elements: Vec<String>,
    pub changed_elements: Vec<ElementChange>,
    pub url_changed: bool,
    pub scroll_changed: bool,
    pub signals_changed: bool,
}

#[derive(Debug, Clone)]
pub struct ElementChange {
    pub element_id: String,
    pub field: ChangeField,
    pub old_value: String,
    pub new_value: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChangeField {
    Name,
    Value,
    Focused,
    Enabled,
    Checked,
}

impl UiMapDiff {
    /// Compute diff between two UiMap snapshots.
    ///
    /// Uses `ax_ref` as stable identity when available, falls back to
    /// `el_N` ID for elements without AX refs.
    pub fn compute(before: &UiMap, after: &UiMap) -> Self {
        // Build identity maps: stable_id → element
        let before_map = Self::build_identity_map(&before.elements);
        let after_map = Self::build_identity_map(&after.elements);

        let added: Vec<String> = after_map
            .keys()
            .filter(|k| !before_map.contains_key(*k))
            .cloned()
            .collect();

        let removed: Vec<String> = before_map
            .keys()
            .filter(|k| !after_map.contains_key(*k))
            .cloned()
            .collect();

        let mut changed = Vec::new();
        for (id, after_el) in &after_map {
            if let Some(before_el) = before_map.get(id) {
                Self::detect_changes(id, before_el, after_el, &mut changed);
            }
        }

        let scroll_threshold = 0.05;
        let scroll_changed = (before.page_signals.scroll_position
            - after.page_signals.scroll_position)
            .abs()
            > scroll_threshold;

        Self {
            added_elements: added,
            removed_elements: removed,
            changed_elements: changed,
            url_changed: before.url != after.url,
            scroll_changed,
            signals_changed: before.page_signals.to_hash_string()
                != after.page_signals.to_hash_string(),
        }
    }

    /// Build a map from stable identity → element reference.
    /// Uses ax_ref if available, falls back to el_N ID.
    fn build_identity_map<'a>(elements: &'a [UiElement]) -> HashMap<String, &'a UiElement> {
        elements
            .iter()
            .map(|e| {
                let key = e
                    .ax_ref
                    .as_deref()
                    .unwrap_or(&e.id)
                    .to_string();
                (key, e)
            })
            .collect()
    }

    fn detect_changes(
        id: &str,
        before: &UiElement,
        after: &UiElement,
        changes: &mut Vec<ElementChange>,
    ) {
        if before.name != after.name {
            changes.push(ElementChange {
                element_id: id.to_string(),
                field: ChangeField::Name,
                old_value: before.name.clone().unwrap_or_default(),
                new_value: after.name.clone().unwrap_or_default(),
            });
        }
        if before.value != after.value {
            changes.push(ElementChange {
                element_id: id.to_string(),
                field: ChangeField::Value,
                old_value: before.value.clone().unwrap_or_default(),
                new_value: after.value.clone().unwrap_or_default(),
            });
        }
        if before.states.focused != after.states.focused {
            changes.push(ElementChange {
                element_id: id.to_string(),
                field: ChangeField::Focused,
                old_value: before.states.focused.to_string(),
                new_value: after.states.focused.to_string(),
            });
        }
        if before.states.enabled != after.states.enabled {
            changes.push(ElementChange {
                element_id: id.to_string(),
                field: ChangeField::Enabled,
                old_value: before.states.enabled.to_string(),
                new_value: after.states.enabled.to_string(),
            });
        }
    }

    /// Check if anything changed.
    pub fn has_changes(&self) -> bool {
        !self.added_elements.is_empty()
            || !self.removed_elements.is_empty()
            || !self.changed_elements.is_empty()
            || self.url_changed
            || self.scroll_changed
            || self.signals_changed
    }

    /// Human-readable summary.
    pub fn summary(&self) -> String {
        let mut parts = Vec::new();
        if self.url_changed {
            parts.push("URL changed".to_string());
        }
        if !self.added_elements.is_empty() {
            parts.push(format!("{} elements added", self.added_elements.len()));
        }
        if !self.removed_elements.is_empty() {
            parts.push(format!("{} elements removed", self.removed_elements.len()));
        }
        if !self.changed_elements.is_empty() {
            parts.push(format!("{} elements changed", self.changed_elements.len()));
        }
        if self.scroll_changed {
            parts.push("scroll position changed".to_string());
        }
        if parts.is_empty() {
            "No changes".to_string()
        } else {
            parts.join(", ")
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{Bounds, Viewport};
    use crate::perception::ui_map::*;

    fn make_element(id: &str, ax_ref: &str, name: &str, enabled: bool) -> UiElement {
        UiElement {
            id: id.to_string(),
            role: UiRole::Button,
            name: Some(name.to_string()),
            value: None,
            bounds: Bounds::new(0.0, 0.0, 80.0, 30.0),
            states: if enabled { UiState::enabled() } else { UiState::disabled() },
            confidence: 0.95,
            source: ElementSource::AccessibilityTree,
            icon_type: None,
            children: vec![],
            ax_ref: Some(ax_ref.to_string()),
        }
    }

    fn make_ui_map(elements: Vec<UiElement>) -> UiMap {
        let vp = Viewport { width: 1280, height: 720, device_pixel_ratio: 2.0 };
        UiMap::new("https://example.com".into(), elements, vec![], PageSignals::default(), vp, String::new())
    }

    #[test]
    fn test_identical_maps() {
        let map = make_ui_map(vec![make_element("el_0", "ax_1", "Submit", true)]);
        let diff = UiMapDiff::compute(&map, &map);
        assert!(!diff.has_changes());
    }

    #[test]
    fn test_element_added() {
        let before = make_ui_map(vec![make_element("el_0", "ax_1", "Submit", true)]);
        let after = make_ui_map(vec![
            make_element("el_0", "ax_1", "Submit", true),
            make_element("el_1", "ax_2", "Cancel", true),
        ]);
        let diff = UiMapDiff::compute(&before, &after);
        assert_eq!(diff.added_elements.len(), 1);
        assert!(diff.added_elements.contains(&"ax_2".to_string()));
        assert!(diff.removed_elements.is_empty());
    }

    #[test]
    fn test_element_removed() {
        let before = make_ui_map(vec![
            make_element("el_0", "ax_1", "Submit", true),
            make_element("el_1", "ax_2", "Cancel", true),
        ]);
        let after = make_ui_map(vec![make_element("el_0", "ax_1", "Submit", true)]);
        let diff = UiMapDiff::compute(&before, &after);
        assert!(diff.added_elements.is_empty());
        assert_eq!(diff.removed_elements.len(), 1);
        assert!(diff.removed_elements.contains(&"ax_2".to_string()));
    }

    #[test]
    fn test_element_changed() {
        let before = make_ui_map(vec![make_element("el_0", "ax_1", "Submit", true)]);
        let after = make_ui_map(vec![make_element("el_0", "ax_1", "Confirm", true)]);
        let diff = UiMapDiff::compute(&before, &after);
        assert!(diff.added_elements.is_empty());
        assert!(diff.removed_elements.is_empty());
        assert_eq!(diff.changed_elements.len(), 1);
        assert_eq!(diff.changed_elements[0].field, ChangeField::Name);
    }

    #[test]
    fn test_id_reflow_stable_with_ax_ref() {
        // Even if el_N IDs shift, ax_ref provides stable identity
        let before = make_ui_map(vec![
            make_element("el_0", "ax_1", "Submit", true),
            make_element("el_1", "ax_2", "Cancel", true),
        ]);
        // IDs shifted: el_0 is now ax_2 (Cancel), el_1 is ax_1 (Submit)
        let after = make_ui_map(vec![
            make_element("el_0", "ax_2", "Cancel", true),
            make_element("el_1", "ax_1", "Submit", true),
        ]);
        let diff = UiMapDiff::compute(&before, &after);
        // No real changes — same elements, just reordered
        assert!(diff.added_elements.is_empty());
        assert!(diff.removed_elements.is_empty());
        assert!(diff.changed_elements.is_empty());
    }

    #[test]
    fn test_url_changed() {
        let vp = Viewport { width: 1280, height: 720, device_pixel_ratio: 2.0 };
        let before = UiMap::new("https://a.com".into(), vec![], vec![], PageSignals::default(), vp, String::new());
        let after = UiMap::new("https://b.com".into(), vec![], vec![], PageSignals::default(), vp, String::new());
        let diff = UiMapDiff::compute(&before, &after);
        assert!(diff.url_changed);
        assert!(diff.has_changes());
    }
}