Skip to main content

fission_test/
driver.rs

1use crate::TestHarness;
2use anyhow::{anyhow, Result};
3use fission_core::action::GlobalState;
4use fission_core::event::{ImeEvent, InputEvent, KeyCode, KeyEvent, PointerButton, PointerEvent};
5use fission_ir::{LayoutOp, Op, WidgetId};
6use fission_layout::{LayoutPoint, LayoutRect, LayoutSize};
7use fission_render::DisplayOp;
8
9#[derive(Debug, Clone)]
10pub struct TextMatch {
11    pub text: String,
12    pub bounds: LayoutRect,
13    pub node_id: Option<WidgetId>,
14}
15
16#[derive(Debug, Clone)]
17pub struct SemanticMatch {
18    pub role: fission_ir::semantics::Role,
19    pub label: Option<String>,
20    pub bounds: LayoutRect,
21    pub node_id: WidgetId,
22}
23
24pub struct TestDriver<S: GlobalState> {
25    pub harness: TestHarness<S>,
26    auto_pump: bool,
27}
28
29impl<S: GlobalState> TestDriver<S> {
30    pub fn new(harness: TestHarness<S>) -> Self {
31        Self {
32            harness,
33            auto_pump: true,
34        }
35    }
36
37    pub fn set_viewport(&mut self, width: f32, height: f32) {
38        self.harness.env.viewport_size = LayoutSize::new(width, height);
39    }
40
41    pub fn pump(&mut self) -> Result<()> {
42        self.harness.pump()
43    }
44
45    // --- Queries ---
46
47    pub fn find_text(&self, needle: &str) -> Option<TextMatch> {
48        self.find_all_text(needle).into_iter().next()
49    }
50
51    pub fn find_all_text(&self, needle: &str) -> Vec<TextMatch> {
52        let dl = match self.harness.get_last_display_list() {
53            Some(dl) => dl,
54            None => return vec![],
55        };
56        let mut results = Vec::new();
57        for op in &dl.ops {
58            match op {
59                DisplayOp::DrawText {
60                    text,
61                    bounds,
62                    node_id,
63                    ..
64                } => {
65                    if text.contains(needle) {
66                        results.push(TextMatch {
67                            text: text.clone(),
68                            bounds: *bounds,
69                            node_id: *node_id,
70                        });
71                    }
72                }
73                DisplayOp::DrawRichText {
74                    runs,
75                    bounds,
76                    node_id,
77                    ..
78                } => {
79                    let combined: String = runs.iter().map(|r| r.text.clone()).collect();
80                    if combined.contains(needle) {
81                        results.push(TextMatch {
82                            text: combined,
83                            bounds: *bounds,
84                            node_id: *node_id,
85                        });
86                    }
87                }
88                _ => {}
89            }
90        }
91        results
92    }
93
94    pub fn find_role(&self, role: fission_ir::semantics::Role) -> Vec<SemanticMatch> {
95        let ir = match &self.harness.last_ir {
96            Some(ir) => ir,
97            None => return vec![],
98        };
99        let snapshot = match &self.harness.last_snapshot {
100            Some(s) => s,
101            None => return vec![],
102        };
103        let mut results = Vec::new();
104        for (id, node) in &ir.nodes {
105            if let Op::Semantics(sem) = &node.op {
106                if sem.role == role {
107                    let bounds = snapshot
108                        .get_node_rect(*id)
109                        .unwrap_or(LayoutRect::new(0.0, 0.0, 0.0, 0.0));
110                    results.push(SemanticMatch {
111                        role: sem.role,
112                        label: sem.label.clone(),
113                        bounds,
114                        node_id: *id,
115                    });
116                }
117            }
118        }
119        results
120    }
121
122    pub fn get_all_visible_text(&self) -> Vec<String> {
123        let dl = match self.harness.get_last_display_list() {
124            Some(dl) => dl,
125            None => return vec![],
126        };
127        let mut texts = Vec::new();
128        for op in &dl.ops {
129            match op {
130                DisplayOp::DrawText { text, .. } => texts.push(text.clone()),
131                DisplayOp::DrawRichText { runs, .. } => {
132                    texts.push(runs.iter().map(|r| r.text.clone()).collect());
133                }
134                _ => {}
135            }
136        }
137        texts
138    }
139
140    // --- Interactions ---
141
142    pub fn tap_point(&mut self, x: f32, y: f32) -> Result<()> {
143        let point = LayoutPoint::new(x, y);
144        self.harness
145            .send_event(InputEvent::Pointer(PointerEvent::Down {
146                point,
147                button: PointerButton::Primary,
148                modifiers: 0,
149            }))?;
150        self.harness
151            .send_event(InputEvent::Pointer(PointerEvent::Up {
152                point,
153                button: PointerButton::Primary,
154                modifiers: 0,
155            }))?;
156        if self.auto_pump {
157            self.harness.pump()?;
158        }
159        Ok(())
160    }
161
162    pub fn tap_text(&mut self, needle: &str) -> Result<()> {
163        let m = self
164            .find_text(needle)
165            .ok_or_else(|| anyhow!("text '{}' not found in display list", needle))?;
166        let cx = m.bounds.x() + m.bounds.width() / 2.0;
167        let cy = m.bounds.y() + m.bounds.height() / 2.0;
168        self.tap_point(cx, cy)
169    }
170
171    pub fn scroll(&mut self, at: LayoutPoint, delta: LayoutPoint) -> Result<()> {
172        self.harness
173            .send_event(InputEvent::Pointer(PointerEvent::Scroll {
174                point: at,
175                delta,
176                modifiers: 0,
177            }))?;
178        if self.auto_pump {
179            self.harness.pump()?;
180        }
181        Ok(())
182    }
183
184    pub fn scroll_down(&mut self, at: LayoutPoint, pixels: f32) -> Result<()> {
185        self.scroll(at, LayoutPoint::new(0.0, pixels))
186    }
187
188    pub fn scroll_to_text(&mut self, needle: &str) -> Result<()> {
189        // Find the text
190        let text_match = self
191            .find_text(needle)
192            .ok_or_else(|| anyhow!("text '{}' not found", needle))?;
193
194        // Find the text's node_id, then walk up the IR to find enclosing Scroll
195        let ir = self
196            .harness
197            .last_ir
198            .as_ref()
199            .ok_or_else(|| anyhow!("no IR"))?;
200        let snapshot = self
201            .harness
202            .last_snapshot
203            .as_ref()
204            .ok_or_else(|| anyhow!("no snapshot"))?;
205
206        let text_node_id = text_match
207            .node_id
208            .ok_or_else(|| anyhow!("text has no node_id"))?;
209
210        // Walk up parents to find Scroll container
211        let mut current = ir.nodes.get(&text_node_id).and_then(|n| n.parent);
212        let mut scroll_id = None;
213        while let Some(pid) = current {
214            if let Some(pnode) = ir.nodes.get(&pid) {
215                if matches!(pnode.op, Op::Layout(LayoutOp::Scroll { .. })) {
216                    scroll_id = Some(pid);
217                    break;
218                }
219                current = pnode.parent;
220            } else {
221                break;
222            }
223        }
224
225        let scroll_id =
226            scroll_id.ok_or_else(|| anyhow!("no enclosing Scroll found for '{}'", needle))?;
227        let scroll_rect = snapshot
228            .get_node_rect(scroll_id)
229            .ok_or_else(|| anyhow!("scroll node has no rect"))?;
230
231        // Calculate needed offset to bring text into view
232        let text_y = text_match.bounds.y();
233        let viewport_top = scroll_rect.y();
234        let viewport_bottom = viewport_top + scroll_rect.height();
235
236        if text_y >= viewport_top && text_y + text_match.bounds.height() <= viewport_bottom {
237            return Ok(()); // Already visible
238        }
239
240        // Set scroll offset directly for reliability
241        let needed_offset = (text_y - viewport_top - 20.0).max(0.0); // 20px margin from top
242        self.harness
243            .runtime
244            .runtime_state
245            .scroll
246            .set_offset(scroll_id, needed_offset);
247        self.harness.pump()?;
248        Ok(())
249    }
250
251    pub fn type_text(&mut self, text: &str) -> Result<()> {
252        for ch in text.chars() {
253            if ch.is_ascii() {
254                let key_code = if ch == ' ' {
255                    KeyCode::Space
256                } else if ch == '\n' {
257                    KeyCode::Enter
258                } else {
259                    KeyCode::Char(ch)
260                };
261                self.harness
262                    .send_event(InputEvent::Keyboard(KeyEvent::Down {
263                        key_code: key_code.clone(),
264                        modifiers: 0,
265                    }))?;
266            } else {
267                // Non-ASCII: use IME commit
268                self.harness.send_event(InputEvent::Ime(ImeEvent::Commit {
269                    text: ch.to_string(),
270                }))?;
271            }
272        }
273        if self.auto_pump {
274            self.harness.pump()?;
275        }
276        Ok(())
277    }
278
279    pub fn press_key(&mut self, key: KeyCode, modifiers: u8) -> Result<()> {
280        self.harness
281            .send_event(InputEvent::Keyboard(KeyEvent::Down {
282                key_code: key,
283                modifiers,
284            }))?;
285        if self.auto_pump {
286            self.harness.pump()?;
287        }
288        Ok(())
289    }
290
291    pub fn tick(&mut self, dt_ms: u64) -> Result<()> {
292        self.harness.tick(dt_ms)?;
293        if self.auto_pump {
294            self.harness.pump()?;
295        }
296        Ok(())
297    }
298
299    // --- Assertions ---
300
301    pub fn assert_text_visible(&self, needle: &str) {
302        assert!(
303            self.find_text(needle).is_some(),
304            "expected text '{}' to be visible, but it was not found in the display list",
305            needle
306        );
307    }
308
309    pub fn assert_text_not_visible(&self, needle: &str) {
310        assert!(
311            self.find_text(needle).is_none(),
312            "expected text '{}' to NOT be visible, but it was found",
313            needle
314        );
315    }
316}