1use std::collections::HashMap;
2
3use super::{Component, Rect};
4
5#[derive(Debug, Clone)]
6pub struct SnapshotOptions {
7 pub interactive_only: bool,
8 pub tab_row_threshold: u16,
11}
12
13impl Default for SnapshotOptions {
14 fn default() -> Self {
15 Self {
16 interactive_only: false,
17 tab_row_threshold: 2,
18 }
19 }
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct Bounds {
24 pub x: u16,
25 pub y: u16,
26 pub width: u16,
27 pub height: u16,
28}
29
30impl From<Rect> for Bounds {
31 fn from(r: Rect) -> Self {
32 Self {
33 x: r.x,
34 y: r.y,
35 width: r.width,
36 height: r.height,
37 }
38 }
39}
40
41#[derive(Debug, Clone)]
47pub struct ElementRef {
48 pub role: String,
49 pub name: Option<String>,
50 pub bounds: Bounds,
51 pub visual_hash: u64,
52 pub nth: Option<usize>,
53 pub selected: bool,
54}
55
56#[derive(Debug, Clone, Default)]
57pub struct RefMap {
58 pub refs: HashMap<String, ElementRef>,
59}
60
61impl RefMap {
62 pub fn get(&self, ref_id: &str) -> Option<&ElementRef> {
63 self.refs.get(ref_id)
64 }
65}
66
67#[derive(Debug, Clone)]
68pub struct SnapshotStats {
69 pub total: usize,
70 pub interactive: usize,
71 pub lines: usize,
72}
73
74#[derive(Debug, Clone)]
75pub struct AccessibilitySnapshot {
76 pub tree: String,
77 pub refs: RefMap,
78 pub stats: SnapshotStats,
79}
80
81pub fn format_snapshot(
82 components: &[Component],
83 options: &SnapshotOptions,
84) -> AccessibilitySnapshot {
85 let mut refs = RefMap::default();
86 let mut lines = Vec::with_capacity(components.len());
87 let mut ref_counter = 0usize;
88 let mut interactive_count = 0usize;
89 let mut role_counts: HashMap<String, usize> = HashMap::with_capacity(16);
91
92 for component in components {
93 if options.interactive_only && !component.role.is_interactive() {
94 continue;
95 }
96
97 ref_counter += 1;
98 let ref_id = format!("e{}", ref_counter);
99
100 if component.role.is_interactive() {
101 interactive_count += 1;
102 }
103
104 let name = component.text_content.trim();
105 let role_str = component.role.to_string();
106
107 let entry = role_counts.entry(role_str.clone()).or_insert(0);
109 let nth = *entry;
110 *entry += 1;
111
112 let line = if name.is_empty() {
113 format!("- {} [ref={}]", component.role, ref_id)
114 } else {
115 let escaped = name.replace('"', "\\\"");
116 format!("- {} \"{}\" [ref={}]", component.role, escaped, ref_id)
117 };
118 lines.push(line);
119
120 refs.refs.insert(
121 ref_id,
122 ElementRef {
123 role: role_str,
124 name: (!name.is_empty()).then(|| name.to_string()),
125 bounds: component.bounds.into(),
126 visual_hash: component.visual_hash,
127 nth: Some(nth),
128 selected: component.selected,
129 },
130 );
131 }
132
133 let tree = lines.join("\n");
134 let line_count = lines.len();
135
136 AccessibilitySnapshot {
137 tree,
138 refs,
139 stats: SnapshotStats {
140 total: ref_counter,
141 interactive: interactive_count,
142 lines: line_count,
143 },
144 }
145}
146
147pub fn parse_ref(arg: &str) -> Option<String> {
148 if let Some(stripped) = arg.strip_prefix('@') {
149 Some(stripped.to_string())
150 } else if let Some(stripped) = arg.strip_prefix("ref=") {
151 Some(stripped.to_string())
152 } else if let Some(suffix) = arg.strip_prefix('e') {
153 if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
154 Some(arg.to_string())
155 } else {
156 None
157 }
158 } else {
159 None
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::core::vom::Role;
167 use uuid::Uuid;
168
169 fn make_component(role: Role, text: &str, x: u16, y: u16, width: u16) -> Component {
170 Component {
171 id: Uuid::new_v4(),
172 role,
173 bounds: Rect::new(x, y, width, 1),
174 text_content: text.to_string(),
175 visual_hash: 12345,
176 selected: false,
177 }
178 }
179
180 #[test]
181 fn test_snapshot_text_format_button() {
182 let components = vec![make_component(Role::Button, "[ OK ]", 10, 5, 6)];
183 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
184
185 assert!(snapshot.tree.contains("button"));
186 assert!(snapshot.tree.contains("[ref=e1]"));
187 assert!(snapshot.tree.contains("[ OK ]"));
188 }
189
190 #[test]
191 fn test_snapshot_text_format_multiple() {
192 let components = vec![
193 make_component(Role::Button, "[ OK ]", 10, 5, 6),
194 make_component(Role::Input, ">", 0, 0, 1),
195 make_component(Role::StaticText, "Hello", 0, 1, 5),
196 ];
197 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
198
199 assert!(snapshot.tree.contains("[ref=e1]"));
200 assert!(snapshot.tree.contains("[ref=e2]"));
201 assert!(snapshot.tree.contains("[ref=e3]"));
202 }
203
204 #[test]
205 fn test_snapshot_refs_sequential() {
206 let components = vec![
207 make_component(Role::Button, "A", 0, 0, 1),
208 make_component(Role::Button, "B", 5, 0, 1),
209 make_component(Role::Input, "C", 10, 0, 1),
210 ];
211 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
212
213 assert!(snapshot.refs.get("e1").is_some());
214 assert!(snapshot.refs.get("e2").is_some());
215 assert!(snapshot.refs.get("e3").is_some());
216 assert!(snapshot.refs.get("e4").is_none());
217 }
218
219 #[test]
220 fn test_snapshot_stats() {
221 let components = vec![
222 make_component(Role::Button, "A", 0, 0, 1),
223 make_component(Role::StaticText, "B", 5, 0, 1),
224 ];
225 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
226
227 assert_eq!(snapshot.stats.total, 2);
228 assert_eq!(snapshot.stats.interactive, 1);
229 }
230
231 #[test]
232 fn test_parse_ref_at_prefix() {
233 assert_eq!(parse_ref("@e1"), Some("e1".to_string()));
234 assert_eq!(parse_ref("@e42"), Some("e42".to_string()));
235 }
236
237 #[test]
238 fn test_parse_ref_ref_equals() {
239 assert_eq!(parse_ref("ref=e1"), Some("e1".to_string()));
240 }
241
242 #[test]
243 fn test_parse_ref_bare() {
244 assert_eq!(parse_ref("e1"), Some("e1".to_string()));
245 assert_eq!(parse_ref("e123"), Some("e123".to_string()));
246 }
247
248 #[test]
249 fn test_parse_ref_invalid() {
250 assert_eq!(parse_ref("button"), None);
251 assert_eq!(parse_ref("1"), None);
252 assert_eq!(parse_ref(""), None);
253 }
254
255 #[test]
256 fn test_refmap_contains_bounds() {
257 let components = vec![make_component(Role::Button, "OK", 10, 5, 6)];
258 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
259
260 let elem = snapshot.refs.get("e1").unwrap();
261 assert_eq!(elem.bounds.x, 10);
262 assert_eq!(elem.bounds.y, 5);
263 assert_eq!(elem.bounds.width, 6);
264 assert_eq!(elem.bounds.height, 1);
265 }
266
267 #[test]
268 fn test_refmap_contains_role() {
269 let components = vec![make_component(Role::Input, ">", 0, 0, 1)];
270 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
271
272 let elem = snapshot.refs.get("e1").unwrap();
273 assert_eq!(elem.role, "input");
274 }
275
276 #[test]
277 fn test_refmap_contains_name() {
278 let components = vec![make_component(Role::Button, "Submit", 0, 0, 6)];
279 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
280
281 let elem = snapshot.refs.get("e1").unwrap();
282 assert_eq!(elem.name, Some("Submit".to_string()));
283 }
284
285 #[test]
286 fn test_refmap_empty_name_is_none() {
287 let components = vec![make_component(Role::Panel, "", 0, 0, 10)];
288 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
289
290 let elem = snapshot.refs.get("e1").unwrap();
291 assert_eq!(elem.name, None);
292 }
293
294 #[test]
295 fn test_refmap_lookup_missing() {
296 let components = vec![make_component(Role::Button, "OK", 0, 0, 2)];
297 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
298
299 assert!(snapshot.refs.get("e999").is_none());
300 }
301
302 #[test]
303 fn test_interactive_filter_excludes_static_text() {
304 let components = vec![
305 make_component(Role::Button, "OK", 0, 0, 2),
306 make_component(Role::StaticText, "Hello", 5, 0, 5),
307 make_component(Role::Input, ">", 0, 1, 1),
308 ];
309 let options = SnapshotOptions {
310 interactive_only: true,
311 ..Default::default()
312 };
313 let snapshot = format_snapshot(&components, &options);
314
315 assert_eq!(snapshot.stats.total, 2);
316 assert!(!snapshot.tree.contains("text"));
317 assert!(snapshot.tree.contains("button"));
318 assert!(snapshot.tree.contains("input"));
319 }
320
321 #[test]
322 fn test_interactive_filter_excludes_panel() {
323 let components = vec![
324 make_component(Role::Panel, "───", 0, 0, 3),
325 make_component(Role::Button, "OK", 5, 0, 2),
326 ];
327 let options = SnapshotOptions {
328 interactive_only: true,
329 ..Default::default()
330 };
331 let snapshot = format_snapshot(&components, &options);
332
333 assert_eq!(snapshot.stats.total, 1);
334 assert!(!snapshot.tree.contains("panel"));
335 assert!(snapshot.tree.contains("button"));
336 }
337
338 #[test]
339 fn test_interactive_filter_excludes_status() {
340 let components = vec![
341 make_component(Role::Status, "⠋ Loading", 0, 0, 10),
342 make_component(Role::Button, "Cancel", 0, 1, 6),
343 ];
344 let options = SnapshotOptions {
345 interactive_only: true,
346 ..Default::default()
347 };
348 let snapshot = format_snapshot(&components, &options);
349
350 assert_eq!(snapshot.stats.total, 1);
351 assert!(!snapshot.tree.contains("status"));
352 assert!(snapshot.tree.contains("button"));
353 }
354
355 #[test]
356 fn test_interactive_filter_includes_all_interactive() {
357 let components = vec![
358 make_component(Role::Button, "OK", 0, 0, 2),
359 make_component(Role::Input, ">", 0, 1, 1),
360 make_component(Role::Checkbox, "[x]", 0, 2, 3),
361 make_component(Role::MenuItem, "> opt", 0, 3, 5),
362 make_component(Role::Tab, "Tab1", 0, 4, 4),
363 make_component(Role::PromptMarker, ">", 0, 5, 1),
364 ];
365 let options = SnapshotOptions {
366 interactive_only: true,
367 ..Default::default()
368 };
369 let snapshot = format_snapshot(&components, &options);
370
371 assert_eq!(snapshot.stats.total, 6);
372 assert_eq!(snapshot.stats.interactive, 6);
373 }
374
375 #[test]
376 fn test_snapshot_escapes_quotes_in_name() {
377 let components = vec![make_component(Role::Button, r#"Say "Hello""#, 0, 0, 12)];
378 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
379 assert!(snapshot.tree.contains(r#"Say \"Hello\""#));
380 }
381
382 #[test]
383 fn test_interactive_filter_refs_renumbered() {
384 let components = vec![
385 make_component(Role::StaticText, "A", 0, 0, 1),
386 make_component(Role::Button, "B", 0, 1, 1),
387 make_component(Role::StaticText, "C", 0, 2, 1),
388 make_component(Role::Input, "D", 0, 3, 1),
389 ];
390 let options = SnapshotOptions {
391 interactive_only: true,
392 ..Default::default()
393 };
394 let snapshot = format_snapshot(&components, &options);
395
396 assert!(snapshot.refs.get("e1").is_some());
397 assert!(snapshot.refs.get("e2").is_some());
398 assert!(snapshot.refs.get("e3").is_none());
399 }
400
401 #[test]
402 fn test_nth_field_populated() {
403 let components = vec![
404 make_component(Role::Button, "A", 0, 0, 1),
405 make_component(Role::StaticText, "text", 5, 0, 4),
406 make_component(Role::Button, "B", 10, 0, 1),
407 make_component(Role::Button, "C", 15, 0, 1),
408 ];
409 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
410
411 let e1 = snapshot.refs.get("e1").unwrap();
413 assert_eq!(e1.nth, Some(0));
414
415 let e2 = snapshot.refs.get("e2").unwrap();
417 assert_eq!(e2.nth, Some(0));
418
419 let e3 = snapshot.refs.get("e3").unwrap();
421 assert_eq!(e3.nth, Some(1));
422
423 let e4 = snapshot.refs.get("e4").unwrap();
425 assert_eq!(e4.nth, Some(2));
426 }
427
428 #[test]
429 fn test_selected_state_from_inverse() {
430 let mut comp = make_component(Role::MenuItem, "Option 1", 0, 0, 8);
431 comp.selected = true;
432 let components = vec![comp];
433 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
434 let elem = snapshot.refs.get("e1").unwrap();
435 assert!(elem.selected);
436 }
437
438 #[test]
439 fn test_selected_state_default_false() {
440 let comp = make_component(Role::MenuItem, "Option 1", 0, 0, 8);
441 let components = vec![comp];
442 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
443 let elem = snapshot.refs.get("e1").unwrap();
444 assert!(!elem.selected);
445 }
446
447 mod prop_tests {
448 use super::*;
449 use proptest::prelude::*;
450
451 fn arb_role() -> impl Strategy<Value = Role> {
452 prop_oneof![
453 Just(Role::Button),
454 Just(Role::Tab),
455 Just(Role::Input),
456 Just(Role::StaticText),
457 Just(Role::Panel),
458 Just(Role::Checkbox),
459 Just(Role::MenuItem),
460 Just(Role::Status),
461 Just(Role::ToolBlock),
462 Just(Role::PromptMarker),
463 ]
464 }
465
466 fn arb_component() -> impl Strategy<Value = Component> {
467 (
468 arb_role(),
469 "[a-zA-Z0-9 ]{0,20}",
470 0u16..100,
471 0u16..50,
472 1u16..20,
473 )
474 .prop_map(|(role, text, x, y, width)| Component {
475 id: Uuid::new_v4(),
476 role,
477 bounds: Rect::new(x, y, width, 1),
478 text_content: text,
479 visual_hash: 12345,
480 selected: false,
481 })
482 }
483
484 proptest! {
485 #[test]
486 fn snapshot_is_deterministic(
487 components in prop::collection::vec(arb_component(), 1..20)
488 ) {
489 let options = SnapshotOptions::default();
490
491 let snapshot1 = format_snapshot(&components, &options);
492 let snapshot2 = format_snapshot(&components, &options);
493
494 prop_assert_eq!(&snapshot1.tree, &snapshot2.tree);
495 prop_assert_eq!(snapshot1.stats.total, snapshot2.stats.total);
496 prop_assert_eq!(snapshot1.stats.interactive, snapshot2.stats.interactive);
497 prop_assert_eq!(snapshot1.stats.lines, snapshot2.stats.lines);
498 prop_assert_eq!(snapshot1.refs.refs.len(), snapshot2.refs.refs.len());
499 }
500
501 #[test]
502 fn snapshot_ref_count_matches_components(
503 components in prop::collection::vec(arb_component(), 0..20)
504 ) {
505 let options = SnapshotOptions::default();
506 let snapshot = format_snapshot(&components, &options);
507
508 prop_assert_eq!(snapshot.refs.refs.len(), components.len());
509 prop_assert_eq!(snapshot.stats.total, components.len());
510 }
511
512 #[test]
513 fn interactive_filter_reduces_or_maintains_count(
514 components in prop::collection::vec(arb_component(), 0..20)
515 ) {
516 let all_snapshot = format_snapshot(&components, &SnapshotOptions::default());
517 let interactive_snapshot = format_snapshot(
518 &components,
519 &SnapshotOptions { interactive_only: true, ..Default::default() }
520 );
521
522 prop_assert!(interactive_snapshot.stats.total <= all_snapshot.stats.total);
523 prop_assert!(interactive_snapshot.refs.refs.len() <= all_snapshot.refs.refs.len());
524 }
525
526 #[test]
527 fn refs_are_sequential_starting_at_e1(
528 components in prop::collection::vec(arb_component(), 1..10)
529 ) {
530 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
531
532 for i in 1..=components.len() {
533 let ref_key = format!("e{}", i);
534 prop_assert!(
535 snapshot.refs.get(&ref_key).is_some(),
536 "Missing ref: {}", ref_key
537 );
538 }
539
540 let extra_ref = format!("e{}", components.len() + 1);
541 prop_assert!(snapshot.refs.get(&extra_ref).is_none());
542 }
543
544 #[test]
545 fn tree_contains_all_refs(
546 components in prop::collection::vec(arb_component(), 1..10)
547 ) {
548 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
549
550 for i in 1..=components.len() {
551 let ref_marker = format!("[ref=e{}]", i);
552 prop_assert!(
553 snapshot.tree.contains(&ref_marker),
554 "Tree missing ref marker: {}", ref_marker
555 );
556 }
557 }
558
559 #[test]
560 fn nth_is_sequential_per_role(
561 components in prop::collection::vec(arb_component(), 1..20)
562 ) {
563 let snapshot = format_snapshot(&components, &SnapshotOptions::default());
564
565 let mut by_role: HashMap<String, Vec<usize>> = HashMap::new();
567 for elem in snapshot.refs.refs.values() {
568 by_role.entry(elem.role.clone())
569 .or_default()
570 .push(elem.nth.unwrap());
571 }
572
573 for (role, mut nths) in by_role {
575 nths.sort();
576 let expected: Vec<usize> = (0..nths.len()).collect();
577 prop_assert_eq!(nths, expected, "Non-sequential nth for role: {}", role);
578 }
579 }
580 }
581 }
582}