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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "transcript-fixture"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"client-core",
|
||||
"event-model",
|
||||
"iris",
|
||||
"serde_json",
|
||||
"transcript-ui",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "transcript-ui"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ name = "message_list"
|
||||
harness = false
|
||||
|
||||
[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
|
||||
# -- excluded so `cargo build --workspace --all-targets` on the host stays
|
||||
# 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 event;
|
||||
pub mod harness;
|
||||
pub mod platform;
|
||||
pub mod sense;
|
||||
pub mod state;
|
||||
|
||||
+23
-3
@@ -259,7 +259,16 @@ pub struct List {
|
||||
struct Fling {
|
||||
calc: FlingCalculator,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -474,7 +483,7 @@ impl List {
|
||||
self.fling = Some(Fling {
|
||||
calc: FlingCalculator::new(self.density),
|
||||
velocity: velocity_px_per_s,
|
||||
started_at: Instant::now(),
|
||||
started_at: None,
|
||||
applied: 0.0,
|
||||
});
|
||||
}
|
||||
@@ -487,6 +496,17 @@ impl List {
|
||||
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
|
||||
/// touch-down's job, per `fling`'s own doc.
|
||||
pub fn cancel_fling(&mut self) {
|
||||
@@ -508,7 +528,7 @@ impl List {
|
||||
let Some(f) = &mut self.fling else {
|
||||
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 delta = target - f.applied;
|
||||
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