Skip to main content

yog_kit/
testing.rs

1//! Driving a screen in unit tests — no game, no graphics.
2//!
3//! ```ignore
4//! let mut h = TestHost::<View>::new(CounterVm::default());
5//! h.open();
6//! h.click_id("add");
7//! assert_eq!(h.vm().count, 1);
8//! assert_eq!(h.text("label").as_deref(), Some("Clicked 1 times"));
9//! ```
10//!
11//! Every input is followed by a frame, like in the game.
12
13use yog_api::ui::event::keys;
14use yog_api::ui::widget::WidgetKind;
15use yog_api::ui::{LayoutNode, Rect, StateFlags, UiRoot, Widget};
16
17use crate::host::{Host, LOG_CLOSE, find_path};
18use crate::{ScreenHandle, View};
19
20/// A screen hosted for tests.
21pub struct TestHost<W: View> {
22    host: Host<W>,
23}
24
25/// The GUI size (w, h) a new [`TestHost`] starts at.
26const DEFAULT_SIZE: (f32, f32) = (427.0, 240.0);
27
28impl<W: View> TestHost<W> {
29    /// The screen with `vm`, sized 427 × 240 (a common GUI size) — not
30    /// opened yet (see [`Self::open`]).
31    pub fn new(vm: W::Vm) -> Self {
32        let mut host = Host::new(vm, ScreenHandle::new(W::META), false);
33        host.size = DEFAULT_SIZE;
34        Self { host }
35    }
36
37    /// Changes the GUI size.
38    pub fn size(&mut self, w: f32, h: f32) -> &mut Self {
39        self.host.size = (w, h);
40        self
41    }
42
43    /// Opens the screen (`on_open`) and draws a frame.
44    pub fn open(&mut self) -> &mut Self {
45        let (w, h) = self.host.size;
46        self.event(&format!("open:{w}:{h}"));
47        self
48    }
49
50    /// Closes the screen (`on_close`).
51    pub fn close(&mut self) -> &mut Self {
52        self.event("close");
53        self
54    }
55
56    /// Draws a frame (queued updates, bindings, layout).
57    pub fn frame(&mut self) -> &mut Self {
58        let (w, h) = self.host.size;
59        self.host.frame(w, h);
60        self
61    }
62
63    /// Sends a raw host event (`click:X:Y`, `key:K:M`, ...), then a frame;
64    /// returns whether the screen consumed it.
65    pub fn event(&mut self, raw: &str) -> bool {
66        let used = self.host.on_event(raw);
67        self.frame();
68        used
69    }
70
71    /// Clicks (and releases) the middle of the widget at content `path`.
72    pub fn click_path(&mut self, path: &[u16]) -> bool {
73        let Some(r) = self.host.content_node(path).map(|n| n.rect) else {
74            panic!("yog-kit test: nothing laid out at {path:?}");
75        };
76        let (x, y) = (r.x + r.w / 2.0, r.y + r.h / 2.0);
77        self.move_to(x, y);
78        let used = self.event(&format!("click:{x}:{y}"));
79        self.event(&format!("release:{x}:{y}"));
80        used
81    }
82
83    /// Clicks the widget tagged `id`.
84    pub fn click_id(&mut self, id: &str) -> bool {
85        let path = self.path(id);
86        self.click_path(&path)
87    }
88
89    /// Moves the cursor.
90    pub fn move_to(&mut self, x: f32, y: f32) -> &mut Self {
91        self.event(&format!("move:{x}:{y}"));
92        self
93    }
94
95    /// A mouse drag: the button goes down at the first point, moves through
96    /// the others and comes up at the last; returns whether the press was
97    /// consumed.
98    pub fn drag(&mut self, points: &[(f32, f32)]) -> bool {
99        let Some((&(x0, y0), rest)) = points.split_first() else {
100            return false;
101        };
102        self.move_to(x0, y0);
103        let used = self.event(&format!("click:{x0}:{y0}"));
104        for &(x, y) in rest {
105            self.event(&format!("drag:{x}:{y}"));
106        }
107        let (x, y) = points[points.len() - 1];
108        self.event(&format!("release:{x}:{y}"));
109        used
110    }
111
112    /// Mouse wheel (positive = up).
113    pub fn scroll(&mut self, dy: f32) -> bool {
114        self.event(&format!("scroll:{dy}"))
115    }
116
117    /// A key press (GLFW code, see `yog_api::ui::event::keys`).
118    pub fn key(&mut self, key: i32) -> bool {
119        self.key_mods(key, 0)
120    }
121
122    pub fn key_mods(&mut self, key: i32, mods: i32) -> bool {
123        self.event(&format!("key:{key}:{mods}"))
124    }
125
126    /// Types `text` into the focused text box.
127    pub fn type_text(&mut self, text: &str) -> &mut Self {
128        for c in text.chars() {
129            self.event(&format!("char:{}", c as u32));
130        }
131        self
132    }
133
134    /// Clicks the text box `id` and types `text` at its end.
135    pub fn type_into(&mut self, id: &str, text: &str) -> &mut Self {
136        self.click_id(id);
137        self.key(keys::END);
138        self.type_text(text)
139    }
140
141    pub fn vm(&self) -> &W::Vm {
142        &self.host.vm
143    }
144
145    /// The view model, for arranging state; the bindings update at the
146    /// next frame.
147    pub fn vm_mut(&mut self) -> &mut W::Vm {
148        self.host.dirty = true;
149        &mut self.host.vm
150    }
151
152    /// A handle like the one `register()` returns.
153    pub fn handle(&self) -> ScreenHandle<W::Vm> {
154        self.host.handle.clone()
155    }
156
157    /// The content tree.
158    pub fn root(&self) -> &Widget {
159        self.host.content()
160    }
161
162    /// The whole laid-out screen (content is child 0).
163    pub fn ui(&self) -> &UiRoot {
164        &self.host.ui
165    }
166
167    /// Content path of the widget tagged `id`; panics when there's none.
168    pub fn path(&self, id: &str) -> Vec<u16> {
169        find_path(self.root(), id)
170            .unwrap_or_else(|| panic!("yog-kit test: no widget with id {id:?}"))
171    }
172
173    pub fn widget(&self, id: &str) -> Option<&Widget> {
174        find_path(self.root(), id).and_then(|p| self.root().get_path(&p))
175    }
176
177    /// The text of a label, button or text box.
178    pub fn text_at(&self, path: &[u16]) -> Option<String> {
179        match &self.root().get_path(path)?.kind {
180            WidgetKind::Label(t) | WidgetKind::Button(t) => Some(t.clone()),
181            WidgetKind::TextBox(tb) => Some(tb.text.clone()),
182            _ => None,
183        }
184    }
185
186    /// The text of the widget tagged `id`.
187    pub fn text(&self, id: &str) -> Option<String> {
188        self.text_at(&find_path(self.root(), id)?)
189    }
190
191    /// The widget `id` is visible (it and every ancestor).
192    pub fn is_visible(&self, id: &str) -> bool {
193        let Some(p) = find_path(self.root(), id) else {
194            return false;
195        };
196        (0..=p.len()).all(|n| self.root().get_path(&p[..n]).is_some_and(|w| w.visible))
197    }
198
199    pub fn state(&self, id: &str) -> StateFlags {
200        self.widget(id).map_or(StateFlags::NONE, |w| w.state)
201    }
202
203    pub fn node(&self, id: &str) -> Option<&LayoutNode> {
204        self.host.content_node(&find_path(self.root(), id)?)
205    }
206
207    pub fn rect(&self, id: &str) -> Option<Rect> {
208        self.node(id).map(|n| n.rect)
209    }
210
211    /// Scroll offset of scroller `i`.
212    pub fn scroll_offset(&self, i: usize) -> f32 {
213        self.host.parts.scrollers[i].state.offset
214    }
215
216    /// Requests made so far: `close`, `navigate:<id>`, `back`.
217    pub fn requests(&self) -> &[String] {
218        &self.host.log
219    }
220
221    /// The screen asked to close.
222    pub fn closed(&self) -> bool {
223        self.host.log.iter().any(|r| r == LOG_CLOSE)
224    }
225}