Skip to main content

max / quasi

14.4 KB · 425 lines History Blame Raw
1 //! What egui draws from a description.
2 //!
3 //! Assertions are over what came back rather than over pixels: egui's test
4 //! harness lays a real `Ui` out and answers real `Response`s, so what a renderer
5 //! owes is that pressing a described control produces the described request.
6 //! What it looks like is the palette's answer and changes with it.
7
8 use egui::Color32;
9 use makeover_immediate::Palette;
10 use quasi_router::{
11 Act, Action, Address, Chrome, Field, Message, Method, Node, Outcome, RegionKind, Request,
12 Response, Screen, Slot, layout,
13 };
14
15 use crate::{Immediate, Runtime, Step, View};
16
17 fn palette() -> Palette {
18 Palette {
19 page: Color32::from_rgb(1, 1, 1),
20 raised: Color32::from_rgb(2, 2, 2),
21 overlay: Color32::from_rgb(3, 3, 3),
22 well: Color32::from_rgb(4, 4, 4),
23 sunken: Color32::from_rgb(5, 5, 5),
24 bevel_light: Color32::WHITE,
25 bevel_dark: Color32::BLACK,
26 elevation: Color32::from_black_alpha(46),
27 content: Color32::from_rgb(6, 6, 6),
28 content_muted: Color32::from_rgb(7, 7, 7),
29 action: Color32::from_rgb(8, 8, 8),
30 danger: Color32::from_rgb(9, 9, 9),
31 success: Color32::from_rgb(10, 10, 10),
32 warning: Color32::from_rgb(11, 11, 11),
33 info: Color32::from_rgb(12, 12, 12),
34 }
35 }
36
37 fn renderer() -> Immediate {
38 Immediate::new(palette())
39 }
40
41 /// A screen with one region holding these nodes.
42 fn screen_of(nodes: impl IntoIterator<Item = Node>) -> Screen {
43 Screen::sidebar_content("Test").with(
44 nodes
45 .into_iter()
46 .fold(Slot::new("main", RegionKind::Pane), Slot::with),
47 )
48 }
49
50 /// Draw a screen once, with nothing pressed.
51 fn draw(screen: &Screen, view: &mut View) {
52 let immediate = renderer();
53 egui::__run_test_ui(|ui| {
54 immediate.screen(ui, screen, view);
55 });
56 }
57
58 #[test]
59 fn every_described_node_draws_without_panicking() {
60 // The walk is exhaustive over `Node`, so this is the assertion that the
61 // exhaustiveness is real rather than a match that compiles: one of
62 // everything, drawn.
63 let mut view = View::new();
64 let screen = screen_of([
65 Node::page("Tasks"),
66 Node::text("plain"),
67 Node::rich("**bold** and `code`"),
68 Node::Act(Act::new("Save", Action::post("/save"))),
69 Node::Link {
70 text: "Docs".to_owned(),
71 action: Action::get("/docs"),
72 },
73 Node::Figure(quasi_router::Figure::new("17", "Streak")),
74 Node::Token(quasi_router::Tag::badge("beta")),
75 Node::Meter(quasi_router::Meter::new(3, 6)),
76 Node::Field(Box::new(Field::new(
77 layout::FieldKind::Text,
78 "title",
79 "Title",
80 ))),
81 Node::list([quasi_router::Row::new("One")]),
82 ]);
83 draw(&screen, &mut view);
84 }
85
86 #[test]
87 fn a_screen_with_no_press_asks_for_nothing() {
88 // egui redraws continuously, so the ordinary frame is a user doing nothing.
89 // A renderer answering a request per frame would call the router sixty
90 // times a second.
91 let immediate = renderer();
92 let screen = screen_of([Node::Act(Act::new("Save", Action::post("/save")))]);
93 let mut view = View::new();
94 egui::__run_test_ui(|ui| {
95 assert!(immediate.screen(ui, &screen, &mut view).is_none());
96 });
97 }
98
99 #[test]
100 fn what_is_typed_lives_in_the_view_and_not_in_the_description() {
101 // The one thing egui does not hold for this renderer: a described field is
102 // rebuilt every frame, so the buffer behind it has to outlive the frame.
103 let mut view = View::new();
104 view.set("title", "hello");
105 assert_eq!(view.showing("title", Some("described")), "hello");
106 // Untouched reads the description; cleared does not.
107 assert_eq!(view.showing("other", Some("described")), "described");
108 view.set("other", "");
109 assert_eq!(view.showing("other", Some("described")), "");
110 }
111
112 #[test]
113 fn a_form_submits_every_name_it_declared() {
114 // A form that omits an untouched field is a form that cannot clear one.
115 let mut view = View::new();
116 view.set("title", "typed");
117 let described = [("body".to_owned(), "offered".to_owned())]
118 .into_iter()
119 .collect();
120 let params = view.submission(
121 &["title".to_owned(), "body".to_owned(), "empty".to_owned()],
122 &described,
123 );
124 assert_eq!(params.get("title"), Some("typed"));
125 assert_eq!(params.get("body"), Some("offered"));
126 assert_eq!(params.get("empty"), Some(""));
127 }
128
129 #[test]
130 fn a_tick_is_the_views_and_the_description_only_seeds_it() {
131 // After arrival the user's ticks are the truth, which is why seeding is
132 // applied once rather than read on every draw.
133 let mut row = quasi_router::Row::new("One");
134 row.selected = Some(true);
135 row.value = Some("1".to_owned());
136 let screen = screen_of([Node::list([row])]);
137
138 let mut view = View::new();
139 view.seed(&screen);
140 assert!(view.is_ticked("1"));
141 view.tick("1");
142 assert!(
143 !view.is_ticked("1"),
144 "the user untocked it and it stayed off"
145 );
146 }
147
148 #[test]
149 fn an_overlay_is_not_a_place_and_dismissing_it_reveals_what_was_under_it() {
150 let mut runtime = Runtime::new(screen_of([Node::text("underneath")]));
151 // Two navigations, because history holds where you *were*: the first
152 // records where we are and the second pushes it behind us.
153 for path in ["/two", "/three"] {
154 runtime.apply(
155 &Request::get(path),
156 Response {
157 outcome: Outcome::Screen(screen_of([Node::text("a place")])),
158 notice: None,
159 address: None,
160 invalidates: Vec::new(),
161 },
162 );
163 }
164
165 runtime.apply(
166 &Request::get("/palette"),
167 Response {
168 outcome: Outcome::Over(screen_of([Node::text("palette")])),
169 notice: None,
170 address: None,
171 invalidates: Vec::new(),
172 },
173 );
174 assert!(runtime.overlaid());
175 assert_eq!(runtime.screen().title, "Test");
176
177 // Dismissing reveals rather than navigates, so history is untouched and
178 // still has somewhere to go afterwards.
179 assert!(matches!(runtime.back(), Step::Call(_)));
180 }
181
182 #[test]
183 fn a_navigation_takes_the_overlay_with_it() {
184 // Arriving somewhere new with a palette still floating over it is the state
185 // nobody asked for.
186 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
187 runtime.apply(
188 &Request::get("/palette"),
189 Response {
190 outcome: Outcome::Over(screen_of([Node::text("palette")])),
191 notice: None,
192 address: None,
193 invalidates: Vec::new(),
194 },
195 );
196 assert!(runtime.overlaid());
197 runtime.apply(
198 &Request::get("/two"),
199 Response {
200 outcome: Outcome::Screen(screen_of([Node::text("second")])),
201 notice: None,
202 address: None,
203 invalidates: Vec::new(),
204 },
205 );
206 assert!(!runtime.overlaid());
207 }
208
209 #[test]
210 fn a_write_is_not_a_place_and_a_read_of_a_screen_is() {
211 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
212 for path in ["/two", "/three"] {
213 runtime.apply(
214 &Request::get(path),
215 Response {
216 outcome: Outcome::Screen(screen_of([Node::text("place")])),
217 notice: None,
218 address: None,
219 invalidates: Vec::new(),
220 },
221 );
222 }
223 // A write answering a screen is not somewhere to come back to.
224 runtime.apply(
225 &Request::post("/save"),
226 Response {
227 outcome: Outcome::Screen(screen_of([Node::text("saved")])),
228 notice: None,
229 address: None,
230 invalidates: Vec::new(),
231 },
232 );
233 match runtime.back() {
234 Step::Call(request) => assert_eq!(request.path, "/two"),
235 other => panic!("expected the place behind, got {other:?}"),
236 }
237 }
238
239 #[test]
240 fn an_address_the_router_named_overrides_the_derivation() {
241 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
242 runtime.apply(
243 &Request::get("/one"),
244 Response {
245 outcome: Outcome::Screen(screen_of([Node::text("one")])),
246 notice: None,
247 address: None,
248 invalidates: Vec::new(),
249 },
250 );
251 // `Unchanged` says this read is not a place, so nothing is pushed behind it.
252 runtime.apply(
253 &Request::get("/transient"),
254 Response {
255 outcome: Outcome::Screen(screen_of([Node::text("transient")])),
256 notice: None,
257 address: Some(Address::Unchanged),
258 invalidates: Vec::new(),
259 },
260 );
261 assert!(matches!(runtime.back(), Step::Idle));
262 }
263
264 #[test]
265 fn a_fragment_naming_a_region_that_is_not_there_says_so() {
266 // A terminal and a window can both say it, where a webview swallows it:
267 // drawing nothing would look like a control that does nothing.
268 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
269 runtime.apply(
270 &Request::get("/x"),
271 Response {
272 outcome: Outcome::Fragment {
273 region: "nowhere".to_owned(),
274 node: Node::text("new"),
275 },
276 notice: None,
277 address: None,
278 invalidates: Vec::new(),
279 },
280 );
281 let said = runtime
282 .screen()
283 .notices
284 .iter()
285 .any(|node| matches!(node, Node::Notice { text, .. } if text.contains("nowhere")));
286 assert!(said, "the missing region was not reported");
287 }
288
289 #[test]
290 fn what_a_response_says_lands_on_the_screen_it_belongs_to() {
291 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
292 runtime.apply(
293 &Request::post("/save"),
294 Response {
295 outcome: Outcome::Screen(screen_of([Node::text("second")])),
296 notice: Some(Message {
297 kind: layout::Notice::Toast,
298 tone: layout::Tone::Success,
299 text: "Saved".to_owned(),
300 undo: None,
301 }),
302 address: None,
303 invalidates: Vec::new(),
304 },
305 );
306 assert!(
307 runtime
308 .screen()
309 .notices
310 .iter()
311 .any(|node| matches!(node, Node::Notice { text, .. } if text == "Saved")),
312 "the notice did not arrive with the screen it belongs to"
313 );
314 }
315
316 #[test]
317 fn an_external_destination_is_handed_back_to_the_host() {
318 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
319 let step = runtime.answer(false);
320 assert!(
321 matches!(step, Step::Idle),
322 "an unasked question does nothing"
323 );
324
325 // The question a destructive control raises, answered both ways.
326 let mut runtime = Runtime::new(screen_of([Node::Act(
327 Act::new("Delete", Action::post("/delete")).confirm("Sure?"),
328 )]));
329 assert!(matches!(runtime.answer(true), Step::Idle));
330 }
331
332 #[test]
333 fn a_chrome_binding_this_renderer_cannot_read_is_ignored_rather_than_guessed() {
334 // The same rule `Act::key` states: the vocabulary of keys is the host's, and
335 // a name this one does not know never matches.
336 let chrome = Chrome::new()
337 .bind("ctrl+k", "Search", Action::get("/palette"))
338 .bind("dpad-left", "Nope", Action::get("/nope"));
339 let runtime = Runtime::new(screen_of([Node::text("x")])).with_chrome(chrome);
340 // Nothing is pressed in a test context, so this asserts the parse rather
341 // than the press: an unreadable name must not panic the frame.
342 let immediate = renderer();
343 let mut runtime = runtime;
344 egui::__run_test_ui(|ui| {
345 assert!(matches!(runtime.show(ui, &immediate), Step::Idle));
346 });
347 }
348
349 #[test]
350 fn a_method_survives_the_trip_from_description_to_request() {
351 // What a control carries is what the router is asked for. The regression
352 // this guards is a renderer that turns every press into a GET.
353 let immediate = renderer();
354 let screen = screen_of([Node::Act(Act::new("Delete", Action::post("/delete")))]);
355 let mut view = View::new();
356 egui::__run_test_ui(|ui| {
357 // Not pressed, so nothing fires; the assertion is that drawing a write
358 // control does not itself produce a request.
359 assert!(immediate.screen(ui, &screen, &mut view).is_none());
360 });
361 assert_eq!(Action::post("/delete").method, Method::Post);
362 }
363
364 #[test]
365 fn a_selection_is_gathered_under_the_name_the_screen_gave_it() {
366 // The commit half of a staged tick: the runtime reads the set the view is
367 // holding and sends it with the call.
368 let mut view = View::new();
369 view.tick("1");
370 view.tick("2");
371 let params = view.gathering(Node::TICKED);
372 let sent: Vec<&str> = params.get_all(Node::TICKED).collect();
373 assert_eq!(sent, ["1", "2"]);
374 }
375
376 #[test]
377 fn the_host_can_say_something_no_handler_knows_about() {
378 // A route that failed has to land somewhere the user is looking. For a
379 // windowed app stderr is nowhere.
380 let mut runtime = Runtime::new(screen_of([Node::text("first")]));
381 runtime.say("The library is not reachable.");
382 assert!(
383 runtime.screen().notices.iter().any(
384 |node| matches!(node, Node::Notice { text, .. } if text.contains("not reachable"))
385 ),
386 "the host had nowhere to put it"
387 );
388 }
389
390 #[test]
391 fn a_described_table_draws_its_columns_and_cells() {
392 // The node this renderer declined to draw until 2026-08-14. The narrowing
393 // and the tracks are makeover-immediate's; what is asserted here is that a
394 // described table reaches them at all, with a cell holding an ordinary node.
395 use quasi_router::{Cell, Cells, Column};
396
397 let columns = vec![Column::new("name"), Column::new("bpm")];
398 let rows = vec![
399 Cells::new(["kick.wav", "120"]),
400 // A cell holding a control rather than a value, which is what
401 // `CellPart` exists to separate and what a file list actually has.
402 Cells::new([
403 Cell::new("snare.wav"),
404 Cell::acts([Act::new("Play", Action::post("/play"))]),
405 ]),
406 ];
407
408 let mut view = View::new();
409 let screen = screen_of([Node::Table { columns, rows }]);
410 draw(&screen, &mut view);
411 }
412
413 #[test]
414 fn a_table_with_no_columns_draws_nothing_rather_than_panicking() {
415 // `egui_extras` panics on a table with no tracks, and a description with no
416 // columns is reachable. makeover-immediate answers `None` for that case and
417 // this is the assertion that the described path takes it.
418 let mut view = View::new();
419 let screen = screen_of([Node::Table {
420 columns: Vec::new(),
421 rows: Vec::new(),
422 }]);
423 draw(&screen, &mut view);
424 }
425