1use std::collections::HashMap;
7use super::ui_map::{UiElement, UiMap};
8
9#[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 pub fn compute(before: &UiMap, after: &UiMap) -> Self {
43 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 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 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 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 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 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 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}