Skip to main content

actl_core/
snapshot.rs

1//! 快照语义:UiNode、ref 编号引擎、skeleton 投影、snapshot_id 生成。
2//!
3//! 纯逻辑(平台无关):actl-uia 产出 DFS 序的节点流,本模块负责产品语义——
4//! 哪些元素可交互(06 §3.1)、ref 如何编号(06 §5)、skeleton 如何折叠(06 §3.2 L2)。
5//! spike 结论固化:可交互判定不依赖名称(docs/spike-findings.md #5)。
6
7use std::sync::atomic::{AtomicU32, Ordering};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use serde::{Deserialize, Serialize};
11
12/// DFS 序节点(UIA 后端产出)。`parent` 为同流内的索引,不参与序列化。
13#[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/// 快照输出元素:可交互元素携带 ref(serde 字段名 `ref`,与 06 §4 一致)。
26#[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
38/// 可交互角色表(06 §3.1 命名规则;全集变更属 spec 提交)。
39/// 注意:判定只看 role,不看名称是否为空(spike #5)。
40pub 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
66/// 进程内唯一的短 id(s 前缀 + 时间戳十六进制 + 原子计数)。
67pub 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
77/// 快照构建器:吃 DFS 序节点流,产出带 ref 的输出。
78///
79/// - ref 只分配给可交互元素,编号从 @e1 起,按 DFS 序(06 §5);
80/// - `max_elements` 超限时截断并标记 `truncated`(防失控 UI);
81/// - skeleton 模式仅保留可交互元素及其祖先链(06 §3.2 L2,Chromium 71% 空名容器的解药)。
82pub 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    /// 压入一个 DFS 序节点;返回 false 表示已达上限(调用方应停止遍历)。
105    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    /// skeleton 保留集:可交互元素 ∪ 其祖先链,再**单链折叠**——非交互保留节点
155    /// 若只剩一个保留子则自身折叠(Chromium/Electron 的深度单子容器是削减大头;
156    /// depth 跳变即折叠信号,窗口根豁免以保留窗口身份)。ref 只编号交互元素,
157    /// DFS 序不变 → 折叠不影响 @eN 编号(与全量快照互通)。
158    fn skeleton_keep_set(&self) -> Vec<bool> {
159        let mut keep = vec![false; self.nodes.len()];
160        for (i, n) in self.nodes.iter().enumerate() {
161            if is_interactive_role(&n.role) {
162                keep[i] = true;
163                let mut p = n.parent;
164                while let Some(pi) = p {
165                    if keep[pi] {
166                        break; // 祖先链已标记,剪枝
167                    }
168                    keep[pi] = true;
169                    p = self.nodes[pi].parent;
170                }
171            }
172        }
173        // 单链折叠:kept_children[i] = i 的保留子数;倒序(深→浅)一遍收敛——
174        // 处理父时子的去留已定。窗口根(i=0)豁免,保留窗口身份。
175        let mut kept_children = vec![0usize; self.nodes.len()];
176        for (i, n) in self.nodes.iter().enumerate() {
177            if keep[i] {
178                if let Some(pi) = n.parent {
179                    kept_children[pi] += 1;
180                }
181            }
182        }
183        for i in (0..self.nodes.len()).rev() {
184            if i > 0
185                && keep[i]
186                && !is_interactive_role(&self.nodes[i].role)
187                && kept_children[i] == 1
188            {
189                keep[i] = false;
190                if let Some(pi) = self.nodes[i].parent {
191                    kept_children[pi] -= 1;
192                }
193            }
194        }
195        keep
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn node(depth: u32, role: &str, parent: Option<usize>) -> UiNode {
204        UiNode {
205            depth,
206            role: role.into(),
207            name: None,
208            automation_id: None,
209            parent,
210        }
211    }
212
213    #[test]
214    fn refs_are_sequential_and_interactive_only() {
215        let mut b = SnapshotBuilder::new(100);
216        b.push(node(0, "Window", None));
217        b.push(node(1, "Pane", Some(0)));
218        b.push(node(2, "Button", Some(1)));
219        b.push(node(2, "Text", Some(1)));
220        b.push(node(2, "Edit", Some(1)));
221        let out = b.finish(false);
222        let refs: Vec<&str> = out
223            .elements
224            .iter()
225            .filter_map(|e| e.ref_id.as_deref())
226            .collect();
227        assert_eq!(refs, vec!["@e1", "@e2"]); // Button、Edit 依次编号,Window/Pane/Text 无 ref
228    }
229
230    #[test]
231    fn skeleton_keeps_interactives_and_collapses_single_chains() {
232        let mut b = SnapshotBuilder::new(100);
233        b.push(node(0, "Window", None));
234        b.push(node(1, "Pane", Some(0))); // 单保留子链上的祖先:折叠
235        b.push(node(2, "Pane", Some(1))); // 同上:折叠
236        b.push(node(3, "Text", Some(2))); // 空文本:折叠
237        b.push(node(2, "Button", Some(1))); // 交互:保留(2/1 折叠后由 depth 跳变表达)
238        b.push(node(1, "Text", Some(0))); // 无交互子孙的分支:折叠
239        let out = b.finish(true);
240        let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
241        // 根豁免 + 交互元素;单链祖先全部折叠
242        assert_eq!(roles, vec!["Window", "Button"]);
243    }
244
245    #[test]
246    fn skeleton_keeps_shared_ancestors_with_multiple_kept_children() {
247        let mut b = SnapshotBuilder::new(100);
248        b.push(node(0, "Window", None));
249        b.push(node(1, "Pane", Some(0))); // 两个保留子(Button+Edit):不折叠
250        b.push(node(2, "Button", Some(1)));
251        b.push(node(2, "Edit", Some(1)));
252        let out = b.finish(true);
253        let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
254        assert_eq!(roles, vec!["Window", "Pane", "Button", "Edit"]);
255    }
256
257    #[test]
258    fn truncation_is_flagged() {
259        let mut b = SnapshotBuilder::new(2);
260        assert!(b.push(node(0, "Window", None)));
261        assert!(b.push(node(1, "Button", Some(0))));
262        assert!(!b.push(node(1, "Button", Some(0))));
263        let out = b.finish(false);
264        assert!(out.truncated);
265        assert_eq!(out.elements.len(), 2);
266    }
267
268    #[test]
269    fn snapshot_ids_are_unique_within_process() {
270        let a = new_snapshot_id();
271        let b = new_snapshot_id();
272        assert_ne!(a, b);
273        assert!(a.starts_with('s'));
274    }
275
276    #[test]
277    fn chinese_names_survive_round_trip() {
278        let n = UiNode {
279            depth: 2,
280            role: "Button".into(),
281            name: Some("确定".into()),
282            automation_id: Some("OK".into()),
283            parent: None,
284        };
285        let mut b = SnapshotBuilder::new(10);
286        b.push(n);
287        let out = b.finish(false);
288        let json = serde_json::to_value(&out.elements[0]).unwrap();
289        assert_eq!(json["name"], "确定");
290        assert_eq!(json["ref"], "@e1");
291    }
292}