use super::*;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, serde::Serialize)]
pub struct AccessibilityDiff {
pub from_revision: u64,
pub to_revision: u64,
pub added: Vec<DiffElement>,
pub removed: Vec<DiffElement>,
pub changed: Vec<DiffChange>,
pub total_after: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DiffElement {
pub reference: String,
pub role: String,
pub name: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DiffChange {
pub reference: String,
pub role: String,
pub name: String,
pub changed_properties: Vec<String>,
}
pub fn diff_accessibility(
before: &CompactAccessibilitySnapshot,
after: &CompactAccessibilitySnapshot,
) -> AccessibilityDiff {
let before_refs: HashSet<&str> = before
.interactive
.iter()
.map(|e| e.reference.as_str())
.collect();
let after_refs: HashSet<&str> = after
.interactive
.iter()
.map(|e| e.reference.as_str())
.collect();
let mut added = Vec::new();
let mut removed = Vec::new();
let mut changed = Vec::new();
for elem in &after.interactive {
if !before_refs.contains(elem.reference.as_str()) {
added.push(DiffElement {
reference: elem.reference.clone(),
role: elem.role.clone(),
name: elem.name.clone(),
});
}
}
for elem in &before.interactive {
if !after_refs.contains(elem.reference.as_str()) {
removed.push(DiffElement {
reference: elem.reference.clone(),
role: elem.role.clone(),
name: elem.name.clone(),
});
}
}
let after_by_ref: HashMap<&str, &CompactInteractiveElement> = after
.interactive
.iter()
.map(|e| (e.reference.as_str(), e))
.collect();
for before_elem in &before.interactive {
if let Some(after_elem) = after_by_ref.get(before_elem.reference.as_str()) {
let mut props = Vec::new();
if before_elem.name != after_elem.name {
props.push("name".into());
}
if before_elem.role != after_elem.role {
props.push("role".into());
}
if before_elem.value != after_elem.value {
props.push("value".into());
}
if before_elem.checked != after_elem.checked {
props.push("checked".into());
}
if before_elem.selected_option != after_elem.selected_option {
props.push("selectedOption".into());
}
if before_elem.empty != after_elem.empty {
props.push("empty".into());
}
if before_elem.read_only != after_elem.read_only {
props.push("readOnly".into());
}
if before_elem.required != after_elem.required {
props.push("required".into());
}
if !props.is_empty() {
changed.push(DiffChange {
reference: before_elem.reference.clone(),
role: after_elem.role.clone(),
name: after_elem.name.clone(),
changed_properties: props,
});
}
}
}
AccessibilityDiff {
from_revision: before.revision,
to_revision: after.revision,
added,
removed,
changed,
total_after: after.interactive.len(),
}
}
impl BrowserSession {
pub async fn diff_observation(
&self,
before: &CompactAccessibilitySnapshot,
) -> BrowserResult<AccessibilityDiff> {
let ctx = self.observe().await?;
Ok(diff_accessibility(before, &ctx.accessibility))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_element(reference: &str, role: &str, name: &str) -> CompactInteractiveElement {
CompactInteractiveElement {
reference: reference.to_string(),
role: role.to_string(),
name: name.to_string(),
backend_dom_node_id: 1,
ancestor_path: Vec::new(),
shadow_host_path: None,
input_type: None,
value: None,
checked: None,
selected_option: None,
empty: false,
read_only: false,
required: false,
}
}
fn make_snapshot(
revision: u64,
elements: Vec<CompactInteractiveElement>,
) -> CompactAccessibilitySnapshot {
CompactAccessibilitySnapshot {
page: crate::browser::session::PageInfo {
url: "about:blank".to_string(),
title: String::new(),
ready_state: "complete".to_string(),
target_id: "t".to_string(),
frame_id: "f".to_string(),
},
revision,
roots: vec![],
interactive: elements,
truncated: false,
omitted_count: 0,
ranking_applied: false,
completeness: None,
}
}
#[test]
fn diff_same_elements_produces_no_changes() {
let elements = vec![make_element("r1:b42", "button", "Save")];
let before = make_snapshot(1, elements.clone());
let after = make_snapshot(2, elements);
let diff = diff_accessibility(&before, &after);
assert_eq!(diff.from_revision, 1);
assert_eq!(diff.to_revision, 2);
assert!(diff.added.is_empty());
assert!(diff.removed.is_empty());
assert!(diff.changed.is_empty());
assert_eq!(diff.total_after, 1);
}
#[test]
fn diff_detects_name_change() {
let before = make_snapshot(1, vec![make_element("r1:b1", "button", "Save")]);
let after = make_snapshot(2, vec![make_element("r1:b1", "button", "Submit")]);
let diff = diff_accessibility(&before, &after);
assert_eq!(diff.changed.len(), 1);
assert_eq!(diff.changed[0].reference, "r1:b1");
assert!(
diff.changed[0]
.changed_properties
.contains(&"name".to_string())
);
}
#[test]
fn diff_detects_role_change() {
let before = make_snapshot(1, vec![make_element("r1:b1", "button", "OK")]);
let after = make_snapshot(2, vec![make_element("r1:b1", "link", "OK")]);
let diff = diff_accessibility(&before, &after);
assert_eq!(diff.changed.len(), 1);
assert!(
diff.changed[0]
.changed_properties
.contains(&"role".to_string())
);
}
#[test]
fn diff_detects_added_and_removed_elements() {
let before = make_snapshot(1, vec![make_element("r1:b1", "button", "Old")]);
let after = make_snapshot(2, vec![make_element("r2:b2", "button", "New")]);
let diff = diff_accessibility(&before, &after);
assert_eq!(diff.added.len(), 1);
assert_eq!(diff.added[0].reference, "r2:b2");
assert_eq!(diff.removed.len(), 1);
assert_eq!(diff.removed[0].reference, "r1:b1");
assert!(diff.changed.is_empty());
}
#[test]
fn diff_detects_value_change() {
let mut before_elem = make_element("r1:b1", "textbox", "Search");
before_elem.value = Some("old".to_string());
let mut after_elem = make_element("r1:b1", "textbox", "Search");
after_elem.value = Some("new".to_string());
let before = make_snapshot(1, vec![before_elem]);
let after = make_snapshot(2, vec![after_elem]);
let diff = diff_accessibility(&before, &after);
assert_eq!(diff.changed.len(), 1);
assert!(
diff.changed[0]
.changed_properties
.contains(&"value".to_string())
);
}
#[test]
fn diff_detects_checked_state_change() {
let mut before_elem = make_element("r1:b1", "checkbox", "Agree");
before_elem.checked = Some(false);
let mut after_elem = make_element("r1:b1", "checkbox", "Agree");
after_elem.checked = Some(true);
let before = make_snapshot(1, vec![before_elem]);
let after = make_snapshot(2, vec![after_elem]);
let diff = diff_accessibility(&before, &after);
assert_eq!(diff.changed.len(), 1);
assert!(
diff.changed[0]
.changed_properties
.contains(&"checked".to_string())
);
}
#[test]
fn diff_with_both_empty_snapshots_is_clean() {
let before = make_snapshot(1, vec![]);
let after = make_snapshot(2, vec![]);
let diff = diff_accessibility(&before, &after);
assert_eq!(diff.from_revision, 1);
assert_eq!(diff.to_revision, 2);
assert_eq!(diff.total_after, 0);
assert!(diff.added.is_empty());
assert!(diff.removed.is_empty());
assert!(diff.changed.is_empty());
}
#[test]
fn diff_with_empty_before_marks_all_as_added() {
let before = make_snapshot(1, vec![]);
let after = make_snapshot(
2,
vec![
make_element("r2:b1", "button", "Click"),
make_element("r2:b2", "textbox", "Name"),
],
);
let diff = diff_accessibility(&before, &after);
assert_eq!(diff.added.len(), 2);
assert!(diff.removed.is_empty());
assert!(diff.changed.is_empty());
assert_eq!(diff.total_after, 2);
}
#[test]
fn diff_with_empty_after_marks_all_as_removed() {
let before = make_snapshot(
1,
vec![
make_element("r1:b1", "button", "Old"),
make_element("r1:b2", "link", "Gone"),
],
);
let after = make_snapshot(2, vec![]);
let diff = diff_accessibility(&before, &after);
assert!(diff.added.is_empty());
assert_eq!(diff.removed.len(), 2);
assert!(diff.changed.is_empty());
assert_eq!(diff.total_after, 0);
}
#[test]
fn diff_element_serializes_to_json() {
let elem = DiffElement {
reference: "r7:b42".to_string(),
role: "button".to_string(),
name: "Submit".to_string(),
};
let json = serde_json::to_value(&elem).unwrap();
assert_eq!(json["reference"], "r7:b42");
assert_eq!(json["role"], "button");
assert_eq!(json["name"], "Submit");
}
#[test]
fn diff_change_serializes_to_json() {
let change = DiffChange {
reference: "r3:b99".to_string(),
role: "textbox".to_string(),
name: "Search".to_string(),
changed_properties: vec!["value".to_string(), "name".to_string()],
};
let json = serde_json::to_value(&change).unwrap();
assert_eq!(json["reference"], "r3:b99");
assert_eq!(json["role"], "textbox");
let props: Vec<&str> = json["changed_properties"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(props.contains(&"value"));
assert!(props.contains(&"name"));
}
#[test]
fn accessibility_diff_serializes_full_structure() {
let diff = AccessibilityDiff {
from_revision: 5,
to_revision: 6,
added: vec![DiffElement {
reference: "r6:b1".to_string(),
role: "button".to_string(),
name: "New".to_string(),
}],
removed: vec![],
changed: vec![DiffChange {
reference: "r5:b2".to_string(),
role: "textbox".to_string(),
name: "Email".to_string(),
changed_properties: vec!["value".to_string()],
}],
total_after: 2,
};
let json = serde_json::to_value(&diff).unwrap();
assert_eq!(json["from_revision"], 5);
assert_eq!(json["to_revision"], 6);
assert_eq!(json["total_after"], 2);
assert_eq!(json["added"].as_array().unwrap().len(), 1);
assert_eq!(json["changed"].as_array().unwrap().len(), 1);
assert!(json["removed"].as_array().unwrap().is_empty());
}
}