car-browser 0.32.1

Browser automation and perception pipeline for Common Agent Runtime
//! Accessibility tree to UiElement converter.

use super::ui_map::{ElementSource, IconType, UiElement, UiRole, UiState};
use crate::models::A11yNode;

/// Converts A11yNodes from the browser's accessibility tree into UiElements.
pub struct AxConverter {
    /// Minimum bounds size to include an element.
    min_size: f64,
}

impl AxConverter {
    pub fn new() -> Self {
        Self { min_size: 1.0 }
    }

    /// Convert a list of A11yNodes to UiElements.
    ///
    /// Assigns stable IDs based on role priority then position.
    pub fn convert(&self, nodes: &[A11yNode]) -> Vec<UiElement> {
        let mut elements: Vec<UiElement> = nodes
            .iter()
            .filter(|n| self.should_include(n))
            .map(|n| self.convert_node(n))
            .collect();

        // Sort by role priority (interactable first) then by position (top-left)
        elements.sort_by(|a, b| {
            let a_priority = if a.role.is_interactable() { 0 } else { 1 };
            let b_priority = if b.role.is_interactable() { 0 } else { 1 };
            a_priority.cmp(&b_priority).then_with(|| {
                a.bounds
                    .y
                    .partial_cmp(&b.bounds.y)
                    .unwrap_or(std::cmp::Ordering::Equal)
                    .then_with(|| {
                        a.bounds
                            .x
                            .partial_cmp(&b.bounds.x)
                            .unwrap_or(std::cmp::Ordering::Equal)
                    })
            })
        });

        // Assign stable IDs
        for (i, element) in elements.iter_mut().enumerate() {
            element.id = format!("el_{}", i);
        }

        elements
    }

    fn should_include(&self, node: &A11yNode) -> bool {
        // Skip zero-size or very small elements
        if node.bounds.width < self.min_size || node.bounds.height < self.min_size {
            return false;
        }
        // Skip generic containers with no name
        let role = node.role.to_lowercase();
        if matches!(
            role.as_str(),
            "generic" | "group" | "div" | "section" | "webarea"
        ) && node.name.is_none()
        {
            return false;
        }
        true
    }

    fn convert_node(&self, node: &A11yNode) -> UiElement {
        let role = UiRole::from_ax_role(&node.role);
        let states = UiState::from_ax_states(node.disabled, node.focused, None, None, None);

        // Classify icon controls so an icon-only button (terse/symbolic AX
        // name, e.g. "✕" or "Close") carries a stable semantic handle the
        // agent can reason about. Only for clickable roles — text/containers
        // don't have actionable icons.
        let icon_type = if matches!(role, UiRole::Button | UiRole::Link | UiRole::MenuItem) {
            IconType::classify(node.name.as_deref())
        } else {
            None
        };

        let mut confidence = ElementSource::AccessibilityTree.base_confidence();
        if node.name.is_some() {
            confidence += 0.05;
        }
        if node.focusable {
            confidence += 0.02;
        }
        if node.disabled {
            confidence -= 0.05;
        }
        if node.bounds.width < 10.0 || node.bounds.height < 10.0 {
            confidence -= 0.10;
        }
        if node.bounds.x == 0.0 && node.bounds.y == 0.0 {
            confidence -= 0.05;
        }
        if matches!(role, UiRole::Container | UiRole::Other(_)) {
            confidence -= 0.05;
        }
        confidence = confidence.clamp(0.0, 1.0);

        UiElement {
            id: String::new(), // Assigned later during sorting
            role,
            name: node.name.clone(),
            value: node.value.clone(),
            bounds: node.bounds,
            states,
            confidence,
            source: ElementSource::AccessibilityTree,
            icon_type,
            children: node.children.clone(),
            ax_ref: Some(node.node_id.clone()),
        }
    }
}

impl Default for AxConverter {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::Bounds;

