//! 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 ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; use iris::harness::{Harness, TouchScript}; use iris::prelude::*; /// 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) } fn script(name: &str, text: &str) -> TouchScript { TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}")) } fn offset(h: &mut Harness, screen: &ai_app::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 `Scroll::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"); // Compose's own answer for this recording's five samples, printed by // `iris/benches/velocity_reference.py` -- not a number read off this // code. **Positive** because the flick runs *down* the screen and a // delta now carries the finger's own direction the whole way, from the // gesture through `Selection::drag` (which passes it straight to // `Scroll::fling`) to the anchor. It read -15250 while the transcript // negated the velocity on its way into a `LazySpan` whose anchor // offset ran the other way; the magnitude is the number that came from // `velocity_reference.py` and it has not changed. // The 2026-09-07 before/after: the old average estimator read // 12250px/s here, which is the fling Iris reported as too slow. assert!( (velocity - 15_250.0).abs() < 20.0, "expected ~15250px/s from velocity_reference.py, got {velocity}" ); // `iris/benches/fling_spline_reference.py`'s own line for this exact // case -- `density=2.55 v=15250.0: distance=11057.424px // duration=2.0716s`. **Not** `FlingCalculator::new(PHONE_SCALE)`, // which is the calculator under test: bounding a fling with the thing // being measured is the "compared the code with itself" shape 73f956f // found in the spline's own tests, and it left this one able to fail // in the "ran too long" direction only -- never in the "stopped dead" // direction, which is what Iris actually reported // (review, 2026-09-07's T1). const REFERENCE_MS: u64 = 2071; const REFERENCE_PX: f32 = 11057.0; let end = flick.end_ms() + REFERENCE_MS * 2; let mut settled_at = None; let mut t = flick.end_ms(); // Travel in pixels, measured from a row's own on-screen extent, since // `LazySpan` has no travel accessor and this needs none: follow whatever // row is under the viewport's middle until it leaves, then pick // another. Deliberately an *under*-count -- the frame a row leaves on // contributes nothing -- which is why it is only ever a lower bound. let middle = phone_size().y / 2.0; let mut travelled = 0.0f32; let mut tracked: Option<(RowKey, f32)> = None; while t <= end { h.frame(t); let list = (screen.list)(&mut h.rsc); tracked = match tracked.and_then(|(key, was)| list.extent(key).map(|(now, _)| (key, was, now))) { Some((key, was, now)) => { travelled += (now - was).abs(); Some((key, now)) } None => list .key_at(middle) .and_then(|key| list.extent(key).map(|(top, _)| (key, top))), }; 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(); // Both directions. The lower bound is the one that fails when a fling // settles on its first tick; the upper is the one that was here. assert!( ran_for >= REFERENCE_MS - PHONE_FRAME_MS * 2, "the fling stopped after {ran_for}ms against the spline reference's {REFERENCE_MS}ms" ); assert!( ran_for <= REFERENCE_MS + PHONE_FRAME_MS * 2, "the fling ran {ran_for}ms against the spline reference's {REFERENCE_MS}ms" ); // 80% of the reference, against 10527px measured today -- the 5% // shortfall is the frames a tracked row leaves the screen on. A fling // that moves one row's worth fails this; scaling `tick_fling`'s delta // by 0.01 reports 111px, which is how it was confirmed to fail in the // direction the bug goes. assert!( travelled >= REFERENCE_PX * 0.8, "the fling travelled {travelled:.0}px against the spline reference's {REFERENCE_PX:.0}px" ); } /// 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 ); } /// A newline typed into the composer must leave the caret inside the /// bar's own padding, not flush against its bottom edge. /// /// Iris's phone, 2026-09-08: "when typing with the keyboard up and /// entering enough newlines ... the text drops down close to the bottom /// and seems to ignore the padding. If I close (and optionally reopen) /// the keyboard it seems to fix itself." The cause was `Scroll::draw` /// placing its child against *last* frame's content length and stopping /// there: each newline drew the field in a box one line short of its /// text, and since the text is centred in its box it hung half a line /// past each end, putting the caret's line box a full padding below the /// bar's inside edge. Nothing dirtied that subtree again, so the stale /// placement was simply the last one drawn -- until the keyboard closed /// and the inset rewrite forced a redraw, which is the "fixes itself" /// half of the report. No settling frame here on purpose: the placement /// is corrected within the frame that typed, so the first frame drawn /// after a keystroke is already right. #[test] fn a_newline_leaves_the_caret_inside_the_composers_padding() { let (mut h, screen) = opened(); let height = h.size().y; let ime = 1000.0; screen.composer.set_bottom_inset(&mut h.rsc, ime); h.frame(PHONE_FRAME_MS * 2); h.state.set_focus(Some(screen.composer.field)); screen.composer.field.edit(&mut h.rsc).set_cursor_byte(0); // Past `composer::MAX_LINES`, so the bar is capped and scrolling // rather than still growing -- the state the report is about. for _ in 0..12 { screen.composer.field.edit(&mut h.rsc).insert("a\n"); h.frame(PHONE_FRAME_MS); } let message = h .render .debug(h.rsc.widgets(), "Message") .find(|a| !a.primitives.is_empty()) .expect("the composer field is drawn"); // The caret is the last primitive `TextEdit::draw` emits. let caret = h .render .primitive_corners(message.primitives.last().unwrap().slot, &h.rsc); // The bar sits directly on the IME, so its inside edge is one // `FIELD_PAD_DP` above `height - ime`. Stated in pixels rather than // read back from the composer, which is the thing under test. let bar_bottom = height - ime; let padding = 12.0 * PHONE_SCALE; assert!( caret.bot_right.y < bar_bottom - padding / 2.0, "the caret is in the bar's bottom padding: it ends at {}, the bar's edge is {bar_bottom} \ and its padding is {padding}px", caret.bot_right.y, ); // The old assertion only guarded the last line. A viewport one line // shorter than the field still kept that caret above the bottom while // moving the first line above the bar's mask, visibly slicing it off. let mask = h.rsc.ui.masks[message.mask.idx()]; let bar = h.render.primitive_corners(mask.primitive, &h.rsc); let visible_content_top = message .primitives .iter() .map(|p| h.render.primitive_corners(p.slot, &h.rsc)) .filter(|r| r.bot_right.y > bar.top_left.y) .map(|r| r.top_left.y) .fold(f32::INFINITY, f32::min); assert!( visible_content_top > bar.top_left.y, "the composer's first visible line is clipped above its bar: content starts at {visible_content_top}, bar starts at {}", bar.top_left.y, ); }