1use crate::element::ScreenContext;
8use crate::events::CelEvent;
9use std::collections::HashSet;
10
11pub struct ContextWatchdog {
13 last_element_ids: HashSet<String>,
15 last_focused_id: Option<String>,
17 last_network_idle: bool,
19 initialized: bool,
21}
22
23impl Default for ContextWatchdog {
24 fn default() -> Self {
25 Self::new()
26 }
27}
28
29impl ContextWatchdog {
30 pub fn new() -> Self {
31 Self {
32 last_element_ids: HashSet::new(),
33 last_focused_id: None,
34 last_network_idle: true,
35 initialized: false,
36 }
37 }
38
39 pub fn tick(&mut self, context: &ScreenContext, network_idle: bool) -> Vec<CelEvent> {
46 let current_ids: HashSet<String> = context.elements.iter().map(|e| e.id.clone()).collect();
47
48 let current_focused = context
49 .elements
50 .iter()
51 .find(|e| e.state.focused)
52 .map(|e| e.id.clone());
53
54 if !self.initialized {
56 self.last_element_ids = current_ids;
57 self.last_focused_id = current_focused;
58 self.last_network_idle = network_idle;
59 self.initialized = true;
60 return vec![];
61 }
62
63 let mut events = Vec::new();
64
65 let added: Vec<String> = current_ids
67 .difference(&self.last_element_ids)
68 .cloned()
69 .collect();
70 let removed: Vec<String> = self
71 .last_element_ids
72 .difference(¤t_ids)
73 .cloned()
74 .collect();
75 if !added.is_empty() || !removed.is_empty() {
76 events.push(CelEvent::TreeChanged { added, removed });
77 }
78
79 if current_focused != self.last_focused_id {
83 events.push(CelEvent::FocusChanged {
84 old: self.last_focused_id.clone(),
85 new: current_focused.clone(),
86 });
87 }
88
89 if network_idle && !self.last_network_idle {
91 events.push(CelEvent::NetworkIdle);
92 }
93
94 self.last_element_ids = current_ids;
96 self.last_focused_id = current_focused;
97 self.last_network_idle = network_idle;
98
99 events
100 }
101
102 pub fn merge_events(&self, events: Vec<CelEvent>) -> Vec<CelEvent> {
104 events
105 }
106
107 pub fn merge_ax_events<T>(&self, _events: Vec<T>) -> Vec<CelEvent> {
111 Vec::new()
112 }
113
114 pub fn reset(&mut self) {
116 self.last_element_ids.clear();
117 self.last_focused_id = None;
118 self.last_network_idle = true;
119 self.initialized = false;
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use crate::element::{Bounds, ContentRole, ContextElement, ContextSource, ElementState};
127
128 fn make_element(id: &str, focused: bool) -> ContextElement {
129 ContextElement {
130 id: id.to_string(),
131 label: Some(format!("Element {}", id)),
132 description: None,
133 element_type: "button".to_string(),
134 value: None,
135 bounds: Some(Bounds {
136 x: 0,
137 y: 0,
138 width: 100,
139 height: 30,
140 }),
141 state: ElementState {
142 focused,
143 enabled: true,
144 visible: true,
145 selected: false,
146 expanded: None,
147 checked: None,
148 },
149 parent_id: None,
150 actions: vec![],
151 properties: std::collections::HashMap::new(),
152 confidence: 0.85,
153 source: ContextSource::AccessibilityTree,
154 content_role: ContentRole::default(),
155 }
156 }
157
158 fn make_context(elements: Vec<ContextElement>) -> ScreenContext {
159 ScreenContext {
160 app: "Test".into(),
161 window: "Test Window".into(),
162 elements,
163 network_events: vec![],
164 http_events: vec![],
165 timestamp_ms: 0,
166 screen_width: None,
167 screen_height: None,
168 clipboard: None,
169 window_list: vec![],
170 audio: None,
171 power: None,
172 running_apps: vec![],
173 recent_files: vec![],
174 transcripts: vec![],
175 }
176 }
177
178 #[test]
179 fn test_first_tick_no_events() {
180 let mut wd = ContextWatchdog::new();
181 let ctx = make_context(vec![make_element("a", true)]);
182 let events = wd.tick(&ctx, true);
183 assert!(events.is_empty(), "First tick should not emit events");
184 }
185
186 #[test]
187 fn test_no_change_no_events() {
188 let mut wd = ContextWatchdog::new();
189 let ctx = make_context(vec![make_element("a", true)]);
190 wd.tick(&ctx, true); let events = wd.tick(&ctx, true);
192 assert!(events.is_empty());
193 }
194
195 #[test]
196 fn test_element_added() {
197 let mut wd = ContextWatchdog::new();
198 let ctx1 = make_context(vec![make_element("a", true)]);
199 wd.tick(&ctx1, true);
200
201 let ctx2 = make_context(vec![make_element("a", true), make_element("b", false)]);
202 let events = wd.tick(&ctx2, true);
203 assert_eq!(events.len(), 1);
204 match &events[0] {
205 CelEvent::TreeChanged { added, removed } => {
206 assert_eq!(added, &["b"]);
207 assert!(removed.is_empty());
208 }
209 _ => panic!("Expected TreeChanged"),
210 }
211 }
212
213 #[test]
214 fn test_element_removed() {
215 let mut wd = ContextWatchdog::new();
216 let ctx1 = make_context(vec![make_element("a", true), make_element("b", false)]);
217 wd.tick(&ctx1, true);
218
219 let ctx2 = make_context(vec![make_element("a", true)]);
220 let events = wd.tick(&ctx2, true);
221 assert_eq!(events.len(), 1);
222 match &events[0] {
223 CelEvent::TreeChanged { added, removed } => {
224 assert!(added.is_empty());
225 assert_eq!(removed, &["b"]);
226 }
227 _ => panic!("Expected TreeChanged"),
228 }
229 }
230
231 #[test]
232 fn test_focus_changed() {
233 let mut wd = ContextWatchdog::new();
234 let ctx1 = make_context(vec![make_element("a", true), make_element("b", false)]);
235 wd.tick(&ctx1, true);
236
237 let ctx2 = make_context(vec![make_element("a", false), make_element("b", true)]);
238 let events = wd.tick(&ctx2, true);
239 assert!(events
240 .iter()
241 .any(|e| matches!(e, CelEvent::FocusChanged { .. })));
242 }
243
244 #[test]
245 fn test_network_idle_transition() {
246 let mut wd = ContextWatchdog::new();
247 let ctx = make_context(vec![make_element("a", true)]);
248 wd.tick(&ctx, false); let events = wd.tick(&ctx, true); assert!(events.iter().any(|e| matches!(e, CelEvent::NetworkIdle)));
252 }
253
254 #[test]
255 fn test_network_stays_idle_no_event() {
256 let mut wd = ContextWatchdog::new();
257 let ctx = make_context(vec![make_element("a", true)]);
258 wd.tick(&ctx, true); let events = wd.tick(&ctx, true); assert!(!events.iter().any(|e| matches!(e, CelEvent::NetworkIdle)));
261 }
262
263 #[test]
264 fn test_reset() {
265 let mut wd = ContextWatchdog::new();
266 let ctx = make_context(vec![make_element("a", true)]);
267 wd.tick(&ctx, true);
268 wd.reset();
269 let events = wd.tick(&ctx, true);
271 assert!(events.is_empty());
272 }
273}