//! Layer 1 of docs/RUST.md's "Three test layers": a whole screen driven //! in-process with **no window, no compositor and no GPU**, on an //! explicit clock and a replayed touch stream. //! //! `layout_tests.rs` and `sense_tests.rs` already build trees over //! `UiRenderState` with a hand-rolled `Rsc` each; this is the same idea //! carried far enough to open a real app screen (`transcript-ui`'s, over //! the bench fixture -- see the `transcript-fixture` crate) at the //! phone's size and density, feed it a recorded flick, and assert on //! where the list ended up. What it answers that the emulator cannot: //! Android batches a 120Hz flick into one or two `MotionEvent`s //! (`CursorState::time`), and a `ui-trace` swipe is many evenly-spaced //! ones -- so the gesture shape a finger actually makes is only //! reproducible from a *file* of timestamped samples. //! //! It is a third backend in the sense `default/` and `android/` are, and //! deliberately the smallest one: the platform half of each of those //! (a surface, an IME, a URL opener) becomes a recorded fact here -- //! [`HarnessState::keyboard_shown`], [`HarnessState::opened_urls`] -- //! so a test can assert the platform *was asked*, which is the only //! thing either backend does with those calls anyway. //! //! ```ignore //! let mut h = Harness::new(phone_size(), PHONE_SCALE); //! let screen = transcript_ui::build(&mut h.rsc, &mut h.state, rows); //! h.frame(0); //! h.replay(&TouchScript::parse(include_str!("flick.touch"))?); //! h.frames_until(20, 2_000, 8); //! ``` use crate::prelude::*; use std::marker::PhantomData; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; /// One replayed pointer sample: what Android's `MotionEvent` carries, cut /// down to the part iris reads (`IrisViewPeer::on_touch_event`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TouchAction { Down, Move, Up, /// The gesture taken away by the system (a parent view claiming it, a /// call arriving, the swipe up from the bottom edge to leave the /// app). It ends the press, because a release that never arrives /// leaves pointer capture held forever -- but it is not a release, /// and nothing follows from it: no tap, no selection, no fling. See /// `CursorState::cancelled`, which is what it sets. Cancel, } impl TouchAction { fn parse(word: &str) -> Option { match word { "down" => Some(Self::Down), "move" => Some(Self::Move), "up" => Some(Self::Up), "cancel" => Some(Self::Cancel), _ => None, } } /// The inverse of [`Self::parse`] -- what [`Harness::touch`] hands /// [`crate::sense::log_input_event`], so an `iris::input` line and a /// `.touch` file agree on one spelling of each action. pub fn word(self) -> &'static str { match self { Self::Down => "down", Self::Move => "move", Self::Up => "up", Self::Cancel => "cancel", } } } #[derive(Clone, Copy, Debug)] pub struct TouchSample { /// Milliseconds since the start of the recording -- the sample's own /// time, which becomes `CursorState::time`. See that field's doc for /// why a replay may not date its samples by when the loop got to /// them. pub t_ms: u64, pub action: TouchAction, pub pos: Vec2, } /// A recorded gesture: one `t_ms action x y` line per sample, `#` and /// blank lines ignored. Deliberately a plain text file rather than a /// serialisation format -- it is written by hand as often as it is /// recorded, and a diff of one has to be readable. pub struct TouchScript { pub samples: Vec, } impl TouchScript { /// Parses a script, naming the line and what was wrong with it: these /// are hand-written files, so a typo is the ordinary case and /// "expected 4 fields" without a line number is not enough to fix it. pub fn parse(text: &str) -> Result { let mut samples: Vec = Vec::new(); for (i, line) in text.lines().enumerate() { let line = line.split('#').next().unwrap_or("").trim(); if line.is_empty() { continue; } let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1); let mut words = line.split_whitespace(); let (Some(t), Some(action), Some(x), Some(y), None) = ( words.next(), words.next(), words.next(), words.next(), words.next(), ) else { return Err(at("expected `t_ms action x y`")); }; let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?; let action = TouchAction::parse(action) .ok_or_else(|| at("action is not down/move/up/cancel"))?; let x: f32 = x.parse().map_err(|_| at("x is not a number"))?; let y: f32 = y.parse().map_err(|_| at("y is not a number"))?; if let Some(last) = samples.last() && t_ms < last.t_ms { return Err(at("samples must be in time order")); } samples.push(TouchSample { t_ms, action, pos: Vec2::new(x, y), }); } Ok(Self { samples }) } /// The last sample's time, i.e. how long the recording runs. pub fn end_ms(&self) -> u64 { self.samples.last().map(|s| s.t_ms).unwrap_or(0) } } /// Counts the frames something asked for without drawing any -- the /// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for /// the next frame through this (`UiData::animate` and `Widget::tick`), so a test can /// tell "nothing moved" from "nothing was even asked to move". #[derive(Default)] pub struct RedrawCounter(AtomicUsize); impl RedrawCounter { pub fn count(&self) -> usize { self.0.load(Ordering::Relaxed) } } impl RequestRedraw for RedrawCounter { fn request_redraw(&self) { self.0.fetch_add(1, Ordering::Relaxed); } } /// The harness's app state: what each real backend keeps for the platform /// half, recorded instead of performed. pub struct HarnessState { pub root: Option, pub focus: Option>, last_click: Instant, /// How many times a tap asked for the keyboard (`FocusHost:: /// focus_gained` with a region -- `showSoftInput` on Android, /// `set_ime_cursor_area` on winit). The platform's own answer is not /// available here, so this says what was *asked*, and a test must not /// read it as "the IME is up". pub keyboard_shown: usize, /// Every URL a tapped link asked the platform to open, in order. pub opened_urls: Vec, } impl HarnessState { fn new() -> Self { Self { root: None, focus: None, last_click: Instant::now(), keyboard_shown: 0, opened_urls: Vec::new(), } } } impl HasRoot for HarnessState { fn set_root(&mut self, root: StrongWidget) { self.root = Some(root); } } impl FocusHost for HarnessState { fn recent_click(&mut self) -> bool { crate::attr::recent_click(&mut self.last_click) } fn set_focus(&mut self, id: Option>) { self.focus = id; } fn is_focused(&self, id: WeakWidget) -> bool { self.focus == Some(id) } fn focus_gained(&mut self, region: Option) { if region.is_some() { self.keyboard_shown += 1; } } } impl OpenUrl for HarnessState { fn open_url(&mut self, url: &str) { self.opened_urls.push(url.to_string()); } } /// The harness's `Rsc` -- identical in substance to `DefaultRsc`/ /// `AndroidRsc` minus the windowing, for the same reason those two are /// separate types (`AndroidRsc`'s own doc). pub struct HarnessRsc { pub ui: UiData, pub events: EventManager, pub tasks: Tasks, pub state: WidgetState, _state: PhantomData, } impl UiRsc for HarnessRsc { fn ui(&self) -> &UiData { &self.ui } fn ui_mut(&mut self) -> &mut UiData { &mut self.ui } fn on_draw(&mut self, active: &ActiveData) { self.events.draw(active); } fn on_undraw(&mut self, active: &ActiveData) { self.events.undraw(active); } fn on_remove(&mut self, id: WidgetId) { self.events.remove(id); self.state.remove(id); } } impl HasState for HarnessRsc { type State = HarnessState; } impl HasEvents for HarnessRsc { fn events(&self) -> &EventManager { &self.events } fn events_mut(&mut self) -> &mut EventManager { &mut self.events } } impl HasTasks for HarnessRsc { fn tasks_mut(&mut self) -> &mut Tasks { &mut self.tasks } } impl HasWidgetState for HarnessRsc { fn widget_state(&self) -> &WidgetState { &self.state } fn widget_state_mut(&mut self) -> &mut WidgetState { &mut self.state } } impl> std::ops::Index for HarnessRsc { type Output = I::Output; fn index(&self, index: I) -> &Self::Output { index.get(self) } } impl> std::ops::IndexMut for HarnessRsc { fn index_mut(&mut self, index: I) -> &mut Self::Output { index.get_mut(self) } } /// A screen running with no window: the widget tree, the frame loop and /// the pointer, all advanced by the caller. See the module doc. pub struct Harness { pub rsc: HarnessRsc, pub render: UiRenderState, pub state: HarnessState, task_recv: TaskMsgReceiver, redraws: Arc, cursor: CursorState, /// Time zero. Every `t_ms` in this harness is an offset from here, so /// nothing reads the wall clock -- see [`Self::at`]. base: Instant, size: Vec2, } impl Harness { /// `size` is in physical pixels and `density` is physical pixels per /// dp, the pair Android reads from the surface and /// `DisplayMetrics.density` (`AndroidUiState::content_scale`). The /// phone's own numbers are `transcript_fixture::PHONE_SIZE`/ /// `PHONE_SCALE`. pub fn new(size: Vec2, density: f32) -> Self { let redraws = Arc::new(RedrawCounter::default()); let (tasks, task_recv) = Tasks::init(redraws.clone()); let mut rsc = HarnessRsc { ui: UiData::default(), events: EventManager::default(), tasks, state: WidgetState::default(), _state: PhantomData, }; rsc.ui.text.density = density; let mut render = UiRenderState::new(); render.set_density(density); render.resize(size); Self { rsc, render, state: HarnessState::new(), task_recv, redraws, cursor: CursorState::default(), base: Instant::now(), size, } } /// The `Instant` this harness means by `t_ms`. Public because a /// caller driving `ScrollController::tick` or `DragGesture` by hand needs /// to date those calls on the same clock the touch samples use. pub fn at(&self, t_ms: u64) -> Instant { self.base + Duration::from_millis(t_ms) } pub fn size(&self) -> Vec2 { self.size } /// How many frames were asked for so far -- see [`RedrawCounter`]. pub fn redraws(&self) -> usize { self.redraws.count() } /// One frame at `t_ms`: drain finished tasks, advance anything /// animating, lay out and "draw". The same three steps /// `DefaultApp::window_event`'s `RedrawRequested` arm and /// `IrisViewPeer::render` take, minus handing primitives to a GPU. pub fn frame(&mut self, t_ms: u64) { while let Ok(update) = self.task_recv.try_recv() { update(&mut self.state, &mut self.rsc); } let now = self.at(t_ms); let at = Instant::now(); let animating = self.rsc.ui.tick_animations(now); self.render.update(&self.state.root, &mut self.rsc); // No GPU here, so there is nothing to acquire and nothing to // submit: the frame is all `build`, which is honest rather than // zero-filled (`FrameParts::whole`). `layout`/`redraw`/ // `primitives` are still real, because `render.update` just ran; // see `iris::diagnostics::log_frame`'s own doc for why this reads // those back rather than timing anything itself. crate::diagnostics::log_frame( &self.render, now, FrameParts::whole(at.elapsed()), animating, ); } /// Frames every `step_ms` up to and including `end_ms` -- what a /// fling needs, since it moves only while something ticks it /// (`ScrollController::fling`'s doc). Returns the time of the last frame run. pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 { debug_assert!(step_ms > 0, "a frame loop with no step never ends"); let mut t = from_ms; while t <= end_ms { self.frame(t); t += step_ms; } t - step_ms } /// One pointer sample through the sensors, then the frame it belongs /// to -- `IrisViewPeer::on_touch_event` and `after_input`, in one /// call. Each sample is its own input frame, dated by the sample /// rather than by when this ran. pub fn touch(&mut self, action: TouchAction, pos: Vec2, t_ms: u64) { self.cursor.time = self.at(t_ms); self.cursor.pos = pos; match action { TouchAction::Down => { self.cursor.exists = true; self.cursor.buttons.left.update(true); } TouchAction::Move => {} TouchAction::Up => self.cursor.buttons.left.update(false), // The platform taking the gesture away, not the finger // lifting -- see `CursorState::cancelled`. TouchAction::Cancel => { self.cursor.buttons.left.update(false); self.cursor.cancelled = true; } } // Layer 1's half of `iris::input` (`sense::log_input_event`'s own // doc): no batching happens here, so `historical` is always empty // and `t_ms` is the script's own column, which is what makes this // round-trip through `report_to_touch.py` back into an identical // `TouchScript`. crate::sense::log_input_event(action.word(), pos.x, pos.y, t_ms, &[]); let cursor = self.cursor.clone(); self.render .run_sensors(&mut self.rsc, &mut self.state, cursor, self.size); self.frame(t_ms); self.cursor.end_frame(); } /// Replays a whole recorded gesture. Nothing is inserted between the /// samples: a file with three lines produces three input frames, so /// the batched shape a real flick arrives in is preserved exactly as /// recorded rather than smoothed into evenly-spaced motion. pub fn replay(&mut self, script: &TouchScript) { for sample in &script.samples { self.touch(sample.action, sample.pos, sample.t_ms); } } }