1use super::*;
8use std::collections::{HashMap, HashSet};
9
10#[derive(Debug, Clone, serde::Serialize)]
14pub struct AccessibilityDiff {
15 pub from_revision: u64,
17 pub to_revision: u64,
19 pub added: Vec<DiffElement>,
21 pub removed: Vec<DiffElement>,
23 pub changed: Vec<DiffChange>,
25 pub total_after: usize,
27}
28
29#[derive(Debug, Clone, serde::Serialize)]
31pub struct DiffElement {
32 pub reference: String,
34 pub role: String,
36 pub name: String,
38}
39
40#[derive(Debug, Clone, serde::Serialize)]
42pub struct DiffChange {
43 pub reference: String,
45 pub role: String,
47 pub name: String,
49 pub changed_properties: Vec<String>,
51}
52
53pub fn diff_accessibility(
64 before: &CompactAccessibilitySnapshot,
65 after: &CompactAccessibilitySnapshot,
66) -> AccessibilityDiff {
67 let before_refs: HashSet<&str> = before
68 .interactive
69 .iter()
70 .map(|e| e.reference.as_str())
71 .collect();
72 let after_refs: HashSet<&str> = after
73 .interactive
74 .iter()
75 .map(|e| e.reference.as_str())
76 .collect();
77
78 let mut added = Vec::new();
79 let mut removed = Vec::new();
80 let mut changed = Vec::new();
81
82 for elem in &after.interactive {
83 if !before_refs.contains(elem.reference.as_str()) {
84 added.push(DiffElement {
85 reference: elem.reference.clone(),
86 role: elem.role.clone(),
87 name: elem.name.clone(),
88 });
89 }
90 }
91 for elem in &before.interactive {
92 if !after_refs.contains(elem.reference.as_str()) {
93 removed.push(DiffElement {
94 reference: elem.reference.clone(),
95 role: elem.role.clone(),
96 name: elem.name.clone(),
97 });
98 }
99 }
100
101 let after_by_ref: HashMap<&str, &CompactInteractiveElement> = after
102 .interactive
103 .iter()
104 .map(|e| (e.reference.as_str(), e))
105 .collect();
106 for before_elem in &before.interactive {
107 if let Some(after_elem) = after_by_ref.get(before_elem.reference.as_str()) {
108 let mut props = Vec::new();
109 if before_elem.name != after_elem.name {
110 props.push("name".into());
111 }
112 if before_elem.role != after_elem.role {
113 props.push("role".into());
114 }
115 if before_elem.value != after_elem.value {
116 props.push("value".into());
117 }
118 if before_elem.checked != after_elem.checked {
119 props.push("checked".into());
120 }
121 if before_elem.selected_option != after_elem.selected_option {
122 props.push("selectedOption".into());
123 }
124 if before_elem.empty != after_elem.empty {
125 props.push("empty".into());
126 }
127 if before_elem.read_only != after_elem.read_only {
128 props.push("readOnly".into());
129 }
130 if before_elem.required != after_elem.required {
131 props.push("required".into());
132 }
133 if !props.is_empty() {
134 changed.push(DiffChange {
135 reference: before_elem.reference.clone(),
136 role: after_elem.role.clone(),
137 name: after_elem.name.clone(),
138 changed_properties: props,
139 });
140 }
141 }
142 }
143
144 AccessibilityDiff {
145 from_revision: before.revision,
146 to_revision: after.revision,
147 added,
148 removed,
149 changed,
150 total_after: after.interactive.len(),
151 }
152}
153
154impl BrowserSession {
155 pub async fn diff_observation(
161 &self,
162 before: &CompactAccessibilitySnapshot,
163 ) -> BrowserResult<AccessibilityDiff> {
164 let ctx = self.observe().await?;
165 Ok(diff_accessibility(before, &ctx.accessibility))
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 fn make_element(reference: &str, role: &str, name: &str) -> CompactInteractiveElement {
174 CompactInteractiveElement {
175 reference: reference.to_string(),
176 role: role.to_string(),
177 name: name.to_string(),
178 backend_dom_node_id: 1,
179 ancestor_path: Vec::new(),
180 shadow_host_path: None,
181 input_type: None,
182 value: None,
183 checked: None,
184 selected_option: None,
185 empty: false,
186 read_only: false,
187 required: false,
188 }
189 }
190
191 fn make_snapshot(
192 revision: u64,
193 elements: Vec<CompactInteractiveElement>,
194 ) -> CompactAccessibilitySnapshot {
195 CompactAccessibilitySnapshot {
196 page: crate::browser::session::PageInfo {
197 url: "about:blank".to_string(),
198 title: String::new(),
199 ready_state: "complete".to_string(),
200 target_id: "t".to_string(),
201 frame_id: "f".to_string(),
202 },
203 revision,
204 roots: vec![],
205 interactive: elements,
206 truncated: false,
207 omitted_count: 0,
208 ranking_applied: false,
209 completeness: None,
210 }
211 }
212
213 #[test]
214 fn diff_same_elements_produces_no_changes() {
215 let elements = vec![make_element("r1:b42", "button", "Save")];
216 let before = make_snapshot(1, elements.clone());
217 let after = make_snapshot(2, elements);
218 let diff = diff_accessibility(&before, &after);
219 assert_eq!(diff.from_revision, 1);
220 assert_eq!(diff.to_revision, 2);
221 assert!(diff.added.is_empty());
222 assert!(diff.removed.is_empty());
223 assert!(diff.changed.is_empty());
224 assert_eq!(diff.total_after, 1);
225 }
226
227 #[test]
228 fn diff_detects_name_change() {
229 let before = make_snapshot(1, vec![make_element("r1:b1", "button", "Save")]);
230 let after = make_snapshot(2, vec![make_element("r1:b1", "button", "Submit")]);
231 let diff = diff_accessibility(&before, &after);
232 assert_eq!(diff.changed.len(), 1);
233 assert_eq!(diff.changed[0].reference, "r1:b1");
234 assert!(
235 diff.changed[0]
236 .changed_properties
237 .contains(&"name".to_string())
238 );
239 }
240
241 #[test]
242 fn diff_detects_role_change() {
243 let before = make_snapshot(1, vec![make_element("r1:b1", "button", "OK")]);
244 let after = make_snapshot(2, vec![make_element("r1:b1", "link", "OK")]);
245 let diff = diff_accessibility(&before, &after);
246 assert_eq!(diff.changed.len(), 1);
247 assert!(
248 diff.changed[0]
249 .changed_properties
250 .contains(&"role".to_string())
251 );
252 }
253
254 #[test]
255 fn diff_detects_added_and_removed_elements() {
256 let before = make_snapshot(1, vec![make_element("r1:b1", "button", "Old")]);
257 let after = make_snapshot(2, vec![make_element("r2:b2", "button", "New")]);
258 let diff = diff_accessibility(&before, &after);
259 assert_eq!(diff.added.len(), 1);
260 assert_eq!(diff.added[0].reference, "r2:b2");
261 assert_eq!(diff.removed.len(), 1);
262 assert_eq!(diff.removed[0].reference, "r1:b1");
263 assert!(diff.changed.is_empty());
264 }
265
266 #[test]
267 fn diff_detects_value_change() {
268 let mut before_elem = make_element("r1:b1", "textbox", "Search");
269 before_elem.value = Some("old".to_string());
270 let mut after_elem = make_element("r1:b1", "textbox", "Search");
271 after_elem.value = Some("new".to_string());
272 let before = make_snapshot(1, vec![before_elem]);
273 let after = make_snapshot(2, vec![after_elem]);
274 let diff = diff_accessibility(&before, &after);
275 assert_eq!(diff.changed.len(), 1);
276 assert!(
277 diff.changed[0]
278 .changed_properties
279 .contains(&"value".to_string())
280 );
281 }
282
283 #[test]
284 fn diff_detects_checked_state_change() {
285 let mut before_elem = make_element("r1:b1", "checkbox", "Agree");
286 before_elem.checked = Some(false);
287 let mut after_elem = make_element("r1:b1", "checkbox", "Agree");
288 after_elem.checked = Some(true);
289 let before = make_snapshot(1, vec![before_elem]);
290 let after = make_snapshot(2, vec![after_elem]);
291 let diff = diff_accessibility(&before, &after);
292 assert_eq!(diff.changed.len(), 1);
293 assert!(
294 diff.changed[0]
295 .changed_properties
296 .contains(&"checked".to_string())
297 );
298 }
299
300 #[test]
301 fn diff_with_both_empty_snapshots_is_clean() {
302 let before = make_snapshot(1, vec![]);
303 let after = make_snapshot(2, vec![]);
304 let diff = diff_accessibility(&before, &after);
305 assert_eq!(diff.from_revision, 1);
306 assert_eq!(diff.to_revision, 2);
307 assert_eq!(diff.total_after, 0);
308 assert!(diff.added.is_empty());
309 assert!(diff.removed.is_empty());
310 assert!(diff.changed.is_empty());
311 }
312
313 #[test]
314 fn diff_with_empty_before_marks_all_as_added() {
315 let before = make_snapshot(1, vec![]);
316 let after = make_snapshot(
317 2,
318 vec![
319 make_element("r2:b1", "button", "Click"),
320 make_element("r2:b2", "textbox", "Name"),
321 ],
322 );
323 let diff = diff_accessibility(&before, &after);
324 assert_eq!(diff.added.len(), 2);
325 assert!(diff.removed.is_empty());
326 assert!(diff.changed.is_empty());
327 assert_eq!(diff.total_after, 2);
328 }
329
330 #[test]
331 fn diff_with_empty_after_marks_all_as_removed() {
332 let before = make_snapshot(
333 1,
334 vec![
335 make_element("r1:b1", "button", "Old"),
336 make_element("r1:b2", "link", "Gone"),
337 ],
338 );
339 let after = make_snapshot(2, vec![]);
340 let diff = diff_accessibility(&before, &after);
341 assert!(diff.added.is_empty());
342 assert_eq!(diff.removed.len(), 2);
343 assert!(diff.changed.is_empty());
344 assert_eq!(diff.total_after, 0);
345 }
346
347 #[test]
348 fn diff_element_serializes_to_json() {
349 let elem = DiffElement {
350 reference: "r7:b42".to_string(),
351 role: "button".to_string(),
352 name: "Submit".to_string(),
353 };
354 let json = serde_json::to_value(&elem).unwrap();
355 assert_eq!(json["reference"], "r7:b42");
356 assert_eq!(json["role"], "button");
357 assert_eq!(json["name"], "Submit");
358 }
359
360 #[test]
361 fn diff_change_serializes_to_json() {
362 let change = DiffChange {
363 reference: "r3:b99".to_string(),
364 role: "textbox".to_string(),
365 name: "Search".to_string(),
366 changed_properties: vec!["value".to_string(), "name".to_string()],
367 };
368 let json = serde_json::to_value(&change).unwrap();
369 assert_eq!(json["reference"], "r3:b99");
370 assert_eq!(json["role"], "textbox");
371 let props: Vec<&str> = json["changed_properties"]
372 .as_array()
373 .unwrap()
374 .iter()
375 .map(|v| v.as_str().unwrap())
376 .collect();
377 assert!(props.contains(&"value"));
378 assert!(props.contains(&"name"));
379 }
380
381 #[test]
382 fn accessibility_diff_serializes_full_structure() {
383 let diff = AccessibilityDiff {
384 from_revision: 5,
385 to_revision: 6,
386 added: vec![DiffElement {
387 reference: "r6:b1".to_string(),
388 role: "button".to_string(),
389 name: "New".to_string(),
390 }],
391 removed: vec![],
392 changed: vec![DiffChange {
393 reference: "r5:b2".to_string(),
394 role: "textbox".to_string(),
395 name: "Email".to_string(),
396 changed_properties: vec!["value".to_string()],
397 }],
398 total_after: 2,
399 };
400 let json = serde_json::to_value(&diff).unwrap();
401 assert_eq!(json["from_revision"], 5);
402 assert_eq!(json["to_revision"], 6);
403 assert_eq!(json["total_after"], 2);
404 assert_eq!(json["added"].as_array().unwrap().len(), 1);
405 assert_eq!(json["changed"].as_array().unwrap().len(), 1);
406 assert!(json["removed"].as_array().unwrap().is_empty());
407 }
408}