Skip to main content

fenestra_shell/
harness.rs

1//! The verification harness: drive an [`App`] headlessly through
2//! semantic queries instead of coordinates, and assert at three levels —
3//! pixels, accessibility tree, and emitted messages.
4//!
5//! ```no_run
6//! use fenestra_core::{App, by};
7//! use fenestra_shell::Harness;
8//! # struct Todo; #[derive(Clone)] enum Msg { Add }
9//! # impl App for Todo { type Msg = Msg; fn update(&mut self, _: Msg) {}
10//! #   fn view(&self) -> fenestra_core::Element<Msg> { fenestra_core::col() } }
11//! let mut h = Harness::new(Todo, fenestra_core::Theme::light(), (480, 320));
12//! h.click(&by::label("Add"));            // find like a user, not by (x, y)
13//! h.type_text("buy milk");
14//! assert!(h.query(&by::label("buy milk")).is_some());
15//! let _png = h.render();                 // pixels only when asked
16//! ```
17//!
18//! Determinism: scale 1.0, reduced motion, embedded fonts, and an
19//! explicit clock — animations only advance when [`Harness::pump`] is
20//! called. Nothing is painted unless [`Harness::render`] is called, so
21//! structural tests stay fast.
22
23use std::sync::{Arc, Mutex, PoisonError};
24
25use std::collections::HashMap;
26
27use fenestra_core::{
28    AccessNode, App, Element, Frame, FrameState, InputEvent, KeyInput, MAIN_WINDOW, Proxy, Query,
29    Theme, build_frame, dispatch,
30};
31use image::RgbaImage;
32
33use crate::element_render::with_fonts;
34use crate::with_headless;
35
36/// One headless window: its own retained state, view, and frame —
37/// exactly like the windowed runner keeps per window.
38struct WindowSlot<Msg> {
39    state: FrameState,
40    view: Element<Msg>,
41    frame: Frame,
42    logical: (f32, f32),
43    size: (u32, u32),
44}
45
46/// A headless app under test. See the module docs for the model.
47pub struct Harness<A: App> {
48    app: A,
49    theme: Theme,
50    /// Deterministic clock in seconds, advanced only by [`Self::pump`].
51    clock: f64,
52    /// Messages emitted by handlers since the last [`Self::take_messages`].
53    msgs: Vec<A::Msg>,
54    pending: Arc<Mutex<Vec<A::Msg>>>,
55    /// Open windows by key; reconciled against [`App::windows`] after
56    /// every update, exactly like the windowed runner.
57    slots: HashMap<String, WindowSlot<A::Msg>>,
58    /// The window verbs and queries currently target.
59    active: String,
60}
61
62impl<A: App> Harness<A>
63where
64    A::Msg: Send,
65{
66    /// Builds the first frame. [`App::init`] runs with a collecting
67    /// [`Proxy`]; proxied messages drain at every rebuild (after each
68    /// input, [`Self::pump`], or [`Self::update`]).
69    ///
70    /// # Panics
71    /// If no compute-capable GPU adapter exists.
72    pub fn new(mut app: A, theme: Theme, size: (u32, u32)) -> Self {
73        let size =
74            with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
75        let pending: Arc<Mutex<Vec<A::Msg>>> = Arc::new(Mutex::new(Vec::new()));
76        let sink = Arc::clone(&pending);
77        app.init(Proxy::new(move |msg| {
78            sink.lock()
79                .unwrap_or_else(PoisonError::into_inner)
80                .push(msg);
81        }));
82        Self::drain(&mut app, &pending);
83        let mut harness = Self {
84            app,
85            theme,
86            clock: 0.0,
87            msgs: Vec::new(),
88            pending,
89            slots: HashMap::new(),
90            active: MAIN_WINDOW.to_owned(),
91        };
92        harness.slots.insert(
93            MAIN_WINDOW.to_owned(),
94            Self::new_slot(&harness.app, &harness.theme, MAIN_WINDOW, size, 0.0),
95        );
96        harness.rebuild();
97        harness
98    }
99
100    fn new_slot(
101        app: &A,
102        theme: &Theme,
103        key: &str,
104        size: (u32, u32),
105        clock: f64,
106    ) -> WindowSlot<A::Msg> {
107        let size =
108            with_headless(|h| h.clamp_size(size.0, size.1)).expect("headless renderer unavailable");
109        let mut state = FrameState::new();
110        state.reduced_motion = true;
111        state.tick(clock);
112        #[expect(clippy::cast_precision_loss, reason = "window sizes fit in f32")]
113        let logical = (size.0 as f32, size.1 as f32);
114        let view = app.view_for(key);
115        let frame = with_fonts(|fonts| build_frame(&view, theme, fonts, &mut state, logical, 1.0));
116        WindowSlot {
117            state,
118            view,
119            frame,
120            logical,
121            size,
122        }
123    }
124
125    fn drain(app: &mut A, pending: &Mutex<Vec<A::Msg>>) {
126        let msgs = std::mem::take(&mut *pending.lock().unwrap_or_else(PoisonError::into_inner));
127        for msg in msgs {
128            app.update(msg);
129        }
130    }
131
132    /// Rebuilds every window from current app state (proxied messages
133    /// drain first) and reconciles the declared window set: new keys
134    /// open, missing keys close (the active window falls back to main).
135    /// Runs automatically after every input; call it yourself only
136    /// after mutating via [`Self::app_mut`].
137    pub fn rebuild(&mut self) {
138        Self::drain(&mut self.app, &self.pending);
139        let descs = self.app.windows();
140        self.slots
141            .retain(|key, _| key == MAIN_WINDOW || descs.iter().any(|d| &d.key == key));
142        for desc in &descs {
143            if !self.slots.contains_key(&desc.key) {
144                #[expect(
145                    clippy::cast_possible_truncation,
146                    clippy::cast_sign_loss,
147                    reason = "logical window sizes are small positive numbers"
148                )]
149                let size = (desc.size.0.max(1.0) as u32, desc.size.1.max(1.0) as u32);
150                let slot = Self::new_slot(&self.app, &self.theme, &desc.key, size, self.clock);
151                self.slots.insert(desc.key.clone(), slot);
152            }
153        }
154        if !self.slots.contains_key(&self.active) {
155            self.active = MAIN_WINDOW.to_owned();
156        }
157        let keys: Vec<String> = self.slots.keys().cloned().collect();
158        for key in keys {
159            let slot = self.slots.get_mut(&key).expect("slot exists");
160            slot.view = self.app.view_for(&key);
161            slot.state.tick(self.clock);
162            slot.frame = with_fonts(|fonts| {
163                build_frame(
164                    &slot.view,
165                    &self.theme,
166                    fonts,
167                    &mut slot.state,
168                    slot.logical,
169                    1.0,
170                )
171            });
172        }
173    }
174
175    fn slot(&self) -> &WindowSlot<A::Msg> {
176        self.slots.get(&self.active).expect("active slot exists")
177    }
178
179    /// Switches which window the verbs and queries target. Open windows
180    /// come from [`App::windows`]; [`MAIN_WINDOW`] is always open.
181    ///
182    /// # Panics
183    /// If no open window has this key (the message lists the open ones).
184    pub fn activate_window(&mut self, key: &str) {
185        assert!(
186            self.slots.contains_key(key),
187            "no open window {key:?}; open windows: {:?}",
188            self.window_keys()
189        );
190        self.active = key.to_owned();
191    }
192
193    /// The keys of every open window, sorted (main first).
194    pub fn window_keys(&self) -> Vec<String> {
195        let mut keys: Vec<String> = self.slots.keys().cloned().collect();
196        keys.sort_by_key(|k| (k != MAIN_WINDOW, k.clone()));
197        keys
198    }
199
200    /// Dispatches one raw input event against the active window's
201    /// current frame, logs and applies the emitted messages, and
202    /// rebuilds (which also reconciles the window set).
203    pub fn input(&mut self, event: InputEvent) {
204        let slot = self
205            .slots
206            .get_mut(&self.active)
207            .expect("active slot exists");
208        let result =
209            with_fonts(|fonts| dispatch(&slot.view, &slot.frame, &mut slot.state, fonts, event));
210        for msg in result.msgs {
211            self.msgs.push(msg.clone());
212            self.app.update(msg);
213        }
214        self.rebuild();
215    }
216
217    fn center(&self, q: &Query) -> (f32, f32) {
218        let node = self.slot().frame.get(q);
219        let c = node.rect.center();
220        #[expect(clippy::cast_possible_truncation, reason = "logical px fit in f32")]
221        (c.x as f32, c.y as f32)
222    }
223
224    /// Moves the pointer to the center of the matched node.
225    ///
226    /// # Panics
227    /// If the query matches zero or several nodes.
228    pub fn hover(&mut self, q: &Query) {
229        let (x, y) = self.center(q);
230        self.input(InputEvent::PointerMove { x, y });
231    }
232
233    /// Clicks (press + release) the center of the matched node.
234    ///
235    /// # Panics
236    /// If the query matches zero or several nodes.
237    pub fn click(&mut self, q: &Query) {
238        self.hover(q);
239        self.input(InputEvent::PointerDown);
240        self.input(InputEvent::PointerUp);
241    }
242
243    /// Right-clicks the center of the matched node.
244    ///
245    /// # Panics
246    /// If the query matches zero or several nodes.
247    pub fn right_click(&mut self, q: &Query) {
248        self.hover(q);
249        self.input(InputEvent::RightDown);
250        self.input(InputEvent::RightUp);
251    }
252
253    /// Double-clicks the matched node (two clicks inside the
254    /// double-click window — the harness clock does not advance).
255    ///
256    /// # Panics
257    /// If the query matches zero or several nodes.
258    pub fn double_click(&mut self, q: &Query) {
259        self.click(q);
260        self.click(q);
261    }
262
263    /// Triple-clicks the matched node (text inputs select the line).
264    ///
265    /// # Panics
266    /// If the query matches zero or several nodes.
267    pub fn triple_click(&mut self, q: &Query) {
268        self.click(q);
269        self.click(q);
270        self.click(q);
271    }
272
273    /// Clicks with Shift held (text inputs extend the selection from
274    /// the caret to the click point).
275    ///
276    /// # Panics
277    /// If the query matches zero or several nodes.
278    pub fn shift_click(&mut self, q: &Query) {
279        self.input(InputEvent::Modifiers {
280            shift: true,
281            ctrl: false,
282            alt: false,
283            meta: false,
284        });
285        self.click(q);
286        self.input(InputEvent::Modifiers {
287            shift: false,
288            ctrl: false,
289            alt: false,
290            meta: false,
291        });
292    }
293
294    /// Commits text to the focused element (like typing or IME commit).
295    pub fn type_text(&mut self, text: impl Into<String>) {
296        self.input(InputEvent::Text(text.into()));
297    }
298
299    /// Presses one key.
300    pub fn key(&mut self, key: KeyInput) {
301        self.input(InputEvent::Key(key));
302    }
303
304    /// Focuses the next focusable element (Tab).
305    pub fn tab(&mut self) {
306        self.input(InputEvent::Tab);
307    }
308
309    /// Focuses the previous focusable element (Shift-Tab).
310    pub fn shift_tab(&mut self) {
311        self.input(InputEvent::ShiftTab);
312    }
313
314    /// Focuses the matched node directly (what assistive technology's
315    /// Focus action does). Prefer [`Self::tab`] to test the real path.
316    ///
317    /// # Panics
318    /// If the query matches zero or several nodes.
319    pub fn focus(&mut self, q: &Query) {
320        let slot = self
321            .slots
322            .get_mut(&self.active)
323            .expect("active slot exists");
324        let id = slot.frame.get(q).id;
325        slot.state.set_focus(Some(id));
326        self.rebuild();
327    }
328
329    /// Drags from one node to another: press on `from`, move to `to`
330    /// (recomputed after the press, in case layout shifted), release.
331    ///
332    /// # Panics
333    /// If either query matches zero or several nodes.
334    pub fn drag(&mut self, from: &Query, to: &Query) {
335        self.hover(from);
336        self.input(InputEvent::PointerDown);
337        let (x, y) = self.center(to);
338        self.input(InputEvent::PointerMove { x, y });
339        self.input(InputEvent::PointerUp);
340    }
341
342    /// Drops an OS file onto the matched node.
343    ///
344    /// # Panics
345    /// If the query matches zero or several nodes.
346    pub fn drop_file(&mut self, q: &Query, path: impl Into<std::path::PathBuf>) {
347        self.hover(q);
348        self.input(InputEvent::FileDrop(path.into()));
349    }
350
351    /// Scrolls the wheel over the matched node (positive `dy` moves
352    /// content down, winit convention).
353    ///
354    /// # Panics
355    /// If the query matches zero or several nodes.
356    pub fn wheel(&mut self, q: &Query, dy: f32) {
357        self.hover(q);
358        self.input(InputEvent::Wheel { dy });
359    }
360
361    /// Advances the deterministic clock by `ms` milliseconds and
362    /// rebuilds — animations and timers move exactly this far.
363    pub fn pump(&mut self, ms: f64) {
364        self.clock += ms / 1000.0;
365        self.rebuild();
366    }
367
368    /// Applies one message directly (as a proxy or window event would)
369    /// and rebuilds. Not logged in [`Self::take_messages`].
370    pub fn update(&mut self, msg: A::Msg) {
371        self.app.update(msg);
372        self.rebuild();
373    }
374
375    /// The single matching node; panics (with the accessibility tree in
376    /// the message) on zero or several matches.
377    ///
378    /// # Panics
379    /// If the query matches zero or several nodes.
380    pub fn get(&self, q: &Query) -> AccessNode {
381        self.slot().frame.get(q)
382    }
383
384    /// The single matching node, or `None`. Use to assert absence.
385    ///
386    /// # Panics
387    /// If the query matches several nodes.
388    pub fn query(&self, q: &Query) -> Option<AccessNode> {
389        self.slot().frame.query(q)
390    }
391
392    /// Every matching node in tree order.
393    pub fn get_all(&self, q: &Query) -> Vec<AccessNode> {
394        self.slot().frame.get_all(q)
395    }
396
397    /// Messages emitted by handlers since the last call (the Elm-level
398    /// assertion: *what the UI said*, independent of state effects).
399    /// Proxied and [`Self::update`] messages are inputs, not logged.
400    pub fn take_messages(&mut self) -> Vec<A::Msg> {
401        std::mem::take(&mut self.msgs)
402    }
403
404    /// The active window's current frame, for direct queries and
405    /// `access_yaml()`.
406    pub fn frame(&self) -> &Frame {
407        &self.slot().frame
408    }
409
410    /// The app under test.
411    pub fn app(&self) -> &A {
412        &self.app
413    }
414
415    /// Mutable access to the app; call [`Self::rebuild`] afterwards.
416    pub fn app_mut(&mut self) -> &mut A {
417        &mut self.app
418    }
419
420    /// Renders the active window to pixels. Mid-test captures are fine —
421    /// the frame is not consumed.
422    ///
423    /// # Panics
424    /// If rendering fails.
425    pub fn render(&mut self) -> RgbaImage {
426        let key = self.active.clone();
427        self.render_window(&key)
428    }
429
430    /// Renders any open window to pixels.
431    ///
432    /// # Panics
433    /// If no open window has this key, or rendering fails.
434    pub fn render_window(&mut self, key: &str) -> RgbaImage {
435        assert!(
436            self.slots.contains_key(key),
437            "no open window {key:?}; open windows: {:?}",
438            self.window_keys()
439        );
440        let bg = self.theme.bg;
441        let slot = self.slots.get_mut(key).expect("checked above");
442        let scene = with_fonts(|fonts| slot.frame.paint(fonts, &mut slot.state));
443        with_headless(|h| h.render(&scene, slot.size.0, slot.size.1, bg))
444            .expect("headless renderer unavailable")
445            .expect("headless render failed")
446    }
447}