use std::collections::HashMap;
use super::ui_map::{UiElement, UiMap};
#[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 {
pub fn compute(before: &UiMap, after: &UiMap) -> Self {
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(),
}
}
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(),
});
}
}
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
}
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() {
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_2", "Cancel", true),
make_element("el_1", "ax_1", "Submit", true),
]);
let diff = UiMapDiff::compute(&before, &after);
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());
}
}