Skip to main content

ashlar_app/
driver.rs

1//! A headless event loop for tests and benches: runs a [`Runtime`] on an egui context without a
2//! window, the way eframe does, on a virtual clock. A frame runs only when something asked for
3//! one (input, `request_repaint`, a repaint timer), so tests can prove that an idle app draws
4//! nothing.
5//!
6//! By default there is no GPU: `ui.screenshot` gets a flat grey image of the screen size (the PNG
7//! plumbing is real, the pixels are not). [`Driver::with_renderer`] with [`Renderer::wgpu`]
8//! renders real pixels on a GPU with no window; screenshot replies say which was used
9//! (`"renderer": "wgpu"` or `"none"`). Each frame is still tessellated, as a window would, so the frame
10//! stats ([`Runtime::stats`]) hold the whole CPU frame: `pass`, `tessellate` and `frame_cpu`.
11
12use std::collections::VecDeque;
13use std::sync::{Arc, Mutex, PoisonError};
14use std::time::Duration;
15
16use ashlar_control::{Reply, Request};
17use egui::{Event, Rect, ViewportCommand, ViewportId, pos2, vec2};
18use serde_json::Value;
19
20use crate::renderer::Renderer;
21use crate::runtime::Runtime;
22use crate::session::{Document, now};
23
24/// Repaint requests made outside a pass (another thread: a job, the control server).
25#[derive(Default)]
26struct Wakes {
27    /// (delay, pass number when requested)
28    pending: Vec<(Duration, u64)>,
29}
30
31/// Drives a [`Runtime`] headless. See the module docs.
32pub struct Driver<D: Document> {
33    pub runtime: Runtime<D>,
34    ctx: egui::Context,
35    time: f64,
36    wakes: Arc<Mutex<Wakes>>,
37    /// Virtual time of the next frame egui asked for.
38    next: Option<f64>,
39    input: VecDeque<Event>,
40    /// Files dropped on the window in the next frame.
41    #[cfg(not(target_arch = "wasm32"))]
42    dropped: Vec<std::path::PathBuf>,
43    screen: Rect,
44    frames: u64,
45    closed: bool,
46    close_requested: bool,
47    /// Deliver `ViewportCommand::Screenshot` captures (off: a window that presents no frame).
48    screenshots: bool,
49    /// The window is minimized: frames run only [`Runtime::logic`], as eframe does.
50    hidden: bool,
51    /// The display's pixels per point (`None`: egui's default, 1).
52    pixels_per_point: Option<f32>,
53    /// What screenshots are rendered with.
54    renderer: Renderer,
55    /// The motion gate's recorder while [`Driver::record`] runs.
56    recorder: Option<ashlar_ui::motion_gate::MotionRecorder>,
57}
58
59impl<D: Document> Driver<D> {
60    /// A 1280×800 point screen at time 0, with grey screenshots.
61    pub fn new(runtime: Runtime<D>) -> Self {
62        Self::with_renderer(runtime, Renderer::none())
63    }
64
65    /// [`Driver::new`] rendering screenshots with `renderer` (e.g. [`Renderer::wgpu`]: real
66    /// pixels, or grey when there is no GPU).
67    pub fn with_renderer(runtime: Runtime<D>, renderer: Renderer) -> Self {
68        let ctx = egui::Context::default();
69        let wakes: Arc<Mutex<Wakes>> = Arc::default();
70        let w = Arc::clone(&wakes);
71        ctx.set_request_repaint_callback(move |info| {
72            if info.viewport_id == ViewportId::ROOT {
73                w.lock().unwrap_or_else(PoisonError::into_inner).pending.push((info.delay, info.current_cumulative_pass_nr));
74            }
75        });
76        // no window, so no screen reader: devtools may switch AccessKit off after a request
77        ashlar_ui::devtools::set_platform_accesskit(&ctx, false);
78        Driver {
79            runtime,
80            ctx,
81            time: 0.0,
82            wakes,
83            next: Some(0.0),
84            input: VecDeque::new(),
85            #[cfg(not(target_arch = "wasm32"))]
86            dropped: Vec::new(),
87            screen: Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 800.0)),
88            frames: 0,
89            closed: false,
90            close_requested: false,
91            screenshots: true,
92            hidden: false,
93            pixels_per_point: None,
94            renderer,
95            recorder: None,
96        }
97        .install_renderer()
98    }
99
100    fn install_renderer(self) -> Self {
101        if let Some(offscreen) = self.renderer.offscreen() {
102            ashlar_ui::devtools::set_offscreen(&self.ctx, Some(offscreen));
103        }
104        self
105    }
106
107    /// What screenshots are rendered with.
108    pub fn renderer(&self) -> &Renderer {
109        &self.renderer
110    }
111
112    /// The grey screenshot of a driver with no GPU renderer: the screen's size in pixels.
113    fn blank(&self) -> Arc<egui::ColorImage> {
114        let ppp = self.ctx.pixels_per_point();
115        let [w, h] = [(self.screen.width() * ppp).round() as usize, (self.screen.height() * ppp).round() as usize];
116        Arc::new(egui::ColorImage::new([w, h], vec![egui::Color32::from_gray(30); w.saturating_mul(h)]))
117    }
118
119    /// Stop driving; the runtime is handed back (e.g. to [`Runtime::abandon`] it).
120    pub fn into_runtime(self) -> Runtime<D> {
121        self.runtime
122    }
123
124    pub fn ctx(&self) -> &egui::Context {
125        &self.ctx
126    }
127
128    pub fn set_screen(&mut self, width: f32, height: f32) {
129        self.screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(width, height));
130        self.next = Some(self.time);
131    }
132
133    /// Draw as on a display with `ppp` pixels per point (2 on a Retina display), from the next
134    /// frame on. Values that are not finite or not positive are ignored; others are clamped to
135    /// 0.25–8.
136    pub fn pixels_per_point(&mut self, ppp: f32) {
137        if ppp.is_finite() && ppp > 0.0 {
138            self.pixels_per_point = Some(ppp.clamp(0.25, 8.0));
139            self.next = Some(self.time);
140        }
141    }
142
143    /// Virtual seconds since the start.
144    pub fn time(&self) -> f64 {
145        self.time
146    }
147
148    /// Frames run so far.
149    pub fn frames(&self) -> u64 {
150        self.frames
151    }
152
153    /// The app closed its window.
154    pub fn is_closed(&self) -> bool {
155        self.closed
156    }
157
158    /// Queue raw input for the next frame (it also wakes the loop).
159    pub fn push_event(&mut self, e: Event) {
160        self.input.push_back(e);
161    }
162
163    /// Whether screenshots are delivered (on by default). Off, the loop behaves like a window
164    /// that is covered or on a locked screen: capture requests are dropped.
165    pub fn set_screenshots(&mut self, deliver: bool) {
166        self.screenshots = deliver;
167    }
168
169    /// Minimize the window (or show it again). Hidden, a frame runs [`Runtime::logic`] with no
170    /// egui pass, the way eframe treats a minimized or covered window, then the runtime's
171    /// [`Runtime::hidden_pass`] for pending debugger work; other input waits for the next shown
172    /// frame. A raise from the app (`Focus`, `Minimized(false)`, `Visible(true)`) shows it again.
173    pub fn set_hidden(&mut self, hidden: bool) {
174        self.hidden = hidden;
175        self.next = Some(self.time);
176    }
177
178    /// Whether the window is hidden ([`Driver::set_hidden`]).
179    pub fn is_hidden(&self) -> bool {
180        self.hidden
181    }
182
183    /// Ask to close the window, as the window's close button does.
184    pub fn request_close(&mut self) {
185        self.close_requested = true;
186    }
187
188    /// Pick `id` in the OS menu bar, as the native menu (feature `native-menu`) reports a click or
189    /// a key equivalent. It runs in the next frame's [`Runtime::logic`] under the same rules:
190    /// ignored while a dialog is open, or while a text field has the keyboard and the command's
191    /// shortcut belongs to the field.
192    pub fn native_menu_pick(&mut self, id: &str) {
193        self.runtime.push_native_pick(id);
194    }
195
196    /// Drop files on the window in the next frame, as the OS reports a drag and drop.
197    #[cfg(not(target_arch = "wasm32"))]
198    pub fn drop_files(&mut self, paths: impl IntoIterator<Item = impl Into<std::path::PathBuf>>) {
199        self.dropped.extend(paths.into_iter().map(Into::into));
200    }
201
202    /// A second launch of the app forwards its arguments, as with
203    /// [`App::single_instance`](crate::App::single_instance). The next frame opens its first file.
204    pub fn forward_launch(&mut self, forwarded: crate::Forwarded) {
205        self.runtime.forwarded(forwarded);
206    }
207
208    /// Run one frame now, whether or not one was asked for.
209    pub fn frame(&mut self) -> egui::FullOutput {
210        if self.hidden {
211            self.logic_only();
212            return egui::FullOutput::default();
213        }
214        let mut raw = egui::RawInput { screen_rect: Some(self.screen), time: Some(self.time), focused: true, ..Default::default() };
215        raw.events.extend(self.input.drain(..));
216        #[cfg(not(target_arch = "wasm32"))]
217        raw.dropped_files.extend(self.dropped.drain(..).map(|p| -> egui::DroppedFileHandle { Arc::new(PathDrop(p)) }));
218        if std::mem::take(&mut self.close_requested) {
219            raw.viewports.entry(ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
220        }
221        if let Some(ppp) = self.pixels_per_point {
222            raw.viewports.entry(ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp);
223        }
224        let ctx = self.ctx.clone();
225        let started = now();
226        self.runtime.raw_input_hook(&ctx, &mut raw);
227        let rt = &mut self.runtime;
228        let mut out = ctx.run_ui(raw, |ui| {
229            rt.logic(ui.ctx());
230            rt.ui(ui);
231        });
232        let passed = now();
233        // a real frame turns the shapes into meshes next (the copy stays out of the timing)
234        let shapes = out.shapes.clone();
235        let tess_start = now();
236        let meshes = ctx.tessellate(shapes, out.pixels_per_point);
237        let tess_end = now();
238        drop(meshes);
239        if let (Some(s), Some(p), Some(ts), Some(te)) = (started, passed, tess_start, tess_end) {
240            let (pass, tess) = (p.duration_since(s), te.duration_since(ts));
241            let stats = self.runtime.stats_mut();
242            stats.record_pass(pass);
243            stats.record_tessellate(tess);
244            stats.record_frame_cpu(pass.saturating_add(tess));
245        }
246        self.renderer.textures(&out.textures_delta);
247        out.textures_delta.clear();
248        self.frames = self.frames.saturating_add(1);
249        let done = self.ctx.cumulative_pass_nr();
250        self.next = None;
251        if let Some(vp) = out.viewport_output.get(&ViewportId::ROOT) {
252            if vp.repaint_delay < Duration::MAX {
253                self.next = Some(self.time + vp.repaint_delay.as_secs_f64());
254            }
255            for c in &vp.commands {
256                match c {
257                    ViewportCommand::Screenshot(_) if !self.screenshots => {}
258                    ViewportCommand::Screenshot(user_data) => {
259                        let image = self.blank();
260                        self.input.push_back(Event::Screenshot { viewport_id: ViewportId::ROOT, user_data: user_data.clone(), image });
261                    }
262                    ViewportCommand::Close => self.closed = true,
263                    _ => {}
264                }
265            }
266        }
267        // requests made during the passes just run are in `repaint_delay`; keep later ones
268        self.wakes.lock().unwrap_or_else(PoisonError::into_inner).pending.retain(|(_, nr)| *nr >= done);
269        if let Some(rec) = &self.recorder {
270            rec.frame(&self.ctx, &out);
271        }
272        out
273    }
274
275    /// Run `f` while recording motion for the motion gate: values animated through
276    /// `ashlar_ui::motion` and, each frame, the automation elements' rects and the frames asked
277    /// for. Check the result with [`MotionRecorder::check`](ashlar_ui::motion_gate::MotionRecorder::check).
278    ///
279    /// ```rust,ignore
280    /// let rec = d.record(|d| drop(d.run_ui("click overlays.open\nwait 1")));
281    /// let report = rec.check(&MotionRules::default());
282    /// assert!(report.is_clean(), "{report}");
283    /// ```
284    pub fn record(&mut self, f: impl FnOnce(&mut Self)) -> ashlar_ui::motion_gate::MotionRecorder {
285        let rec = ashlar_ui::motion_gate::MotionRecorder::install(&self.ctx);
286        self.recorder = Some(rec.clone());
287        f(self);
288        self.recorder = None;
289        ashlar_ui::motion_gate::MotionRecorder::uninstall(&self.ctx);
290        rec
291    }
292
293    /// A frame of a hidden window: `logic` only, and the viewport commands it gave.
294    fn logic_only(&mut self) {
295        let mut raw = egui::RawInput { screen_rect: Some(self.screen), time: Some(self.time), focused: false, ..Default::default() };
296        let vp = raw.viewports.entry(ViewportId::ROOT).or_default();
297        vp.minimized = Some(true);
298        vp.native_pixels_per_point = self.pixels_per_point;
299        // screenshots the last pass asked for arrive with this one (a hidden window has none to
300        // present; this stands in for the debugger's off-screen renderer)
301        let (shots, rest): (Vec<Event>, Vec<Event>) = self.input.drain(..).partition(|e| matches!(e, Event::Screenshot { .. }));
302        self.input.extend(rest);
303        // wakes asked for from here on (also by a hidden pass, which has no repaint delay to
304        // report) stay pending
305        let done = self.ctx.cumulative_pass_nr();
306        let (rt, time, renderer) = (&mut self.runtime, self.time, &self.renderer);
307        let out = self.ctx.run_logic(&raw, |ctx| {
308            rt.logic(ctx);
309            if let Some(mut out) = rt.hidden_pass(ctx, Some(time), shots) {
310                renderer.textures(&out.textures_delta);
311                out.textures_delta.clear();
312            }
313        });
314        self.frames = self.frames.saturating_add(1);
315        self.next = None;
316        for c in out.viewport_commands.get(&ViewportId::ROOT).into_iter().flatten() {
317            match c {
318                ViewportCommand::Screenshot(user_data) if self.screenshots => {
319                    let image = self.blank();
320                    self.input.push_back(Event::Screenshot { viewport_id: ViewportId::ROOT, user_data: user_data.clone(), image });
321                    self.next = Some(self.time);
322                }
323                ViewportCommand::Focus | ViewportCommand::Minimized(false) | ViewportCommand::Visible(true) => {
324                    self.hidden = false;
325                    self.next = Some(self.time);
326                }
327                ViewportCommand::Close => self.closed = true,
328                _ => {}
329            }
330        }
331        self.wakes.lock().unwrap_or_else(PoisonError::into_inner).pending.retain(|(_, nr)| *nr >= done);
332    }
333
334    /// When the next frame is due, if one is.
335    fn due(&self) -> Option<f64> {
336        let wake =
337            self.wakes.lock().unwrap_or_else(PoisonError::into_inner).pending.iter().map(|(d, _)| self.time + d.as_secs_f64()).reduce(f64::min);
338        // a hidden window keeps its input for the next shown frame
339        let input = ((!self.input.is_empty() && !self.hidden) || self.close_requested).then_some(self.time);
340        [self.next, wake, input].into_iter().flatten().reduce(f64::min)
341    }
342
343    /// Let `secs` of virtual time pass, running every frame that is asked for (at most one per
344    /// `1/120` s, like a display would). Returns the frames run.
345    pub fn advance(&mut self, secs: f64) -> u64 {
346        let end = self.time + secs.max(0.0);
347        let start = self.frames;
348        let min_dt = 1.0 / 120.0;
349        let mut last_frame = f64::NEG_INFINITY;
350        while !self.closed {
351            let Some(due) = self.due() else { break };
352            let at = due.max(last_frame + min_dt).max(self.time);
353            if at > end {
354                break;
355            }
356            self.time = at;
357            self.frame();
358            last_frame = at;
359        }
360        self.time = self.time.max(end);
361        self.frames - start
362    }
363
364    /// Run frames until none is asked for (at most `max`); returns how many ran. Timers further
365    /// than `horizon` seconds away do not count as pending.
366    pub fn settle(&mut self, max: u64) -> u64 {
367        let start = self.frames;
368        let horizon = 0.25;
369        while self.frames - start < max && !self.closed {
370            match self.due() {
371                Some(t) if t <= self.time + horizon => {
372                    self.time = t.max(self.time) + 1.0 / 120.0;
373                    self.frame();
374                }
375                _ => break,
376            }
377        }
378        self.frames - start
379    }
380
381    /// Play a recording made with `ASHLAR_RECORD` or [`Runtime::record_to`] into this driver
382    /// (normally a fresh one for the same app): each recorded frame runs at its time, with its
383    /// screen, input events and control requests (their replies are dropped). Returns the number
384    /// of frames run. See [`crate::record`].
385    pub fn replay(&mut self, path: impl AsRef<std::path::Path>) -> std::io::Result<usize> {
386        let frames = crate::record::read(path.as_ref())?;
387        for f in &frames {
388            if self.closed {
389                break;
390            }
391            if f.time.is_finite() {
392                self.time = f.time.max(self.time);
393            }
394            if let Some((w, h)) = f.screen.filter(|(w, h)| *w >= 1.0 && *h >= 1.0 && *w <= 100_000.0 && *h <= 100_000.0) {
395                self.set_screen(w, h);
396            }
397            if let Some(ppp) = f.ppp {
398                self.pixels_per_point(ppp);
399            }
400            self.input.extend(f.events.iter().cloned());
401            for (method, params) in &f.requests {
402                let (req, _reply) = Request::new(method.as_str(), params.clone());
403                self.runtime.inject_request(req);
404            }
405            self.frame();
406        }
407        Ok(frames.len())
408    }
409
410    /// Send a control request (as the control port would) and run frames until it is answered:
411    /// one frame for the request, then only the frames something asks for, on the virtual clock
412    /// (a request that needs frames nobody asks for is reported, not papered over by polling).
413    pub fn request(&mut self, method: &str, params: Value) -> Reply {
414        let (req, rx) = Request::new(method, params);
415        self.runtime.inject_request(req);
416        // the port's waker asks for the frame that serves a request
417        crate::wake::wake(&self.ctx, crate::wake::Reason::CONTROL);
418        self.time += 1.0 / 120.0;
419        self.frame();
420        let limit = self.time + 600.0;
421        for _ in 0..10_000 {
422            if let Ok(r) = rx.try_recv() {
423                return self.mark_renderer(r);
424            }
425            if self.closed {
426                break;
427            }
428            let Some(due) = self.due().filter(|d| *d <= limit) else { break };
429            self.time = due.max(self.time + 1.0 / 120.0);
430            self.frame();
431        }
432        let r = rx.try_recv().unwrap_or_else(|_| Reply::err(format!("`{method}` was not answered, and no frame is pending")));
433        self.mark_renderer(r)
434    }
435
436    /// Say in a screenshot reply what rendered it, so callers can tell grey from real pixels.
437    fn mark_renderer(&self, mut r: Reply) -> Reply {
438        if let Reply::Ok(Value::Object(o)) = &mut r
439            && o.contains_key("png")
440        {
441            o.insert("renderer".into(), Value::from(self.renderer.last_used()));
442            if let Some((adapter, backend)) = self.renderer.adapter_parts() {
443                o.insert("adapter".into(), Value::from(adapter));
444                o.insert("backend".into(), Value::from(backend));
445            }
446            o.insert("scale".into(), Value::from(self.pixels_per_point.unwrap_or(1.0)));
447        }
448        r
449    }
450
451    /// [`Driver::request`] expecting success.
452    pub fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
453        self.request(method, params).into_result().map_err(|(e, _)| e)
454    }
455
456    /// Run a `.ui` script (the `ashlar-debug batch` language) on this app, in process: no port, no
457    /// thread. `sleep N` and `wait N` let virtual time pass, so a script gives the same steps on
458    /// every run.
459    #[cfg(not(target_arch = "wasm32"))]
460    pub fn run_ui(&mut self, script: &str) -> Result<ashlar_debug::Report, ashlar_debug::ScriptError> {
461        ashlar_debug::run_script(self, "script", script)
462    }
463
464    /// Apply the app's scene `name` ([`crate::App::scene`]) through the `ui.scene` control
465    /// method, then run the frames it asks for.
466    pub fn scene(&mut self, name: &str) -> Result<(), String> {
467        self.call(ashlar_control::UI_SCENE, serde_json::json!({"name": name}))?;
468        self.settle(60);
469        Ok(())
470    }
471
472    /// Compare a screenshot of the whole window with the golden snapshot `name` in
473    /// `tests/snapshots` (see [`ashlar_debug::snapshot`]): exact by default; on a mismatch it
474    /// writes `<name>-new.png` and `<name>-diff.png` and returns the reason.
475    /// `ASHLAR_UPDATE_SNAPSHOTS=1` records the baseline instead. A baseline from another adapter,
476    /// or a run with no GPU renderer, is skipped with a printed note.
477    #[cfg(not(target_arch = "wasm32"))]
478    pub fn assert_snapshot(&mut self, name: &str) -> Result<ashlar_debug::SnapshotOutcome, String> {
479        self.check_snapshot(name, &ashlar_debug::Snapshots::from_env(), None)
480    }
481
482    /// [`Driver::assert_snapshot`] with explicit settings, of `target` (a control-protocol target,
483    /// e.g. `json!({"id": "list"})`) or the whole window.
484    #[cfg(not(target_arch = "wasm32"))]
485    pub fn check_snapshot(
486        &mut self,
487        name: &str,
488        snapshots: &ashlar_debug::Snapshots,
489        target: Option<Value>,
490    ) -> Result<ashlar_debug::SnapshotOutcome, String> {
491        let mut p = serde_json::json!({});
492        if let Some(t) = target {
493            p["target"] = t;
494        }
495        let shot = self.call("ui.screenshot", p)?;
496        let out = snapshots.check(name, &shot)?;
497        if let ashlar_debug::SnapshotOutcome::Skipped(note) = &out {
498            eprintln!("note: {note}");
499        }
500        Ok(out)
501    }
502}
503
504#[cfg(not(target_arch = "wasm32"))]
505impl<D: Document> ashlar_debug::Transport for Driver<D> {
506    fn call(&mut self, method: &str, params: Value) -> Result<Value, ashlar_control::ControlError> {
507        match self.request(method, params) {
508            Reply::Ok(v) => Ok(v),
509            Reply::Err { error, detail } => Err(ashlar_control::ControlError::Remote { error, detail }),
510        }
511    }
512
513    fn pause(&mut self, ms: u64) {
514        self.advance(ms as f64 / 1000.0);
515    }
516}
517
518/// A file the driver drops on the window.
519#[cfg(not(target_arch = "wasm32"))]
520#[derive(Debug)]
521struct PathDrop(std::path::PathBuf);
522
523#[cfg(not(target_arch = "wasm32"))]
524impl egui::DroppedFile for PathDrop {
525    fn path(&self) -> &std::path::Path {
526        &self.0
527    }
528    fn bytes(&self) -> Result<Vec<u8>, String> {
529        std::fs::read(&self.0).map_err(|e| e.to_string())
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use crate::App;
537
538    fn driver() -> Driver<i64> {
539        Driver::new(App::<i64>::new("drivertest", "0.0.0").view(|ui, _cx| drop(ui.label("hello"))).runtime())
540    }
541
542    /// A driver whose view is a focused text field when `typing`, with one undoable edit made.
543    fn edited(typing: bool) -> Driver<i64> {
544        let app = App::<i64>::new("drivertest", "0.0.0").view(move |ui, _cx| {
545            if typing {
546                let mut text = String::new();
547                ui.text_edit_singleline(&mut text).request_focus();
548            }
549        });
550        let mut d = Driver::new(app.runtime());
551        d.frame();
552        d.runtime.session_mut().edit("bump", |n| *n += 1);
553        d.frame();
554        assert!(d.runtime.session().can_undo());
555        d
556    }
557
558    #[test]
559    fn the_view_sees_who_changed_the_document() {
560        use crate::session::Origin;
561        use std::sync::{Arc, Mutex};
562        let seen: Arc<Mutex<Vec<Option<Origin>>>> = Arc::default();
563        let log = Arc::clone(&seen);
564        let app = App::<i64>::new("drivertest", "0.0.0").view(move |_ui, cx| {
565            log.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(cx.origin());
566            let _ = cx.motion("x", *cx.doc() as f32, ashlar_motion::tokens::SNAP.enter);
567        });
568        let mut d = Driver::new(app.runtime());
569        let last = |d: &mut Driver<i64>| {
570            d.frame();
571            seen.lock().unwrap_or_else(std::sync::PoisonError::into_inner).last().copied().flatten()
572        };
573        assert_eq!(last(&mut d), None);
574        d.runtime.session_mut().edit("bump", |n| *n += 1);
575        assert_eq!(last(&mut d), Some(Origin::User));
576        assert_eq!(last(&mut d), None, "only the frame after a change reports it");
577        d.runtime.session_mut().edit_merged("drag", "k", |n| *n += 1);
578        assert_eq!(last(&mut d), Some(Origin::Drag));
579        let r = d.request("engine.execute", serde_json::json!({"command": "app.undo"}));
580        assert!(r.is_ok(), "{r:?}");
581        assert_eq!(d.runtime.session().origin(), Some(Origin::Agent), "an agent's undo is the agent's change");
582        d.runtime.session_mut().undo();
583        assert_eq!(last(&mut d), Some(Origin::Undo));
584        assert!(Origin::Agent.animates() && Origin::Job.animates() && !Origin::User.animates());
585    }
586
587    #[test]
588    fn native_undo_while_typing_stays_with_the_text_field() {
589        let mut d = edited(true);
590        assert!(d.ctx().egui_wants_keyboard_input(), "the text field has the keyboard");
591        d.native_menu_pick("app.undo");
592        d.frame();
593        assert_eq!(*d.runtime.session().doc(), 1, "⌘Z while typing never reaches app.undo");
594
595        let mut d = edited(false);
596        d.native_menu_pick("app.undo");
597        d.frame();
598        assert_eq!(*d.runtime.session().doc(), 0, "without a text field the pick undoes");
599    }
600
601    #[test]
602    fn native_picks_wait_for_no_dialog() {
603        let mut d = edited(false);
604        // the document is dirty, so New asks first
605        d.native_menu_pick("file.new");
606        d.frame();
607        assert!(d.runtime.modal_open(), "the confirm dialog is up");
608        d.native_menu_pick("app.undo");
609        d.frame();
610        d.frame();
611        assert_eq!(*d.runtime.session().doc(), 1, "a pick under the confirm dialog does not run");
612        assert!(d.runtime.modal_open());
613    }
614
615    #[test]
616    fn frames_are_measured_by_phase_at_the_display_scale() {
617        let mut d = driver();
618        d.pixels_per_point(2.0);
619        d.pixels_per_point(f32::NAN);
620        d.pixels_per_point(-1.0);
621        let out = d.frame();
622        assert_eq!(out.pixels_per_point, 2.0);
623        assert!(!out.shapes.is_empty(), "the shapes are still handed back");
624        assert_eq!(d.ctx().pixels_per_point(), 2.0);
625        let s = d.runtime.stats().summary();
626        assert_eq!((s.window, s.pass.window, s.tessellate.window, s.frame_cpu.window), (1, 1, 1, 1));
627        assert!(s.frame_cpu.max_ms >= s.pass.max_ms && s.frame_cpu.max_ms >= s.tessellate.max_ms, "{s:?}");
628        assert!(s.pass.max_ms >= s.max_ms, "the pass holds the UI: {s:?}");
629    }
630
631    /// A driver whose view registers a `box` element at `x(time)` and asks for frames while
632    /// `busy(time)`.
633    fn moving(x: fn(f64) -> f32, busy: fn(f64) -> bool) -> Driver<i64> {
634        use ashlar_ui::automation::{self, Details, Role};
635        let app = App::<i64>::new("drivertest", "0.0.0").view(move |ui, _cx| {
636            let t = ui.input(|i| i.time);
637            let rect = egui::Rect::from_min_size(pos2(x(t), 40.0), vec2(60.0, 20.0));
638            automation::register_fmt(ui, rect, format_args!("box"), "Box", Role::Group, true, Details::default());
639            if busy(t) {
640                ashlar_ui::wake::wake(ui.ctx(), ashlar_ui::wake::Reason::ANIMATION);
641            }
642        });
643        Driver::new(app.runtime())
644    }
645
646    #[test]
647    fn the_motion_gate_catches_a_teleport_and_frames_that_never_stop() {
648        use ashlar_ui::motion_gate::{MotionRules, Rule};
649        let rules = MotionRules::default();
650        // slides 2 pt a frame, then teleports to the end
651        let mut d = moving(|t| if t < 0.1 { (t * 240.0) as f32 } else { 300.0 }, |t| t < 0.1);
652        let report = d
653            .record(|d| {
654                d.advance(1.0);
655            })
656            .check(&rules);
657        assert_eq!(report.of(Rule::Pop).count(), 1, "{report}");
658        assert_eq!(report.of(Rule::Idle).count(), 0, "{report}");
659        // a smooth slide that stops passes
660        let mut d = moving(|t| (t.min(0.25) * 240.0) as f32, |t| t < 0.25);
661        let report = d
662            .record(|d| {
663                d.advance(1.0);
664            })
665            .check(&rules);
666        assert!(report.is_clean(), "{report}");
667        // asking for frames forever fails the idle check
668        let mut d = moving(|_| 10.0, |_| true);
669        let report = d
670            .record(|d| {
671                d.advance(0.5);
672            })
673            .check(&rules);
674        assert_eq!(report.of(Rule::Idle).count(), 1, "{report}");
675        assert!(report.to_string().contains("animation"), "{report}");
676    }
677
678    #[test]
679    fn grey_screenshots_say_so_and_have_the_pixel_size() {
680        let mut d = driver();
681        d.pixels_per_point(2.0);
682        d.set_screen(200.0, 100.0);
683        d.settle(10);
684        assert_eq!(d.renderer().name(), "none");
685        let shot = d.call("ui.screenshot", serde_json::json!({})).unwrap();
686        assert_eq!(shot["renderer"], "none");
687        assert_eq!((shot["width"].as_u64(), shot["height"].as_u64()), (Some(400), Some(200)));
688        assert!(d.call("app.stats", serde_json::json!({})).unwrap().get("renderer").is_none(), "only screenshot replies are marked");
689    }
690}