diff --git a/iris/Cargo.lock b/iris/Cargo.lock index df39da9..30f05a8 100644 --- a/iris/Cargo.lock +++ b/iris/Cargo.lock @@ -3646,6 +3646,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "transcript-fixture" +version = "0.1.0" +dependencies = [ + "client-core", + "event-model", + "iris", + "serde_json", + "transcript-ui", + "winit", +] + [[package]] name = "transcript-ui" version = "0.1.0" diff --git a/iris/Cargo.toml b/iris/Cargo.toml index c431c37..bdd3481 100644 --- a/iris/Cargo.toml +++ b/iris/Cargo.toml @@ -89,7 +89,7 @@ name = "message_list" harness = false [workspace] -members = ["core", "macro", "tabs-ui", "transcript-ui", "desktop-app"] +members = ["core", "macro", "tabs-ui", "transcript-ui", "transcript-fixture", "desktop-app"] # android-app pulls in android-view, which needs the NDK sysroot to link # -- excluded so `cargo build --workspace --all-targets` on the host stays # buildable. Cross-compile it from its own directory (its own single-crate diff --git a/iris/src/harness.rs b/iris/src/harness.rs new file mode 100644 index 0000000..9b05871 --- /dev/null +++ b/iris/src/harness.rs @@ -0,0 +1,396 @@ +//! 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). It ends the press exactly as `Up` does -- a + /// release that never arrives leaves pointer capture held forever -- + /// which is why a replay file can say it. + 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, + } + } +} + +#[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 `List` coasting through a fling asks for +/// the next frame through this (`List::set_redraw_handle`), 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 `List::tick_fling` 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); + self.rsc.ui.tick_animations(now); + self.render.update(&self.state.root, &mut self.rsc); + } + + /// Frames every `step_ms` up to and including `end_ms` -- what a + /// fling needs, since it moves only while something ticks it + /// (`List::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 | TouchAction::Cancel => self.cursor.buttons.left.update(false), + } + 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); + } + } +} diff --git a/iris/src/lib.rs b/iris/src/lib.rs index 1dda0fe..1473f70 100644 --- a/iris/src/lib.rs +++ b/iris/src/lib.rs @@ -21,6 +21,7 @@ pub mod default; pub mod attr; pub mod event; +pub mod harness; pub mod platform; pub mod sense; pub mod state; diff --git a/iris/src/widget/list.rs b/iris/src/widget/list.rs index 50f758e..6241083 100644 --- a/iris/src/widget/list.rs +++ b/iris/src/widget/list.rs @@ -259,7 +259,16 @@ pub struct List { struct Fling { calc: FlingCalculator, velocity: f32, - started_at: Instant, + /// When the fling's own curve begins -- **the first `tick_fling`, + /// not the release**. It is set there rather than in `fling` so the + /// only clock this widget reads is the one its driver hands it: a + /// caller running frames on an explicit clock (`iris::harness`, and + /// `bench_client.rs`'s scripted phases) would otherwise start every + /// fling at the wall clock and advance it on a different one, and a + /// fling released at t=500ms would arrive already over. The + /// difference in a running app is at most one frame, since that is + /// how soon the fling is first ticked. + started_at: Option, applied: f32, } @@ -474,7 +483,7 @@ impl List { self.fling = Some(Fling { calc: FlingCalculator::new(self.density), velocity: velocity_px_per_s, - started_at: Instant::now(), + started_at: None, applied: 0.0, }); } @@ -487,6 +496,17 @@ impl List { self.fling.is_some() } + /// The velocity a fling in progress is coasting at, in this list's + /// own pixel space -- `None` when nothing is flinging. What a + /// release's decision looks like from the outside: a + /// `GestureOutcome::Released(Some(v))` is the only thing that puts a + /// value here, so a test (or a diagnostic) can read what the gesture + /// measured at the place it landed, rather than re-timing the + /// gesture itself. + pub fn fling_velocity(&self) -> Option { + self.fling.as_ref().map(|f| f.velocity) + } + /// Cancel any fling in progress with no further movement -- the next /// touch-down's job, per `fling`'s own doc. pub fn cancel_fling(&mut self) { @@ -508,7 +528,7 @@ impl List { let Some(f) = &mut self.fling else { return false; }; - let elapsed = now.saturating_duration_since(f.started_at); + let elapsed = now.saturating_duration_since(*f.started_at.get_or_insert(now)); let target = f.calc.position_at(f.velocity, elapsed); let delta = target - f.applied; f.applied = target; diff --git a/iris/transcript-fixture/Cargo.toml b/iris/transcript-fixture/Cargo.toml new file mode 100644 index 0000000..236c4e9 --- /dev/null +++ b/iris/transcript-fixture/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "transcript-fixture" +version.workspace = true +edition.workspace = true + +# The bench fixture, opened as a real transcript screen with no server -- +# docs/RUST.md's "Three test layers". It was `iris-android-app`'s +# `bench_client.rs` alone until 2026-09-07; the fixture-loading and +# fold-driving half moved here so the headless harness (layer 1), the +# phone-shaped desktop window (layer 2) and the Android bench (layer 3) +# all open the *same* screen from the same bytes, per AGENTS.md's rule +# that nothing UI-shaped lives in a platform crate. + +[dependencies] +iris = { path = ".." } +transcript-ui = { path = "../transcript-ui" } +client-core = { path = "../../client-core" } +event-model = { path = "../../event-model" } +# `float_roundtrip` for the same reason `server/Cargo.toml` has it: a `ts` +# read back must be the one that was written (AGENTS.md). +serde_json = { version = "1", features = ["float_roundtrip"] } + +[dev-dependencies] +winit = { workspace = true } diff --git a/iris/transcript-fixture/src/lib.rs b/iris/transcript-fixture/src/lib.rs new file mode 100644 index 0000000..4ba65d3 --- /dev/null +++ b/iris/transcript-fixture/src/lib.rs @@ -0,0 +1,139 @@ +//! The checked-in bench fixture, opened as a real transcript screen with +//! no server -- shared by every layer of docs/RUST.md's test rig. +//! +//! The bytes are `app/bench-fixture/assets/transcript.jsonl` (1,915,760 +//! bytes, generated by `app/bench-fixture/generate.py`, never a real +//! transcript -- that file's own README), embedded with `include_str!`. +//! The first [`BACKLOG_COUNT`] non-blank lines are the opening window, +//! folded once through `client_core::transcript_fold::fold_page` exactly +//! as a real `/transcript` page would be; the rest are the streaming +//! tail, replayed one at a time through `fold_event` the way a live SSE +//! frame arrives. +//! +//! This half used to live in `iris-android-app`'s `bench_client.rs`, and +//! moved here on 2026-09-07 so the headless harness and a desktop window +//! open the same screen from the same bytes (AGENTS.md: nothing +//! UI-shaped in a platform crate). What stayed there is the JNI half -- +//! the clipboard, the battery sampler, the IME calls and the report. + +use client_core::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs}; +use event_model::SeqEvent; +use iris::prelude::*; + +/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are +/// the opening window; the rest are the streaming tail. Kept in sync with +/// `BenchFixture.kt`'s identical constant by hand -- both read the same +/// checked-in file, so a mismatch would only mean the two apps' bench +/// builds open a different split of it, not a wrong-vs-right answer. +pub const BACKLOG_COUNT: usize = 3200; + +const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl"); + +/// Iris's phone as `docs/bench/iris-phone-v2-2026-09-06.md` and +/// `docs/IRIS_TODO.md` record it: a 1080x2424 surface at +/// `content_scale: 2.55`, 120Hz. Read from those reports, never typed +/// from memory -- every layer of the rig lays out at this size and +/// density so a screenshot and a headless assertion are about the same +/// screen. +pub const PHONE_WIDTH: f32 = 1080.0; +pub const PHONE_HEIGHT: f32 = 2424.0; +pub const PHONE_SCALE: f32 = 2.55; +/// 120Hz, the refresh rate that report ran at: 8.3ms a frame. +pub const PHONE_FRAME_MS: u64 = 8; + +pub fn phone_size() -> Vec2 { + Vec2::new(PHONE_WIDTH, PHONE_HEIGHT) +} + +/// The fixture split the way the wire delivers it: raw JSON values for +/// the opening page (`fold_page` takes a page of wire JSON, same as a +/// real `/transcript` response) and parsed `SeqEvent`s for the tail +/// (`fold_event` takes one live event at a time, same as an SSE frame). +pub struct Fixture { + pub backlog: Vec, + pub stream_tail: Vec, +} + +impl Fixture { + /// Parses the whole fixture. Panics on malformed input: this is a + /// generated file compiled into the binary, so a parse failure is a + /// broken build rather than a condition a caller could recover from + /// (CODE_RULES: separate recoverable conditions from programmer + /// error). + pub fn parse() -> Self { + let mut backlog = Vec::with_capacity(BACKLOG_COUNT); + let mut stream_tail = Vec::new(); + for (i, line) in FIXTURE_JSONL + .lines() + .filter(|line| !line.trim().is_empty()) + .enumerate() + { + let value: serde_json::Value = + serde_json::from_str(line).expect("bench fixture is generated JSON, always valid"); + if i < BACKLOG_COUNT { + backlog.push(value); + } else { + stream_tail.push( + serde_json::from_value(value) + .expect("bench fixture event matches event-model's SeqEvent"), + ); + } + } + Self { + backlog, + stream_tail, + } + } + + /// The opening page folded into transcript items -- the same + /// `fold_page` a real first load runs. `Err` carries the fold's own + /// message, which a caller shows on screen rather than panicking, so + /// a fixture that stops folding is visible in the app instead of + /// being a crash on launch. + pub fn backlog_items(&self) -> Result, String> { + fold_page(&self.backlog) + } +} + +/// The fixture's opening page as the rows a screen is built from. +pub fn rows(items: &[TranscriptItem]) -> Vec { + group_tool_runs(items) +} + +/// Build the transcript screen over the fixture's opening page and make +/// it the root -- what every layer of the rig opens. Returns the screen +/// and the items behind it, so a caller can go on streaming the tail +/// through `fold_event`/`TranscriptScreen::apply` as the Android bench +/// does. +pub fn open( + rsc: &mut Rsc, + ui_state: &mut impl HasRoot, +) -> Result<(transcript_ui::TranscriptScreen, Vec), String> +where + Rsc::State: FocusHost + OpenUrl, +{ + let fixture = Fixture::parse(); + let items = fixture.backlog_items()?; + let screen = transcript_ui::build(rsc, ui_state, rows(&items)); + Ok((screen, items)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The split is what both bench clients assume; a fixture that + /// stopped having a streaming tail would make the Android bench's + /// stream phase silently measure nothing. + #[test] + fn the_fixture_has_a_backlog_and_a_streaming_tail() { + let fixture = Fixture::parse(); + assert_eq!(fixture.backlog.len(), BACKLOG_COUNT); + assert!( + fixture.stream_tail.len() >= 400, + "the stream phase replays 400 events; the fixture has {}", + fixture.stream_tail.len() + ); + assert!(!fixture.backlog_items().expect("the page folds").is_empty()); + } +} diff --git a/iris/transcript-fixture/tests/phone_screen.rs b/iris/transcript-fixture/tests/phone_screen.rs new file mode 100644 index 0000000..206a45a --- /dev/null +++ b/iris/transcript-fixture/tests/phone_screen.rs @@ -0,0 +1,176 @@ +//! Layer 1 of docs/RUST.md's "Three test layers": the real transcript +//! screen, over the real bench fixture, at the phone's size and density, +//! driven by `iris::harness` with no window, no compositor and no GPU. +//! +//! Every gesture here is a file under `touch/` -- see +//! `flick-120hz.touch` for why the *shape* of the delivery is the whole +//! point, and why the emulator cannot produce it (a `ui-trace` swipe is +//! many evenly-spaced events; a finger at 120Hz is five samples in +//! 20ms). + +use iris::harness::{Harness, TouchScript}; +use iris::prelude::*; +use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; + +/// The screen open on the fixture, framed twice: once to draw, once for +/// `List::repair_anchor` to resolve the opening `snap_end` into a real +/// anchor, which is what every assertion about scroll position reads. +fn opened() -> (Harness, transcript_ui::TranscriptScreen) { + let mut h = Harness::new(phone_size(), PHONE_SCALE); + let (screen, _items) = + transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds"); + h.frame(0); + h.frame(PHONE_FRAME_MS); + (h, screen) +} + +fn script(name: &str, text: &str) -> TouchScript { + TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}")) +} + +fn offset(h: &mut Harness, screen: &transcript_ui::TranscriptScreen) -> String { + (screen.list)(&mut h.rsc).anchor_position_display() +} + +/// (a) and (b) together, because the second is only meaningful if the +/// first happened: the recorded flick must release with a real velocity +/// (`GestureOutcome::Released(Some(v))`, which is the only thing that +/// puts a value in `List::fling_velocity`), and the list must then +/// actually travel and stop on the spline's own schedule. +#[test] +fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() { + let (mut h, screen) = opened(); + let before = offset(&mut h, &screen); + + let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch")); + h.replay(&flick); + + let velocity = (screen.list)(&mut h.rsc) + .fling_velocity() + .expect("the flick must release as a pan with a velocity, not a tap"); + assert!( + velocity.abs() > 1_000.0, + "a 188px, 16ms flick is thousands of px/s; got {velocity}" + ); + + // Android's own spline says how long a fling at this speed runs. The + // list learns its density from the painter, so this is the same + // curve it is using. + let expected = FlingCalculator::new(PHONE_SCALE).duration(velocity); + let end = flick.end_ms() + expected.as_millis() as u64 * 2; + let mut settled_at = None; + let mut t = flick.end_ms(); + while t <= end { + h.frame(t); + if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() { + settled_at = Some(t); + } + t += PHONE_FRAME_MS; + } + + let after = offset(&mut h, &screen); + assert_ne!( + before, after, + "the fling ticks must have moved the list off where the flick left it" + ); + let settled_at = settled_at.expect("the fling must stop on its own, not run forever"); + let ran_for = settled_at - flick.end_ms(); + assert!( + ran_for <= expected.as_millis() as u64 + PHONE_FRAME_MS * 2, + "the fling ran {ran_for}ms against the spline's own {}ms", + expected.as_millis() + ); +} + +/// The half the flick fix had no reason to touch: a tap must decide +/// `Tapped`, which means no velocity anywhere and nothing moved. +#[test] +fn a_tap_on_a_row_moves_nothing() { + let (mut h, screen) = opened(); + let before = offset(&mut h, &screen); + + h.replay(&script("tap", include_str!("../touch/tap.touch"))); + + assert_eq!( + (screen.list)(&mut h.rsc).fling_velocity(), + None, + "a tap must not fling" + ); + // Frames it would have moved in, had anything been moving. + h.frames_until(100, 400, PHONE_FRAME_MS); + assert_eq!(before, offset(&mut h, &screen), "a tap must scroll nothing"); + assert_eq!( + h.state.opened_urls, + Vec::::new(), + "no link was under this tap" + ); +} + +/// A press held past `LONG_PRESS` and then dragged selects text rather +/// than panning -- the other branch of the same arbiter the flick goes +/// through. +#[test] +fn a_long_press_and_drag_selects_text() { + let (mut h, screen) = opened(); + let before = offset(&mut h, &screen); + + h.replay(&script( + "long-press", + include_str!("../touch/long-press.touch"), + )); + + let selected = screen + .selected_text(&mut h.rsc) + .expect("a long-press then drag must leave text selected"); + assert!( + !selected.trim().is_empty(), + "the selection covered no characters: {selected:?}" + ); + assert_eq!( + before, + offset(&mut h, &screen), + "a selection must not also pan the list" + ); +} + +/// The composer sits on whatever the platform says the bottom of usable +/// space is -- the keyboard's inset while it is open +/// (`Composer::set_bottom_inset`, the path Android's +/// `on_insets_changed` feeds). Checked here rather than on the emulator +/// because it is a layout fact, and the emulator costs minutes. +#[test] +fn the_composer_sits_above_a_simulated_ime_inset() { + let (mut h, screen) = opened(); + let height = h.size().y; + let field_bottom = |h: &mut Harness| { + h.render + .window_region(&screen.composer.field, &h.rsc) + .expect("the composer field is on screen") + .bot_right + .y + }; + + let closed = field_bottom(&mut h); + assert!( + closed <= height, + "the composer is off the bottom of the window even with no keyboard: {closed} > {height}" + ); + + // A Gboard-sized keyboard on this surface. Any real number would do; + // what matters is that the bar clears it. + let ime = 1000.0; + screen.composer.set_bottom_inset(&mut h.rsc, ime); + h.frame(PHONE_FRAME_MS * 2); + + let open = field_bottom(&mut h); + assert!( + open <= height - ime, + "the keyboard covers the composer: its bottom is at {open}, the IME starts at {}", + height - ime + ); + assert!( + (closed - open - ime).abs() < 1.0, + "the composer moved {} for a {ime}px inset", + closed - open + ); +} diff --git a/iris/transcript-fixture/touch/flick-120hz.touch b/iris/transcript-fixture/touch/flick-120hz.touch new file mode 100644 index 0000000..1e69abb --- /dev/null +++ b/iris/transcript-fixture/touch/flick-120hz.touch @@ -0,0 +1,21 @@ +# A finger flick the shape Iris's phone delivers one, from +# docs/bench/iris-phone-v2-2026-09-06.md and docs/IRIS_TODO.md's +# "From the phone, 2026-09-06, 22:16": at 120Hz a flick reaches the app +# as DOWN, one or two MOVEs and UP inside a few frames, with the +# intermediate positions batched inside those MOVEs as historical +# samples (~4ms apart, the touch digitiser's own rate) rather than +# arriving as separate events. Each line here is one such sample, which +# is exactly what `IrisViewPeer::on_touch_event` replays through the +# sensors one at a time -- so the whole gesture is 20ms and five +# samples, and the velocity has to come out of *those*. +# +# Downward (increasing y) on purpose: the screen opens pinned to the +# newest end, so a flick the other way has nothing left to scroll to and +# the fling clamps on its first tick -- a pass that would prove nothing. +# Coordinates are physical pixels on a 1080x2424 surface. +0 down 540 1000 +4 move 540 1040 +8 move 540 1086 +12 move 540 1138 +16 move 540 1196 +20 up 540 1196 diff --git a/iris/transcript-fixture/touch/long-press.touch b/iris/transcript-fixture/touch/long-press.touch new file mode 100644 index 0000000..d0a254f --- /dev/null +++ b/iris/transcript-fixture/touch/long-press.touch @@ -0,0 +1,11 @@ +# A long-press then a drag across the text: held past LONG_PRESS +# (500ms) without moving, which is what starts a selection rather than a +# pan, then dragged sideways so the selection actually covers +# something. A press alone leaves a collapsed caret and no selected +# text (`Selection::begin`), which is why this file does not stop at the +# hold. +0 down 300 1000 +520 move 300 1000 +560 move 700 1000 +600 move 900 1000 +640 up 900 1000 diff --git a/iris/transcript-fixture/touch/tap.touch b/iris/transcript-fixture/touch/tap.touch new file mode 100644 index 0000000..da89399 --- /dev/null +++ b/iris/transcript-fixture/touch/tap.touch @@ -0,0 +1,5 @@ +# The case the flick had no reason to touch: a press and release in one +# place, well inside DRAG_SLOP and well under LONG_PRESS. It must be a +# tap -- no pan, no velocity, nothing moved. +0 down 540 1000 +80 up 540 1000