use proptest::prelude::*;
use proptest::test_runner::FileFailurePersistence;
use smudge_core::geometry::{Point, Size};
use smudge_core::input::InputEvent;
use smudge_core::reactive::{RwSignal, prelude::*};
use smudge_core::style::px;
use smudge_core::widget::panel;
use smudge_test::Harness;
use smudge_text::{TextSystem, label};
use smudge_widgets::{ScrollExt, column, scroll, virtual_list};
const ROW_HEIGHT: f32 = 24.0;
const VIEWPORT_HEIGHT: f32 = 480.0;
const WINDOW: Size = Size {
width: 640.0,
height: 480.0,
};
const POINTER: Point = Point { x: 320.0, y: 240.0 };
const DEFAULT_CASES: u32 = 64;
fn config() -> ProptestConfig {
ProptestConfig {
cases: DEFAULT_CASES,
failure_persistence: Some(Box::new(FileFailurePersistence::Direct(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/proptest-regressions/virtual_list.txt"
)))),
..ProptestConfig::default()
}
}
fn list_harness(count: RwSignal<usize>) -> Harness {
let bound = count.clone();
Harness::new(WINDOW.width, WINDOW.height, 1.0, move || {
panel().column().child(
virtual_list(
move || bound.get(),
ROW_HEIGHT,
|index| label(format!("Row {index}")),
)
.height(px(VIEWPORT_HEIGHT)),
)
})
}
fn full_list_harness(count: usize, offset: f32) -> Harness {
Harness::new(WINDOW.width, WINDOW.height, 1.0, move || {
let rows = (0..count).fold(column(), |rows, index| {
rows.child(
panel()
.height(px(ROW_HEIGHT))
.child(label(format!("Row {index}"))),
)
});
panel().column().child(
scroll(rows)
.offset(RwSignal::new(offset))
.height(px(VIEWPORT_HEIGHT)),
)
})
}
fn wheel(harness: &mut Harness, down: f32) {
harness.event(InputEvent::Wheel {
delta: Point { x: 0.0, y: down },
});
}
fn apply_edit(edit: &Edit, count: &RwSignal<usize>, harness: &mut Harness) {
match *edit {
Edit::Wheel(down) => wheel(harness, down),
Edit::Count(rows) => count.set(rows),
}
harness.frame();
assert_settled(harness);
harness.assert_matches_full_repaint();
settle(harness);
}
#[derive(Debug, Clone)]
enum Edit {
Wheel(f32),
Count(usize),
}
fn edit_strategy() -> impl Strategy<Value = Edit> {
prop_oneof![
3 => (-600.0f32..6_000.0).prop_map(Edit::Wheel),
1 => (0usize..400usize).prop_map(Edit::Count),
]
}
fn assert_settled(harness: &mut Harness) {
assert!(
!harness.app().needs_frame(),
"the frame left work pending, so the screen would not be settled \
until another frame ran"
);
}
fn settle(harness: &mut Harness) {
for _ in 0..4 {
if !harness.woken() {
return;
}
harness.frame();
assert_settled(harness);
harness.assert_matches_full_repaint();
}
panic!("the list still woke the app after four frames, so it never settles");
}
fn label_rows(harness: &Harness) -> Vec<f32> {
harness
.layout_dump()
.lines()
.filter_map(|line| {
let start = line.find("Label [")?;
let numbers = &line[start + "Label [".len()..line.len() - 1];
numbers.split(", ").nth(1)?.parse().ok()
})
.collect()
}
fn mounted_indices(harness: &Harness, offset: f32) -> Vec<usize> {
let mut indices: Vec<usize> = label_rows(harness)
.into_iter()
.map(|y0| ((y0 + offset) / ROW_HEIGHT).round() as usize)
.collect();
indices.sort_unstable();
indices
}
#[test]
fn a_hundred_thousand_rows_mount_a_bounded_window() {
TextSystem::bundled_only().install();
let count = RwSignal::new(100_000);
let mut harness = list_harness(count.clone());
harness.frame();
harness.settle();
let mounted = label_rows(&harness).len();
assert!(mounted <= 28, "the top of the list mounted {mounted} rows");
harness.move_to(POINTER);
wheel(&mut harness, 600_000.0);
harness.frame();
harness.settle();
wheel(&mut harness, 600_000.0);
harness.frame();
harness.settle();
let mounted = label_rows(&harness).len();
assert!(
mounted <= 28,
"the middle of the list mounted {mounted} rows"
);
harness.assert_matches_full_repaint();
}
#[test]
fn scrolling_to_the_middle_mounts_the_rows_around_it() {
TextSystem::bundled_only().install();
let count = RwSignal::new(100_000);
let mut harness = list_harness(count.clone());
harness.frame();
harness.settle();
harness.move_to(POINTER);
wheel(&mut harness, 600_000.0);
harness.frame();
harness.settle();
wheel(&mut harness, 600_000.0);
harness.frame();
harness.settle();
let expected: Vec<usize> = (49_996..50_024).collect();
assert_eq!(
mounted_indices(&harness, 1_200_000.0),
expected,
"the middle of the list mounted the wrong rows"
);
harness.assert_matches_full_repaint();
}
#[test]
fn a_scroll_inside_one_row_moves_the_rows_without_remounting() {
TextSystem::bundled_only().install();
let count = RwSignal::new(100_000);
let mut harness = list_harness(count.clone());
harness.frame();
harness.settle();
harness.move_to(POINTER);
wheel(&mut harness, 10.0);
harness.frame();
harness.settle();
let before = mounted_indices(&harness, 10.0);
wheel(&mut harness, 10.0);
let report = harness.frame();
assert_eq!(
mounted_indices(&harness, 20.0),
before,
"a scroll inside one row changed the mounted window"
);
let rows = label_rows(&harness);
assert_eq!(rows.first().copied(), Some(-20.0), "the rows moved up");
assert!(
!report.layout_ran,
"a scroll that mounts nothing needs no layout pass"
);
harness.settle();
harness.assert_matches_full_repaint();
}
#[test]
fn a_shrinking_count_clamps_the_offset_and_shows_the_first_rows() {
TextSystem::bundled_only().install();
let count = RwSignal::new(100_000);
let mut harness = list_harness(count.clone());
harness.frame();
harness.settle();
harness.move_to(POINTER);
wheel(&mut harness, 1_000_000_000.0);
harness.frame();
harness.settle();
assert_eq!(
mounted_indices(&harness, 2399520.0),
(99_976..100_000).collect::<Vec<usize>>(),
"the end of the list mounted the wrong rows"
);
count.set(10);
harness.frame();
harness.settle();
assert_eq!(
mounted_indices(&harness, 0.0),
(0..10).collect::<Vec<usize>>(),
"the shrunken list clamped to the top and shows rows 0 to 9"
);
let first_row = label_rows(&harness).first().copied();
assert_eq!(first_row, Some(0.0), "the first row sits at the top");
harness.assert_matches_full_repaint();
}
#[test]
fn a_row_height_that_is_not_positive_mounts_no_rows() {
TextSystem::bundled_only().install();
for row_height in [-24.0, f32::NAN, 0.0] {
let mut harness = Harness::new(WINDOW.width, WINDOW.height, 1.0, move || {
panel().column().child(
virtual_list(
|| 100_000,
row_height,
|index| label(format!("Row {index}")),
)
.height(px(VIEWPORT_HEIGHT)),
)
});
harness.frame();
harness.settle();
assert_eq!(
label_rows(&harness).len(),
0,
"a row height of {row_height} mounted rows"
);
}
}
proptest! {
#![proptest_config(config())]
#[test]
fn damaged_frames_match_full_repaints_and_the_full_list(edits in prop::collection::vec(edit_strategy(), 1..=8)) {
TextSystem::bundled_only().install();
let count = RwSignal::new(300usize);
let mut harness = list_harness(count.clone());
harness.frame();
assert_settled(&mut harness);
harness.assert_matches_full_repaint();
settle(&mut harness);
harness.move_to(POINTER);
let mut offset = 0.0f32;
for edit in &edits {
apply_edit(edit, &count, &mut harness);
let max = (count.get() as f32 * ROW_HEIGHT - VIEWPORT_HEIGHT).max(0.0);
offset = match *edit {
Edit::Wheel(down) => (offset + down).clamp(0.0, max),
Edit::Count(_) => offset.clamp(0.0, max),
};
}
let mut full = full_list_harness(count.get(), offset);
full.frame();
full.settle();
harness.assert_matches(&full);
}
}
#[test]
fn a_growing_list_in_a_column_takes_the_space_left_and_mounts_only_what_shows() {
TextSystem::bundled_only().install();
let mut harness = Harness::new(WINDOW.width, WINDOW.height, 1.0, move || {
panel()
.column()
.height(px(VIEWPORT_HEIGHT))
.child(panel().height(px(40.0)))
.child(
virtual_list(|| 10_000, ROW_HEIGHT, |index| label(format!("Row {index}")))
.grow(1.0),
)
});
harness.frame();
harness.settle();
let mounted = label_rows(&harness).len();
let visible = ((VIEWPORT_HEIGHT - 40.0) / ROW_HEIGHT).ceil() as usize;
assert!(
mounted <= visible + 8,
"a list that grows in a column lays out at the height left to it, \
not at its content's height, so it mounts about {visible} rows, \
not {mounted}"
);
}