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    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; // 祖先链已标记,剪枝
164                    }
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"]); // Button、Edit 依次编号,Window/Pane/Text 无 ref
203    }
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))); // 祖先链:保留
210        b.push(node(2, "Pane", Some(1))); // 空容器:折叠
211        b.push(node(3, "Text", Some(2))); // 空文本:折叠
212        b.push(node(2, "Button", Some(1)));
213        b.push(node(1, "Text", Some(0))); // 无交互子孙的分支:折叠
214        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}