use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::snapshot::AXSnapshot;
use crate::tree::AXNode;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum NodeKey {
Backend(i64),
Ax(String),
}
fn key_of(node: &AXNode) -> NodeKey {
match node.backend_dom_node_id {
Some(id) => NodeKey::Backend(id),
None => NodeKey::Ax(node.node_id.clone()),
}
}
fn index(snapshot: &AXSnapshot) -> HashMap<NodeKey, &AXNode> {
snapshot
.tree
.iter_all()
.map(|node| (key_of(node), node))
.collect()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AXTreeDiff {
pub added: Vec<String>,
pub removed: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub property_changes: Vec<PropertyChange>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub focus_moved: Option<FocusMove>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title_changed: Option<(String, String)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url_changed: Option<(String, String)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PropertyChange {
pub node_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend_node_id: Option<i64>,
pub property: String,
pub before: String,
pub after: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FocusMove {
pub before: Option<i64>,
pub after: Option<i64>,
}
const TRACKED_PROPERTIES: &[&str] = &[
"expanded", "hidden", "selected", "invalid", "modal", "checked", "pressed",
];
impl AXTreeDiff {
pub fn between(before: &AXSnapshot, after: &AXSnapshot) -> Self {
let mut diff = AXTreeDiff::default();
if before.document_title != after.document_title {
diff.title_changed =
Some((before.document_title.clone(), after.document_title.clone()));
}
if before.url != after.url {
diff.url_changed = Some((before.url.clone(), after.url.clone()));
}
if before.focus.active_backend_node_id != after.focus.active_backend_node_id {
diff.focus_moved = Some(FocusMove {
before: before.focus.active_backend_node_id,
after: after.focus.active_backend_node_id,
});
}
let before_index = index(before);
let after_index = index(after);
for (key, node) in &after_index {
let was_perceivable = before_index.get(key).is_some_and(|n| !n.ignored);
if !node.ignored && !was_perceivable {
diff.added.push(node.node_id.clone());
}
}
for (key, node) in &before_index {
let is_perceivable = after_index.get(key).is_some_and(|n| !n.ignored);
if !node.ignored && !is_perceivable {
diff.removed.push(node.node_id.clone());
}
}
diff.added.sort();
diff.removed.sort();
for (key, after_node) in &after_index {
let Some(before_node) = before_index.get(key) else {
continue;
};
for prop_name in TRACKED_PROPERTIES {
let before_val = before_node.property_value_str(prop_name);
let after_val = after_node.property_value_str(prop_name);
if before_val != after_val {
diff.property_changes.push(PropertyChange {
node_id: after_node.node_id.clone(),
backend_node_id: after_node.backend_dom_node_id,
property: prop_name.to_string(),
before: before_val.unwrap_or_default(),
after: after_val.unwrap_or_default(),
});
}
}
}
diff.property_changes.sort_by(|a, b| {
(a.backend_node_id, &a.property).cmp(&(b.backend_node_id, &b.property))
});
diff
}
pub fn property_change_for(
&self,
backend_node_id: i64,
property: &str,
) -> Option<&PropertyChange> {
self.property_changes
.iter()
.find(|c| c.backend_node_id == Some(backend_node_id) && c.property == property)
}
pub fn has_additions(&self) -> bool {
!self.added.is_empty()
}
pub fn has_removals(&self) -> bool {
!self.removed.is_empty()
}
pub fn is_empty(&self) -> bool {
self.added.is_empty()
&& self.removed.is_empty()
&& self.property_changes.is_empty()
&& self.focus_moved.is_none()
&& self.title_changed.is_none()
&& self.url_changed.is_none()
}
}
#[cfg(test)]
mod tests {
use super::super::snapshot::FocusSnapshot;
use super::super::tree::{AXNode, AXTree};
use super::*;
fn snap(label: &str, title: &str, url: &str, focus: Option<i64>) -> AXSnapshot {
AXSnapshot::new(
label,
url,
title,
0,
AXTree::new(),
FocusSnapshot {
active_backend_node_id: focus,
..Default::default()
},
)
}
#[test]
fn empty_diff_when_identical() {
let a = snap("a", "T", "https://x", Some(1));
let b = snap("b", "T", "https://x", Some(1));
assert!(AXTreeDiff::between(&a, &b).is_empty());
}
#[test]
fn detects_title_and_focus_change() {
let a = snap("a", "Old", "https://x", Some(1));
let b = snap("b", "New", "https://x", Some(2));
let d = AXTreeDiff::between(&a, &b);
assert_eq!(d.title_changed, Some(("Old".into(), "New".into())));
assert_eq!(d.focus_moved.unwrap().after, Some(2));
assert!(d.url_changed.is_none());
}
fn node_with_token(ax_id: &str, backend: i64, name: &str, value: &str) -> AXNode {
let mut n = node(ax_id, backend, None);
n.properties.push(super::super::tree::AXProperty {
name: name.to_string(),
value: super::super::tree::AXValue::String(value.to_string()),
});
n
}
#[test]
fn wahrnehmbar_gewordener_knoten_gilt_als_hinzugekommen() {
let mut hidden = node("16", 16, None);
hidden.ignored = true;
hidden.role = Some("none".to_string());
let mut shown = node("16", 16, None);
shown.role = Some("menu".to_string());
let mut before = snap("a", "T", "https://x", None);
before.tree = AXTree::from_nodes(vec![hidden]);
let mut after = snap("b", "T", "https://x", None);
after.tree = AXTree::from_nodes(vec![shown]);
let diff = AXTreeDiff::between(&before, &after);
assert_eq!(diff.added, vec!["16".to_string()]);
assert!(diff.has_additions());
}
#[test]
fn ignoriert_gewordener_knoten_gilt_als_verschwunden() {
let shown = node("16", 16, None);
let mut hidden = node("16", 16, None);
hidden.ignored = true;
let mut before = snap("a", "T", "https://x", None);
before.tree = AXTree::from_nodes(vec![shown]);
let mut after = snap("b", "T", "https://x", None);
after.tree = AXTree::from_nodes(vec![hidden]);
let diff = AXTreeDiff::between(&before, &after);
assert_eq!(diff.removed, vec!["16".to_string()]);
assert!(diff.has_removals());
}
#[test]
fn dauerhaft_ignorierter_knoten_zaehlt_nicht() {
let mut ignored = node("99", 99, None);
ignored.ignored = true;
let before = snap("a", "T", "https://x", None);
let mut after = snap("b", "T", "https://x", None);
after.tree = AXTree::from_nodes(vec![ignored]);
let diff = AXTreeDiff::between(&before, &after);
assert!(diff.added.is_empty());
assert!(!diff.has_additions());
}
#[test]
fn token_eigenschaften_erzeugen_eine_aenderung() {
let mut before = snap("a", "T", "https://x", None);
before.tree = AXTree::from_nodes(vec![node_with_token("1", 7, "invalid", "false")]);
let mut after = snap("b", "T", "https://x", None);
after.tree = AXTree::from_nodes(vec![node_with_token("1", 7, "invalid", "true")]);
let diff = AXTreeDiff::between(&before, &after);
let change = diff
.property_change_for(7, "invalid")
.expect("Wechsel an aria-invalid muss sichtbar sein");
assert_eq!(change.before, "false");
assert_eq!(change.after, "true");
}
#[test]
fn checked_und_pressed_erzeugen_eine_aenderung() {
for (prop, from, to) in [
("checked", "false", "true"),
("checked", "true", "mixed"),
("pressed", "false", "true"),
] {
let mut before = snap("a", "T", "https://x", None);
before.tree = AXTree::from_nodes(vec![node_with_token("1", 7, prop, from)]);
let mut after = snap("b", "T", "https://x", None);
after.tree = AXTree::from_nodes(vec![node_with_token("1", 7, prop, to)]);
let diff = AXTreeDiff::between(&before, &after);
let change = diff
.property_change_for(7, prop)
.unwrap_or_else(|| panic!("Wechsel an {prop} muss sichtbar sein"));
assert_eq!((change.before.as_str(), change.after.as_str()), (from, to));
}
}
#[test]
fn boolesche_eigenschaften_bleiben_true_false() {
let mut before = snap("a", "T", "https://x", None);
before.tree = AXTree::from_nodes(vec![node("1", 7, Some(("expanded", false)))]);
let mut after = snap("b", "T", "https://x", None);
after.tree = AXTree::from_nodes(vec![node("1", 7, Some(("expanded", true)))]);
let change = AXTreeDiff::between(&before, &after)
.property_change_for(7, "expanded")
.cloned()
.expect("Wechsel an expanded");
assert_eq!(
(change.before.as_str(), change.after.as_str()),
("false", "true")
);
}
fn node(ax_id: &str, backend: i64, prop: Option<(&str, bool)>) -> AXNode {
AXNode {
node_id: ax_id.to_string(),
ignored: false,
ignored_reasons: Vec::new(),
role: Some("button".to_string()),
name: None,
name_source: None,
description: None,
value: None,
properties: prop
.map(|(name, value)| {
vec![super::super::tree::AXProperty {
name: name.to_string(),
value: super::super::tree::AXValue::Bool(value),
}]
})
.unwrap_or_default(),
child_ids: Vec::new(),
parent_id: None,
backend_dom_node_id: Some(backend),
}
}
fn snap_with(label: &str, nodes: Vec<AXNode>) -> AXSnapshot {
AXSnapshot::new(
label,
"https://x",
"T",
0,
AXTree::from_nodes(nodes),
FocusSnapshot::default(),
)
}
#[test]
fn neue_ax_kennungen_erzeugen_keine_scheinaenderung() {
let before = snap_with("a", vec![node("ax-1", 42, Some(("expanded", false)))]);
let after = snap_with("b", vec![node("ax-999", 42, Some(("expanded", false)))]);
let d = AXTreeDiff::between(&before, &after);
assert!(d.added.is_empty(), "added: {:?}", d.added);
assert!(d.removed.is_empty(), "removed: {:?}", d.removed);
assert!(d.property_changes.is_empty());
}
#[test]
fn eigenschaftswechsel_wird_dem_richtigen_knoten_zugeordnet() {
let before = snap_with(
"a",
vec![
node("ax-1", 42, Some(("expanded", false))),
node("ax-2", 77, Some(("expanded", false))),
],
);
let after = snap_with(
"b",
vec![
node("ax-9", 42, Some(("expanded", false))),
node("ax-8", 77, Some(("expanded", true))),
],
);
let d = AXTreeDiff::between(&before, &after);
assert_eq!(d.property_changes.len(), 1);
assert!(d.property_change_for(42, "expanded").is_none());
let change = d
.property_change_for(77, "expanded")
.expect("Änderung an 77");
assert_eq!(change.before, "false");
assert_eq!(change.after, "true");
}
#[test]
fn neuer_inhalt_erscheint_als_zugang() {
let before = snap_with("a", vec![node("ax-1", 42, None)]);
let after = snap_with("b", vec![node("ax-1", 42, None), node("ax-2", 43, None)]);
let d = AXTreeDiff::between(&before, &after);
assert!(d.has_additions());
assert!(!d.has_removals());
}
#[test]
fn detects_spa_navigation() {
let a = snap("a", "T", "https://x/one", Some(1));
let b = snap("b", "T", "https://x/two", Some(1));
let d = AXTreeDiff::between(&a, &b);
assert!(d.url_changed.is_some());
}
}