Make the Rust client the sole app
This commit is contained in:
1 parent
a8602c1626
commit
d8bb1699a8
230 files changed
+762
-27300
No files matched your search
@@ -0,0 +1,166 @@
|
||||
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;
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
const STEP: f32 = 2.0;
|
||||
const SAMPLES: usize = 3;
|
||||
const CATCH_X: f32 = 540.0;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[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::<String>::new(),
|
||||
"a catch is not a tap: nothing under it may be followed"
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::{Harness, TouchAction};
|
||||
use iris::prelude::*;
|
||||
|
||||
fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> {
|
||||
let render_handle = h.rsc.ui.render_state();
|
||||
let render_state = render_handle.get();
|
||||
render_state
|
||||
.active
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&id| {
|
||||
h.rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_dyn(id)
|
||||
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||
.is_some_and(|s| s.axis() == Axis::X)
|
||||
})
|
||||
.find_map(|id| {
|
||||
let r = render_state.window_region(&id, &h.rsc)?;
|
||||
(r.top_left.y >= top && r.bot_right.y <= bottom).then_some((id, r))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_scrolling(h: &Harness, id: WidgetId) -> bool {
|
||||
h.rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_dyn(id)
|
||||
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||
.expect("the fence's scroll area is still drawn")
|
||||
.is_scrolling()
|
||||
}
|
||||
|
||||
fn amt(h: &Harness, id: WidgetId) -> f32 {
|
||||
h.rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_dyn(id)
|
||||
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||
.expect("the fence's scroll area is still drawn")
|
||||
.amt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
|
||||
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||
|
||||
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");
|
||||
let mut screen = opened.screen;
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
|
||||
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||
seq: 9_000_000,
|
||||
text: "```\none two three four five six seven eight nine ten eleven twelve \
|
||||
thirteen fourteen fifteen sixteen seventeen eighteen twenty twentyone\n```"
|
||||
.to_string(),
|
||||
settled: true,
|
||||
});
|
||||
screen.push_row(&mut h.rsc, &fence);
|
||||
(screen.list)(&mut h.rsc).jump_to_end();
|
||||
h.frame(100);
|
||||
h.frame(108);
|
||||
|
||||
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 (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
|
||||
.expect("the pushed fence draws a horizontal scroll area of its own");
|
||||
assert_eq!(amt(&h, fence_scroll), 0.0, "a fence opens at its start");
|
||||
|
||||
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
|
||||
|
||||
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
|
||||
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
|
||||
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
||||
}
|
||||
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
|
||||
|
||||
let at_release = amt(&h, fence_scroll);
|
||||
assert!(
|
||||
at_release > 0.0,
|
||||
"the flick itself must have panned the fence, got {at_release}"
|
||||
);
|
||||
|
||||
let mut t = 240;
|
||||
while t <= 740 {
|
||||
h.frame(t);
|
||||
t += PHONE_FRAME_MS;
|
||||
}
|
||||
let coasted = amt(&h, fence_scroll);
|
||||
assert!(
|
||||
coasted > at_release + 1.0,
|
||||
"the fence stopped dead at the release: {at_release} -> {coasted}"
|
||||
);
|
||||
|
||||
let settled = coasted;
|
||||
while t <= 4_000 {
|
||||
h.frame(t);
|
||||
t += PHONE_FRAME_MS;
|
||||
}
|
||||
let after = amt(&h, fence_scroll);
|
||||
assert!(
|
||||
after >= settled,
|
||||
"a fling must not run backwards: {settled} -> {after}"
|
||||
);
|
||||
let last = after;
|
||||
h.frame(t);
|
||||
assert_eq!(last, amt(&h, fence_scroll), "the fling never settled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting() {
|
||||
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||
|
||||
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");
|
||||
let mut screen = opened.screen;
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
|
||||
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||
seq: 9_000_000,
|
||||
text: format!(
|
||||
"```\n{}\n```",
|
||||
(1..=200)
|
||||
.map(|i| format!("word{i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
),
|
||||
settled: true,
|
||||
});
|
||||
screen.push_row(&mut h.rsc, &fence);
|
||||
(screen.list)(&mut h.rsc).jump_to_end();
|
||||
h.frame(100);
|
||||
h.frame(108);
|
||||
|
||||
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 (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
|
||||
.expect("the pushed fence draws a horizontal scroll area of its own");
|
||||
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
|
||||
|
||||
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
|
||||
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
|
||||
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
||||
}
|
||||
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
|
||||
h.frame(248);
|
||||
assert!(
|
||||
is_scrolling(&h, fence_scroll),
|
||||
"the fence has to still be coasting for this to be the reported case",
|
||||
);
|
||||
|
||||
let probe = box_.top_left.y - 500.0;
|
||||
let row = (screen.list)(&mut h.rsc)
|
||||
.key_at(probe)
|
||||
.expect("a row that far up the screen");
|
||||
let (row_top, row_bottom) = (screen.list)(&mut h.rsc).extent(row).expect("its extent");
|
||||
let from = (row_top + row_bottom) / 2.0;
|
||||
|
||||
let list_before = (screen.list)(&mut h.rsc).anchor_position_display();
|
||||
let fence_before = amt(&h, fence_scroll);
|
||||
h.touch(TouchAction::Down, Vec2::new(540.0, from), 256);
|
||||
let mut t = 264;
|
||||
for i in 1..=8 {
|
||||
h.touch(
|
||||
TouchAction::Move,
|
||||
Vec2::new(540.0, from + 20.0 * i as f32),
|
||||
t,
|
||||
);
|
||||
t += 8;
|
||||
}
|
||||
h.touch(TouchAction::Up, Vec2::new(540.0, from + 160.0), t);
|
||||
|
||||
assert_ne!(
|
||||
list_before,
|
||||
(screen.list)(&mut h.rsc).anchor_position_display(),
|
||||
"the drag was nowhere near the fence, so it belongs to the list",
|
||||
);
|
||||
assert!(
|
||||
amt(&h, fence_scroll) > fence_before,
|
||||
"the fence's fling must carry on through a gesture that was never \
|
||||
its own: {fence_before} -> {}",
|
||||
amt(&h, fence_scroll),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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}"))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_ended_by_a_cancel_leaves_no_origin_for_the_next_one() {
|
||||
let (mut h, screen) = opened();
|
||||
|
||||
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);
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panning_a_code_fence_then_tapping_elsewhere_moves_nothing() {
|
||||
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||
|
||||
let (mut h, mut 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,
|
||||
});
|
||||
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);
|
||||
|
||||
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}"
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::{Harness, TouchScript};
|
||||
|
||||
struct CaptureLogger {
|
||||
lines: Mutex<Vec<(log::Level, String)>>,
|
||||
}
|
||||
|
||||
static LOGGER: OnceLock<CaptureLogger> = OnceLock::new();
|
||||
|
||||
impl log::Log for CaptureLogger {
|
||||
fn enabled(&self, _metadata: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
fn log(&self, record: &log::Record) {
|
||||
self.lines
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((record.level(), record.args().to_string()));
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
fn logger() -> &'static CaptureLogger {
|
||||
let logger = LOGGER.get_or_init(|| CaptureLogger {
|
||||
lines: Mutex::new(Vec::new()),
|
||||
});
|
||||
let _ = log::set_logger(logger);
|
||||
log::set_max_level(log::LevelFilter::Debug);
|
||||
logger
|
||||
}
|
||||
|
||||
fn drain(logger: &CaptureLogger) -> Vec<(log::Level, String)> {
|
||||
std::mem::take(&mut *logger.lines.lock().unwrap())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracing_is_silent_off_and_round_trips_the_flick_on() {
|
||||
let logger = logger();
|
||||
|
||||
iris::diagnostics::set_trace(false);
|
||||
drain(logger); // whatever `opened()` itself logged while building
|
||||
let (mut h, screen) = opened();
|
||||
drain(logger); // and whatever opening logged
|
||||
let flick = TouchScript::parse(include_str!("../touch/flick-120hz.touch"))
|
||||
.unwrap_or_else(|e| panic!("flick-120hz.touch: {e}"));
|
||||
h.replay(&flick);
|
||||
let _ = (screen.list)(&mut h.rsc); // touch the screen the same way a real caller would
|
||||
let quiet = drain(logger);
|
||||
let debug_lines: Vec<_> = quiet
|
||||
.iter()
|
||||
.filter(|(level, _)| *level == log::Level::Debug)
|
||||
.collect();
|
||||
assert!(
|
||||
debug_lines.is_empty(),
|
||||
"tracing is off, but the ring would still have held these `debug!` lines: {debug_lines:#?}"
|
||||
);
|
||||
|
||||
iris::diagnostics::set_trace(true);
|
||||
let (mut h, screen) = opened();
|
||||
drain(logger);
|
||||
h.replay(&flick);
|
||||
let _ = (screen.list)(&mut h.rsc);
|
||||
let traced = drain(logger);
|
||||
iris::diagnostics::set_trace(false); // leave it off for any test after this one
|
||||
|
||||
let input_lines: Vec<&str> = traced
|
||||
.iter()
|
||||
.filter(|(_, msg)| msg.contains("iris input: action="))
|
||||
.map(|(_, msg)| msg.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
input_lines.len(),
|
||||
flick.samples.len(),
|
||||
"expected one `iris::input` line per replayed sample, got:\n{input_lines:#?}"
|
||||
);
|
||||
let frame_lines: Vec<&str> = traced
|
||||
.iter()
|
||||
.filter(|(_, msg)| msg.starts_with("iris frame:"))
|
||||
.map(|(_, msg)| msg.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
!frame_lines.is_empty(),
|
||||
"expected at least one `iris::frame` line once tracing was on"
|
||||
);
|
||||
for line in &frame_lines {
|
||||
assert!(
|
||||
!line.contains("layout=0ns"),
|
||||
"a frame that redrew should not report zero layout time: {line}"
|
||||
);
|
||||
}
|
||||
|
||||
let report = input_lines.join("\n");
|
||||
let script_path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../iris/benches/report_to_touch.py"
|
||||
);
|
||||
let mut child = Command::new("python3")
|
||||
.arg(script_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("python3 must be on PATH to run report_to_touch.py");
|
||||
{
|
||||
use std::io::Write;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(report.as_bytes())
|
||||
.unwrap();
|
||||
}
|
||||
let output = child.wait_with_output().expect("report_to_touch.py exited");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"report_to_touch.py failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let touch_text = String::from_utf8(output.stdout).expect("report_to_touch.py wrote UTF-8");
|
||||
let round_tripped =
|
||||
TouchScript::parse(&touch_text).unwrap_or_else(|e| panic!("round-tripped script: {e}"));
|
||||
|
||||
assert_eq!(
|
||||
round_tripped.samples.len(),
|
||||
flick.samples.len(),
|
||||
"round trip produced a different number of samples:\n{touch_text}"
|
||||
);
|
||||
for (original, back) in flick.samples.iter().zip(round_tripped.samples.iter()) {
|
||||
assert_eq!(original.t_ms, back.t_ms);
|
||||
assert_eq!(original.action, back.action);
|
||||
assert_eq!(original.pos, back.pos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
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::<String>::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::<String>::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.edit(&mut h.rsc).set_cursor_byte(0);
|
||||
|
||||
for text in ["h", "i", " "] {
|
||||
screen.composer.field.edit(&mut h.rsc).insert(text);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
}
|
||||
|
||||
assert_eq!(screen.composer.field.edit(&mut h.rsc).text.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.edit(&mut h.rsc).set_cursor_byte(0);
|
||||
for _ in 0..12 {
|
||||
screen.composer.field.edit(&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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::Harness;
|
||||
use iris::prelude::*;
|
||||
|
||||
const HEADER_H: f32 = 300.0;
|
||||
const HEADER: Srgba8 = Srgba8::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(&mut h.rsc, root);
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
(h, opened.screen)
|
||||
}
|
||||
|
||||
fn list_box(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> PixelRegion {
|
||||
h.rsc
|
||||
.ui
|
||||
.render_state()
|
||||
.get()
|
||||
.window_region(&screen.list.id(), &h.rsc)
|
||||
.expect("the list is on screen")
|
||||
}
|
||||
|
||||
fn drawn_rows(h: &Harness, screen: &ai_app::ui::TranscriptScreen) -> Vec<(f32, f32)> {
|
||||
let render_handle = h.rsc.ui.render_state();
|
||||
let render_state = render_handle.get();
|
||||
let mut rows: Vec<(f32, f32)> = render_state
|
||||
.active
|
||||
.get(&screen.list.id())
|
||||
.expect("the list is drawn")
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|id| render_state.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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_is_clipped_to_its_own_box() {
|
||||
let (h, screen) = opened();
|
||||
let render_handle = h.rsc.ui.render_state();
|
||||
let render_state = render_handle.get();
|
||||
let active = render_state.active.get(&screen.list.id()).expect("drawn");
|
||||
assert!(
|
||||
active.mask != MaskIdx::NONE,
|
||||
"the transcript's list is drawn with nothing clipping it",
|
||||
);
|
||||
let clip = render_state.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",
|
||||
);
|
||||
|
||||
let rows = render_state
|
||||
.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",
|
||||
);
|
||||
}
|
||||
|
||||
fn primitives_under(h: &Harness, id: WidgetId) -> Vec<MaskIdx> {
|
||||
let render_handle = h.rsc.ui.render_state();
|
||||
let render_state = render_handle.get();
|
||||
let Some(active) = render_state.active.get(&id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<MaskIdx> = active
|
||||
.primitives
|
||||
.iter()
|
||||
.filter(|p| p.binding != IMAGE_BINDING)
|
||||
.map(|p| render_state.primitives.instance(p.slot).mask_idx)
|
||||
.collect();
|
||||
for child in &active.children {
|
||||
out.extend(primitives_under(h, *child));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn mask_chain(h: &Harness, mask: MaskIdx) -> Vec<MaskIdx> {
|
||||
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
|
||||
}
|
||||
|
||||
#[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| {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
#[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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user