    #[test]
    fn test_convert_basic() {
        let converter = AxConverter::new();
        let nodes = vec![
            A11yNode {
                node_id: "n0".to_string(),
                role: "button".to_string(),
                name: Some("Submit".to_string()),
                value: None,
                bounds: Bounds::new(100.0, 100.0, 80.0, 30.0),
                children: vec![],
                focusable: true,
                focused: false,
                disabled: false,
            },
            A11yNode {
                node_id: "n1".to_string(),
                role: "link".to_string(),
                name: Some("Home".to_string()),
                value: None,
                bounds: Bounds::new(0.0, 0.0, 50.0, 20.0),
                children: vec![],
                focusable: true,
                focused: false,
                disabled: false,
            },
        ];

        let elements = converter.convert(&nodes);
        assert_eq!(elements.len(), 2);
        assert_eq!(elements[0].id, "el_0"); // Link at (0,0) first by position
        assert_eq!(elements[1].id, "el_1");
        assert!(elements[0].ax_ref.is_some());
    }

    #[test]
    fn test_icon_type_classified_for_clickable() {
        let converter = AxConverter::new();
        let nodes = vec![
            A11yNode {
                node_id: "n0".to_string(),
                role: "button".to_string(),
                name: Some("Close".to_string()),
                value: None,
                bounds: Bounds::new(10.0, 10.0, 20.0, 20.0),
                children: vec![],
                focusable: true,
                focused: false,
                disabled: false,
            },
            // A glyph-only button — the AX name IS the symbol.
            A11yNode {
                node_id: "n1".to_string(),
                role: "button".to_string(),
                name: Some("🔍".to_string()),
                value: None,
                bounds: Bounds::new(40.0, 10.0, 20.0, 20.0),
                children: vec![],
                focusable: true,
                focused: false,
                disabled: false,
            },
            // A plain text node must NOT get an icon even if its text matches.
            A11yNode {
                node_id: "n2".to_string(),
                role: "statictext".to_string(),
                name: Some("Search results".to_string()),
                value: None,
                bounds: Bounds::new(10.0, 40.0, 200.0, 20.0),
                children: vec![],
                focusable: false,
                focused: false,
                disabled: false,
            },
        ];
        let els = converter.convert(&nodes);
        let close = els.iter().find(|e| e.ax_ref.as_deref() == Some("n0")).unwrap();
        assert_eq!(close.icon_type, Some(IconType::Close));
        let search = els.iter().find(|e| e.ax_ref.as_deref() == Some("n1")).unwrap();
        assert_eq!(search.icon_type, Some(IconType::Search));
        let text = els.iter().find(|e| e.ax_ref.as_deref() == Some("n2")).unwrap();
        assert_eq!(text.icon_type, None, "non-clickable roles get no icon");
    }

    #[test]
    fn test_icon_only_button_shows_icon_label_in_output() {
        // An icon button whose AX name is the bare glyph should reach the
        // model with a usable handle, not a label-less "[el_0] button".
        let converter = AxConverter::new();
        let nodes = vec![A11yNode {
            node_id: "n0".to_string(),
            role: "button".to_string(),
            name: Some("".to_string()),
            value: None,
            bounds: Bounds::new(10.0, 10.0, 20.0, 20.0),
            children: vec![],
            focusable: true,
            focused: false,
            disabled: false,
        }];
        let el = &converter.convert(&nodes)[0];
        assert_eq!(el.icon_type, Some(IconType::Menu));
        // display_label prefers the glyph name, but a truly empty name falls to
        // the icon hint — verify the fallback directly.
        let mut nameless = el.clone();
        nameless.name = None;
        assert_eq!(nameless.display_label(40), " <icon: menu>");
    }

    #[test]
    fn test_filters_zero_size() {
        let converter = AxConverter::new();
        let nodes = vec![A11yNode {
            node_id: "n0".to_string(),
            role: "button".to_string(),
            name: Some("Hidden".to_string()),
            value: None,
            bounds: Bounds::new(0.0, 0.0, 0.0, 0.0),
            children: vec![],
            focusable: false,
            focused: false,
            disabled: false,
        }];

        let elements = converter.convert(&nodes);
        assert!(elements.is_empty());
    }

    #[test]
    fn test_filters_nameless_containers() {
        let converter = AxConverter::new();
        let nodes = vec![A11yNode {
            node_id: "n0".to_string(),
            role: "generic".to_string(),
            name: None,
            value: None,
            bounds: Bounds::new(0.0, 0.0, 1280.0, 720.0),
            children: vec![],
            focusable: false,
            focused: false,
            disabled: false,
        }];

        let elements = converter.convert(&nodes);
        assert!(elements.is_empty());
    }
}