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