//! Layer 1 of docs/RUST.md's "Three test layers", for catching a fling: //! the real transcript screen over the real bench fixture, at the phone's //! size and density, with no window, no compositor and no GPU. //! //! docs/IRIS_TODO.md's 2026-09-07 night report -- "sometimes when I try to //! catch it while it's still moving (particularly if I drag) then it fails //! to stop & snap to where finger is". The finger goes down on content //! that is still travelling and the content does not follow it until //! `DRAG_SLOP` has been crossed, which at a fling's speed is several //! frames of the content sliding *away* from a finger that is already //! down. Compose does not do that: a down while `isScrollInProgress` //! starts the drag immediately (`scrollable`'s `startDragImmediately`). use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; use iris::harness::{Harness, TouchAction, TouchScript}; use iris::prelude::*; use iris::sense::DRAG_SLOP; /// The screen open on the fixture, framed twice -- once to draw, once for /// `LazySpan::repair_anchor` to resolve the opening `snap_end` into a real /// anchor, which is what every assertion about scroll position reads. fn opened() -> (Harness, ai_app::ui::TranscriptScreen) { let mut h = Harness::new(phone_size(), PHONE_SCALE); let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds"); h.frame(0); h.frame(PHONE_FRAME_MS); (h, opened.screen) } /// Where the content is, in window pixels: the top of whichever row is /// under the middle of the viewport. `LazySpan` has no travel accessor and /// this needs none -- a row's own extent moves exactly as far as the /// content does, and the row is picked once so the two readings compare. fn tracked_row(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> (RowKey, f32) { let middle = phone_size().y / 2.0; let list = (screen.list)(&mut h.rsc); let key = list.key_at(middle).expect("a row under the viewport"); let (top, _) = list.extent(key).expect("that row has an extent"); (key, top) } fn row_top(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, key: RowKey) -> f32 { (screen.list)(&mut h.rsc) .extent(key) .expect("the tracked row is still loaded") .0 } /// Three finger samples 8ms apart, each moving `STEP` further down the /// screen. `STEP * 3` is deliberately **under** `DRAG_SLOP`: a gesture /// this small moves nothing at all on a settled list (the control below), /// so anything it moves here is the catch and not the slop being crossed. const STEP: f32 = 2.0; const SAMPLES: usize = 3; const CATCH_X: f32 = 540.0; /// Feeds the down and its `SAMPLES` moves from `y0` at `t0`, asserting /// after each one that the content moved by exactly the finger's own /// delta. Returns the release time. fn drag_from( h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, key: RowKey, y0: f32, t0: u64, expect_tracking: bool, ) -> u64 { let before = row_top(h, screen, key); h.touch(TouchAction::Down, Vec2::new(CATCH_X, y0), t0); assert_eq!( row_top(h, screen, key), before, "the down itself must not move the content, only stop it" ); let mut t = t0; for i in 1..=SAMPLES { let moved = STEP * i as f32; t = t0 + 8 * i as u64; h.touch(TouchAction::Move, Vec2::new(CATCH_X, y0 + moved), t); let travelled = row_top(h, screen, key) - before; if expect_tracking { assert!( (travelled - moved).abs() < 0.5, "sample {i}: the finger has moved {moved}px since the down and the content \ {travelled:.1}px -- it is not pinned to the finger" ); } else { assert!( travelled.abs() < 0.5, "sample {i}: a {moved}px drag is inside DRAG_SLOP ({DRAG_SLOP}px) and must move \ nothing, but the content moved {travelled:.1}px" ); } } t += 8; h.touch( TouchAction::Up, Vec2::new(CATCH_X, y0 + STEP * SAMPLES as f32), t, ); t } /// The report itself: flick, let the fling run for 150ms, then put a /// finger down and drag it a little. From the down onwards the content is /// pinned to the finger, sample for sample -- no slop, and no coasting /// past the place the finger stopped it. #[test] fn a_press_on_a_flinging_list_pins_the_content_to_the_finger() { let (mut h, screen) = opened(); let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch")) .unwrap_or_else(|e| panic!("flick-120hz.touch: {e}")); h.replay(&flick); // Frames, not a file: the second half of this gesture has to arrive // *while* the fling is ticking, and a `.touch` replay inserts no // frames between its samples, so a fling recorded that way would be // running on paper and stationary in fact. let catch_at = flick.end_ms() + 150; h.frames_until( flick.end_ms() + PHONE_FRAME_MS, catch_at - PHONE_FRAME_MS, PHONE_FRAME_MS, ); assert!( (screen.list)(&mut h.rsc).is_scrolling(), "the fling must still be running 150ms in, or this test catches nothing" ); let (key, _) = tracked_row(&mut h, &screen); drag_from(&mut h, &screen, key, 1200.0, catch_at, true); } /// The other half of the same rule: a catch that never moved at all is a /// `Released(None)`, not a tap. Compose's scrollable consumes that DOWN, /// so no click detector under it ever sees the gesture -- stopping a /// fling with a finger must not also follow the link it landed on, and /// must not hand the list a velocity to start again with. #[test] fn a_catch_that_never_moved_is_not_a_tap_and_does_not_fling() { let (mut h, screen) = opened(); let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch")) .unwrap_or_else(|e| panic!("flick-120hz.touch: {e}")); h.replay(&flick); let catch_at = flick.end_ms() + 150; h.frames_until( flick.end_ms() + PHONE_FRAME_MS, catch_at - PHONE_FRAME_MS, PHONE_FRAME_MS, ); assert!( (screen.list)(&mut h.rsc).is_scrolling(), "the fling must still be running 150ms in, or this test catches nothing" ); let (key, _) = tracked_row(&mut h, &screen); h.touch(TouchAction::Down, Vec2::new(CATCH_X, 1200.0), catch_at); let stopped_at = row_top(&mut h, &screen, key); h.touch(TouchAction::Up, Vec2::new(CATCH_X, 1200.0), catch_at + 8); assert_eq!( (screen.list)(&mut h.rsc).fling_velocity(), None, "a press that stopped a fling and moved nothing must not start another" ); h.frames_until(catch_at + 16, catch_at + 500, PHONE_FRAME_MS); assert!( (row_top(&mut h, &screen, key) - stopped_at).abs() < 0.5, "the content moved after a catch was released without moving" ); assert_eq!( h.state.opened_urls, Vec::::new(), "a catch is not a tap: nothing under it may be followed" ); } /// The half this change had no reason to touch: on a list that is *not* /// moving, the same tiny drag is still inside `DRAG_SLOP` and still moves /// nothing. Without this, making every press pin the content would pass /// the test above and take the slop away from every ordinary press. #[test] fn the_same_small_drag_on_a_settled_list_moves_nothing() { let (mut h, screen) = opened(); let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch")) .unwrap_or_else(|e| panic!("flick-120hz.touch: {e}")); h.replay(&flick); // Long past the spline's own 2071ms for this recording. let settled = h.frames_until( flick.end_ms() + PHONE_FRAME_MS, flick.end_ms() + 4000, PHONE_FRAME_MS, ); assert!( !(screen.list)(&mut h.rsc).is_scrolling(), "the fling must have stopped, or this is the same case as the test above" ); let (key, _) = tracked_row(&mut h, &screen); drag_from( &mut h, &screen, key, 1200.0, settled + PHONE_FRAME_MS, false, ); }