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,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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user