///|
/// Structural Diff for Accessibility Trees
/// Lightweight visual regression without screenshots
// =============================================================================
// Types
// =============================================================================
///|
/// Type of change detected
pub(all) enum ChangeType {
Added
Removed
Moved
Resized
RoleChanged
NameChanged
StateChanged
VisibilityChanged
}
///|
/// A single difference between two trees
pub(all) struct Difference {
change_type : ChangeType
/// Path to the element (e.g., "root > main > article")
path : String
/// Element identifier (ref_id or source_id)
element_id : String
/// Description of the change
description : String
/// Severity: 0 = info, 1 = minor, 2 = major, 3 = breaking
severity : Int
}
///|
/// Complete diff result
pub(all) struct DiffResult {
differences : Array[Difference]
added_count : Int
removed_count : Int
changed_count : Int
/// Overall similarity score (0.0 - 1.0)
similarity : Double
}
///|
/// Configuration for diff sensitivity
pub(all) struct DiffConfig {
/// Position change threshold in pixels
position_threshold : Double
/// Size change threshold in pixels
size_threshold : Double
/// Ignore non-visible elements
ignore_invisible : Bool
/// Ignore generic elements without names
ignore_anonymous_generic : Bool
}
///|
pub fn DiffConfig::default() -> DiffConfig {
{
position_threshold: 5.0,
size_threshold: 3.0,
ignore_invisible: true,
ignore_anonymous_generic: true,
}
}
///|
pub fn DiffConfig::strict() -> DiffConfig {
{
position_threshold: 1.0,
size_threshold: 1.0,
ignore_invisible: false,
ignore_anonymous_generic: false,
}
}
// =============================================================================
// Main API
// =============================================================================
///|
/// Compare two accessibility trees and return differences
pub fn diff_trees(
before : @aom.AccessibilityTree,
after : @aom.AccessibilityTree,
config : DiffConfig,
) -> DiffResult {
let differences : Array[Difference] = []
// Build maps of elements by ID for comparison
let before_map = build_element_map(before.root, config)
let after_map = build_element_map(after.root, config)
// Find removed elements
for id, before_elem in before_map {
match after_map.get(id) {
None =>
differences.push({
change_type: Removed,
path: before_elem.path,
element_id: id,
description: "Element removed: \{before_elem.role}",
severity: 2,
})
Some(after_elem) =>
// Compare elements
compare_elements(before_elem, after_elem, config, differences)
}
}
// Find added elements
for id, after_elem in after_map {
match before_map.get(id) {
None =>
differences.push({
change_type: Added,
path: after_elem.path,
element_id: id,
description: "Element added: \{after_elem.role}",
severity: 1,
})
Some(_) => () // Already handled in comparison
}
}
// Calculate statistics
let mut added = 0
let mut removed = 0
let mut changed = 0
for diff in differences {
match diff.change_type {
Added => added = added + 1
Removed => removed = removed + 1
_ => changed = changed + 1
}
}
// Calculate similarity
let total_elements = before_map.length() + after_map.length()
let unchanged = total_elements - differences.length()
let similarity = if total_elements > 0 {
unchanged.to_double() / total_elements.to_double()
} else {
1.0
}
{
differences,
added_count: added,
removed_count: removed,
changed_count: changed,
similarity,
}
}
///|
/// Quick check if two trees are structurally equivalent
pub fn trees_equal(
before : @aom.AccessibilityTree,
after : @aom.AccessibilityTree,
) -> Bool {
let result = diff_trees(before, after, DiffConfig::default())
result.differences.length() == 0
}
///|
/// Get a summary of differences
pub fn diff_summary(
before : @aom.AccessibilityTree,
after : @aom.AccessibilityTree,
) -> String {
let result = diff_trees(before, after, DiffConfig::default())
result.to_summary()
}
// =============================================================================
// Element Mapping
// =============================================================================
///|
priv struct ElementSnapshot {
id : String
path : String
role : String
name : String?
bounds : @aom.Bounds?
states : Array[@aom.State]
visible : Bool
}
///|
fn build_element_map(
root : @aom.AccessibilityNode,
config : DiffConfig,
) -> Map[String, ElementSnapshot] {
let map : Map[String, ElementSnapshot] = {}
collect_elements(root, "", config, map)
map
}
///|
fn collect_elements(
node : @aom.AccessibilityNode,
parent_path : String,
config : DiffConfig,
map : Map[String, ElementSnapshot],
) -> Unit {
// Skip invisible if configured
if config.ignore_invisible && !node.visible {
return
}
// Skip anonymous generic if configured
if config.ignore_anonymous_generic {
if node.role == @aom.Generic && node.name is None && node.ref_id is None {
// Still recurse into children
for child in node.children {
collect_elements(child, parent_path, config, map)
}
return
}
}
// Build path
let path = if parent_path.is_empty() {
node.role.to_string()
} else {
parent_path + " > " + node.role.to_string()
}
// Get element ID (prefer ref_id, fall back to source_id or generated)
let id = match node.ref_id {
Some(ref_id) => ref_id
None =>
match node.source_id {
Some(src_id) => src_id
None => node.id
}
}
// Create snapshot
let snapshot : ElementSnapshot = {
id,
path,
role: node.role.to_string(),
name: node.name,
bounds: node.bounds,
states: node.states,
visible: node.visible,
}
map[id] = snapshot
// Recurse
for child in node.children {
collect_elements(child, path, config, map)
}
}
// =============================================================================
// Element Comparison
// =============================================================================
///|
fn compare_elements(
before : ElementSnapshot,
after : ElementSnapshot,
config : DiffConfig,
diffs : Array[Difference],
) -> Unit {
// Role changed
if before.role != after.role {
diffs.push({
change_type: RoleChanged,
path: after.path,
element_id: after.id,
description: "Role changed: \{before.role} -> \{after.role}",
severity: 2,
})
}
// Name changed
match (before.name, after.name) {
(Some(b), Some(a)) if b != a =>
diffs.push({
change_type: NameChanged,
path: after.path,
element_id: after.id,
description: "Name changed: \"\{b}\" -> \"\{a}\"",
severity: 1,
})
(Some(b), None) =>
diffs.push({
change_type: NameChanged,
path: after.path,
element_id: after.id,
description: "Name removed: \"\{b}\"",
severity: 1,
})
(None, Some(a)) =>
diffs.push({
change_type: NameChanged,
path: after.path,
element_id: after.id,
description: "Name added: \"\{a}\"",
severity: 0,
})
_ => ()
}
// Bounds changed
match (before.bounds, after.bounds) {
(Some(b_bounds), Some(a_bounds)) =>
compare_bounds(before.id, after.path, b_bounds, a_bounds, config, diffs)
_ => ()
}
// States changed
if !states_equal(before.states, after.states) {
let before_str = states_to_string(before.states)
let after_str = states_to_string(after.states)
diffs.push({
change_type: StateChanged,
path: after.path,
element_id: after.id,
description: "States changed: [\{before_str}] -> [\{after_str}]",
severity: 1,
})
}
// Visibility changed
if before.visible != after.visible {
let visibility = if after.visible { "visible" } else { "hidden" }
diffs.push({
change_type: VisibilityChanged,
path: after.path,
element_id: after.id,
description: "Visibility changed to \{visibility}",
severity: 1,
})
}
}
///|
fn compare_bounds(
id : String,
path : String,
before : @aom.Bounds,
after : @aom.Bounds,
config : DiffConfig,
diffs : Array[Difference],
) -> Unit {
let dx = (after.x - before.x).abs()
let dy = (after.y - before.y).abs()
let dw = (after.width - before.width).abs()
let dh = (after.height - before.height).abs()
// Position changed
if dx > config.position_threshold || dy > config.position_threshold {
diffs.push({
change_type: Moved,
path,
element_id: id,
description: "Moved: (\{before.x}, \{before.y}) -> (\{after.x}, \{after.y})",
severity: 1,
})
}
// Size changed
if dw > config.size_threshold || dh > config.size_threshold {
diffs.push({
change_type: Resized,
path,
element_id: id,
description: "Resized: \{before.width}x\{before.height} -> \{after.width}x\{after.height}",
severity: 1,
})
}
}
///|
fn states_equal(a : Array[@aom.State], b : Array[@aom.State]) -> Bool {
if a.length() != b.length() {
return false
}
for state in a {
if !array_contains_state(b, state) {
return false
}
}
true
}
///|
fn array_contains_state(arr : Array[@aom.State], state : @aom.State) -> Bool {
for s in arr {
if s == state {
return true
}
}
false
}
///|
fn states_to_string(states : Array[@aom.State]) -> String {
let parts : Array[String] = []
for state in states {
parts.push(state.to_string())
}
parts
.iter()
.fold(init="", fn(acc, s) { if acc.is_empty() { s } else { acc + ", " + s } })
}
// =============================================================================
// Output
// =============================================================================
///|
pub fn Difference::to_string(self : Difference) -> String {
let severity_str = match self.severity {
0 => "INFO"
1 => "MINOR"
2 => "MAJOR"
_ => "BREAKING"
}
let change_str = match self.change_type {
Added => "ADDED"
Removed => "REMOVED"
Moved => "MOVED"
Resized => "RESIZED"
RoleChanged => "ROLE_CHANGED"
NameChanged => "NAME_CHANGED"
StateChanged => "STATE_CHANGED"
VisibilityChanged => "VISIBILITY_CHANGED"
}
"[\{severity_str}] \{change_str}: \{self.description} (at \{self.path})"
}
///|
pub fn DiffResult::to_summary(self : DiffResult) -> String {
let buf = StringBuilder::new()
buf.write_string("=== Structural Diff Result ===\n")
buf.write_string("Similarity: ")
buf.write_string((self.similarity * 100.0).to_string())
buf.write_string("%\n")
buf.write_string("Added: ")
buf.write_string(self.added_count.to_string())
buf.write_string(", Removed: ")
buf.write_string(self.removed_count.to_string())
buf.write_string(", Changed: ")
buf.write_string(self.changed_count.to_string())
buf.write_string("\n\nDifferences:\n")
if self.differences.length() == 0 {
buf.write_string(" (no differences)\n")
} else {
for diff in self.differences {
buf.write_string(" ")
buf.write_string(diff.to_string())
buf.write_string("\n")
}
}
buf.to_string()
}
///|
pub fn DiffResult::to_json(self : DiffResult) -> String {
let buf = StringBuilder::new()
buf.write_string("{\n")
buf.write_string(" \"similarity\": ")
buf.write_string(self.similarity.to_string())
buf.write_string(",\n")
buf.write_string(" \"added\": ")
buf.write_string(self.added_count.to_string())
buf.write_string(",\n")
buf.write_string(" \"removed\": ")
buf.write_string(self.removed_count.to_string())
buf.write_string(",\n")
buf.write_string(" \"changed\": ")
buf.write_string(self.changed_count.to_string())
buf.write_string(",\n")
buf.write_string(" \"differences\": [\n")
for i, diff in self.differences {
if i > 0 {
buf.write_string(",\n")
}
buf.write_string(" { \"type\": \"")
buf.write_string(change_type_string(diff.change_type))
buf.write_string("\", \"path\": \"")
buf.write_string(escape_json(diff.path))
buf.write_string("\", \"id\": \"")
buf.write_string(escape_json(diff.element_id))
buf.write_string("\", \"description\": \"")
buf.write_string(escape_json(diff.description))
buf.write_string("\", \"severity\": ")
buf.write_string(diff.severity.to_string())
buf.write_string(" }")
}
buf.write_string("\n ]\n}")
buf.to_string()
}
///|
fn change_type_string(ct : ChangeType) -> String {
match ct {
Added => "added"
Removed => "removed"
Moved => "moved"
Resized => "resized"
RoleChanged => "role_changed"
NameChanged => "name_changed"
StateChanged => "state_changed"
VisibilityChanged => "visibility_changed"
}
}
///|
fn escape_json(s : String) -> String {
let buf = StringBuilder::new()
for c in s {
match c {
'"' => buf.write_string("\\\"")
'\\' => buf.write_string("\\\\")
'\n' => buf.write_string("\\n")
'\r' => buf.write_string("\\r")
'\t' => buf.write_string("\\t")
_ => buf.write_char(c)
}
}
buf.to_string()
}