iris: a headless in-process harness, and the bench fixture as a shared crate
Layer 1 of docs/RUST.md's "Three test layers": `iris::harness` opens a real screen with no window, no compositor and no GPU, on an explicit clock and a replayed touch stream -- a trivial `t_ms action x y` file, so the batched 120Hz flick shape from Iris's phone report is reproducible as a test. The emulator cannot produce that shape at all: a `ui-trace` swipe is many evenly-spaced events, a finger is five samples in 20ms. `transcript-fixture` is the fixture-loading and fold-driving half of `iris-android-app`'s `bench_client.rs`, moved out of the platform crate so the harness, a desktop window and the Android bench open the same screen from the same bytes (AGENTS.md's sharing rule). Two supporting changes in iris itself, both about reading a clock that was not handed in: `Fling::started_at` is now set on the first `tick_fling` rather than at the release, so a driver running frames on its own clock does not start every fling at the wall clock and advance it on a different one; and `List::fling_velocity` exposes what the release measured, which is where `Released(Some(v))` lands. Four tests, each confirmed to fail without its subject: dropping `animate(id)` from `Selection::drag` (the phone's own "fling does nothing" defect) and reverting `started_at` each fail the flick test alone; flinging on `Tapped` fails only the tap test; a 5s `LONG_PRESS` fails only the selection test; a `set_bottom_inset` that ignores its argument fails only the composer/IME test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
7f4ea7e8fd
commit
333220196e
11 files changed
+809
-4
No files matched your search
Generated
+12
@@ -3646,6 +3646,18 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "transcript-fixture"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"client-core",
|
||||||
|
"event-model",
|
||||||
|
"iris",
|
||||||
|
"serde_json",
|
||||||
|
"transcript-ui",
|
||||||
|
"winit",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "transcript-ui"
|
name = "transcript-ui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
+1
-1
@@ -89,7 +89,7 @@ name = "message_list"
|
|||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["core", "macro", "tabs-ui", "transcript-ui", "desktop-app"]
|
members = ["core", "macro", "tabs-ui", "transcript-ui", "transcript-fixture", "desktop-app"]
|
||||||
# android-app pulls in android-view, which needs the NDK sysroot to link
|
# android-app pulls in android-view, which needs the NDK sysroot to link
|
||||||
# -- excluded so `cargo build --workspace --all-targets` on the host stays
|
# -- excluded so `cargo build --workspace --all-targets` on the host stays
|
||||||
# buildable. Cross-compile it from its own directory (its own single-crate
|
# buildable. Cross-compile it from its own directory (its own single-crate
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
//! Layer 1 of docs/RUST.md's "Three test layers": a whole screen driven
|
||||||
|
//! in-process with **no window, no compositor and no GPU**, on an
|
||||||
|
//! explicit clock and a replayed touch stream.
|
||||||
|
//!
|
||||||
|
//! `layout_tests.rs` and `sense_tests.rs` already build trees over
|
||||||
|
//! `UiRenderState` with a hand-rolled `Rsc` each; this is the same idea
|
||||||
|
//! carried far enough to open a real app screen (`transcript-ui`'s, over
|
||||||
|
//! the bench fixture -- see the `transcript-fixture` crate) at the
|
||||||
|
//! phone's size and density, feed it a recorded flick, and assert on
|
||||||
|
//! where the list ended up. What it answers that the emulator cannot:
|
||||||
|
//! Android batches a 120Hz flick into one or two `MotionEvent`s
|
||||||
|
//! (`CursorState::time`), and a `ui-trace` swipe is many evenly-spaced
|
||||||
|
//! ones -- so the gesture shape a finger actually makes is only
|
||||||
|
//! reproducible from a *file* of timestamped samples.
|
||||||
|
//!
|
||||||
|
//! It is a third backend in the sense `default/` and `android/` are, and
|
||||||
|
//! deliberately the smallest one: the platform half of each of those
|
||||||
|
//! (a surface, an IME, a URL opener) becomes a recorded fact here --
|
||||||
|
//! [`HarnessState::keyboard_shown`], [`HarnessState::opened_urls`] --
|
||||||
|
//! so a test can assert the platform *was asked*, which is the only
|
||||||
|
//! thing either backend does with those calls anyway.
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
//! let screen = transcript_ui::build(&mut h.rsc, &mut h.state, rows);
|
||||||
|
//! h.frame(0);
|
||||||
|
//! h.replay(&TouchScript::parse(include_str!("flick.touch"))?);
|
||||||
|
//! h.frames_until(20, 2_000, 8);
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// One replayed pointer sample: what Android's `MotionEvent` carries, cut
|
||||||
|
/// down to the part iris reads (`IrisViewPeer::on_touch_event`).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum TouchAction {
|
||||||
|
Down,
|
||||||
|
Move,
|
||||||
|
Up,
|
||||||
|
/// The gesture taken away by the system (a parent view claiming it, a
|
||||||
|
/// call arriving). It ends the press exactly as `Up` does -- a
|
||||||
|
/// release that never arrives leaves pointer capture held forever --
|
||||||
|
/// which is why a replay file can say it.
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TouchAction {
|
||||||
|
fn parse(word: &str) -> Option<Self> {
|
||||||
|
match word {
|
||||||
|
"down" => Some(Self::Down),
|
||||||
|
"move" => Some(Self::Move),
|
||||||
|
"up" => Some(Self::Up),
|
||||||
|
"cancel" => Some(Self::Cancel),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct TouchSample {
|
||||||
|
/// Milliseconds since the start of the recording -- the sample's own
|
||||||
|
/// time, which becomes `CursorState::time`. See that field's doc for
|
||||||
|
/// why a replay may not date its samples by when the loop got to
|
||||||
|
/// them.
|
||||||
|
pub t_ms: u64,
|
||||||
|
pub action: TouchAction,
|
||||||
|
pub pos: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recorded gesture: one `t_ms action x y` line per sample, `#` and
|
||||||
|
/// blank lines ignored. Deliberately a plain text file rather than a
|
||||||
|
/// serialisation format -- it is written by hand as often as it is
|
||||||
|
/// recorded, and a diff of one has to be readable.
|
||||||
|
pub struct TouchScript {
|
||||||
|
pub samples: Vec<TouchSample>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TouchScript {
|
||||||
|
/// Parses a script, naming the line and what was wrong with it: these
|
||||||
|
/// are hand-written files, so a typo is the ordinary case and
|
||||||
|
/// "expected 4 fields" without a line number is not enough to fix it.
|
||||||
|
pub fn parse(text: &str) -> Result<Self, String> {
|
||||||
|
let mut samples: Vec<TouchSample> = Vec::new();
|
||||||
|
for (i, line) in text.lines().enumerate() {
|
||||||
|
let line = line.split('#').next().unwrap_or("").trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1);
|
||||||
|
let mut words = line.split_whitespace();
|
||||||
|
let (Some(t), Some(action), Some(x), Some(y), None) = (
|
||||||
|
words.next(),
|
||||||
|
words.next(),
|
||||||
|
words.next(),
|
||||||
|
words.next(),
|
||||||
|
words.next(),
|
||||||
|
) else {
|
||||||
|
return Err(at("expected `t_ms action x y`"));
|
||||||
|
};
|
||||||
|
let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?;
|
||||||
|
let action = TouchAction::parse(action)
|
||||||
|
.ok_or_else(|| at("action is not down/move/up/cancel"))?;
|
||||||
|
let x: f32 = x.parse().map_err(|_| at("x is not a number"))?;
|
||||||
|
let y: f32 = y.parse().map_err(|_| at("y is not a number"))?;
|
||||||
|
if let Some(last) = samples.last()
|
||||||
|
&& t_ms < last.t_ms
|
||||||
|
{
|
||||||
|
return Err(at("samples must be in time order"));
|
||||||
|
}
|
||||||
|
samples.push(TouchSample {
|
||||||
|
t_ms,
|
||||||
|
action,
|
||||||
|
pos: Vec2::new(x, y),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self { samples })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last sample's time, i.e. how long the recording runs.
|
||||||
|
pub fn end_ms(&self) -> u64 {
|
||||||
|
self.samples.last().map(|s| s.t_ms).unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts the frames something asked for without drawing any -- the
|
||||||
|
/// harness's `RequestRedraw`. A `List` coasting through a fling asks for
|
||||||
|
/// the next frame through this (`List::set_redraw_handle`), so a test can
|
||||||
|
/// tell "nothing moved" from "nothing was even asked to move".
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct RedrawCounter(AtomicUsize);
|
||||||
|
|
||||||
|
impl RedrawCounter {
|
||||||
|
pub fn count(&self) -> usize {
|
||||||
|
self.0.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestRedraw for RedrawCounter {
|
||||||
|
fn request_redraw(&self) {
|
||||||
|
self.0.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The harness's app state: what each real backend keeps for the platform
|
||||||
|
/// half, recorded instead of performed.
|
||||||
|
pub struct HarnessState {
|
||||||
|
pub root: Option<StrongWidget>,
|
||||||
|
pub focus: Option<WeakWidget<TextEdit>>,
|
||||||
|
last_click: Instant,
|
||||||
|
/// How many times a tap asked for the keyboard (`FocusHost::
|
||||||
|
/// focus_gained` with a region -- `showSoftInput` on Android,
|
||||||
|
/// `set_ime_cursor_area` on winit). The platform's own answer is not
|
||||||
|
/// available here, so this says what was *asked*, and a test must not
|
||||||
|
/// read it as "the IME is up".
|
||||||
|
pub keyboard_shown: usize,
|
||||||
|
/// Every URL a tapped link asked the platform to open, in order.
|
||||||
|
pub opened_urls: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HarnessState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
root: None,
|
||||||
|
focus: None,
|
||||||
|
last_click: Instant::now(),
|
||||||
|
keyboard_shown: 0,
|
||||||
|
opened_urls: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasRoot for HarnessState {
|
||||||
|
fn set_root(&mut self, root: StrongWidget) {
|
||||||
|
self.root = Some(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FocusHost for HarnessState {
|
||||||
|
fn recent_click(&mut self) -> bool {
|
||||||
|
crate::attr::recent_click(&mut self.last_click)
|
||||||
|
}
|
||||||
|
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
|
||||||
|
self.focus = id;
|
||||||
|
}
|
||||||
|
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||||
|
self.focus == Some(id)
|
||||||
|
}
|
||||||
|
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
||||||
|
if region.is_some() {
|
||||||
|
self.keyboard_shown += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenUrl for HarnessState {
|
||||||
|
fn open_url(&mut self, url: &str) {
|
||||||
|
self.opened_urls.push(url.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The harness's `Rsc` -- identical in substance to `DefaultRsc`/
|
||||||
|
/// `AndroidRsc` minus the windowing, for the same reason those two are
|
||||||
|
/// separate types (`AndroidRsc`'s own doc).
|
||||||
|
pub struct HarnessRsc {
|
||||||
|
pub ui: UiData,
|
||||||
|
pub events: EventManager<Self>,
|
||||||
|
pub tasks: Tasks<Self>,
|
||||||
|
pub state: WidgetState,
|
||||||
|
_state: PhantomData<HarnessState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiRsc for HarnessRsc {
|
||||||
|
fn ui(&self) -> &UiData {
|
||||||
|
&self.ui
|
||||||
|
}
|
||||||
|
fn ui_mut(&mut self) -> &mut UiData {
|
||||||
|
&mut self.ui
|
||||||
|
}
|
||||||
|
fn on_draw(&mut self, active: &ActiveData) {
|
||||||
|
self.events.draw(active);
|
||||||
|
}
|
||||||
|
fn on_undraw(&mut self, active: &ActiveData) {
|
||||||
|
self.events.undraw(active);
|
||||||
|
}
|
||||||
|
fn on_remove(&mut self, id: WidgetId) {
|
||||||
|
self.events.remove(id);
|
||||||
|
self.state.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasState for HarnessRsc {
|
||||||
|
type State = HarnessState;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasEvents for HarnessRsc {
|
||||||
|
fn events(&self) -> &EventManager<Self> {
|
||||||
|
&self.events
|
||||||
|
}
|
||||||
|
fn events_mut(&mut self) -> &mut EventManager<Self> {
|
||||||
|
&mut self.events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasTasks for HarnessRsc {
|
||||||
|
fn tasks_mut(&mut self) -> &mut Tasks<Self> {
|
||||||
|
&mut self.tasks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasWidgetState for HarnessRsc {
|
||||||
|
fn widget_state(&self) -> &WidgetState {
|
||||||
|
&self.state
|
||||||
|
}
|
||||||
|
fn widget_state_mut(&mut self) -> &mut WidgetState {
|
||||||
|
&mut self.state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<I: RscIdx<HarnessRsc>> std::ops::Index<I> for HarnessRsc {
|
||||||
|
type Output = I::Output;
|
||||||
|
fn index(&self, index: I) -> &Self::Output {
|
||||||
|
index.get(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<I: RscIdx<HarnessRsc>> std::ops::IndexMut<I> for HarnessRsc {
|
||||||
|
fn index_mut(&mut self, index: I) -> &mut Self::Output {
|
||||||
|
index.get_mut(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A screen running with no window: the widget tree, the frame loop and
|
||||||
|
/// the pointer, all advanced by the caller. See the module doc.
|
||||||
|
pub struct Harness {
|
||||||
|
pub rsc: HarnessRsc,
|
||||||
|
pub render: UiRenderState,
|
||||||
|
pub state: HarnessState,
|
||||||
|
task_recv: TaskMsgReceiver<HarnessRsc>,
|
||||||
|
redraws: Arc<RedrawCounter>,
|
||||||
|
cursor: CursorState,
|
||||||
|
/// Time zero. Every `t_ms` in this harness is an offset from here, so
|
||||||
|
/// nothing reads the wall clock -- see [`Self::at`].
|
||||||
|
base: Instant,
|
||||||
|
size: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Harness {
|
||||||
|
/// `size` is in physical pixels and `density` is physical pixels per
|
||||||
|
/// dp, the pair Android reads from the surface and
|
||||||
|
/// `DisplayMetrics.density` (`AndroidUiState::content_scale`). The
|
||||||
|
/// phone's own numbers are `transcript_fixture::PHONE_SIZE`/
|
||||||
|
/// `PHONE_SCALE`.
|
||||||
|
pub fn new(size: Vec2, density: f32) -> Self {
|
||||||
|
let redraws = Arc::new(RedrawCounter::default());
|
||||||
|
let (tasks, task_recv) = Tasks::init(redraws.clone());
|
||||||
|
let mut rsc = HarnessRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
tasks,
|
||||||
|
state: WidgetState::default(),
|
||||||
|
_state: PhantomData,
|
||||||
|
};
|
||||||
|
rsc.ui.text.density = density;
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.set_density(density);
|
||||||
|
render.resize(size);
|
||||||
|
Self {
|
||||||
|
rsc,
|
||||||
|
render,
|
||||||
|
state: HarnessState::new(),
|
||||||
|
task_recv,
|
||||||
|
redraws,
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
base: Instant::now(),
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `Instant` this harness means by `t_ms`. Public because a
|
||||||
|
/// caller driving `List::tick_fling` or `DragGesture` by hand needs
|
||||||
|
/// to date those calls on the same clock the touch samples use.
|
||||||
|
pub fn at(&self, t_ms: u64) -> Instant {
|
||||||
|
self.base + Duration::from_millis(t_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn size(&self) -> Vec2 {
|
||||||
|
self.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many frames were asked for so far -- see [`RedrawCounter`].
|
||||||
|
pub fn redraws(&self) -> usize {
|
||||||
|
self.redraws.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One frame at `t_ms`: drain finished tasks, advance anything
|
||||||
|
/// animating, lay out and "draw". The same three steps
|
||||||
|
/// `DefaultApp::window_event`'s `RedrawRequested` arm and
|
||||||
|
/// `IrisViewPeer::render` take, minus handing primitives to a GPU.
|
||||||
|
pub fn frame(&mut self, t_ms: u64) {
|
||||||
|
while let Ok(update) = self.task_recv.try_recv() {
|
||||||
|
update(&mut self.state, &mut self.rsc);
|
||||||
|
}
|
||||||
|
let now = self.at(t_ms);
|
||||||
|
self.rsc.ui.tick_animations(now);
|
||||||
|
self.render.update(&self.state.root, &mut self.rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frames every `step_ms` up to and including `end_ms` -- what a
|
||||||
|
/// fling needs, since it moves only while something ticks it
|
||||||
|
/// (`List::fling`'s doc). Returns the time of the last frame run.
|
||||||
|
pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 {
|
||||||
|
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
|
||||||
|
let mut t = from_ms;
|
||||||
|
while t <= end_ms {
|
||||||
|
self.frame(t);
|
||||||
|
t += step_ms;
|
||||||
|
}
|
||||||
|
t - step_ms
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pointer sample through the sensors, then the frame it belongs
|
||||||
|
/// to -- `IrisViewPeer::on_touch_event` and `after_input`, in one
|
||||||
|
/// call. Each sample is its own input frame, dated by the sample
|
||||||
|
/// rather than by when this ran.
|
||||||
|
pub fn touch(&mut self, action: TouchAction, pos: Vec2, t_ms: u64) {
|
||||||
|
self.cursor.time = self.at(t_ms);
|
||||||
|
self.cursor.pos = pos;
|
||||||
|
match action {
|
||||||
|
TouchAction::Down => {
|
||||||
|
self.cursor.exists = true;
|
||||||
|
self.cursor.buttons.left.update(true);
|
||||||
|
}
|
||||||
|
TouchAction::Move => {}
|
||||||
|
TouchAction::Up | TouchAction::Cancel => self.cursor.buttons.left.update(false),
|
||||||
|
}
|
||||||
|
let cursor = self.cursor.clone();
|
||||||
|
self.render
|
||||||
|
.run_sensors(&mut self.rsc, &mut self.state, cursor, self.size);
|
||||||
|
self.frame(t_ms);
|
||||||
|
self.cursor.end_frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replays a whole recorded gesture. Nothing is inserted between the
|
||||||
|
/// samples: a file with three lines produces three input frames, so
|
||||||
|
/// the batched shape a real flick arrives in is preserved exactly as
|
||||||
|
/// recorded rather than smoothed into evenly-spaced motion.
|
||||||
|
pub fn replay(&mut self, script: &TouchScript) {
|
||||||
|
for sample in &script.samples {
|
||||||
|
self.touch(sample.action, sample.pos, sample.t_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ pub mod default;
|
|||||||
|
|
||||||
pub mod attr;
|
pub mod attr;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
pub mod harness;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod sense;
|
pub mod sense;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|||||||
+23
-3
@@ -259,7 +259,16 @@ pub struct List {
|
|||||||
struct Fling {
|
struct Fling {
|
||||||
calc: FlingCalculator,
|
calc: FlingCalculator,
|
||||||
velocity: f32,
|
velocity: f32,
|
||||||
started_at: Instant,
|
/// When the fling's own curve begins -- **the first `tick_fling`,
|
||||||
|
/// not the release**. It is set there rather than in `fling` so the
|
||||||
|
/// only clock this widget reads is the one its driver hands it: a
|
||||||
|
/// caller running frames on an explicit clock (`iris::harness`, and
|
||||||
|
/// `bench_client.rs`'s scripted phases) would otherwise start every
|
||||||
|
/// fling at the wall clock and advance it on a different one, and a
|
||||||
|
/// fling released at t=500ms would arrive already over. The
|
||||||
|
/// difference in a running app is at most one frame, since that is
|
||||||
|
/// how soon the fling is first ticked.
|
||||||
|
started_at: Option<Instant>,
|
||||||
applied: f32,
|
applied: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,7 +483,7 @@ impl List {
|
|||||||
self.fling = Some(Fling {
|
self.fling = Some(Fling {
|
||||||
calc: FlingCalculator::new(self.density),
|
calc: FlingCalculator::new(self.density),
|
||||||
velocity: velocity_px_per_s,
|
velocity: velocity_px_per_s,
|
||||||
started_at: Instant::now(),
|
started_at: None,
|
||||||
applied: 0.0,
|
applied: 0.0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -487,6 +496,17 @@ impl List {
|
|||||||
self.fling.is_some()
|
self.fling.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The velocity a fling in progress is coasting at, in this list's
|
||||||
|
/// own pixel space -- `None` when nothing is flinging. What a
|
||||||
|
/// release's decision looks like from the outside: a
|
||||||
|
/// `GestureOutcome::Released(Some(v))` is the only thing that puts a
|
||||||
|
/// value here, so a test (or a diagnostic) can read what the gesture
|
||||||
|
/// measured at the place it landed, rather than re-timing the
|
||||||
|
/// gesture itself.
|
||||||
|
pub fn fling_velocity(&self) -> Option<f32> {
|
||||||
|
self.fling.as_ref().map(|f| f.velocity)
|
||||||
|
}
|
||||||
|
|
||||||
/// Cancel any fling in progress with no further movement -- the next
|
/// Cancel any fling in progress with no further movement -- the next
|
||||||
/// touch-down's job, per `fling`'s own doc.
|
/// touch-down's job, per `fling`'s own doc.
|
||||||
pub fn cancel_fling(&mut self) {
|
pub fn cancel_fling(&mut self) {
|
||||||
@@ -508,7 +528,7 @@ impl List {
|
|||||||
let Some(f) = &mut self.fling else {
|
let Some(f) = &mut self.fling else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let elapsed = now.saturating_duration_since(f.started_at);
|
let elapsed = now.saturating_duration_since(*f.started_at.get_or_insert(now));
|
||||||
let target = f.calc.position_at(f.velocity, elapsed);
|
let target = f.calc.position_at(f.velocity, elapsed);
|
||||||
let delta = target - f.applied;
|
let delta = target - f.applied;
|
||||||
f.applied = target;
|
f.applied = target;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "transcript-fixture"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
# The bench fixture, opened as a real transcript screen with no server --
|
||||||
|
# docs/RUST.md's "Three test layers". It was `iris-android-app`'s
|
||||||
|
# `bench_client.rs` alone until 2026-09-07; the fixture-loading and
|
||||||
|
# fold-driving half moved here so the headless harness (layer 1), the
|
||||||
|
# phone-shaped desktop window (layer 2) and the Android bench (layer 3)
|
||||||
|
# all open the *same* screen from the same bytes, per AGENTS.md's rule
|
||||||
|
# that nothing UI-shaped lives in a platform crate.
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
iris = { path = ".." }
|
||||||
|
transcript-ui = { path = "../transcript-ui" }
|
||||||
|
client-core = { path = "../../client-core" }
|
||||||
|
event-model = { path = "../../event-model" }
|
||||||
|
# `float_roundtrip` for the same reason `server/Cargo.toml` has it: a `ts`
|
||||||
|
# read back must be the one that was written (AGENTS.md).
|
||||||
|
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
winit = { workspace = true }
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! The checked-in bench fixture, opened as a real transcript screen with
|
||||||
|
//! no server -- shared by every layer of docs/RUST.md's test rig.
|
||||||
|
//!
|
||||||
|
//! The bytes are `app/bench-fixture/assets/transcript.jsonl` (1,915,760
|
||||||
|
//! bytes, generated by `app/bench-fixture/generate.py`, never a real
|
||||||
|
//! transcript -- that file's own README), embedded with `include_str!`.
|
||||||
|
//! The first [`BACKLOG_COUNT`] non-blank lines are the opening window,
|
||||||
|
//! folded once through `client_core::transcript_fold::fold_page` exactly
|
||||||
|
//! as a real `/transcript` page would be; the rest are the streaming
|
||||||
|
//! tail, replayed one at a time through `fold_event` the way a live SSE
|
||||||
|
//! frame arrives.
|
||||||
|
//!
|
||||||
|
//! This half used to live in `iris-android-app`'s `bench_client.rs`, and
|
||||||
|
//! moved here on 2026-09-07 so the headless harness and a desktop window
|
||||||
|
//! open the same screen from the same bytes (AGENTS.md: nothing
|
||||||
|
//! UI-shaped in a platform crate). What stayed there is the JNI half --
|
||||||
|
//! the clipboard, the battery sampler, the IME calls and the report.
|
||||||
|
|
||||||
|
use client_core::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs};
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are
|
||||||
|
/// the opening window; the rest are the streaming tail. Kept in sync with
|
||||||
|
/// `BenchFixture.kt`'s identical constant by hand -- both read the same
|
||||||
|
/// checked-in file, so a mismatch would only mean the two apps' bench
|
||||||
|
/// builds open a different split of it, not a wrong-vs-right answer.
|
||||||
|
pub const BACKLOG_COUNT: usize = 3200;
|
||||||
|
|
||||||
|
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
|
||||||
|
|
||||||
|
/// Iris's phone as `docs/bench/iris-phone-v2-2026-09-06.md` and
|
||||||
|
/// `docs/IRIS_TODO.md` record it: a 1080x2424 surface at
|
||||||
|
/// `content_scale: 2.55`, 120Hz. Read from those reports, never typed
|
||||||
|
/// from memory -- every layer of the rig lays out at this size and
|
||||||
|
/// density so a screenshot and a headless assertion are about the same
|
||||||
|
/// screen.
|
||||||
|
pub const PHONE_WIDTH: f32 = 1080.0;
|
||||||
|
pub const PHONE_HEIGHT: f32 = 2424.0;
|
||||||
|
pub const PHONE_SCALE: f32 = 2.55;
|
||||||
|
/// 120Hz, the refresh rate that report ran at: 8.3ms a frame.
|
||||||
|
pub const PHONE_FRAME_MS: u64 = 8;
|
||||||
|
|
||||||
|
pub fn phone_size() -> Vec2 {
|
||||||
|
Vec2::new(PHONE_WIDTH, PHONE_HEIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixture split the way the wire delivers it: raw JSON values for
|
||||||
|
/// the opening page (`fold_page` takes a page of wire JSON, same as a
|
||||||
|
/// real `/transcript` response) and parsed `SeqEvent`s for the tail
|
||||||
|
/// (`fold_event` takes one live event at a time, same as an SSE frame).
|
||||||
|
pub struct Fixture {
|
||||||
|
pub backlog: Vec<serde_json::Value>,
|
||||||
|
pub stream_tail: Vec<SeqEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
/// Parses the whole fixture. Panics on malformed input: this is a
|
||||||
|
/// generated file compiled into the binary, so a parse failure is a
|
||||||
|
/// broken build rather than a condition a caller could recover from
|
||||||
|
/// (CODE_RULES: separate recoverable conditions from programmer
|
||||||
|
/// error).
|
||||||
|
pub fn parse() -> Self {
|
||||||
|
let mut backlog = Vec::with_capacity(BACKLOG_COUNT);
|
||||||
|
let mut stream_tail = Vec::new();
|
||||||
|
for (i, line) in FIXTURE_JSONL
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.trim().is_empty())
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
|
||||||
|
if i < BACKLOG_COUNT {
|
||||||
|
backlog.push(value);
|
||||||
|
} else {
|
||||||
|
stream_tail.push(
|
||||||
|
serde_json::from_value(value)
|
||||||
|
.expect("bench fixture event matches event-model's SeqEvent"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
backlog,
|
||||||
|
stream_tail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The opening page folded into transcript items -- the same
|
||||||
|
/// `fold_page` a real first load runs. `Err` carries the fold's own
|
||||||
|
/// message, which a caller shows on screen rather than panicking, so
|
||||||
|
/// a fixture that stops folding is visible in the app instead of
|
||||||
|
/// being a crash on launch.
|
||||||
|
pub fn backlog_items(&self) -> Result<Vec<TranscriptItem>, String> {
|
||||||
|
fold_page(&self.backlog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixture's opening page as the rows a screen is built from.
|
||||||
|
pub fn rows(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
|
||||||
|
group_tool_runs(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the transcript screen over the fixture's opening page and make
|
||||||
|
/// it the root -- what every layer of the rig opens. Returns the screen
|
||||||
|
/// and the items behind it, so a caller can go on streaming the tail
|
||||||
|
/// through `fold_event`/`TranscriptScreen::apply` as the Android bench
|
||||||
|
/// does.
|
||||||
|
pub fn open<Rsc: HasEvents>(
|
||||||
|
rsc: &mut Rsc,
|
||||||
|
ui_state: &mut impl HasRoot,
|
||||||
|
) -> Result<(transcript_ui::TranscriptScreen, Vec<TranscriptItem>), String>
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost + OpenUrl,
|
||||||
|
{
|
||||||
|
let fixture = Fixture::parse();
|
||||||
|
let items = fixture.backlog_items()?;
|
||||||
|
let screen = transcript_ui::build(rsc, ui_state, rows(&items));
|
||||||
|
Ok((screen, items))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The split is what both bench clients assume; a fixture that
|
||||||
|
/// stopped having a streaming tail would make the Android bench's
|
||||||
|
/// stream phase silently measure nothing.
|
||||||
|
#[test]
|
||||||
|
fn the_fixture_has_a_backlog_and_a_streaming_tail() {
|
||||||
|
let fixture = Fixture::parse();
|
||||||
|
assert_eq!(fixture.backlog.len(), BACKLOG_COUNT);
|
||||||
|
assert!(
|
||||||
|
fixture.stream_tail.len() >= 400,
|
||||||
|
"the stream phase replays 400 events; the fixture has {}",
|
||||||
|
fixture.stream_tail.len()
|
||||||
|
);
|
||||||
|
assert!(!fixture.backlog_items().expect("the page folds").is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
//! 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 iris::harness::{Harness, TouchScript};
|
||||||
|
use iris::prelude::*;
|
||||||
|
use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||||
|
|
||||||
|
/// The screen open on the fixture, framed twice: once to draw, once for
|
||||||
|
/// `List::repair_anchor` to resolve the opening `snap_end` into a real
|
||||||
|
/// anchor, which is what every assertion about scroll position reads.
|
||||||
|
fn opened() -> (Harness, transcript_ui::TranscriptScreen) {
|
||||||
|
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
let (screen, _items) =
|
||||||
|
transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||||
|
h.frame(0);
|
||||||
|
h.frame(PHONE_FRAME_MS);
|
||||||
|
(h, screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn script(name: &str, text: &str) -> TouchScript {
|
||||||
|
TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offset(h: &mut Harness, screen: &transcript_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 `List::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");
|
||||||
|
assert!(
|
||||||
|
velocity.abs() > 1_000.0,
|
||||||
|
"a 188px, 16ms flick is thousands of px/s; got {velocity}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Android's own spline says how long a fling at this speed runs. The
|
||||||
|
// list learns its density from the painter, so this is the same
|
||||||
|
// curve it is using.
|
||||||
|
let expected = FlingCalculator::new(PHONE_SCALE).duration(velocity);
|
||||||
|
let end = flick.end_ms() + expected.as_millis() as u64 * 2;
|
||||||
|
let mut settled_at = None;
|
||||||
|
let mut t = flick.end_ms();
|
||||||
|
while t <= end {
|
||||||
|
h.frame(t);
|
||||||
|
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 <= expected.as_millis() as u64 + PHONE_FRAME_MS * 2,
|
||||||
|
"the fling ran {ran_for}ms against the spline's own {}ms",
|
||||||
|
expected.as_millis()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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::<String>::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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# A finger flick the shape Iris's phone delivers one, from
|
||||||
|
# docs/bench/iris-phone-v2-2026-09-06.md and docs/IRIS_TODO.md's
|
||||||
|
# "From the phone, 2026-09-06, 22:16": at 120Hz a flick reaches the app
|
||||||
|
# as DOWN, one or two MOVEs and UP inside a few frames, with the
|
||||||
|
# intermediate positions batched inside those MOVEs as historical
|
||||||
|
# samples (~4ms apart, the touch digitiser's own rate) rather than
|
||||||
|
# arriving as separate events. Each line here is one such sample, which
|
||||||
|
# is exactly what `IrisViewPeer::on_touch_event` replays through the
|
||||||
|
# sensors one at a time -- so the whole gesture is 20ms and five
|
||||||
|
# samples, and the velocity has to come out of *those*.
|
||||||
|
#
|
||||||
|
# Downward (increasing y) on purpose: the screen opens pinned to the
|
||||||
|
# newest end, so a flick the other way has nothing left to scroll to and
|
||||||
|
# the fling clamps on its first tick -- a pass that would prove nothing.
|
||||||
|
# Coordinates are physical pixels on a 1080x2424 surface.
|
||||||
|
0 down 540 1000
|
||||||
|
4 move 540 1040
|
||||||
|
8 move 540 1086
|
||||||
|
12 move 540 1138
|
||||||
|
16 move 540 1196
|
||||||
|
20 up 540 1196
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# A long-press then a drag across the text: held past LONG_PRESS
|
||||||
|
# (500ms) without moving, which is what starts a selection rather than a
|
||||||
|
# pan, then dragged sideways so the selection actually covers
|
||||||
|
# something. A press alone leaves a collapsed caret and no selected
|
||||||
|
# text (`Selection::begin`), which is why this file does not stop at the
|
||||||
|
# hold.
|
||||||
|
0 down 300 1000
|
||||||
|
520 move 300 1000
|
||||||
|
560 move 700 1000
|
||||||
|
600 move 900 1000
|
||||||
|
640 up 900 1000
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# The case the flick had no reason to touch: a press and release in one
|
||||||
|
# place, well inside DRAG_SLOP and well under LONG_PRESS. It must be a
|
||||||
|
# tap -- no pan, no velocity, nothing moved.
|
||||||
|
0 down 540 1000
|
||||||
|
80 up 540 1000
|
||||||
Reference in new issue
Block a user