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
12use crate::{CtlError, ErrorCode};
13
14/// DFS 序节点(UIA 后端产出)。`parent` 为同流内的索引,不参与序列化。
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct UiNode {
17    pub depth: u32,
18    pub role: String,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub name: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub automation_id: Option<String>,
23    #[serde(skip)]
24    pub parent: Option<usize>,
25}
26
27/// 快照输出元素:可交互元素携带 ref(serde 字段名 `ref`,与 06 §4 一致)。
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct ElementOut {
30    #[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
31    pub ref_id: Option<String>,
32    pub role: String,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub name: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub automation_id: Option<String>,
37    pub depth: u32,
38}
39
40/// 可交互角色表(06 §3.1 命名规则;全集变更属 spec 提交)。
41/// 注意:判定只看 role,不看名称是否为空(spike #5)。
42pub const INTERACTIVE_ROLES: &[&str] = &[
43    "Button",
44    "CheckBox",
45    "RadioButton",
46    "ComboBox",
47    "Edit",
48    "ListItem",
49    "MenuItem",
50    "TabItem",
51    "Hyperlink",
52    "ToggleButton",
53    "DataGrid",
54    "Spinner",
55    "Slider",
56    "Document",
57    "Tree",
58    "TreeItem",
59    "Table",
60    "Calendar",
61    "Menu",
62];
63
64pub fn is_interactive_role(role: &str) -> bool {
65    INTERACTIVE_ROLES.contains(&role)
66}
67
68/// 进程内唯一的短 id(s 前缀 + 时间戳十六进制 + 原子计数)。
69pub fn new_snapshot_id() -> String {
70    static COUNTER: AtomicU32 = AtomicU32::new(0);
71    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
72    let nanos = SystemTime::now()
73        .duration_since(UNIX_EPOCH)
74        .map(|d| d.as_nanos() as u64)
75        .unwrap_or(0);
76    format!("s{nanos:x}{n:x}")
77}
78
79/// 快照投影模式(06 §3.2 L2 / §3.4)。
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Projection {
82    /// 全量树。
83    Full,
84    /// 仅可交互元素 + 祖先链 + 单链折叠(Chromium 71% 空名容器的解药)。
85    Skeleton,
86    /// 内容域:保留 Document 子树(网页/文档正文)与根→Document 路径,
87    /// 应用 chrome(工具栏/ribbon)整枝丢弃。`skeleton: true` 时组合——
88    /// 折叠只作用于 Document 子树**内部**,chrome 交互元素不参与保留判定
89    /// (浏览器形态 skeleton 仅削 ~36% 的根因修复)。
90    Content { skeleton: bool },
91}
92
93/// 快照构建器:吃 DFS 序节点流,产出带 ref 的输出。
94///
95/// - ref 只分配给可交互元素,编号从 @e1 起,按 DFS 序(06 §5);
96/// - `max_elements` 超限时截断并标记 `truncated`(防失控 UI);
97/// - 投影模式见 [`Projection`](06 §3.2 L2);Content 模式下窗口无任何
98///   Document → `NOT_FOUND`(内容域不存在,如实报而非静默回退全量)。
99pub struct SnapshotBuilder {
100    nodes: Vec<UiNode>,
101    max_elements: usize,
102    truncated: bool,
103}
104
105#[derive(Debug, Clone, PartialEq)]
106pub struct SnapshotOutput {
107    pub snapshot_id: String,
108    pub truncated: bool,
109    pub elements: Vec<ElementOut>,
110}
111
112impl SnapshotBuilder {
113    pub fn new(max_elements: usize) -> Self {
114        Self {
115            nodes: Vec::new(),
116            max_elements,
117            truncated: false,
118        }
119    }
120
121    /// 压入一个 DFS 序节点;返回 false 表示已达上限(调用方应停止遍历)。
122    pub fn push(&mut self, node: UiNode) -> bool {
123        if self.nodes.len() >= self.max_elements {
124            self.truncated = true;
125            return false;
126        }
127        self.nodes.push(node);
128        true
129    }
130
131    pub fn finish(self, view: Projection) -> Result<SnapshotOutput, CtlError> {
132        let keep = match view {
133            Projection::Full => self.all_keep(),
134            Projection::Skeleton => self.skeleton_keep_set(),
135            Projection::Content { skeleton } => {
136                if !self.nodes.iter().any(|n| n.role == "Document") {
137                    return Err(CtlError::new(
138                        ErrorCode::NotFound,
139                        "no Document (content) region in this window - retry without --view content",
140                    ));
141                }
142                self.content_keep_set(skeleton)
143            }
144        };
145        let mut next_ref = 0;
146        let elements = self
147            .nodes
148            .iter()
149            .enumerate()
150            .filter(|(i, _)| keep[*i])
151            .map(|(i, n)| {
152                let ref_id = if is_interactive_role(&n.role) {
153                    next_ref += 1;
154                    Some(format!("@e{next_ref}"))
155                } else {
156                    None
157                };
158                let _ = i;
159                ElementOut {
160                    ref_id,
161                    role: n.role.clone(),
162                    name: n.name.clone(),
163                    automation_id: n.automation_id.clone(),
164                    depth: n.depth,
165                }
166            })
167            .collect();
168        Ok(SnapshotOutput {
169            snapshot_id: new_snapshot_id(),
170            truncated: self.truncated,
171            elements,
172        })
173    }
174
175    fn all_keep(&self) -> Vec<bool> {
176        vec![true; self.nodes.len()]
177    }
178
179    /// skeleton 保留集:可交互元素 ∪ 其祖先链,再**单链折叠**——非交互保留节点
180    /// 若只剩一个保留子则自身折叠(Chromium/Electron 的深度单子容器是削减大头;
181    /// depth 跳变即折叠信号,窗口根豁免以保留窗口身份)。ref 只编号交互元素,
182    /// DFS 序不变 → 折叠不影响 @eN 编号(与全量快照互通)。
183    fn skeleton_keep_set(&self) -> Vec<bool> {
184        let mut keep = vec![false; self.nodes.len()];
185        for (i, n) in self.nodes.iter().enumerate() {
186            if is_interactive_role(&n.role) {
187                keep[i] = true;
188                let mut p = n.parent;
189                while let Some(pi) = p {
190                    if keep[pi] {
191                        break; // 祖先链已标记,剪枝
192                    }
193                    keep[pi] = true;
194                    p = self.nodes[pi].parent;
195                }
196            }
197        }
198        self.collapse_single_chains(&mut keep);
199        keep
200    }
201
202    /// content 保留集:in_doc(Document 子树)∪ 其祖先链(根→Document 路径)。
203    /// DFS 先序保证 parent 索引恒小于子,一遍即成。组合模式(skeleton=true)
204    /// 交互种子只取 in_doc 节点——chrome 里的按钮/地址栏不再强制保留,
205    /// 这是浏览器形态削减率的主增量。
206    fn content_keep_set(&self, skeleton: bool) -> Vec<bool> {
207        let mut in_doc = vec![false; self.nodes.len()];
208        for (i, n) in self.nodes.iter().enumerate() {
209            in_doc[i] = n.role == "Document" || n.parent.is_some_and(|p| in_doc[p]);
210        }
211        let mut keep = vec![false; self.nodes.len()];
212        for (i, n) in self.nodes.iter().enumerate() {
213            if in_doc[i] && (!skeleton || is_interactive_role(&n.role)) {
214                keep[i] = true;
215                let mut p = n.parent;
216                while let Some(pi) = p {
217                    if keep[pi] {
218                        break;
219                    }
220                    keep[pi] = true;
221                    p = self.nodes[pi].parent;
222                }
223            }
224        }
225        if skeleton {
226            self.collapse_single_chains(&mut keep);
227        }
228        keep
229    }
230
231    /// 单链折叠:kept_children[i] = i 的保留子数;倒序(深→浅)一遍收敛——
232    /// 处理父时子的去留已定。窗口根(i=0)豁免,保留窗口身份。
233    fn collapse_single_chains(&self, keep: &mut [bool]) {
234        let mut kept_children = vec![0usize; self.nodes.len()];
235        for (i, n) in self.nodes.iter().enumerate() {
236            if keep[i]
237                && let Some(pi) = n.parent
238            {
239                kept_children[pi] += 1;
240            }
241        }
242        for i in (0..self.nodes.len()).rev() {
243            if i > 0
244                && keep[i]
245                && !is_interactive_role(&self.nodes[i].role)
246                && kept_children[i] == 1
247            {
248                keep[i] = false;
249                if let Some(pi) = self.nodes[i].parent {
250                    kept_children[pi] -= 1;
251                }
252            }
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    fn node(depth: u32, role: &str, parent: Option<usize>) -> UiNode {
262        UiNode {
263            depth,
264            role: role.into(),
265            name: None,
266            automation_id: None,
267            parent,
268        }
269    }
270
271    #[test]
272    fn refs_are_sequential_and_interactive_only() {
273        let mut b = SnapshotBuilder::new(100);
274        b.push(node(0, "Window", None));
275        b.push(node(1, "Pane", Some(0)));
276        b.push(node(2, "Button", Some(1)));
277        b.push(node(2, "Text", Some(1)));
278        b.push(node(2, "Edit", Some(1)));
279        let out = b.finish(Projection::Full).expect("full");
280        let refs: Vec<&str> = out
281            .elements
282            .iter()
283            .filter_map(|e| e.ref_id.as_deref())
284            .collect();
285        assert_eq!(refs, vec!["@e1", "@e2"]); // Button、Edit 依次编号,Window/Pane/Text 无 ref
286    }
287
288    #[test]
289    fn skeleton_keeps_interactives_and_collapses_single_chains() {
290        let mut b = SnapshotBuilder::new(100);
291        b.push(node(0, "Window", None));
292        b.push(node(1, "Pane", Some(0))); // 单保留子链上的祖先:折叠
293        b.push(node(2, "Pane", Some(1))); // 同上:折叠
294        b.push(node(3, "Text", Some(2))); // 空文本:折叠
295        b.push(node(2, "Button", Some(1))); // 交互:保留(2/1 折叠后由 depth 跳变表达)
296        b.push(node(1, "Text", Some(0))); // 无交互子孙的分支:折叠
297        let out = b.finish(Projection::Skeleton).expect("skeleton");
298        let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
299        // 根豁免 + 交互元素;单链祖先全部折叠
300        assert_eq!(roles, vec!["Window", "Button"]);
301    }
302
303    #[test]
304    fn skeleton_keeps_shared_ancestors_with_multiple_kept_children() {
305        let mut b = SnapshotBuilder::new(100);
306        b.push(node(0, "Window", None));
307        b.push(node(1, "Pane", Some(0))); // 两个保留子(Button+Edit):不折叠
308        b.push(node(2, "Button", Some(1)));
309        b.push(node(2, "Edit", Some(1)));
310        let out = b.finish(Projection::Skeleton).expect("skeleton");
311        let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
312        assert_eq!(roles, vec!["Window", "Pane", "Button", "Edit"]);
313    }
314
315    /// 浏览器形态的最小树:Window → [ToolBar(按钮×2) + Pane → Document → 链接×2]。
316    /// content 投影须整枝丢 ToolBar、保 Document 子树与根→Document 路径。
317    #[test]
318    fn content_view_drops_chrome_and_keeps_document_subtree() {
319        let mut b = SnapshotBuilder::new(100);
320        b.push(node(0, "Window", None));
321        b.push(node(1, "ToolBar", Some(0)));
322        b.push(node(2, "Button", Some(1))); // chrome 交互元素
323        b.push(node(2, "Button", Some(1)));
324        b.push(node(1, "Pane", Some(0)));
325        b.push(node(2, "Document", Some(4)));
326        b.push(node(3, "Hyperlink", Some(5)));
327        b.push(node(3, "Text", Some(5)));
328        b.push(node(3, "Hyperlink", Some(5)));
329        let out = b
330            .finish(Projection::Content { skeleton: false })
331            .expect("content");
332        let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
333        assert_eq!(
334            roles,
335            vec![
336                "Window",
337                "Pane",
338                "Document",
339                "Hyperlink",
340                "Text",
341                "Hyperlink"
342            ]
343        );
344        // ref 只落在 Document 子树内
345        let refs: Vec<&str> = out
346            .elements
347            .iter()
348            .filter_map(|e| e.ref_id.as_deref())
349            .collect();
350        assert_eq!(refs, vec!["@e1", "@e2", "@e3"]);
351    }
352
353    /// 组合模式:chrome 交互元素不参与保留判定(纯 skeleton 削不动的根因),
354    /// Document 子树内部按 skeleton 折叠(Text 无交互子孙 → 丢)。
355    #[test]
356    fn content_view_combined_with_skeleton_ignores_chrome_interactives() {
357        let mut b = SnapshotBuilder::new(100);
358        b.push(node(0, "Window", None));
359        b.push(node(1, "ToolBar", Some(0)));
360        b.push(node(2, "Button", Some(1))); // chrome 按钮:skeleton 会保,content+skeleton 必丢
361        b.push(node(1, "Pane", Some(0)));
362        b.push(node(2, "Document", Some(3)));
363        b.push(node(3, "Hyperlink", Some(4)));
364        b.push(node(3, "Text", Some(4))); // 文档内非交互:组合模式折叠
365        let out = b
366            .finish(Projection::Content { skeleton: true })
367            .expect("content+skeleton");
368        let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
369        assert_eq!(roles, vec!["Window", "Document", "Hyperlink"]);
370    }
371
372    /// 无 Document(计算器类纯控件应用):NOT_FOUND,不静默回退全量。
373    #[test]
374    fn content_view_without_document_is_not_found() {
375        let mut b = SnapshotBuilder::new(100);
376        b.push(node(0, "Window", None));
377        b.push(node(1, "Button", Some(0)));
378        let err = b
379            .finish(Projection::Content { skeleton: false })
380            .unwrap_err();
381        assert_eq!(err.code, ErrorCode::NotFound);
382    }
383
384    /// 嵌套 Document(iframe):内层 Document 仍在内容域内。
385    #[test]
386    fn content_view_keeps_nested_documents() {
387        let mut b = SnapshotBuilder::new(100);
388        b.push(node(0, "Window", None));
389        b.push(node(1, "Document", Some(0)));
390        b.push(node(2, "Pane", Some(1)));
391        b.push(node(3, "Document", Some(2))); // iframe
392        b.push(node(4, "Hyperlink", Some(3)));
393        let out = b
394            .finish(Projection::Content { skeleton: false })
395            .expect("content");
396        let roles: Vec<&str> = out.elements.iter().map(|e| e.role.as_ref()).collect();
397        assert_eq!(
398            roles,
399            vec!["Window", "Document", "Pane", "Document", "Hyperlink"]
400        );
401    }
402
403    #[test]
404    fn truncation_is_flagged() {
405        let mut b = SnapshotBuilder::new(2);
406        assert!(b.push(node(0, "Window", None)));
407        assert!(b.push(node(1, "Button", Some(0))));
408        assert!(!b.push(node(1, "Button", Some(0))));
409        let out = b.finish(Projection::Full).expect("full");
410        assert!(out.truncated);
411        assert_eq!(out.elements.len(), 2);
412    }
413
414    #[test]
415    fn snapshot_ids_are_unique_within_process() {
416        let a = new_snapshot_id();
417        let b = new_snapshot_id();
418        assert_ne!(a, b);
419        assert!(a.starts_with('s'));
420    }
421
422    #[test]
423    fn chinese_names_survive_round_trip() {
424        let n = UiNode {
425            depth: 2,
426            role: "Button".into(),
427            name: Some("确定".into()),
428            automation_id: Some("OK".into()),
429            parent: None,
430        };
431        let mut b = SnapshotBuilder::new(10);
432        b.push(n);
433        let out = b.finish(Projection::Full).expect("full");
434        let json = serde_json::to_value(&out.elements[0]).unwrap();
435        assert_eq!(json["name"], "确定");
436        assert_eq!(json["ref"], "@e1");
437    }
438}