1use serde::{Deserialize, Serialize};
7
8pub const CHILD_CAP: usize = 40;
10pub const DEPTH_CAP: usize = 12;
12pub const LINE_CAP: usize = 400;
14pub const VALUE_CAP: usize = 120;
16pub const URL_PATH_CAP: usize = 60;
18
19#[derive(Clone, Debug, PartialEq)]
21pub struct UiNode {
22 pub role: String,
24 pub subrole: Option<String>,
26 pub title: Option<String>,
28 pub description: Option<String>,
30 pub identifier: Option<String>,
32 pub value: Option<String>,
34 pub secure: bool,
36 pub actions: Vec<String>,
38 pub frame: Option<NodeFrame>,
40 pub child_count: Option<usize>,
42 pub children: Vec<UiNode>,
44}
45
46#[derive(Clone, Copy, Debug, PartialEq)]
48pub struct NodeFrame {
49 pub x: f64,
51 pub y: f64,
53 pub width: f64,
55 pub height: f64,
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
61pub struct SavedFrame {
62 pub x: i32,
64 pub y: i32,
66 pub width: u32,
68 pub height: u32,
70}
71
72impl From<NodeFrame> for SavedFrame {
73 fn from(frame: NodeFrame) -> Self {
74 Self {
75 x: round_i32(frame.x),
76 y: round_i32(frame.y),
77 width: round_u32(frame.width),
78 height: round_u32(frame.height),
79 }
80 }
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
85pub struct BoundElement {
86 pub pid: i32,
88 pub window_id: u32,
90 pub revision: u64,
92 pub index: u32,
94}
95
96impl UiNode {
97 pub fn bare(role: impl Into<String>) -> Self {
99 Self {
100 role: role.into(),
101 subrole: None,
102 title: None,
103 description: None,
104 identifier: None,
105 value: None,
106 secure: false,
107 actions: Vec::new(),
108 frame: None,
109 child_count: None,
110 children: Vec::new(),
111 }
112 }
113
114 fn real_child_count(&self) -> usize {
115 self.child_count.unwrap_or(self.children.len())
116 }
117}
118
119pub fn find_ui(root: &UiNode, index: u32) -> Option<&UiNode> {
121 let mut cursor = 0u32;
122 fn walk<'a>(node: &'a UiNode, index: u32, cursor: &mut u32) -> Option<&'a UiNode> {
123 if *cursor == index {
124 return Some(node);
125 }
126 *cursor = cursor.saturating_add(1);
127 for child in &node.children {
128 if let Some(found) = walk(child, index, cursor) {
129 return Some(found);
130 }
131 }
132 None
133 }
134 walk(root, index, &mut cursor)
135}
136
137pub fn find_sig(root: &SigNode, index: u32) -> Option<&SigNode> {
139 if root.index == index {
140 return Some(root);
141 }
142 for child in &root.children {
143 if let Some(found) = find_sig(child, index) {
144 return Some(found);
145 }
146 }
147 None
148}
149
150#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
152pub struct SigNode {
153 pub index: u32,
155 pub role: String,
157 pub identity: String,
159 pub child_count: usize,
161 pub value: String,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub frame: Option<SavedFrame>,
166 #[serde(default, skip_serializing_if = "is_false_bool")]
168 pub secure: bool,
169 pub children: Vec<SigNode>,
171}
172
173#[derive(Clone, Debug, PartialEq)]
175pub struct Rendered {
176 pub text: String,
178 pub truncated: bool,
180 pub root: SigNode,
182}
183
184#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
188pub struct FlagState {
189 pub manual: Option<bool>,
191 pub enhanced: Option<bool>,
193}
194
195#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
197pub struct ElectronEdit {
198 pub set_manual: Option<bool>,
200 pub set_enhanced: Option<bool>,
202 pub restore: FlagState,
204 pub previous: FlagState,
206}
207
208#[derive(Clone, Debug, Default, PartialEq, Eq)]
210pub struct TreeQuery {
211 pub app: Option<String>,
213 pub window: Option<String>,
215 pub full: bool,
217 pub session: Option<String>,
219}
220
221#[derive(Clone, Debug, PartialEq, Eq)]
223pub struct TreeReport {
224 pub pid: i32,
226 pub window_id: u32,
228 pub revision: u64,
230 pub source: String,
232 pub truncated: bool,
234 pub tree: String,
236 pub full_text: String,
238 pub diff: Option<String>,
240 pub app: Option<String>,
242 pub note: Option<String>,
244 pub legacy: Option<String>,
246}
247
248pub fn render(root: &UiNode) -> Rendered {
250 let mut lines = Vec::new();
251 let mut truncated = false;
252 let mut index = 0;
253 let signature = walk(root, 0, &mut index, &mut lines, &mut truncated);
254 if truncated {
255 lines.push("truncated=true".to_string());
256 }
257 let mut text = lines.join("\n");
258 text.push('\n');
259 Rendered {
260 text,
261 truncated,
262 root: signature,
263 }
264}
265
266pub fn diff_trees(previous: &SigNode, current: &SigNode) -> String {
268 let mut lines = Vec::new();
269 diff_level(
270 std::slice::from_ref(previous),
271 std::slice::from_ref(current),
272 &mut lines,
273 );
274 if lines.is_empty() {
275 String::new()
276 } else {
277 let mut text = lines.join("\n");
278 text.push('\n');
279 text
280 }
281}
282
283pub fn choose_text(previous: Option<&SigNode>, rendered: &Rendered, force_full: bool) -> String {
285 match previous {
286 Some(previous) if !force_full => diff_trees(previous, &rendered.root),
287 _ => rendered.text.clone(),
288 }
289}
290
291pub fn revision_of(root: &SigNode) -> u64 {
293 let mut hash = 0xcbf29ce484222325u64;
294 hash_node(root, &mut hash);
295 hash
296}
297
298pub fn electron_enable(current: FlagState) -> ElectronEdit {
300 let set_manual = (current.manual != Some(true)).then_some(true);
301 let set_enhanced = (current.enhanced != Some(true)).then_some(true);
302 ElectronEdit {
303 set_manual,
304 set_enhanced,
305 restore: FlagState {
306 manual: restore_value(set_manual.is_some(), current.manual),
307 enhanced: restore_value(set_enhanced.is_some(), current.enhanced),
308 },
309 previous: current,
310 }
311}
312
313pub fn electron_restore(edit: &ElectronEdit) -> FlagState {
315 edit.restore.clone()
316}
317
318#[derive(Clone, Debug, PartialEq, Eq)]
323pub struct AttributeMap {
324 pub manual: Option<bool>,
326 pub enhanced: Option<bool>,
328}
329
330impl AttributeMap {
331 pub fn enable_electron(&mut self) -> ElectronEdit {
333 let edit = electron_enable(FlagState {
334 manual: self.manual,
335 enhanced: self.enhanced,
336 });
337 if edit.set_manual == Some(true) {
338 self.manual = Some(true);
339 }
340 if edit.set_enhanced == Some(true) {
341 self.enhanced = Some(true);
342 }
343 edit
344 }
345
346 pub fn restore_electron(&mut self, edit: &ElectronEdit) {
348 if let Some(value) = edit.restore.manual {
349 self.manual = Some(value);
350 }
351 if let Some(value) = edit.restore.enhanced {
352 self.enhanced = Some(value);
353 }
354 }
355}
356
357pub fn hosted_helper_pids(app_pid: i32, child_pids: &[i32], scanned: &[i32]) -> Vec<i32> {
362 let mut out = Vec::new();
363 push_unique(&mut out, app_pid, child_pids);
364 if out.is_empty() {
365 push_unique(&mut out, app_pid, scanned);
366 }
367 out.truncate(3);
368 out
369}
370
371pub fn bundle_matches_parent(
373 parent_name: &str,
374 parent_bundle: Option<&str>,
375 child_bundle: &str,
376) -> bool {
377 let child = child_bundle.to_ascii_lowercase();
378 let squashed = parent_name.to_ascii_lowercase().replace(' ', "");
379 if squashed.len() >= 3 && child.contains(&squashed) {
380 return true;
381 }
382 let Some(parent_bundle) = parent_bundle else {
383 return false;
384 };
385 let parent_bundle = parent_bundle.to_ascii_lowercase();
386 if child != parent_bundle && child.contains(&parent_bundle) {
387 return true;
388 }
389 parent_bundle
390 .rsplit('.')
391 .next()
392 .is_some_and(|label| label.len() >= 3 && child.contains(label))
393}
394
395pub fn shorten_url(value: &str) -> String {
397 let (scheme, rest) = if let Some(rest) = value.strip_prefix("https://") {
398 ("https://", rest)
399 } else if let Some(rest) = value.strip_prefix("http://") {
400 ("http://", rest)
401 } else {
402 return value.to_string();
403 };
404 let rest = rest.split(['?', '#']).next().unwrap_or(rest);
405 let (host, path) = match rest.find('/') {
406 Some(index) => (&rest[..index], &rest[index..]),
407 None => (rest, ""),
408 };
409 format!("{scheme}{host}{}", truncate_chars(path, URL_PATH_CAP))
410}
411
412fn restore_value(changed: bool, previous: Option<bool>) -> Option<bool> {
413 if changed {
414 Some(previous.unwrap_or(false))
415 } else {
416 None
417 }
418}
419
420fn push_unique(out: &mut Vec<i32>, app_pid: i32, pids: &[i32]) {
421 for pid in pids {
422 if *pid == app_pid || *pid <= 0 || out.contains(pid) {
423 continue;
424 }
425 out.push(*pid);
426 if out.len() == 3 {
427 break;
428 }
429 }
430}
431
432fn walk(
433 node: &UiNode,
434 depth: usize,
435 index: &mut u32,
436 lines: &mut Vec<String>,
437 truncated: &mut bool,
438) -> SigNode {
439 let my_index = *index;
440 *index += 1;
441 let value = display_value(node);
442 let identity = identity_of(node);
443 if lines.len() < LINE_CAP {
444 lines.push(format_line(node, my_index, depth, &value));
445 } else {
446 *truncated = true;
447 }
448 let total = node.real_child_count();
449 let mut children = Vec::new();
450 if depth >= DEPTH_CAP {
451 if total > 0 {
452 *truncated = true;
453 }
454 } else {
455 let show = total.min(CHILD_CAP).min(node.children.len());
456 for child in node.children.iter().take(show) {
457 if lines.len() >= LINE_CAP {
458 *truncated = true;
459 break;
460 }
461 children.push(walk(child, depth + 1, index, lines, truncated));
462 }
463 if total > CHILD_CAP {
464 *truncated = true;
465 if lines.len() < LINE_CAP {
466 let indent = " ".repeat(depth + 1);
467 lines.push(format!("{indent}… +{}", total - CHILD_CAP));
468 }
469 } else if node.children.len() < total {
470 *truncated = true;
472 }
473 }
474 SigNode {
475 index: my_index,
476 role: node.role.clone(),
477 identity,
478 child_count: total,
479 value,
480 frame: node.frame.map(SavedFrame::from),
481 secure: node.secure || is_secure(node),
482 children,
483 }
484}
485
486pub fn identity_text(node: &UiNode) -> String {
488 identity_of(node)
489}
490
491pub fn node_is_secure(node: &UiNode) -> bool {
493 node.secure || is_secure(node)
494}
495
496fn is_false_bool(value: &bool) -> bool {
497 !*value
498}
499
500fn format_line(node: &UiNode, index: u32, depth: usize, value: &str) -> String {
501 let indent = " ".repeat(depth);
502 let mut line = format!("{indent}[{index}] {}", node.role);
503 if let Some(label) = quoted_label(node) {
504 line.push(' ');
505 line.push_str("e(&label));
506 }
507 if let Some(frame) = node.frame.map(SavedFrame::from) {
508 line.push_str(&format!(
509 " @{x},{y} {width}x{height}",
510 x = frame.x,
511 y = frame.y,
512 width = frame.width,
513 height = frame.height
514 ));
515 }
516 if node.secure || is_secure(node) {
517 line.push_str(" value=\"••••\"");
518 } else if node.value.is_some() {
519 line.push_str(" value=");
520 line.push_str("e(value));
521 }
522 if !node.actions.is_empty() {
523 line.push_str(" actions=");
524 line.push_str(&node.actions.join(","));
525 }
526 line
527}
528
529fn quoted_label(node: &UiNode) -> Option<String> {
530 nonempty(&node.title).or_else(|| nonempty(&node.description))
531}
532
533fn identity_of(node: &UiNode) -> String {
534 nonempty(&node.identifier)
535 .or_else(|| nonempty(&node.title))
536 .or_else(|| nonempty(&node.description))
537 .unwrap_or_default()
538}
539
540fn nonempty(value: &Option<String>) -> Option<String> {
541 value
542 .as_ref()
543 .map(|text| text.trim())
544 .filter(|text| !text.is_empty())
545 .map(str::to_string)
546}
547
548fn display_value(node: &UiNode) -> String {
549 if node.secure || is_secure(node) {
550 return "••••".to_string();
551 }
552 let Some(value) = node.value.as_deref() else {
553 return String::new();
554 };
555 truncate_chars(&shorten_url(value), VALUE_CAP)
556}
557
558fn is_secure(node: &UiNode) -> bool {
559 node.role == "AXSecureTextField"
565 || node.role == "password text"
566 || node
567 .subrole
568 .as_deref()
569 .is_some_and(|subrole| subrole.contains("Secure"))
570}
571
572fn quote(text: &str) -> String {
573 let mut out = String::from("\"");
574 for ch in text.chars() {
575 match ch {
576 '\\' => out.push_str("\\\\"),
577 '"' => out.push_str("\\\""),
578 '\n' | '\r' => out.push(' '),
579 other => out.push(other),
580 }
581 }
582 out.push('"');
583 out
584}
585
586fn round_i32(value: f64) -> i32 {
587 if !value.is_finite() {
588 return 0;
589 }
590 let clamped = value.round().clamp(i32::MIN as f64, i32::MAX as f64);
591 clamped as i32
592}
593
594fn round_u32(value: f64) -> u32 {
595 if !value.is_finite() || value <= 0.0 {
596 return 0;
597 }
598 let clamped = value.round().min(u32::MAX as f64);
599 clamped as u32
600}
601
602fn truncate_chars(text: &str, max: usize) -> String {
603 if text.chars().count() <= max {
604 return text.to_string();
605 }
606 text.chars().take(max).collect()
607}
608
609fn diff_level(previous: &[SigNode], current: &[SigNode], lines: &mut Vec<String>) {
610 let pairs = lcs(previous, current);
611 let mut prev_index = 0;
612 let mut curr_index = 0;
613 for (left, right) in pairs {
614 while prev_index < left {
615 lines.push(removed(&previous[prev_index]));
616 prev_index += 1;
617 }
618 while curr_index < right {
619 lines.push(added(¤t[curr_index]));
620 curr_index += 1;
621 }
622 let before = &previous[left];
623 let after = ¤t[right];
624 if before.value != after.value {
625 lines.push(format!(
626 "~ [{}] {} value: {} -> {}",
627 after.index,
628 after.role,
629 quote(&before.value),
630 quote(&after.value)
631 ));
632 }
633 diff_level(&before.children, &after.children, lines);
634 prev_index = left + 1;
635 curr_index = right + 1;
636 }
637 while prev_index < previous.len() {
638 lines.push(removed(&previous[prev_index]));
639 prev_index += 1;
640 }
641 while curr_index < current.len() {
642 lines.push(added(¤t[curr_index]));
643 curr_index += 1;
644 }
645}
646
647fn added(node: &SigNode) -> String {
648 format!("+ [{}] {}", node.index, labeled(node))
649}
650
651fn removed(node: &SigNode) -> String {
652 format!("- [{}] {}", node.index, labeled(node))
653}
654
655fn labeled(node: &SigNode) -> String {
656 if node.identity.is_empty() {
657 node.role.clone()
658 } else {
659 format!("{} {}", node.role, quote(&node.identity))
660 }
661}
662
663fn same_signature(left: &SigNode, right: &SigNode) -> bool {
664 left.role == right.role
665 && left.identity == right.identity
666 && left.child_count == right.child_count
667}
668
669fn lcs(previous: &[SigNode], current: &[SigNode]) -> Vec<(usize, usize)> {
670 let rows = previous.len() + 1;
671 let cols = current.len() + 1;
672 let mut score = vec![0u32; rows * cols];
673 let at = |row: usize, col: usize| row * cols + col;
674 for row in 1..rows {
675 for col in 1..cols {
676 if same_signature(&previous[row - 1], ¤t[col - 1]) {
677 score[at(row, col)] = score[at(row - 1, col - 1)] + 1;
678 } else {
679 score[at(row, col)] = score[at(row - 1, col)].max(score[at(row, col - 1)]);
680 }
681 }
682 }
683 let mut pairs = Vec::new();
684 let mut row = previous.len();
685 let mut col = current.len();
686 while row > 0 && col > 0 {
687 if same_signature(&previous[row - 1], ¤t[col - 1]) {
688 pairs.push((row - 1, col - 1));
689 row -= 1;
690 col -= 1;
691 } else if score[at(row - 1, col)] >= score[at(row, col - 1)] {
692 row -= 1;
693 } else {
694 col -= 1;
695 }
696 }
697 pairs.reverse();
698 pairs
699}
700
701fn hash_node(node: &SigNode, hash: &mut u64) {
702 hash_bytes(hash, node.role.as_bytes());
703 hash_bytes(hash, node.identity.as_bytes());
704 hash_bytes(hash, &node.child_count.to_le_bytes());
705 for child in &node.children {
706 hash_node(child, hash);
707 }
708}
709
710fn hash_bytes(hash: &mut u64, bytes: &[u8]) {
711 for byte in bytes {
712 *hash ^= u64::from(*byte);
713 *hash = hash.wrapping_mul(0x100000001b3);
714 }
715 *hash ^= 0xff;
716 *hash = hash.wrapping_mul(0x100000001b3);
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722
723 fn frame(x: f64, y: f64, width: f64, height: f64) -> NodeFrame {
724 NodeFrame {
725 x,
726 y,
727 width,
728 height,
729 }
730 }
731
732 #[test]
733 fn numbering_matches_the_contract_example() {
734 let mut window = UiNode::bare("AXWindow");
735 window.title = Some("Notes".into());
736 window.frame = Some(frame(0.0, 0.0, 800.0, 600.0));
737 let mut button = UiNode::bare("AXButton");
738 button.title = Some("New Note".into());
739 button.frame = Some(frame(12.0, 10.0, 88.0, 24.0));
740 button.actions = vec!["AXPress".into()];
741 let mut text = UiNode::bare("AXTextArea");
742 text.frame = Some(frame(12.0, 40.0, 776.0, 500.0));
743 text.value = Some("hello".into());
744 window.children = vec![button, text];
745 let rendered = render(&window);
746 assert_eq!(
747 rendered.text,
748 "[0] AXWindow \"Notes\" @0,0 800x600\n [1] AXButton \"New Note\" @12,10 88x24 actions=AXPress\n [2] AXTextArea @12,40 776x500 value=\"hello\"\n"
749 );
750 assert!(!rendered.truncated);
751 assert_eq!(rendered.root.index, 0);
752 assert_eq!(rendered.root.children[1].index, 2);
753 }
754
755 #[test]
756 fn child_cap_depth_cap_and_line_cap_set_truncated() {
757 let mut parent = UiNode::bare("AXGroup");
758 parent.children = (0..42)
759 .map(|index| {
760 let mut child = UiNode::bare("AXStaticText");
761 child.title = Some(format!("n{index}"));
762 child
763 })
764 .collect();
765 parent.child_count = Some(42);
766 let rendered = render(&parent);
767 assert!(rendered.truncated);
768 assert!(rendered.text.contains("… +2"));
769 assert!(rendered.text.contains("truncated=true"));
770 assert_eq!(rendered.root.children.len(), 40);
771 assert!(rendered.text.contains("[40]"));
772 assert!(!rendered.text.contains("[41]"));
773
774 let mut deep = UiNode::bare("AXGroup");
775 let mut cursor = &mut deep;
776 for _ in 0..14 {
777 cursor.children.push(UiNode::bare("AXGroup"));
778 cursor = cursor.children.last_mut().unwrap();
779 }
780 let rendered = render(&deep);
781 assert!(rendered.truncated);
782 assert!(rendered
783 .text
784 .lines()
785 .any(|line| line.starts_with("truncated=")));
786 let deepest = rendered
787 .text
788 .lines()
789 .filter(|line| line.contains("AXGroup"))
790 .count();
791 assert_eq!(deepest, DEPTH_CAP + 1);
792
793 let mut stopped = UiNode::bare("AXGroup");
794 stopped.children = vec![UiNode::bare("AXStaticText"), UiNode::bare("AXStaticText")];
795 stopped.child_count = Some(5);
796 let rendered = render(&stopped);
797 assert!(rendered.truncated);
798 assert!(rendered.text.contains("truncated=true"));
799 assert!(
800 !rendered.text.contains('…'),
801 "a line-budget stop is not the 40-child marker"
802 );
803
804 let mut wide = UiNode::bare("AXGroup");
805 grow_binary(&mut wide, 0);
806 let rendered = render(&wide);
807 let content: Vec<_> = rendered
808 .text
809 .lines()
810 .filter(|line| *line != "truncated=true")
811 .collect();
812 assert_eq!(content.len(), LINE_CAP);
813 assert!(rendered.truncated);
814 }
815
816 #[test]
817 fn url_strip_and_secure_redaction_drop_the_secret() {
818 assert_eq!(
819 shorten_url("https://example.com/path/to/resource?q=secret"),
820 "https://example.com/path/to/resource"
821 );
822 let long_path = format!("/{}", "a".repeat(80));
823 let shortened = shorten_url(&format!("http://example.com{long_path}?x=1"));
824 assert_eq!(shortened, format!("http://example.com/{}", "a".repeat(59)));
825 assert!(!shortened.contains('?'));
826
827 let mut secret = UiNode::bare("AXSecureTextField");
828 secret.value = Some("hunter2".into());
829 secret.secure = true;
830 let rendered = render(&secret);
831 assert!(rendered.text.contains("value=\"••••\""));
832 assert!(!rendered.text.contains("hunter2"));
833 assert!(!rendered.root.value.contains("hunter2"));
834
835 let mut sub = UiNode::bare("AXTextField");
836 sub.subrole = Some("AXSecureTextField".into());
837 sub.value = Some("hunter2".into());
838 let rendered = render(&sub);
839 assert!(!rendered.text.contains("hunter2"));
840 assert!(rendered.text.contains("••••"));
841 }
842
843 #[test]
844 fn diff_uses_lcs_and_full_replaces_the_printed_text() {
845 let mut before = UiNode::bare("AXWindow");
846 before.identifier = Some("win".into());
847 before.children = vec![
848 labeled("AXButton", "A", "one"),
849 labeled("AXButton", "B", "two"),
850 labeled("AXTextArea", "C", "a"),
851 ];
852 let mut after = UiNode::bare("AXWindow");
853 after.identifier = Some("win".into());
854 after.children = vec![
855 labeled("AXButton", "A", "one"),
856 labeled("AXTextArea", "C", "ab"),
857 labeled("AXButton", "D", "new"),
858 ];
859 let first = render(&before);
860 let second = render(&after);
861 let diff = diff_trees(&first.root, &second.root);
862 assert!(diff.contains("- [2] AXButton \"B\""), "{diff}");
863 assert!(
864 diff.contains("~ [2] AXTextArea value: \"a\" -> \"ab\""),
865 "{diff}"
866 );
867 assert!(diff.contains("+ [3] AXButton \"D\""), "{diff}");
868 assert!(!diff.contains("+ [1]"));
869
870 assert_eq!(choose_text(None, &second, false), second.text);
871 assert_eq!(
872 choose_text(Some(&first.root), &second, true),
873 second.text,
874 "full replaces the baseline text"
875 );
876 let incremental = choose_text(Some(&first.root), &second, false);
877 assert!(incremental.starts_with('-') || incremental.starts_with('~'));
878 assert_ne!(revision_of(&first.root), revision_of(&second.root));
879 }
880
881 #[test]
882 fn electron_set_and_restore_returns_the_previous_values() {
883 let edit = electron_enable(FlagState {
884 manual: Some(false),
885 enhanced: None,
886 });
887 assert_eq!(edit.set_manual, Some(true));
888 assert_eq!(edit.set_enhanced, Some(true));
889 assert_eq!(
890 electron_restore(&edit),
891 FlagState {
892 manual: Some(false),
893 enhanced: Some(false),
894 }
895 );
896 let untouched = electron_enable(FlagState {
897 manual: Some(true),
898 enhanced: Some(true),
899 });
900 assert_eq!(untouched.set_manual, None);
901 assert_eq!(untouched.set_enhanced, None);
902 assert_eq!(
903 electron_restore(&untouched),
904 FlagState {
905 manual: None,
906 enhanced: None,
907 }
908 );
909 }
910
911 #[test]
912 fn attribute_map_sets_and_restores_only_changed_flags() {
913 let mut attrs = AttributeMap {
914 manual: Some(false),
915 enhanced: None,
916 };
917 let edit = attrs.enable_electron();
918 assert_eq!(
919 attrs,
920 AttributeMap {
921 manual: Some(true),
922 enhanced: Some(true),
923 }
924 );
925 assert_eq!(edit.previous.manual, Some(false));
926 assert_eq!(edit.previous.enhanced, None);
927 attrs.restore_electron(&edit);
928 assert_eq!(
929 attrs,
930 AttributeMap {
931 manual: Some(false),
932 enhanced: Some(false),
933 }
934 );
935
936 let mut already = AttributeMap {
937 manual: Some(true),
938 enhanced: Some(true),
939 };
940 let untouched = already.enable_electron();
941 assert_eq!(untouched.set_manual, None);
942 assert_eq!(untouched.set_enhanced, None);
943 already.manual = Some(true);
944 already.restore_electron(&untouched);
945 assert_eq!(already.manual, Some(true));
946 assert_eq!(already.enhanced, Some(true));
947 }
948
949 #[test]
950 fn bound_element_keeps_pid_window_revision_and_index() {
951 let bound = BoundElement {
952 pid: 412,
953 window_id: 88,
954 revision: 3,
955 index: 2,
956 };
957 let text = serde_json::to_string(&bound).unwrap();
958 let back: BoundElement = serde_json::from_str(&text).unwrap();
959 assert_eq!(back, bound);
960 }
961
962 #[test]
963 fn hosted_pid_walker_includes_a_foreign_child_once() {
964 let helpers = hosted_helper_pids(10, &[10, 77, 77, 88], &[77, 99]);
965 assert_eq!(helpers, vec![77, 88]);
966 let scanned = hosted_helper_pids(10, &[], &[10, 55, 55, 66, 67]);
967 assert_eq!(scanned, vec![55, 66, 67]);
968 assert!(bundle_matches_parent(
969 "Google Chrome",
970 Some("com.google.Chrome"),
971 "com.google.Chrome.helper"
972 ));
973 }
974
975 fn grow_binary(node: &mut UiNode, depth: usize) {
976 if depth >= DEPTH_CAP {
977 return;
978 }
979 node.children = vec![UiNode::bare("AXGroup"), UiNode::bare("AXGroup")];
980 for child in &mut node.children {
981 grow_binary(child, depth + 1);
982 }
983 }
984
985 fn labeled(role: &str, identity: &str, value: &str) -> UiNode {
986 let mut node = UiNode::bare(role);
987 node.identifier = Some(identity.into());
988 node.title = Some(identity.into());
989 node.value = Some(value.into());
990 node
991 }
992}