use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; use iris::harness::{Harness, 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}")) } fn offset(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen) -> String { (screen.list)(&mut h.rsc).anchor_position_display() } #[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 - 15_250.0).abs() < 20.0, "expected ~15250px/s from velocity_reference.py, got {velocity}" ); 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(); 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(); 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" ); assert!( travelled >= REFERENCE_PX * 0.8, "the fling travelled {travelled:.0}px against the spline reference's {REFERENCE_PX:.0}px" ); } #[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" ); 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" ); } #[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" ); } #[test] fn a_tap_after_selection_deselects_text() { let (mut h, screen) = opened(); h.replay(&script( "select-then-tap", "0 down 300 1000\n\ 520 move 300 1000\n\ 560 move 700 1000\n\ 600 move 900 1000\n\ 640 up 900 1000\n\ 800 down 540 1000\n\ 880 up 540 1000", )); assert_eq!( screen.selected_text(&mut h.rsc), None, "an ordinary tap after a selection must dismiss it" ); assert_eq!( h.state.opened_urls, Vec::::new(), "the deselecting tap must be consumed rather than activating content" ); } #[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.rsc .ui .render_state() .get() .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}" ); 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 ); } #[test] fn a_space_in_the_composer_finishes_layout() { let (mut h, screen) = opened(); screen.composer.set_bottom_inset(&mut h.rsc, 1000.0); h.frame(PHONE_FRAME_MS * 2); h.state.set_focus(Some(screen.composer.field)); (screen.composer.field)(&mut h.rsc).set_cursor_byte(0); for text in ["h", "i", " "] { (screen.composer.field)(&mut h.rsc).insert(text); h.frame(PHONE_FRAME_MS); } assert_eq!(&*(screen.composer.field)(&mut h.rsc).text(), "hi "); let region = h .rsc .ui .render_state() .get() .window_region(&screen.composer.field, &h.rsc) .expect("the composer field is drawn"); let size = region.bot_right - region.top_left; assert!( size.x > phone_size().x / 2.0, "the field shrink-wrapped to the short message: {region:?}" ); assert!( size.y < 30.0 * PHONE_SCALE, "the trailing space wrapped onto a second line: {region:?}" ); } #[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)(&mut h.rsc).set_cursor_byte(0); for _ in 0..12 { (screen.composer.field)(&mut h.rsc).insert("a\n"); h.frame(PHONE_FRAME_MS); } let render_handle = h.rsc.ui.render_state(); let render_state = render_handle.get(); let message = render_state .debug(h.rsc.widgets(), "Message") .find(|a| !a.primitives.is_empty()) .expect("the composer field is drawn"); let caret = render_state.primitive_corners(message.primitives.last().unwrap().slot, &h.rsc); 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, ); let mask = h.rsc.ui.masks[message.mask.idx()]; let bar = render_state.primitive_corners(mask.primitive, &h.rsc); let visible_content_top = message .primitives .iter() .map(|p| render_state.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, ); }