//! Layer 1 of docs/RUST.md's "Three test layers", for the transcript's //! own edges: the real screen over the real fixture, under a header bar //! like the bench app's, driven by `iris::harness`. //! //! What these are about is docs/IRIS_TODO.md's 2026-09-07 phone report -- //! rows scrolled above the viewport still drawn, over the header, and a //! blank band where the row straddling the top edge should be. Both are //! one rule (`LazySpan::intersects_viewport`): a row is drawn if any part of //! it is inside the list's own box, and nothing outside that box reaches //! the screen. use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; use iris::harness::Harness; use iris::prelude::*; /// A header band above the transcript, as `bench_client.rs` puts one -- /// the surface the rows were drawing over on the phone. Its exact height /// does not matter; what matters is that the list's own box does not /// start at the top of the window, so "above the viewport" and "off the /// screen" are different places. const HEADER_H: f32 = 300.0; const HEADER: UiColor = UiColor::new(28, 28, 34, 255); fn opened() -> (Harness, ai_app::ui::TranscriptScreen) { let mut h = Harness::new(phone_size(), PHONE_SCALE); let (opened, tree) = ai_app::ui::fixture::build_screen(&mut h.rsc).expect("the fixture folds"); let content = WidgetPtr::new().add(&mut h.rsc); content(&mut h.rsc).set(tree); let root = (rect(HEADER).height(abs(HEADER_H)), content.height(rest(1))) .span(Dir::DOWN) .add_strong(&mut h.rsc) .any(); h.state.set_root(root); h.frame(0); h.frame(PHONE_FRAME_MS); (h, opened.screen) } /// The list's own on-screen box, in window pixels. fn list_box(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> PixelRegion { h.render .window_region(&screen.list.id(), &h.rsc) .expect("the list is on screen") } /// Every row the list drew this frame, as `(top, bottom)` window pixels, /// topmost first. A `LazySpan`'s direct children are exactly its rows, and /// `draw_inner`'s old-children diffing means a row it did not place this /// frame is not among them. fn drawn_rows(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> Vec<(f32, f32)> { let mut rows: Vec<(f32, f32)> = h .render .active .get(&screen.list.id()) .expect("the list is drawn") .children .iter() .filter_map(|id| h.render.window_region(id, &h.rsc)) .map(|px| (px.top_left.y, px.bot_right.y)) .collect(); rows.sort_by(|a, b| a.0.total_cmp(&b.0)); rows } /// Scrolls `amount` and runs the frame it asks for, returning the time of /// the next one. **Positive walks back through older rows** -- the /// finger's own direction, and `Scroll::scroll`'s, which is the one /// convention a delta has anywhere in iris since the transcript's scroll /// position lives in the `LazySpan`'s own `ScrollController`. /// It used to be the opposite here, because a `LazySpan`'s anchor offset /// ran the other way. fn scrolled(h: &mut Harness, screen: &ai_app::ui::TranscriptScreen, amount: f32, t: u64) -> u64 { (screen.list)(&mut h.rsc).scroll(amount); h.frame(t); t + PHONE_FRAME_MS } /// (a) of docs/IRIS_TODO.md's reproduction: with a row across the top /// edge, that row is placed -- the viewport's first pixel belongs to /// something. A rule that culled a row once its *top* left the viewport /// would leave a blank band here, which is the second of Iris's two /// screenshots. #[test] fn the_row_across_the_top_edge_is_drawn() { let (mut h, screen) = opened(); let top = list_box(&h, &screen).top_left.y; let mut t = PHONE_FRAME_MS * 2; // 40px a frame, the shape a finger pan arrives in, through a straddle // and out the other side of it many times over. for _ in 0..60 { t = scrolled(&mut h, &screen, 40.0, t); let rows = drawn_rows(&h, &screen); let first = *rows.first().expect("something is on screen"); assert!( first.0 <= top + 0.5, "a band of {:.1}px under the header belongs to no row: rows start at {:.1}, the list \ at {top:.1}", first.0 - top, first.0, ); assert!( first.1 > top, "the row across the top edge was culled: it ends at {:.1}, above the list's own \ {top:.1}", first.1, ); } } /// (b): what falls outside the list's box is clipped rather than drawn /// over whatever is there. The straddling row above is drawn *in full*, /// so the only thing between its earlier lines and the header bar is this /// mask -- with none, the phone drew `version = "0.1.0"` behind the "Run /// benchmark" button. /// /// The clip is one the screen **opted into** (`build_tree`'s `.masked()`), /// so this reads the mask the list *inherited*. A `LazySpan` sets none of /// its own -- masking is opt-in, like scrolling (Iris, 2026-09-08) -- so /// this is also the test that the transcript is still asking for one. #[test] fn the_list_is_clipped_to_its_own_box() { let (h, screen) = opened(); let active = h.render.active.get(&screen.list.id()).expect("drawn"); assert!( active.mask != MaskIdx::NONE, "the transcript's list is drawn with nothing clipping it", ); let clip = h.render.mask_region(active.mask, &h.rsc); let list = list_box(&h, &screen); assert!( clip.top_left.y >= list.top_left.y - 0.5 && clip.bot_right.y <= list.bot_right.y + 0.5, "the clip {clip:?} reaches outside the list's own box {list:?}, so a row straddling an \ edge still draws past it", ); // And the mask has to *reach* what the rows draw. The two above say a // mask exists and sits in the right place; neither says any primitive // references it, so a broken `Mask::parent` chain -- what d507ae4 // introduced -- would leave them green while a code fence inside a row // drew unclipped again (review, 2026-09-07's T3). let rows = h .render .active .get(&screen.list.id()) .expect("the list is drawn") .children .clone(); let mut checked = 0; for row in rows { for prim in primitives_under(&h, row) { assert!( mask_chain(&h, prim).contains(&active.mask), "a primitive of row {row:?} clips to {:?}, a chain that never reaches the list's \ own mask {:?}", mask_chain(&h, prim), active.mask, ); checked += 1; } } assert!( checked > 0, "no row primitive was checked, so this test asserted nothing", ); } /// Every primitive `id` and its descendants drew, as `MaskIdx`es -- images /// excluded, since they live in a separate instance array with their own /// indices (`Primitives::free`). fn primitives_under(h: &Harness, id: WidgetId) -> Vec { let Some(active) = h.render.active.get(&id) else { return Vec::new(); }; let mut out: Vec = active .primitives .iter() .filter(|p| p.binding != IMAGE_BINDING) .map(|p| h.render.primitives.instance(p.slot).mask_idx) .collect(); for child in &active.children { out.extend(primitives_under(h, *child)); } out } /// The chain the fragment stage walks from `mask`, outermost last. fn mask_chain(h: &Harness, mask: MaskIdx) -> Vec { let mut chain = Vec::new(); let mut at = mask; while at != MaskIdx::NONE { assert!( !chain.contains(&at), "the mask chain from {mask:?} loops back to {at:?}", ); chain.push(at); at = h.rsc.ui.masks[at.idx()].parent; } chain } /// A row that has left the viewport entirely is not drawn at all. Before /// the fix the walk ran from the anchor -- which `scroll` leaves wherever /// it was, however far outside the viewport that ends up -- and drew /// every row on the way: 8 scrolls of 3000px left **64 rows** placed for /// a 2012px viewport, ~59 of them off screen and painting over the /// header. /// /// The box is asserted on every leg *except the first*, because a row /// whose height has never been measured has to be drawn to be measured /// (`LazySpan::place`'s doc), which on the first walk back is every row /// entering from the top. Every later leg crosses the same rows with /// every height already known -- including the second walk *back*, which /// is there because a regression that draws rows in the wrong place while /// travelling backwards would otherwise be checked only by the row count /// (review, 2026-09-07's T2). That is also the ordinary state of a /// transcript being panned around in. The bound on how many rows are /// placed at once holds on all three. #[test] fn rows_that_have_left_the_viewport_are_not_drawn() { let (mut h, screen) = opened(); let list = list_box(&h, &screen); let mut t = PHONE_FRAME_MS * 2; let bounded = |rows: &[(f32, f32)], leg: &str, step: usize| { // A handful of rows whatever distance has been travelled -- the // module doc's own claim about this widget. assert!( rows.len() <= 24, "{leg} {step}: {} rows drawn for one 2012px viewport", rows.len(), ); }; let inside = |rows: &[(f32, f32)], leg: &str, step: usize| { for &(top, bottom) in rows { assert!( bottom > list.top_left.y - 0.5 && top < list.bot_right.y + 0.5, "{leg} {step}: a row at ({top:.1}, {bottom:.1}) is outside the list's box \ {list:?} and was drawn anyway", ); } }; for step in 0..40 { t = scrolled(&mut h, &screen, 400.0, t); bounded(&drawn_rows(&h, &screen), "measuring", step); } for step in 0..40 { t = scrolled(&mut h, &screen, -400.0, t); let rows = drawn_rows(&h, &screen); bounded(&rows, "forward", step); inside(&rows, "forward", step); } for step in 0..40 { t = scrolled(&mut h, &screen, 400.0, t); let rows = drawn_rows(&h, &screen); bounded(&rows, "back", step); inside(&rows, "back", step); } } /// The end the fix had no reason to touch: the row across the *bottom* /// edge, where the composer starts. Same rule, other direction -- and the /// list opens pinned there, so this is the ordinary state of the screen /// rather than a scrolled-to one. #[test] fn the_row_across_the_bottom_edge_is_drawn() { let (mut h, screen) = opened(); let list = list_box(&h, &screen); let mut t = PHONE_FRAME_MS * 2; for _ in 0..40 { t = scrolled(&mut h, &screen, 37.0, t); let rows = drawn_rows(&h, &screen); let last = *rows.last().expect("something is on screen"); assert!( last.1 >= list.bot_right.y - 0.5, "a band of {:.1}px above the composer belongs to no row", list.bot_right.y - last.1, ); assert!( last.0 < list.bot_right.y, "the row across the bottom edge was culled: it starts at {:.1}, below the list's own \ {:.1}", last.0, list.bot_right.y, ); } } /// Panning past the first row settles *on* it rather than beyond it. The /// list is scrolled far further back than the fixture is long, which is /// what a hard fling toward the top does; before the clamp existed it /// stayed wherever that left it -- the phone's "black from the header /// down", and a whole blank screen in `iris`'s own /// `fling_toward_the_start_stops_at_the_first_row`. #[test] fn scrolling_past_the_first_row_settles_on_it() { let (mut h, screen) = opened(); let list = list_box(&h, &screen); let mut t = PHONE_FRAME_MS * 2; for _ in 0..60 { t = scrolled(&mut h, &screen, 100_000.0, t); } // No settling frame on purpose: the draw that discovers the gap gives // it back inside that same frame (`LazySpan::overscroll_gap`), so the last // frame `scrolled` drew is already flush with the first row. Adding // one here would hide a regression to the old next-frame correction. let rows = drawn_rows(&h, &screen); let first = *rows.first().expect("the first row is on screen"); assert!( (first.0 - list.top_left.y).abs() < 0.5, "the transcript is parked {:.1}px past its own first row, so the top of the list is blank", first.0 - list.top_left.y, ); } /// The same clamp at the other end, which is where Iris met it second /// ("you shouldn't be able to scroll below the bottom (or above top)"). /// The list opens flush with its newest row, so this drags *forward* off /// the end of the content and back. #[test] fn scrolling_past_the_last_row_settles_on_it() { let (mut h, screen) = opened(); let list = list_box(&h, &screen); let mut t = PHONE_FRAME_MS * 2; for _ in 0..20 { t = scrolled(&mut h, &screen, -100_000.0, t); } let rows = drawn_rows(&h, &screen); let last = *rows.last().expect("the last row is on screen"); assert!( (last.1 - list.bot_right.y).abs() < 0.5, "the transcript is parked {:.1}px past its own last row, so the bottom of the list is \ blank", list.bot_right.y - last.1, ); }