agent_tui/core/vom/
classifier.rs

1use super::Cluster;
2use super::Component;
3use super::Role;
4use super::hash_cluster;
5use super::patterns::{
6    is_button_text, is_checkbox, is_code_block_border, is_diff_line, is_error_message,
7    is_input_field, is_link, is_menu_item, is_panel_border, is_progress_bar, is_prompt_marker,
8    is_status_indicator, is_tool_block_border,
9};
10use crate::core::CursorPosition;
11use crate::core::style::Color;
12
13/// ANSI indexed color for blue background (indicates tab in many TUIs)
14const TAB_BG_BLUE: u8 = 4;
15/// ANSI indexed color for cyan background (indicates tab in many TUIs)
16const TAB_BG_CYAN: u8 = 6;
17
18/// Options for the classification phase.
19#[derive(Debug, Clone)]
20pub struct ClassifyOptions {
21    /// Row threshold for Tab detection (elements on row <= threshold with inverse are Tabs).
22    pub tab_row_threshold: u16,
23}
24
25impl Default for ClassifyOptions {
26    fn default() -> Self {
27        Self {
28            tab_row_threshold: 2,
29        }
30    }
31}
32
33pub fn classify(
34    clusters: Vec<Cluster>,
35    cursor: &CursorPosition,
36    options: &ClassifyOptions,
37) -> Vec<Component> {
38    clusters
39        .into_iter()
40        .map(|cluster| {
41            let role = infer_role(&cluster, cursor, options);
42            let visual_hash = hash_cluster(&cluster);
43            let selected = is_selected(&cluster);
44
45            Component::with_selected(role, cluster.rect, cluster.text, visual_hash, selected)
46        })
47        .collect()
48}
49
50fn is_selected(cluster: &Cluster) -> bool {
51    cluster.style.inverse || cluster.text.starts_with('❯')
52}
53
54/// Infers the role of a cluster based on its content and style.
55///
56/// # Classification Priority Order
57///
58/// The order of checks is important because some patterns overlap. The priority is:
59///
60/// 1. **Cursor position** → Input (cursor within cluster bounds)
61/// 2. **Button text** → Button (bracketed text like `[OK]`, `<Cancel>`)
62/// 3. **Inverse style** → Tab or MenuItem (based on row threshold)
63/// 4. **Tab background color** → Tab (blue/cyan background)
64/// 5. **Error prefixes** → ErrorMessage (`Error:`, `✗`)
65/// 6. **Input field patterns** → Input (`___`, `: _`)
66/// 7. **Checkbox markers** → Checkbox (`[x]`, `☐`)
67/// 8. **Prompt marker** → PromptMarker (`>` alone) - BEFORE MenuItem!
68/// 9. **Menu item prefixes** → MenuItem (`> `, `- `, `• `) - BEFORE Link/DiffLine!
69/// 10. **URL/file paths** → Link (`https://`, `src/main.rs`)
70/// 11. **Progress bar chars** → ProgressBar (`████░░░░`)
71/// 12. **Diff line markers** → DiffLine (`+`, `-` without space, `@@`)
72/// 13. **Tool block borders** → ToolBlock (rounded corners `╭╮╰╯`)
73/// 14. **Code block borders** → CodeBlock (vertical line `│`)
74/// 15. **Panel borders** → Panel (box drawing chars)
75/// 16. **Status indicators** → Status (spinners, checkmarks)
76/// 17. **Default** → StaticText
77///
78/// # Why Order Matters
79///
80/// - `PromptMarker` must precede `MenuItem` because `>` alone is a prompt, not a menu
81/// - `MenuItem` must precede `Link` because `> src/main.rs` is a menu item, not a link
82/// - `MenuItem` must precede `DiffLine` because `- List item` is a menu, not a diff deletion
83fn infer_role(cluster: &Cluster, cursor: &CursorPosition, options: &ClassifyOptions) -> Role {
84    let text = cluster.text.trim();
85
86    // If cursor is within this cluster's bounds, it's an input field
87    if cluster.rect.y == cursor.row
88        && cursor.col >= cluster.rect.x
89        && cursor.col < cluster.rect.x + cluster.rect.width
90    {
91        return Role::Input;
92    }
93
94    if is_button_text(text) {
95        return Role::Button;
96    }
97
98    if cluster.style.inverse {
99        if cluster.rect.y <= options.tab_row_threshold {
100            return Role::Tab;
101        }
102        return Role::MenuItem;
103    }
104
105    if let Some(Color::Indexed(idx)) = &cluster.style.bg_color {
106        if *idx == TAB_BG_BLUE || *idx == TAB_BG_CYAN {
107            return Role::Tab;
108        }
109    }
110
111    if is_error_message(text) {
112        return Role::ErrorMessage;
113    }
114
115    if is_input_field(text) {
116        return Role::Input;
117    }
118
119    if is_checkbox(text) {
120        return Role::Checkbox;
121    }
122
123    // PromptMarker must be checked before MenuItem because ">" alone is a prompt,
124    // not a menu item. MenuItem requires content after the prefix.
125    if is_prompt_marker(text) {
126        return Role::PromptMarker;
127    }
128
129    // Menu items are checked before Link and DiffLine because they use distinctive
130    // prefixes (>, ❯, -, •, *) that could otherwise match those patterns.
131    // For example, "> src/main.rs" should be MenuItem, not Link.
132    // And "- List item" should be MenuItem, not DiffLine.
133    if is_menu_item(text) {
134        return Role::MenuItem;
135    }
136
137    if is_link(text) {
138        return Role::Link;
139    }
140
141    if is_progress_bar(text) {
142        return Role::ProgressBar;
143    }
144
145    if is_diff_line(text) {
146        return Role::DiffLine;
147    }
148
149    if is_tool_block_border(text) {
150        return Role::ToolBlock;
151    }
152
153    if is_code_block_border(text) {
154        return Role::CodeBlock;
155    }
156
157    if is_panel_border(text) {
158        return Role::Panel;
159    }
160
161    if is_status_indicator(text) {
162        return Role::Status;
163    }
164
165    Role::StaticText
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::core::style::CellStyle;
172    use crate::core::vom::Rect;
173
174    fn make_cluster(text: &str, style: CellStyle, x: u16, y: u16) -> Cluster {
175        Cluster {
176            rect: Rect::new(x, y, text.len() as u16, 1),
177            text: text.to_string(),
178            style,
179            is_whitespace: false,
180        }
181    }
182
183    fn default_opts() -> ClassifyOptions {
184        ClassifyOptions::default()
185    }
186
187    fn cursor(row: u16, col: u16) -> CursorPosition {
188        CursorPosition {
189            row,
190            col,
191            visible: true,
192        }
193    }
194
195    fn no_cursor() -> CursorPosition {
196        cursor(99, 99)
197    }
198
199    #[test]
200    fn test_button_detection() {
201        let cluster = make_cluster("[Submit]", CellStyle::default(), 0, 0);
202        let role = infer_role(&cluster, &no_cursor(), &default_opts());
203        assert_eq!(role, Role::Button);
204    }
205
206    #[test]
207    fn test_checkbox_not_button() {
208        let cluster = make_cluster("[x]", CellStyle::default(), 0, 0);
209        let role = infer_role(&cluster, &no_cursor(), &default_opts());
210        assert_eq!(role, Role::Checkbox);
211    }
212
213    #[test]
214    fn test_input_from_cursor() {
215        let cluster = make_cluster("Hello", CellStyle::default(), 0, 0);
216
217        let role = infer_role(&cluster, &cursor(0, 2), &default_opts());
218        assert_eq!(role, Role::Input);
219    }
220
221    #[test]
222    fn test_input_from_underscores() {
223        let cluster = make_cluster("Name: ___", CellStyle::default(), 0, 0);
224        let role = infer_role(&cluster, &no_cursor(), &default_opts());
225        assert_eq!(role, Role::Input);
226    }
227
228    #[test]
229    fn test_tab_from_inverse() {
230        let cluster = make_cluster(
231            "Tab1",
232            CellStyle {
233                inverse: true,
234                ..Default::default()
235            },
236            0,
237            0,
238        );
239        let role = infer_role(&cluster, &no_cursor(), &default_opts());
240        assert_eq!(role, Role::Tab);
241    }
242
243    #[test]
244    fn test_tab_from_blue_bg() {
245        let cluster = make_cluster(
246            "Tab2",
247            CellStyle {
248                bg_color: Some(Color::Indexed(4)),
249                ..Default::default()
250            },
251            0,
252            0,
253        );
254        let role = infer_role(&cluster, &no_cursor(), &default_opts());
255        assert_eq!(role, Role::Tab);
256    }
257
258    #[test]
259    fn test_menu_item() {
260        let cluster = make_cluster("> Option 1", CellStyle::default(), 0, 5);
261        let role = infer_role(&cluster, &no_cursor(), &default_opts());
262        assert_eq!(role, Role::MenuItem);
263    }
264
265    #[test]
266    fn test_static_text_default() {
267        let cluster = make_cluster("Hello World", CellStyle::default(), 0, 5);
268        let role = infer_role(&cluster, &no_cursor(), &default_opts());
269        assert_eq!(role, Role::StaticText);
270    }
271
272    #[test]
273    fn test_classify_multiple() {
274        let clusters = vec![
275            make_cluster("[OK]", CellStyle::default(), 0, 0),
276            make_cluster("Cancel", CellStyle::default(), 10, 0),
277            make_cluster("[ ]", CellStyle::default(), 20, 0),
278        ];
279
280        let components = classify(clusters, &no_cursor(), &default_opts());
281
282        assert_eq!(components.len(), 3);
283        assert_eq!(components[0].role, Role::Button);
284        assert_eq!(components[1].role, Role::StaticText);
285        assert_eq!(components[2].role, Role::Checkbox);
286    }
287
288    #[test]
289    fn test_cursor_at_cluster_start_boundary() {
290        let cluster = make_cluster("Hello", CellStyle::default(), 10, 5);
291
292        let role = infer_role(&cluster, &cursor(5, 10), &default_opts());
293        assert_eq!(role, Role::Input);
294    }
295
296    #[test]
297    fn test_cursor_at_cluster_end_boundary() {
298        let cluster = make_cluster("Hello", CellStyle::default(), 10, 5);
299
300        let role = infer_role(&cluster, &cursor(5, 14), &default_opts());
301        assert_eq!(role, Role::Input);
302    }
303
304    #[test]
305    fn test_cursor_past_cluster_end() {
306        let cluster = make_cluster("Hello", CellStyle::default(), 10, 5);
307
308        let role = infer_role(&cluster, &cursor(5, 15), &default_opts());
309        assert_eq!(role, Role::StaticText);
310    }
311
312    #[test]
313    fn test_status_spinner_braille() {
314        // Braille spinner characters used in CLI loaders
315        for spinner in ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] {
316            let text = format!("{} Loading...", spinner);
317            let cluster = make_cluster(&text, CellStyle::default(), 0, 0);
318            let role = infer_role(&cluster, &no_cursor(), &default_opts());
319            assert_eq!(role, Role::Status, "Failed for spinner: {}", spinner);
320        }
321    }
322
323    #[test]
324    fn test_status_spinner_circle() {
325        // Circle spinner characters
326        for spinner in ['◐', '◑', '◒', '◓'] {
327            let text = format!("{} Processing", spinner);
328            let cluster = make_cluster(&text, CellStyle::default(), 0, 0);
329            let role = infer_role(&cluster, &no_cursor(), &default_opts());
330            assert_eq!(role, Role::Status, "Failed for spinner: {}", spinner);
331        }
332    }
333
334    #[test]
335    fn test_status_thinking_text() {
336        let cluster = make_cluster("⠋ Thinking...", CellStyle::default(), 0, 0);
337        let role = infer_role(&cluster, &no_cursor(), &default_opts());
338        assert_eq!(role, Role::Status);
339    }
340
341    #[test]
342    fn test_status_done_indicator() {
343        let cluster = make_cluster("✓ Done", CellStyle::default(), 0, 0);
344        let role = infer_role(&cluster, &no_cursor(), &default_opts());
345        assert_eq!(role, Role::Status);
346    }
347
348    #[test]
349    fn test_status_checkmark_complete() {
350        let cluster = make_cluster("✔ Complete", CellStyle::default(), 0, 0);
351        let role = infer_role(&cluster, &no_cursor(), &default_opts());
352        assert_eq!(role, Role::Status);
353    }
354
355    #[test]
356    fn test_status_not_regular_text() {
357        // Regular text should NOT be detected as status
358        let cluster = make_cluster("Hello World", CellStyle::default(), 0, 0);
359        let role = infer_role(&cluster, &no_cursor(), &default_opts());
360        assert_ne!(role, Role::Status);
361    }
362
363    #[test]
364    fn test_tool_block_top_border() {
365        // Rounded top border with title: ╭─ Write ─╮
366        let cluster = make_cluster(
367            "╭─ Write ─────────────────────╮",
368            CellStyle::default(),
369            0,
370            0,
371        );
372        let role = infer_role(&cluster, &no_cursor(), &default_opts());
373        assert_eq!(role, Role::ToolBlock);
374    }
375
376    #[test]
377    fn test_tool_block_bottom_border() {
378        // Rounded bottom border: ╰──────────────────────────────╯
379        let cluster = make_cluster(
380            "╰──────────────────────────────╯",
381            CellStyle::default(),
382            0,
383            0,
384        );
385        let role = infer_role(&cluster, &no_cursor(), &default_opts());
386        assert_eq!(role, Role::ToolBlock);
387    }
388
389    #[test]
390    fn test_tool_block_not_regular_panel() {
391        // Regular panel border (square corners) should be Panel, not ToolBlock
392        let cluster = make_cluster(
393            "┌──────────────────────────────┐",
394            CellStyle::default(),
395            0,
396            0,
397        );
398        let role = infer_role(&cluster, &no_cursor(), &default_opts());
399        assert_eq!(role, Role::Panel);
400    }
401
402    #[test]
403    fn test_prompt_marker_simple() {
404        // Simple prompt marker at start of line
405        let cluster = make_cluster(">", CellStyle::default(), 0, 5);
406        let role = infer_role(&cluster, &no_cursor(), &default_opts());
407        assert_eq!(role, Role::PromptMarker);
408    }
409
410    #[test]
411    fn test_prompt_marker_with_space() {
412        // Prompt marker with trailing space
413        let cluster = make_cluster("> ", CellStyle::default(), 0, 5);
414        let role = infer_role(&cluster, &no_cursor(), &default_opts());
415        assert_eq!(role, Role::PromptMarker);
416    }
417
418    #[test]
419    fn test_prompt_marker_not_menu_item() {
420        // Menu item with content after > should be MenuItem, not PromptMarker
421        let cluster = make_cluster("> Option 1", CellStyle::default(), 0, 5);
422        let role = infer_role(&cluster, &no_cursor(), &default_opts());
423        assert_eq!(role, Role::MenuItem);
424    }
425
426    #[test]
427    fn test_prompt_marker_is_interactive() {
428        assert!(Role::PromptMarker.is_interactive());
429    }
430
431    #[test]
432    fn test_yn_button_y_with_spaces() {
433        let cluster = make_cluster("[ Y ]", CellStyle::default(), 0, 0);
434        let role = infer_role(&cluster, &no_cursor(), &default_opts());
435        assert_eq!(role, Role::Button);
436    }
437
438    #[test]
439    fn test_yn_button_n_with_spaces() {
440        let cluster = make_cluster("[ N ]", CellStyle::default(), 0, 0);
441        let role = infer_role(&cluster, &no_cursor(), &default_opts());
442        assert_eq!(role, Role::Button);
443    }
444
445    #[test]
446    fn test_yn_button_yes() {
447        let cluster = make_cluster("[Yes]", CellStyle::default(), 0, 0);
448        let role = infer_role(&cluster, &no_cursor(), &default_opts());
449        assert_eq!(role, Role::Button);
450    }
451
452    #[test]
453    fn test_yn_button_no() {
454        let cluster = make_cluster("[No]", CellStyle::default(), 0, 0);
455        let role = infer_role(&cluster, &no_cursor(), &default_opts());
456        assert_eq!(role, Role::Button);
457    }
458
459    #[test]
460    fn test_yn_not_checkbox() {
461        // Single letter checkboxes should still be detected
462        let cluster = make_cluster("[x]", CellStyle::default(), 0, 0);
463        let role = infer_role(&cluster, &no_cursor(), &default_opts());
464        assert_eq!(role, Role::Checkbox);
465    }
466
467    // ============================================================
468    // NEW ROLE TESTS - Phase 1: RED (failing tests for new roles)
469    // ============================================================
470
471    #[test]
472    fn test_progress_bar_detection() {
473        let cluster = make_cluster("████░░░░", CellStyle::default(), 0, 5);
474        let role = infer_role(&cluster, &no_cursor(), &default_opts());
475        assert_eq!(role, Role::ProgressBar);
476    }
477
478    #[test]
479    fn test_progress_bar_bracket_detection() {
480        let cluster = make_cluster("[===>    ]", CellStyle::default(), 0, 5);
481        let role = infer_role(&cluster, &no_cursor(), &default_opts());
482        assert_eq!(role, Role::ProgressBar);
483    }
484
485    #[test]
486    fn test_link_url_detection() {
487        let cluster = make_cluster("https://example.com", CellStyle::default(), 0, 5);
488        let role = infer_role(&cluster, &no_cursor(), &default_opts());
489        assert_eq!(role, Role::Link);
490    }
491
492    #[test]
493    fn test_link_file_path_detection() {
494        let cluster = make_cluster("src/main.rs:42", CellStyle::default(), 0, 5);
495        let role = infer_role(&cluster, &no_cursor(), &default_opts());
496        assert_eq!(role, Role::Link);
497    }
498
499    #[test]
500    fn test_link_is_interactive() {
501        assert!(Role::Link.is_interactive());
502    }
503
504    #[test]
505    fn test_error_message_detection() {
506        let cluster = make_cluster("Error: something failed", CellStyle::default(), 0, 5);
507        let role = infer_role(&cluster, &no_cursor(), &default_opts());
508        assert_eq!(role, Role::ErrorMessage);
509    }
510
511    #[test]
512    fn test_error_message_failure_marker() {
513        let cluster = make_cluster("✗ Failed to compile", CellStyle::default(), 0, 5);
514        let role = infer_role(&cluster, &no_cursor(), &default_opts());
515        assert_eq!(role, Role::ErrorMessage);
516    }
517
518    #[test]
519    fn test_error_message_not_interactive() {
520        assert!(!Role::ErrorMessage.is_interactive());
521    }
522
523    #[test]
524    fn test_diff_line_addition_detection() {
525        let cluster = make_cluster("+ added line", CellStyle::default(), 0, 5);
526        let role = infer_role(&cluster, &no_cursor(), &default_opts());
527        assert_eq!(role, Role::DiffLine);
528    }
529
530    #[test]
531    fn test_diff_line_deletion_detection() {
532        // Use pattern without space after dash - "- text" is now classified as MenuItem
533        // because TUI menus commonly use "- " as bullet prefix
534        let cluster = make_cluster("-removed_line", CellStyle::default(), 0, 5);
535        let role = infer_role(&cluster, &no_cursor(), &default_opts());
536        assert_eq!(role, Role::DiffLine);
537    }
538
539    #[test]
540    fn test_diff_line_header_detection() {
541        let cluster = make_cluster("@@ -1,5 +1,6 @@", CellStyle::default(), 0, 5);
542        let role = infer_role(&cluster, &no_cursor(), &default_opts());
543        assert_eq!(role, Role::DiffLine);
544    }
545
546    #[test]
547    fn test_diff_line_not_interactive() {
548        assert!(!Role::DiffLine.is_interactive());
549    }
550
551    #[test]
552    fn test_code_block_detection() {
553        let cluster = make_cluster("│ let x = 5;", CellStyle::default(), 0, 5);
554        let role = infer_role(&cluster, &no_cursor(), &default_opts());
555        assert_eq!(role, Role::CodeBlock);
556    }
557
558    #[test]
559    fn test_code_block_not_interactive() {
560        assert!(!Role::CodeBlock.is_interactive());
561    }
562
563    #[test]
564    fn test_menu_item_selected_via_inverse() {
565        let cluster = make_cluster(
566            "Option 1",
567            CellStyle {
568                inverse: true,
569                ..Default::default()
570            },
571            0,
572            5,
573        );
574        let components = classify(vec![cluster], &no_cursor(), &default_opts());
575        assert!(components[0].selected);
576    }
577
578    #[test]
579    fn test_menu_item_selected_via_prefix() {
580        let cluster = make_cluster("❯ Selected Option", CellStyle::default(), 0, 5);
581        let components = classify(vec![cluster], &no_cursor(), &default_opts());
582        assert!(components[0].selected);
583    }
584
585    #[test]
586    fn test_menu_item_not_selected_by_default() {
587        let cluster = make_cluster("Normal Option", CellStyle::default(), 0, 5);
588        let components = classify(vec![cluster], &no_cursor(), &default_opts());
589        assert!(!components[0].selected);
590    }
591
592    #[test]
593    fn test_tab_row_threshold_configurable() {
594        // Element on row 5 with inverse should be MenuItem with default threshold (2)
595        let cluster = make_cluster(
596            "Option",
597            CellStyle {
598                inverse: true,
599                ..Default::default()
600            },
601            0,
602            5,
603        );
604        let role = infer_role(&cluster, &no_cursor(), &default_opts());
605        assert_eq!(role, Role::MenuItem);
606
607        // Same element should be Tab with threshold = 5
608        let opts = ClassifyOptions {
609            tab_row_threshold: 5,
610        };
611        let role = infer_role(&cluster, &no_cursor(), &opts);
612        assert_eq!(role, Role::Tab);
613    }
614
615    #[test]
616    fn test_menu_item_with_file_path_not_link() {
617        // Menu items with file paths should be MenuItem, not Link
618        // This tests the classification priority: is_menu_item() before is_link()
619        let cluster = make_cluster("> src/main.rs", CellStyle::default(), 0, 5);
620        let role = infer_role(&cluster, &no_cursor(), &default_opts());
621        assert_eq!(
622            role,
623            Role::MenuItem,
624            "Menu item with file path should be MenuItem, not Link"
625        );
626    }
627
628    #[test]
629    fn test_menu_item_with_file_path_and_line_number() {
630        // Menu items with file:line notation should still be MenuItem
631        let cluster = make_cluster("> src/lib.rs:42", CellStyle::default(), 0, 5);
632        let role = infer_role(&cluster, &no_cursor(), &default_opts());
633        assert_eq!(
634            role,
635            Role::MenuItem,
636            "Menu item with file:line should be MenuItem"
637        );
638    }
639
640    #[test]
641    fn test_dash_list_item_not_diff_line() {
642        // "- List item" with space after dash should be MenuItem, not DiffLine
643        // This tests classification priority: is_menu_item() before is_diff_line()
644        let cluster = make_cluster("- List item", CellStyle::default(), 0, 5);
645        let role = infer_role(&cluster, &no_cursor(), &default_opts());
646        assert_eq!(
647            role,
648            Role::MenuItem,
649            "Dash list item should be MenuItem, not DiffLine"
650        );
651    }
652
653    #[test]
654    fn test_dash_list_navigation() {
655        // Common TUI navigation patterns with dash prefix
656        let cluster = make_cluster("- Select option", CellStyle::default(), 0, 5);
657        let role = infer_role(&cluster, &no_cursor(), &default_opts());
658        assert_eq!(
659            role,
660            Role::MenuItem,
661            "Dash navigation item should be MenuItem"
662        );
663    }
664
665    mod prop_tests {
666        use super::*;
667        use proptest::prelude::*;
668
669        fn arb_cluster() -> impl Strategy<Value = Cluster> {
670            (
671                "[a-zA-Z0-9 ]{1,20}",
672                any::<bool>(),
673                any::<bool>(),
674                0u16..100,
675                0u16..50,
676            )
677                .prop_map(|(text, bold, inverse, x, y)| Cluster {
678                    rect: Rect::new(x, y, text.len() as u16, 1),
679                    text,
680                    style: CellStyle {
681                        bold,
682                        underline: false,
683                        inverse,
684                        fg_color: None,
685                        bg_color: None,
686                    },
687                    is_whitespace: false,
688                })
689        }
690
691        proptest! {
692            #[test]
693            fn classification_is_deterministic(
694                clusters in prop::collection::vec(arb_cluster(), 1..10),
695                cursor_row in 0u16..50,
696                cursor_col in 0u16..100
697            ) {
698                let clusters_clone: Vec<Cluster> = clusters.iter().map(|c| Cluster {
699                    rect: c.rect,
700                    text: c.text.clone(),
701                    style: c.style.clone(),
702                    is_whitespace: c.is_whitespace,
703                }).collect();
704                let opts = ClassifyOptions::default();
705                let cur = cursor(cursor_row, cursor_col);
706
707                let result1 = classify(clusters, &cur, &opts);
708                let result2 = classify(clusters_clone, &cur, &opts);
709
710                prop_assert_eq!(result1.len(), result2.len());
711                for (a, b) in result1.iter().zip(result2.iter()) {
712                    prop_assert_eq!(a.role, b.role);
713                    prop_assert_eq!(a.bounds, b.bounds);
714                    prop_assert_eq!(&a.text_content, &b.text_content);
715                    prop_assert_eq!(a.visual_hash, b.visual_hash);
716                }
717            }
718
719            #[test]
720            fn classify_preserves_count(
721                clusters in prop::collection::vec(arb_cluster(), 0..20),
722                cursor_row in 0u16..50,
723                cursor_col in 0u16..100
724            ) {
725                let count = clusters.len();
726                let opts = ClassifyOptions::default();
727                let cur = cursor(cursor_row, cursor_col);
728                let components = classify(clusters, &cur, &opts);
729                prop_assert_eq!(components.len(), count);
730            }
731
732            #[test]
733            fn component_ids_unique(
734                clusters in prop::collection::vec(arb_cluster(), 2..10),
735                cursor_row in 0u16..50,
736                cursor_col in 0u16..100
737            ) {
738                let opts = ClassifyOptions::default();
739                let cur = cursor(cursor_row, cursor_col);
740                let components = classify(clusters, &cur, &opts);
741                let ids: Vec<_> = components.iter().map(|c| c.id).collect();
742
743                for (i, id) in ids.iter().enumerate() {
744                    prop_assert!(
745                        !ids[i + 1..].contains(id),
746                        "Duplicate component ID found"
747                    );
748                }
749            }
750        }
751    }
752}