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    /// Mouse wheel (positive = up).
93    pub fn scroll(&mut self, dy: f32) -> bool {
94        self.event(&format!("scroll:{dy}"))
95    }
96
97    /// A key press (GLFW code, see `yog_api::ui::event::keys`).
98    pub fn key(&mut self, key: i32) -> bool {
99        self.key_mods(key, 0)
100    }
101
102    pub fn key_mods(&mut self, key: i32, mods: i32) -> bool {
103        self.event(&format!("key:{key}:{mods}"))
104    }
105
106    /// Types `text` into the focused text box.
107    pub fn type_text(&mut self, text: &str) -> &mut Self {
108        for c in text.chars() {
109            self.event(&format!("char:{}", c as u32));
110        }
111        self
112    }
113
114    /// Clicks the text box `id` and types `text` at its end.
115    pub fn type_into(&mut self, id: &str, text: &str) -> &mut Self {
116        self.click_id(id);
117        self.key(keys::END);
118        self.type_text(text)
119    }
120
121    pub fn vm(&self) -> &W::Vm {
122        &self.host.vm
123    }
124
125    /// The view model, for arranging state; the bindings update at the
126    /// next frame.
127    pub fn vm_mut(&mut self) -> &mut W::Vm {
128        self.host.dirty = true;
129        &mut self.host.vm
130    }
131
132    /// A handle like the one `register()` returns.
133    pub fn handle(&self) -> ScreenHandle<W::Vm> {
134        self.host.handle.clone()
135    }
136
137    /// The content tree.
138    pub fn root(&self) -> &Widget {
139        self.host.content()
140    }
141
142    /// The whole laid-out screen (content is child 0).
143    pub fn ui(&self) -> &UiRoot {
144        &self.host.ui
145    }
146
147    /// Content path of the widget tagged `id`; panics when there's none.
148    pub fn path(&self, id: &str) -> Vec<u16> {
149        find_path(self.root(), id).unwrap_or_else(|| panic!("yog-kit test: no widget with id {id:?}"))
150    }
151
152    pub fn widget(&self, id: &str) -> Option<&Widget> {
153        find_path(self.root(), id).and_then(|p| self.root().get_path(&p))
154    }
155
156    /// The text of a label, button or text box.
157    pub fn text_at(&self, path: &[u16]) -> Option<String> {
158        match &self.root().get_path(path)?.kind {
159            WidgetKind::Label(t) | WidgetKind::Button(t) => Some(t.clone()),
160            WidgetKind::TextBox(tb) => Some(tb.text.clone()),
161            _ => None,
162        }
163    }
164
165    /// The text of the widget tagged `id`.
166    pub fn text(&self, id: &str) -> Option<String> {
167        self.text_at(&find_path(self.root(), id)?)
168    }
169
170    /// The widget `id` is visible (it and every ancestor).
171    pub fn is_visible(&self, id: &str) -> bool {
172        let Some(p) = find_path(self.root(), id) else {
173            return false;
174        };
175        (0..=p.len()).all(|n| self.root().get_path(&p[..n]).is_some_and(|w| w.visible))
176    }
177
178    pub fn state(&self, id: &str) -> StateFlags {
179        self.widget(id).map_or(StateFlags::NONE, |w| w.state)
180    }
181
182    pub fn node(&self, id: &str) -> Option<&LayoutNode> {
183        self.host.content_node(&find_path(self.root(), id)?)
184    }
185
186    pub fn rect(&self, id: &str) -> Option<Rect> {
187        self.node(id).map(|n| n.rect)
188    }
189
190    /// Scroll offset of scroller `i`.
191    pub fn scroll_offset(&self, i: usize) -> f32 {
192        self.host.parts.scrollers[i].state.offset
193    }
194
195    /// Requests made so far: `close`, `navigate:<id>`, `back`.
196    pub fn requests(&self) -> &[String] {
197        &self.host.log
198    }
199
200    /// The screen asked to close.
201    pub fn closed(&self) -> bool {
202        self.host.log.iter().any(|r| r == "close")
203    }
204}