1use super::ui_map::{UiElement, UiMap};
7use std::collections::HashMap;
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 =
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 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 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 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 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 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 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}