1use std::sync::atomic::{AtomicU32, Ordering};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct UiNode {
15 pub depth: u32,
16 pub role: String,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 pub name: Option<String>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub automation_id: Option<String>,
21 #[serde(skip)]
22 pub parent: Option<usize>,
23}
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct ElementOut {
28 #[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
29 pub ref_id: Option<String>,
30 pub role: String,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub name: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub automation_id: Option<String>,
35 pub depth: u32,
36}
37
38pub const INTERACTIVE_ROLES: &[&str] = &[
41 "Button",
42 "CheckBox",
43 "RadioButton",
44 "ComboBox",
45 "Edit",
46 "ListItem",
47 "MenuItem",
48 "TabItem",
49 "Hyperlink",
50 "ToggleButton",
51 "DataGrid",
52 "Spinner",
53 "Slider",
54 "Document",
55 "Tree",
56 "TreeItem",
57 "Table",
58 "Calendar",
59 "Menu",
60];
61
62pub fn is_interactive_role(role: &str) -> bool {
63 INTERACTIVE_ROLES.contains(&role)
64}
65
66pub fn new_snapshot_id() -> String {
68 static COUNTER: AtomicU32 = AtomicU32::new(0);
69 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
70 let nanos = SystemTime::now()
71 .duration_since(UNIX_EPOCH)
72 .map(|d| d.as_nanos() as u64)
73 .unwrap_or(0);
74 format!("s{nanos:x}{n:x}")
75}
76
77pub struct SnapshotBuilder {
83 nodes: Vec<UiNode>,
84 max_elements: usize,
85 truncated: bool,
86}
87
88#[derive(Debug, Clone, PartialEq)]
89pub struct SnapshotOutput {
90 pub snapshot_id: String,
91 pub truncated: bool,
92 pub elements: Vec<ElementOut>,
93}
94
95impl SnapshotBuilder {
96 pub fn new(max_elements: usize) -> Self {
97 Self {
98 nodes: Vec::new(),
99 max_elements,
100 truncated: false,
101 }
102 }
103
104 pub fn push(&mut self, node: UiNode) -> bool {
106 if self.nodes.len() >= self.max_elements {
107 self.truncated = true;
108 return false;
109 }
110 self.nodes.push(node);
111 true
112 }
113
114 pub fn finish(self, skeleton: bool) -> SnapshotOutput {
115 let keep = if skeleton {
116 self.skeleton_keep_set()
117 } else {
118 self.all_keep()
119 };
120 let mut next_ref = 0;
121 let elements = self
122 .nodes
123 .iter()
124 .enumerate()
125 .filter(|(i, _)| keep[*i])
126 .map(|(i, n)| {
127 let ref_id = if is_interactive_role(&n.role) {
128 next_ref += 1;
129 Some(format!("@e{next_ref}"))
130 } else {
131 None
132 };
133 let _ = i;
134 ElementOut {
135 ref_id,
136 role: n.role.clone(),
137 name: n.name.clone(),
138 automation_id: n.automation_id.clone(),
139 depth: n.depth,
140 }
141 })
142 .collect();
143 SnapshotOutput {
144 snapshot_id: new_snapshot_id(),
145 truncated: self.truncated,
146 elements,
147 }
148 }
149
150 fn all_keep(&self) -> Vec<bool> {
151 vec![true; self.nodes.len()]
152 }
153
154 fn skeleton_keep_set(&self) -> Vec<bool> {
156 let mut keep = vec![false; self.nodes.len()];
157 for (i, n) in self.nodes.iter().enumerate() {
158 if is_interactive_role(&n.role) {
159 keep[i] = true;
160 let mut p = n.parent;
161 while let Some(pi) = p {
162 if keep[pi] {
163 break; }
165 keep[pi] = true;
166 p = self.nodes[pi].parent;
167 }
168 }
169 }
170 keep
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn node(depth: u32, role: &str, parent: Option<usize>) -> UiNode {
179 UiNode {
180 depth,
181 role: role.into(),
182 name: None,
183 automation_id: None,
184 parent,
185 }
186 }
187
188 #[test]
189 fn refs_are_sequential_and_interactive_only() {
190 let mut b = SnapshotBuilder::new(100);
191 b.push(node(0, "Window", None));
192 b.push(node(1, "Pane", Some(0)));
193 b.push(node(2, "Button", Some(1)));
194 b.push(node(2, "Text", Some(1)));
195 b.push(node(2, "Edit", Some(1)));
196 let out = b.finish(false);
197 let refs: Vec<&str> = out
198 .elements
199 .iter()
200 .filter_map(|e| e.ref_id.as_deref())
201 .collect();
202 assert_eq!(refs, vec!["@e1", "@e2"]); }
204
205 #[test]
206 fn skeleton_keeps_interactives_and_ancestors_only() {
207 let mut b = SnapshotBuilder::new(100);
208 b.push(node(0, "Window", None));
209 b.push(node(1, "Pane", Some(0))); b.push(node(2, "Pane", Some(1))); b.push(node(3, "Text", Some(2))); b.push(node(2, "Button", Some(1)));
213 b.push(node(1, "Text", Some(0))); let out = b.finish(true);
215 let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
216 assert_eq!(roles, vec!["Window", "Pane", "Button"]);
217 }
218
219 #[test]
220 fn truncation_is_flagged() {
221 let mut b = SnapshotBuilder::new(2);
222 assert!(b.push(node(0, "Window", None)));
223 assert!(b.push(node(1, "Button", Some(0))));
224 assert!(!b.push(node(1, "Button", Some(0))));
225 let out = b.finish(false);
226 assert!(out.truncated);
227 assert_eq!(out.elements.len(), 2);
228 }
229
230 #[test]
231 fn snapshot_ids_are_unique_within_process() {
232 let a = new_snapshot_id();
233 let b = new_snapshot_id();
234 assert_ne!(a, b);
235 assert!(a.starts_with('s'));
236 }
237
238 #[test]
239 fn chinese_names_survive_round_trip() {
240 let n = UiNode {
241 depth: 2,
242 role: "Button".into(),
243 name: Some("确定".into()),
244 automation_id: Some("OK".into()),
245 parent: None,
246 };
247 let mut b = SnapshotBuilder::new(10);
248 b.push(n);
249 let out = b.finish(false);
250 let json = serde_json::to_value(&out.elements[0]).unwrap();
251 assert_eq!(json["name"], "确定");
252 assert_eq!(json["ref"], "@e1");
253 }
254}