//! Layer 1 of docs/RUST.md's "Three test layers" for a gesture the //! *platform* takes away, over the real transcript screen and the real //! bench fixture. //! //! Both halves of Iris's 2026-09-08 report about the transcript moving on //! its own live here. A cancel is not a release, so nothing may follow it //! -- and every widget that was tracking the press has to hear about it, //! or the next press anywhere on screen is measured from the origin the //! abandoned one left behind. use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; use iris::harness::{Harness, TouchAction, TouchScript}; use iris::prelude::*; 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}")) } /// Where the content actually is, in window pixels: the top of whichever /// row is under the middle of the viewport, tracked by key. The anchor's /// own `idx/off` display is not that -- the list rehomes its anchor to a /// different row without the content moving at all -- so a test asserting /// "nothing moved" reads a row's own extent, the way `catch_a_fling.rs` /// does. 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 } /// The system's own swipe up from the bottom edge to leave the app is /// delivered to the app as moves and then `ACTION_CANCEL`. Read as a /// release it hands the list that swipe's velocity, and the transcript /// flings while nobody is looking -- "leaving and reopening the app also /// randomly moved the vertical scroll". #[test] fn a_cancelled_flick_does_not_fling() { let (mut h, screen) = opened(); let flick = script( "flick-cancelled", include_str!("../touch/flick-cancelled.touch"), ); h.replay(&flick); assert_eq!( (screen.list)(&mut h.rsc).fling_velocity(), None, "a gesture the platform took away must not fling" ); // ...and it must not be moving on its own over the following second // either, which is what a fling started some other way would look // like. let (key, settled) = tracked_row(&mut h, &screen); let end = flick.end_ms() + 1_000; let mut t = flick.end_ms(); while t <= end { h.frame(t); t += PHONE_FRAME_MS; } let now = row_top(&mut h, &screen, key); assert!( (now - settled).abs() < 0.5, "the list kept moving after a cancelled gesture: {settled} -> {now}" ); } /// The other half, and the one that made a *later* touch snap: a cancel /// has to reach every widget that was handed a frame of the press, so the /// gesture it was driving forgets its origin. Without it the arbiter is /// still open with the abandoned press's touch-down as its origin, and /// the next press is measured from there -- a jump the size of the /// distance between two unrelated touches. #[test] fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() { let (mut h, screen) = opened(); // Press near the top of the transcript and let the platform take it. h.touch(TouchAction::Down, Vec2::new(540.0, 700.0), 0); h.touch(TouchAction::Cancel, Vec2::new(540.0, 700.0), 8); let (key, before) = tracked_row(&mut h, &screen); // A plain tap, a long way down the screen from where that press // started. It must move nothing at all. h.touch(TouchAction::Down, Vec2::new(540.0, 1900.0), 200); h.touch(TouchAction::Up, Vec2::new(540.0, 1900.0), 250); let after = row_top(&mut h, &screen, key); assert!( (after - before).abs() < 0.5, "a tap after a cancelled press panned the list by {}px, the distance between them", after - before ); assert_eq!( (screen.list)(&mut h.rsc).fling_velocity(), None, "and it must not have flung either" ); } /// The report itself: "if I scroll in a horizontal area and then tap in a /// vertical area, it seems to snap." /// /// A markdown fence pans sideways through its own `ScrollArea`, which takes /// pointer capture the moment it commits. Everything else that was handed /// a frame of that press is told so with `CursorSense::Cancel` -- and the /// widget the press actually landed on is the fence's own text block, /// which drives `ai_app::ui::Selection`'s shared `DragGesture`. A /// block that does not register `Cancel` never hears it, so the gesture /// stays open with the fence's touch-down as its origin and the next /// press anywhere is measured from there. /// /// The fence is pushed here rather than hunted for in the fixture, so the /// test knows exactly which row it is pressing and where. #[test] fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() { use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow}; let (mut h, screen) = opened(); let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg { seq: 9_000_000, text: "```\none two three four five six seven eight nine ten eleven twelve\n\ thirteen fourteen fifteen sixteen seventeen eighteen nineteen\n```" .to_string(), settled: true, }); // A plain paragraph under it, because the tap has to land on // ordinary text: a tap that happens to hit a tool group's header // toggles it, and a row changing height moves the list for a reason // that has nothing to do with this. let para = TranscriptRow::Single(TranscriptItem::AssistantMsg { seq: 9_000_001, text: "A plain paragraph with nothing to tap in it, only words, so that a \ press here is a press on ordinary text and nothing else." .to_string(), settled: true, }); screen.push_row(&mut h.rsc, &fence); screen.push_row(&mut h.rsc, ¶); (screen.list)(&mut h.rsc).jump_to_end(); h.frame(100); h.frame(108); // Press in the middle of the fence's own row, so the gesture starts on // the text block inside the scroll area rather than in a gap. let key = ai_app::ui::row::row_key(&fence.key()); let (top, bottom) = (screen.list)(&mut h.rsc) .extent(key) .expect("the fence row is on screen"); let y = (top + bottom) / 2.0; assert!( y > 0.0 && y < phone_size().y, "the fence row has to be on screen to be pressed: {top}..{bottom}" ); // Sideways, well past `DRAG_SLOP`, so the fence commits and captures. h.touch(TouchAction::Down, Vec2::new(800.0, y), 200); for (i, x) in [760.0, 700.0, 620.0, 540.0].into_iter().enumerate() { h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64); } h.touch(TouchAction::Up, Vec2::new(540.0, y), 248); let (tracked, before) = tracked_row(&mut h, &screen); // A tap on the paragraph, a long way down the screen from where that // pan started. let para_key = ai_app::ui::row::row_key(¶.key()); let (ptop, pbottom) = (screen.list)(&mut h.rsc) .extent(para_key) .expect("the paragraph row is on screen"); h.touch( TouchAction::Down, Vec2::new(540.0, (ptop + pbottom) / 2.0), 400, ); h.touch( TouchAction::Up, Vec2::new(540.0, (ptop + pbottom) / 2.0), 450, ); let after = row_top(&mut h, &screen, tracked); assert!( (after - before).abs() < 0.5, "a tap after panning a code fence moved the transcript by {}px", after - before ); }