Skip to main content

car_browser/perception/
ui_map.rs

1//! UI state representation for perception pipeline.
2//!
3//! UiMap is the core data structure that represents what's visible on screen
4//! in a structured, auditable format.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::models::{Bounds, Viewport};
11
12/// Complete UI state representation.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct UiMap {
15    pub id: String,
16    pub timestamp: DateTime<Utc>,
17    pub url: String,
18    pub elements: Vec<UiElement>,
19    pub text_blocks: Vec<TextBlock>,
20    pub page_signals: PageSignals,
21    pub viewport: Viewport,
22    pub content_hash: String,
23    pub screenshot_path: String,
24}
25
26impl UiMap {
27    pub fn new(
28        url: String,
29        elements: Vec<UiElement>,
30        text_blocks: Vec<TextBlock>,
31        page_signals: PageSignals,
32        viewport: Viewport,
33        screenshot_path: String,
34    ) -> Self {
35        let id = uuid::Uuid::new_v4().to_string();
36        let timestamp = Utc::now();
37        let mut map = Self {
38            id,
39            timestamp,
40            url,
41            elements,
42            text_blocks,
43            page_signals,
44            viewport,
45            content_hash: String::new(),
46            screenshot_path,
47        };
48        map.content_hash = map.compute_content_hash();
49        map
50    }
51
52    pub fn compute_content_hash(&self) -> String {
53        let mut hasher = Sha256::new();
54        for element in &self.elements {
55            hasher.update(element.id.as_bytes());
56            hasher.update(element.role.to_hash_string().as_bytes());
57            if let Some(name) = &element.name {
58                hasher.update(name.as_bytes());
59            }
60            hasher.update(element.states.to_hash_string().as_bytes());
61        }
62        for block in &self.text_blocks {
63            hasher.update(block.text.as_bytes());
64        }
65        hasher.update(self.page_signals.to_hash_string().as_bytes());
66        hex::encode(hasher.finalize())
67    }
68
69    pub fn get_element(&self, element_id: &str) -> Option<&UiElement> {
70        self.elements.iter().find(|e| e.id == element_id)
71    }
72
73    pub fn get_elements_by_role(&self, role: UiRole) -> Vec<&UiElement> {
74        self.elements
75            .iter()
76            .filter(|e| std::mem::discriminant(&e.role) == std::mem::discriminant(&role))
77            .collect()
78    }
79
80    pub fn interactive_elements(&self) -> Vec<&UiElement> {
81        self.elements
82            .iter()
83            .filter(|e| e.role.is_interactable() && e.is_interactable())
84            .collect()
85    }
86
87    pub fn estimate_tokens(&self, interactive_only: bool) -> usize {
88        let count = if interactive_only {
89            self.interactive_elements().len()
90        } else {
91            self.elements.len()
92        };
93        count * 20 + 30
94    }
95
96    pub fn average_confidence(&self) -> f32 {
97        if self.elements.is_empty() {
98            return 0.0;
99        }
100        let sum: f32 = self.elements.iter().map(|e| e.confidence).sum();
101        sum / self.elements.len() as f32
102    }
103
104    /// Format as a structured page summary for LLM consumption.
105    /// Includes: page signals, visible text content, and interactive elements.
106    /// Targets ~4KB output (vs 86KB for raw accessibility tree).
107    pub fn format_summary(&self) -> String {
108        use std::fmt::Write;
109        let mut output = String::new();
110
111        // Page signals
112        if self.page_signals.has_blocking_element() {
113            if self.page_signals.modal_present {
114                let _ = writeln!(output, "⚠ Modal dialog present");
115            }
116            if self.page_signals.cookie_banner {
117                let _ = writeln!(output, "⚠ Cookie banner present");
118            }
119        }
120        if self.page_signals.loading_indicator {
121            let _ = writeln!(output, "⏳ Page loading...");
122        }
123
124        // Visible text content — what a user would actually see
125        let _ = writeln!(output, "\n## Visible Text");
126        let mut seen_texts: std::collections::HashSet<String> = std::collections::HashSet::new();
127        for el in &self.elements {
128            if let Some(ref name) = el.name {
129                let text = name.trim().to_string();
130                if !text.is_empty() && text.len() > 1 && seen_texts.insert(text.clone()) {
131                    let role = el.role.to_hash_string();
132                    let truncated = if text.len() > 80 {
133                        let end = text.floor_char_boundary(77);
134                        format!("{}...", &text[..end])
135                    } else {
136                        text
137                    };
138                    let _ = writeln!(output, "  ({}) {}", role, truncated);
139                }
140            }
141        }
142
143        // Interactive elements — what the agent can click/type
144        let _ = writeln!(output, "\n## Interactive Elements");
145        let interactive = self.interactive_elements();
146        for (i, el) in interactive.iter().enumerate().take(50) {
147            let role_str = el.role.to_hash_string();
148            let name_str = el.display_label(40);
149            let mut state_parts = Vec::new();
150            if element_states_for_summary(&el.states, &mut state_parts) {
151                let _ = writeln!(
152                    output,
153                    "[{}] {}{} {}",
154                    el.id,
155                    role_str,
156                    name_str,
157                    state_parts.join(" ")
158                );
159            } else {
160                let _ = writeln!(output, "[{}] {}{}", el.id, role_str, name_str);
161            }
162            let _ = i; // suppress unused
163        }
164        if interactive.len() > 50 {
165            let _ = writeln!(
166                output,
167                "  ... and {} more interactive elements",
168                interactive.len() - 50
169            );
170        }
171
172        output
173    }
174
175    /// Format as compact text for LLM consumption.
176    ///
177    /// Format: `[el_0] Button "Submit" (120,340) focused`
178    pub fn format_compact(&self) -> String {
179        use std::fmt::Write;
180        let mut output = String::new();
181
182        // Page signals header
183        if self.page_signals.has_blocking_element() {
184            if self.page_signals.modal_present {
185                let _ = writeln!(output, "⚠ Modal dialog present");
186            }
187            if self.page_signals.cookie_banner {
188                let _ = writeln!(output, "⚠ Cookie banner present");
189            }
190        }
191        if self.page_signals.loading_indicator {
192            let _ = writeln!(output, "⏳ Page loading...");
193        }
194
195        // Use interactive-only if >40 elements (compact mode)
196        let elements: Vec<&UiElement> = if self.elements.len() > 40 {
197            self.interactive_elements()
198        } else {
199            self.elements.iter().collect()
200        };
201
202        for element in &elements {
203            let role_str = element.role.to_hash_string();
204            let name_str = element.display_label(50);
205
206            let (cx, cy) = element.bounds.center();
207            let pos_str = format!(" ({:.0},{:.0})", cx, cy);
208
209            let mut state_parts = Vec::new();
210            if element.states.focused {
211                state_parts.push("focused");
212            }
213            if !element.states.enabled {
214                state_parts.push("disabled");
215            }
216            if element.states.checked == Some(true) {
217                state_parts.push("checked");
218            }
219            if element.states.expanded == Some(true) {
220                state_parts.push("expanded");
221            }
222            let state_str = if state_parts.is_empty() {
223                String::new()
224            } else {
225                format!(" {}", state_parts.join(" "))
226            };
227
228            let _ = writeln!(
229                output,
230                "[{}] {}{}{}{}",
231                element.id, role_str, name_str, pos_str, state_str
232            );
233        }
234
235        output
236    }
237}
238
239fn element_states_for_summary(states: &UiState, parts: &mut Vec<&'static str>) -> bool {
240    if states.focused {
241        parts.push("focused");
242    }
243    if !states.enabled {
244        parts.push("disabled");
245    }
246    if states.checked == Some(true) {
247        parts.push("checked");
248    }
249    if states.expanded == Some(true) {
250        parts.push("expanded");
251    }
252    !parts.is_empty()
253}
254
255/// A single interactable UI element.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct UiElement {
258    pub id: String,
259    pub role: UiRole,
260    pub name: Option<String>,
261    /// Current value (for form inputs — text content, selected option, etc.)
262    pub value: Option<String>,
263    pub bounds: Bounds,
264    pub states: UiState,
265    pub confidence: f32,
266    pub source: ElementSource,
267    pub icon_type: Option<IconType>,
268    pub children: Vec<String>,
269    /// Original AX node ID for execution mapping.
270    pub ax_ref: Option<String>,
271}
272
273impl UiElement {
274    pub fn is_interactable(&self) -> bool {
275        self.states.enabled && self.bounds.width > 0.0 && self.bounds.height > 0.0
276    }
277
278    pub fn center(&self) -> (f64, f64) {
279        self.bounds.center()
280    }
281
282    pub fn accepts_text(&self) -> bool {
283        matches!(self.role, UiRole::TextInput)
284    }
285
286    pub fn is_clickable(&self) -> bool {
287        matches!(self.role, UiRole::Button | UiRole::Link)
288    }
289
290    /// Label suffix for prompt output. Prefers the accessible name; falls back
291    /// to the classified icon (`<icon: search>`) so an icon-only control the AX
292    /// tree left nameless still reaches the agent with an actionable handle.
293    /// Empty string when there is neither a name nor a recognized icon.
294    pub fn display_label(&self, max_len: usize) -> String {
295        if let Some(name) = self
296            .name
297            .as_deref()
298            .map(str::trim)
299            .filter(|n| !n.is_empty())
300        {
301            if name.chars().count() > max_len {
302                let truncated: String = name.chars().take(max_len.saturating_sub(3)).collect();
303                format!(" \"{truncated}...\"")
304            } else {
305                format!(" \"{name}\"")
306            }
307        } else if let Some(icon) = self.icon_type {
308            format!(" <icon: {}>", icon.label())
309        } else {
310            String::new()
311        }
312    }
313}
314
315/// Element roles (simplified from full ARIA).
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(rename_all = "snake_case")]
318pub enum UiRole {
319    Button,
320    Link,
321    TextInput,
322    Checkbox,
323    Radio,
324    Dropdown,
325    Menu,
326    MenuItem,
327    Tab,
328    Dialog,
329    Image,
330    Text,
331    Container,
332    List,
333    ListItem,
334    Table,
335    TableRow,
336    TableCell,
337    Toolbar,
338    Other(String),
339}
340
341impl UiRole {
342    pub fn from_ax_role(ax_role: &str) -> Self {
343        match ax_role.to_lowercase().as_str() {
344            "button" | "pushbutton" => UiRole::Button,
345            "link" | "weblink" => UiRole::Link,
346            "textbox" | "textfield" | "textarea" | "combobox" | "searchfield" => UiRole::TextInput,
347            "checkbox" => UiRole::Checkbox,
348            "radio" | "radiobutton" => UiRole::Radio,
349            "select" | "listbox" | "popupbutton" => UiRole::Dropdown,
350            "menu" | "menubar" => UiRole::Menu,
351            "menuitem" | "menuitemcheckbox" | "menuitemradio" => UiRole::MenuItem,
352            "tab" | "tabitem" => UiRole::Tab,
353            "dialog" | "alertdialog" | "sheet" => UiRole::Dialog,
354            "image" | "img" => UiRole::Image,
355            "statictext" | "label" | "heading" => UiRole::Text,
356            "group" | "generic" | "section" | "div" | "webarea" => UiRole::Container,
357            "list" => UiRole::List,
358            "listitem" => UiRole::ListItem,
359            "table" | "grid" => UiRole::Table,
360            "row" | "tablerow" => UiRole::TableRow,
361            "cell" | "tablecell" | "gridcell" => UiRole::TableCell,
362            "toolbar" => UiRole::Toolbar,
363            other => UiRole::Other(other.to_string()),
364        }
365    }
366
367    pub fn is_interactable(&self) -> bool {
368        matches!(
369            self,
370            UiRole::Button
371                | UiRole::Link
372                | UiRole::TextInput
373                | UiRole::Checkbox
374                | UiRole::Radio
375                | UiRole::Dropdown
376                | UiRole::MenuItem
377                | UiRole::Tab
378        )
379    }
380
381    pub fn to_hash_string(&self) -> String {
382        match self {
383            UiRole::Button => "button".to_string(),
384            UiRole::Link => "link".to_string(),
385            UiRole::TextInput => "text_input".to_string(),
386            UiRole::Checkbox => "checkbox".to_string(),
387            UiRole::Radio => "radio".to_string(),
388            UiRole::Dropdown => "dropdown".to_string(),
389            UiRole::Menu => "menu".to_string(),
390            UiRole::MenuItem => "menu_item".to_string(),
391            UiRole::Tab => "tab".to_string(),
392            UiRole::Dialog => "dialog".to_string(),
393            UiRole::Image => "image".to_string(),
394            UiRole::Text => "text".to_string(),
395            UiRole::Container => "container".to_string(),
396            UiRole::List => "list".to_string(),
397            UiRole::ListItem => "list_item".to_string(),
398            UiRole::Table => "table".to_string(),
399            UiRole::TableRow => "table_row".to_string(),
400            UiRole::TableCell => "table_cell".to_string(),
401            UiRole::Toolbar => "toolbar".to_string(),
402            UiRole::Other(s) => format!("other:{}", s),
403        }
404    }
405}
406
407/// Element state flags.
408#[derive(Debug, Clone, Default, Serialize, Deserialize)]
409pub struct UiState {
410    pub enabled: bool,
411    pub focused: bool,
412    pub selected: bool,
413    pub checked: Option<bool>,
414    pub expanded: Option<bool>,
415    pub readonly: bool,
416    pub required: bool,
417}
418
419impl UiState {
420    pub fn enabled() -> Self {
421        Self {
422            enabled: true,
423            ..Default::default()
424        }
425    }
426
427    pub fn disabled() -> Self {
428        Self {
429            enabled: false,
430            ..Default::default()
431        }
432    }
433
434    pub fn from_ax_states(
435        disabled: bool,
436        focused: bool,
437        selected: Option<bool>,
438        checked: Option<bool>,
439        expanded: Option<bool>,
440    ) -> Self {
441        Self {
442            enabled: !disabled,
443            focused,
444            selected: selected.unwrap_or(false),
445            checked,
446            expanded,
447            readonly: false,
448            required: false,
449        }
450    }
451
452    pub fn to_hash_string(&self) -> String {
453        fn opt_bool_str(opt: Option<bool>) -> &'static str {
454            match opt {
455                None => "none",
456                Some(true) => "true",
457                Some(false) => "false",
458            }
459        }
460        format!(
461            "en:{},fo:{},se:{},ch:{},ex:{},ro:{},rq:{}",
462            self.enabled,
463            self.focused,
464            self.selected,
465            opt_bool_str(self.checked),
466            opt_bool_str(self.expanded),
467            self.readonly,
468            self.required
469        )
470    }
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
474#[serde(rename_all = "snake_case")]
475pub enum ElementSource {
476    AccessibilityTree,
477    VisualDetector,
478    Ocr,
479    Merged { sources: Vec<ElementSource> },
480}
481
482impl ElementSource {
483    pub fn base_confidence(&self) -> f32 {
484        match self {
485            ElementSource::AccessibilityTree => 0.90,
486            ElementSource::VisualDetector => 0.75,
487            ElementSource::Ocr => 0.70,
488            ElementSource::Merged { .. } => 0.98,
489        }
490    }
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
494#[serde(rename_all = "snake_case")]
495pub enum IconType {
496    Close,
497    Menu,
498    Search,
499    Back,
500    Forward,
501    Refresh,
502    Settings,
503    Share,
504    Download,
505    Upload,
506    Edit,
507    Delete,
508    Add,
509    Remove,
510    Expand,
511    Collapse,
512    Play,
513    Pause,
514    Stop,
515    Mute,
516    Unmute,
517    Fullscreen,
518    ExitFullscreen,
519    Info,
520    Help,
521    Warning,
522    Error,
523    Success,
524    Unknown,
525}
526
527impl IconType {
528    /// Best-effort classification of an icon control from its accessible name
529    /// (or `aria-label`, title, or a bare glyph). Icon-only buttons often have
530    /// a terse or symbolic name the LLM can't act on confidently; mapping it to
531    /// a known [`IconType`] gives the agent a stable semantic handle (e.g. the
532    /// "✕"/"close"/"dismiss" button is all `Close`). Returns `None` when the
533    /// name carries no recognizable icon hint, so a genuine text label is never
534    /// overwritten with a guess.
535    pub fn classify(name: Option<&str>) -> Option<IconType> {
536        let raw = name?.trim();
537        if raw.is_empty() {
538            return None;
539        }
540        // Single-glyph symbol buttons (the AX name IS the glyph).
541        if let Some(icon) = match raw {
542            "×" | "✕" | "✖" | "⨯" | "╳" => Some(IconType::Close),
543            "☰" | "≡" => Some(IconType::Menu),
544            "🔍" | "⌕" => Some(IconType::Search),
545            "←" | "‹" | "◀" => Some(IconType::Back),
546            "→" | "›" | "▶" => Some(IconType::Forward),
547            "↻" | "⟳" | "⭮" => Some(IconType::Refresh),
548            "⚙" | "⚙️" => Some(IconType::Settings),
549            "⬇" | "↓" => Some(IconType::Download),
550            "⬆" | "↑" => Some(IconType::Upload),
551            "✏" | "✎" => Some(IconType::Edit),
552            "🗑" | "🗑️" => Some(IconType::Delete),
553            "+" | "+" => Some(IconType::Add),
554            "−" | "-" | "–" => Some(IconType::Remove),
555            "▼" | "˅" | "⌄" => Some(IconType::Expand),
556            "▲" | "˄" | "⌃" => Some(IconType::Collapse),
557            "ℹ" | "ℹ️" => Some(IconType::Info),
558            "?" | "?" => Some(IconType::Help),
559            "⚠" | "⚠️" => Some(IconType::Warning),
560            _ => None,
561        } {
562            return Some(icon);
563        }
564        // Whole-WORD match against the name's tokens — NOT substring. Substring
565        // matching false-fires constantly ("feedback"→back, "display"→play,
566        // "address"→add, "credit"→edit), so tokenize on non-alphanumerics and
567        // require an exact token (or, for the two-word labels, both tokens).
568        let n = raw.to_lowercase();
569        let tokens: Vec<&str> = n
570            .split(|c: char| !c.is_alphanumeric())
571            .filter(|t| !t.is_empty())
572            .collect();
573        let has = |word: &str| tokens.contains(&word);
574        // Two-word "exit fullscreen" must be checked before the bare "fullscreen".
575        let icon = if (has("exit") && has("fullscreen"))
576            || (has("exit") && has("full") && has("screen"))
577        {
578            IconType::ExitFullscreen
579        } else if has("fullscreen") || (has("full") && has("screen")) {
580            IconType::Fullscreen
581        } else if has("close") || has("dismiss") {
582            IconType::Close
583        } else if has("hamburger") || has("menu") {
584            IconType::Menu
585        } else if has("search") {
586            IconType::Search
587        } else if has("previous") || has("back") {
588            IconType::Back
589        } else if has("forward") || has("next") {
590            IconType::Forward
591        } else if has("refresh") || has("reload") {
592            IconType::Refresh
593        } else if has("settings") || has("preferences") {
594            IconType::Settings
595        } else if has("share") {
596            IconType::Share
597        } else if has("download") {
598            IconType::Download
599        } else if has("upload") {
600            IconType::Upload
601        } else if has("edit") {
602            IconType::Edit
603        } else if has("delete") || has("trash") {
604            IconType::Delete
605        } else if has("remove") {
606            IconType::Remove
607        } else if has("unmute") {
608            IconType::Unmute
609        } else if has("mute") {
610            IconType::Mute
611        } else if has("pause") {
612            IconType::Pause
613        } else if has("play") {
614            IconType::Play
615        } else if has("expand") {
616            IconType::Expand
617        } else if has("collapse") {
618            IconType::Collapse
619        } else if has("help") {
620            IconType::Help
621        } else if has("info") {
622            IconType::Info
623        } else {
624            return None;
625        };
626        Some(icon)
627    }
628
629    /// Lowercase label for prompt output, e.g. `exit_fullscreen`.
630    pub fn label(&self) -> &'static str {
631        match self {
632            IconType::Close => "close",
633            IconType::Menu => "menu",
634            IconType::Search => "search",
635            IconType::Back => "back",
636            IconType::Forward => "forward",
637            IconType::Refresh => "refresh",
638            IconType::Settings => "settings",
639            IconType::Share => "share",
640            IconType::Download => "download",
641            IconType::Upload => "upload",
642            IconType::Edit => "edit",
643            IconType::Delete => "delete",
644            IconType::Add => "add",
645            IconType::Remove => "remove",
646            IconType::Expand => "expand",
647            IconType::Collapse => "collapse",
648            IconType::Play => "play",
649            IconType::Pause => "pause",
650            IconType::Stop => "stop",
651            IconType::Mute => "mute",
652            IconType::Unmute => "unmute",
653            IconType::Fullscreen => "fullscreen",
654            IconType::ExitFullscreen => "exit_fullscreen",
655            IconType::Info => "info",
656            IconType::Help => "help",
657            IconType::Warning => "warning",
658            IconType::Error => "error",
659            IconType::Success => "success",
660            IconType::Unknown => "unknown",
661        }
662    }
663}
664
665#[derive(Debug, Clone, Serialize, Deserialize)]
666pub struct TextBlock {
667    pub text: String,
668    pub bounds: Bounds,
669    pub source: TextSource,
670    pub confidence: f32,
671}
672
673impl TextBlock {
674    pub fn from_ax(text: String, bounds: Bounds) -> Self {
675        Self {
676            text,
677            bounds,
678            source: TextSource::AccessibilityTree,
679            confidence: 1.0,
680        }
681    }
682
683    /// Text recovered from the screenshot via OCR (text the accessibility tree
684    /// didn't expose — canvas/image content).
685    pub fn from_ocr(text: String, bounds: Bounds, confidence: f32) -> Self {
686        Self {
687            text,
688            bounds,
689            source: TextSource::Ocr,
690            confidence,
691        }
692    }
693}
694
695#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
696#[serde(rename_all = "snake_case")]
697pub enum TextSource {
698    AccessibilityTree,
699    Ocr,
700}
701
702#[derive(Debug, Clone, Default, Serialize, Deserialize)]
703pub struct PageSignals {
704    pub modal_present: bool,
705    pub cookie_banner: bool,
706    pub error_banner: bool,
707    pub loading_indicator: bool,
708    pub scroll_position: f32,
709    pub page_type_hint: Option<String>,
710}
711
712impl PageSignals {
713    pub fn has_blocking_element(&self) -> bool {
714        self.modal_present || self.cookie_banner
715    }
716
717    pub fn needs_special_handling(&self) -> bool {
718        matches!(
719            self.page_type_hint.as_deref(),
720            Some("login") | Some("checkout") | Some("payment")
721        )
722    }
723
724    pub fn to_hash_string(&self) -> String {
725        format!(
726            "mo:{},co:{},er:{},lo:{},sc:{:.2},ty:{}",
727            self.modal_present,
728            self.cookie_banner,
729            self.error_banner,
730            self.loading_indicator,
731            self.scroll_position,
732            self.page_type_hint.as_deref().unwrap_or("none")
733        )
734    }
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740
741    #[test]
742    fn test_icon_classify_whole_word_not_substring() {
743        // Positive: glyphs and whole-word labels.
744        assert_eq!(IconType::classify(Some("✕")), Some(IconType::Close));
745        assert_eq!(IconType::classify(Some("Close")), Some(IconType::Close));
746        assert_eq!(IconType::classify(Some("Go back")), Some(IconType::Back));
747        assert_eq!(IconType::classify(Some("Search")), Some(IconType::Search));
748        assert_eq!(IconType::classify(Some("Unmute")), Some(IconType::Unmute));
749        assert_eq!(IconType::classify(Some("Mute")), Some(IconType::Mute));
750        assert_eq!(
751            IconType::classify(Some("Exit fullscreen")),
752            Some(IconType::ExitFullscreen)
753        );
754        assert_eq!(
755            IconType::classify(Some("Full screen")),
756            Some(IconType::Fullscreen)
757        );
758        // Negative: substrings that must NOT false-fire.
759        assert_eq!(
760            IconType::classify(Some("feedback")),
761            None,
762            "'back' substring"
763        );
764        assert_eq!(
765            IconType::classify(Some("display options")),
766            None,
767            "'play' substring"
768        );
769        assert_eq!(IconType::classify(Some("address")), None, "'add' substring");
770        assert_eq!(
771            IconType::classify(Some("credit card")),
772            None,
773            "'edit' substring"
774        );
775        assert_eq!(IconType::classify(Some("")), None);
776        assert_eq!(IconType::classify(None), None);
777    }
778
779    #[test]
780    fn test_ui_role_from_ax() {
781        assert_eq!(UiRole::from_ax_role("button"), UiRole::Button);
782        assert_eq!(UiRole::from_ax_role("textfield"), UiRole::TextInput);
783        assert!(matches!(UiRole::from_ax_role("custom"), UiRole::Other(_)));
784    }
785
786    #[test]
787    fn test_format_compact() {
788        let viewport = Viewport {
789            width: 1280,
790            height: 720,
791            device_pixel_ratio: 2.0,
792        };
793        let map = UiMap::new(
794            "https://example.com".to_string(),
795            vec![UiElement {
796                id: "el_0".to_string(),
797                role: UiRole::Button,
798                name: Some("Submit".to_string()),
799                value: None,
800                bounds: Bounds::new(100.0, 100.0, 80.0, 30.0),
801                states: UiState {
802                    focused: true,
803                    ..UiState::enabled()
804                },
805                confidence: 0.95,
806                source: ElementSource::AccessibilityTree,
807                icon_type: None,
808                children: vec![],
809                ax_ref: None,
810            }],
811            vec![],
812            PageSignals::default(),
813            viewport,
814            String::new(),
815        );
816        let compact = map.format_compact();
817        assert!(compact.contains("[el_0]"));
818        assert!(compact.contains("button"));
819        assert!(compact.contains("Submit"));
820        assert!(compact.contains("focused"));
821    }
822}