quick_search/crates/quicksearch-gui/src/tutorial/tests.rs

700 lines
23 KiB
Rust
Raw Normal View History

use super::*;
use crate::test_ui::{click_at, painted, painted_rects, painted_spans, painted_text, raw_input_at};
const SCREEN: egui::Vec2 = egui::vec2(1000.0, 700.0);
/// Deliberately not the real home directory: the folders page's fallback
/// wording is the one every other test should see, whatever machine it runs on.
const ROOT: &str = "/srv/projects";
fn roots() -> Vec<String> {
vec![ROOT.to_string()]
}
/// A tour parked on `page` with that page's arrival already dealt with —
/// what every test about the *rendering* of a page wants.
fn at(page: usize) -> Tutorial {
Tutorial {
page,
shown: Some(page),
typing: None,
moved: false,
}
}
/// A tour about to show `page` for the first time, so entering it still has
/// its tab to ask for and its typing to start.
fn entering(page: usize) -> Tutorial {
Tutorial {
page,
shown: None,
typing: None,
moved: false,
}
}
/// One pass, and what the tour asked for during it.
fn pass(
ctx: &egui::Context,
tour: &mut Tutorial,
events: Vec<egui::Event>,
time: f64,
) -> (egui::FullOutput, TourActions) {
let roots = roots();
let mut actions = TourActions::default();
let out = ctx.run(raw_input_at(SCREEN, events, time), |ctx| {
actions = tour.ui(ctx, &roots);
});
(out, actions)
}
fn merge(a: TourActions, b: TourActions) -> TourActions {
TourActions {
dismissed: a.dismissed || b.dismissed,
goto_tab: b.goto_tab.or(a.goto_tab),
set_query: b.set_query.or(a.set_query),
focus_search: a.focus_search || b.focus_search,
}
}
/// Two passes: an `egui::Window` is measured on its first frame and only
/// placed on the next. The app acts on what every pass returns, so the
/// caller sees both merged.
fn frame(
ctx: &egui::Context,
tour: &mut Tutorial,
events: Vec<egui::Event>,
) -> (egui::FullOutput, TourActions) {
let (_, first) = pass(ctx, tour, Vec::new(), 0.0);
let (out, second) = pass(ctx, tour, events, 0.0);
(out, merge(first, second))
}
#[test]
fn every_page_paints_its_own_title_and_body() {
for (page, spec) in PAGES.iter().enumerate() {
let ctx = crate::test_ui::ctx();
let mut tour = at(page);
let (out, _) = frame(&ctx, &mut tour, Vec::new());
let painted = painted_text(&out);
assert!(
painted.iter().any(|t| t == spec.title),
"page {page} painted no title: {painted:?}"
);
assert!(
!paragraphs(spec, &roots()).is_empty(),
"page {page} has nothing to say"
);
assert!(
painted
.iter()
.any(|t| t == &format!("{} of {}", page + 1, PAGES.len())),
"page {page} did not say where it is: {painted:?}"
);
// The brackets are markup. Painting one means a keyword was not
// recognised, and it is the reader who finds out.
for text in &painted {
assert!(
!text.contains('[') && !text.contains(']'),
"page {page} painted its markup: {text:?}"
);
}
crate::test_ui::assert_no_tofu(&ctx, &out);
}
}
/// The keyword-to-spot pairing is positional, so a page that adds a keyword
/// without a spot (or the reverse) silently starts colouring the wrong things.
#[test]
fn every_keyword_has_a_spot_to_point_at() {
for (page, spec) in PAGES.iter().enumerate() {
let keywords: Vec<&str> = paragraphs(spec, &roots())
.iter()
.flat_map(|p| {
runs(p)
.into_iter()
.filter(|(_, keyword)| *keyword)
.map(|(run, _)| run.to_string())
.collect::<Vec<_>>()
})
.map(|s| Box::leak(s.into_boxed_str()) as &str)
.collect();
assert_eq!(
keywords.len(),
spec.spots.len(),
"page {page} ({}) highlights {keywords:?} but points at {:?}",
spec.title,
spec.spots
);
}
}
#[test]
fn brackets_mark_keywords_and_survive_being_unbalanced() {
assert_eq!(runs("plain text"), vec![("plain text", false)]);
assert_eq!(
runs("add them [here]."),
vec![("add them ", false), ("here", true), (".", false)]
);
assert_eq!(
runs("the [a] and the [b]"),
vec![
("the ", false),
("a", true),
(" and the ", false),
("b", true),
]
);
// Nothing to close it: prose, not markup, and certainly not a panic.
assert_eq!(runs("a [ b"), vec![("a [ b", false)]);
assert_eq!(runs("a ] b"), vec![("a ] b", false)]);
assert_eq!(runs(""), Vec::new());
}
/// The point of the whole exercise: the word and the widget it names are the
/// same colour, and different words are different colours.
#[test]
fn each_keyword_is_painted_in_the_colour_of_the_spot_it_names() {
let input = PAGES
.iter()
.position(|p| p.title == "Search: Input")
.expect("the input page");
let ctx = crate::test_ui::ctx();
let mut tour = at(input);
let (out, _) = frame(&ctx, &mut tour, Vec::new());
let dark_mode = ctx.style().visuals.dark_mode;
let spans = painted_spans(&out);
for (n, keyword) in ["search bar", "?"].into_iter().enumerate() {
let want = accent(n, dark_mode);
assert!(
spans.iter().any(|(t, c)| t == keyword && *c == want),
"{keyword:?} is not painted in {want:?}: {spans:?}"
);
}
assert_ne!(
accent(0, dark_mode),
accent(1, dark_mode),
"two keywords on one page must not share a colour"
);
}
/// Both halves of the effect move together, and they move at all.
#[test]
fn the_keyword_and_its_ring_pulse_in_step() {
let status = PAGES
.iter()
.position(|p| p.title == "The status bar")
.expect("the status bar page");
let target = egui::Rect::from_min_size(egui::pos2(0.0, 660.0), egui::vec2(1000.0, 24.0));
// The tint behind a keyword and the rings around its widget, at one time.
let sample = |time: f64| {
let ctx = crate::test_ui::ctx();
let mut tour = at(status);
let roots = roots();
let mut run = || {
ctx.run(raw_input_at(SCREEN, Vec::new(), time), |ctx| {
crate::spotlight::set_active(ctx, true);
crate::spotlight::mark(ctx, Spot::StatusBar, target);
tour.ui(ctx, &roots);
})
};
run();
let out = run();
let tint = crate::test_ui::painted_backgrounds(&out)
.into_iter()
.find(|(text, _)| text == "status bar")
.map(|(_, color)| color.a())
.expect("the keyword is tinted");
let rings: Vec<u8> = painted_rects(&out)
.into_iter()
.filter(|r| r.stroke.width > 0.0 && r.rect.contains_rect(target))
.map(|r| r.stroke.color.a())
.collect();
(tint, rings)
};
// A quarter period apart: the dimmest sample against the brightest.
let (dim_tint, dim_rings) = sample(PULSE_PERIOD * 0.75);
let (bright_tint, bright_rings) = sample(PULSE_PERIOD * 0.25);
assert!(
!dim_rings.is_empty(),
"the marked widget was not ringed at all"
);
assert_eq!(
dim_rings.len(),
bright_rings.len(),
"the ring count must not depend on the phase"
);
assert!(
bright_tint > dim_tint,
"the keyword's tint does not pulse ({dim_tint} → {bright_tint})"
);
for (dim, bright) in dim_rings.iter().zip(&bright_rings) {
assert!(
bright > dim,
"a ring does not pulse with the keyword ({dim} → {bright})"
);
}
}
/// A spot nothing marked this pass — a hidden column, another tab — is not
/// worth painting a ring in the middle of nowhere for.
#[test]
fn an_unmarked_spot_is_not_ringed() {
let status = PAGES
.iter()
.position(|p| p.title == "The status bar")
.expect("the status bar page");
let ctx = crate::test_ui::ctx();
let mut tour = at(status);
let (out, _) = frame(&ctx, &mut tour, Vec::new());
let dark_mode = ctx.style().visuals.dark_mode;
let ring = accent(0, dark_mode);
assert!(
!painted_rects(&out)
.into_iter()
.any(|r| r.stroke.color.to_opaque() == ring.to_opaque() && r.stroke.width > 0.0),
"something was ringed with nothing to ring"
);
}
#[test]
fn the_folders_page_says_which_folders_those_are() {
// The machine's real home, which is what a default install indexes.
if let Some(home) = quicksearch_core::platform::home_dir() {
let home = home.to_string_lossy().into_owned();
let line = folders_line(std::slice::from_ref(&home));
assert!(
line.contains(&home),
"{line:?} does not name the home folder"
);
assert!(line.contains("[here]"), "{line:?} points nowhere");
// A trailing separator in the config is the same folder.
assert_eq!(folders_line(&[format!("{home}/")]), line);
}
// Anything else: say what is actually indexed rather than claim home is.
let line = folders_line(&[ROOT.to_string()]);
assert!(line.contains(ROOT), "{line:?} does not name the root");
assert!(line.contains("[here]"), "{line:?} points nowhere");
// And with nothing indexed at all, which a reopened tour can find.
let line = folders_line(&[]);
assert!(line.contains("[here]"), "{line:?} points nowhere");
}
#[test]
fn the_folders_page_paints_the_configured_folder() {
let page = PAGES
.iter()
.position(|p| p.lead.is_some())
.expect("the folders page");
let ctx = crate::test_ui::ctx();
let mut tour = at(page);
let (out, _) = frame(&ctx, &mut tour, Vec::new());
let painted = painted_text(&out);
assert!(
painted.iter().any(|t| t.contains(ROOT)),
"the page does not name the folder that is indexed: {painted:?}"
);
}
/// The last page sends the reader to a button; that button has to still be
/// called that.
#[test]
fn the_last_page_names_the_help_tab_button() {
let ctx = crate::test_ui::ctx();
let out = ctx.run(raw_input_at(SCREEN, Vec::new(), 0.0), |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
crate::help_tab::ui(ui);
});
});
let buttons = painted_text(&out);
let last = PAGES.last().expect("pages");
let prose = paragraphs(last, &roots()).join(" ");
assert!(
buttons
.iter()
.any(|label| prose.contains(label.as_str())
&& label.to_lowercase().contains("introduction")),
"the last page does not quote the Help tab's button: {prose:?} vs {buttons:?}"
);
}
/// Entering a page moves to the tab it is about — once. Repeating it every
/// frame would drag back a user who clicked somewhere else mid-page.
#[test]
fn a_page_asks_for_its_tab_once_on_arrival() {
let ctx = crate::test_ui::ctx();
let duplicates = PAGES
.iter()
.position(|p| p.tab == Some(Tab::Duplicates))
.expect("the duplicates page");
let mut tour = entering(duplicates);
let (_, actions) = pass(&ctx, &mut tour, Vec::new(), 0.0);
assert_eq!(actions.goto_tab, Some(Tab::Duplicates));
// The second pass is also where the window, measured on the first, is
// finally placed and its buttons painted.
let (out, actions) = pass(&ctx, &mut tour, Vec::new(), 0.1);
assert_eq!(actions.goto_tab, None, "asked twice for one page");
// Next, then Back: arriving again asks again, or the tour walks forward
// into a page whose subject is not on screen.
let next = crate::test_ui::painted_text_center(&out, "Next").expect("a Next button");
let (_, actions) = pass(&ctx, &mut tour, click_at(next), 0.2);
assert_eq!(tour.page, duplicates + 1);
assert_eq!(actions.goto_tab, None, "the click frame is still this page");
let (out, actions) = pass(&ctx, &mut tour, Vec::new(), 0.3);
assert_eq!(actions.goto_tab, PAGES[duplicates + 1].tab);
let back = crate::test_ui::painted_text_center(&out, "Back").expect("a Back button");
pass(&ctx, &mut tour, click_at(back), 0.4);
let (_, actions) = pass(&ctx, &mut tour, Vec::new(), 0.5);
assert_eq!(tour.page, duplicates);
assert_eq!(
actions.goto_tab,
Some(Tab::Duplicates),
"going back re-asks"
);
}
/// Every page is explicit about the tab it wants, so no page can inherit
/// whatever the last one happened to leave on screen.
#[test]
fn every_page_names_the_tab_it_belongs_on() {
for (page, spec) in PAGES.iter().enumerate() {
assert!(
spec.tab.is_some(),
"page {page} ({}) does not say where it should be read",
spec.title
);
}
}
#[test]
fn the_input_page_types_the_query_one_character_at_a_time() {
let input = PAGES
.iter()
.position(|p| p.type_query.is_some())
.expect("a page that types");
let want = PAGES[input].type_query.expect("checked above");
let ctx = crate::test_ui::ctx();
let mut tour = entering(input);
let (_, actions) = pass(&ctx, &mut tour, Vec::new(), 0.0);
assert_eq!(
actions.set_query.as_deref(),
Some(""),
"typing starts from an empty box"
);
assert!(actions.focus_search, "the caret belongs in the box");
// Sampled far finer than one character per step, so a burst that skipped
// to the end would show up as a jump.
let mut seen: Vec<String> = Vec::new();
let mut time = 0.0;
while time < TYPE_INTERVAL * (want.chars().count() as f64 + 2.0) {
time += TYPE_INTERVAL / 3.0;
let (_, actions) = pass(&ctx, &mut tour, Vec::new(), time);
if let Some(query) = actions.set_query {
seen.push(query);
}
}
let expected: Vec<String> = (1..=want.chars().count())
.map(|n| want.chars().take(n).collect())
.collect();
assert_eq!(seen, expected, "not typed one character at a time");
// And then it stops: `seed` re-arms the debounce, so a page that kept
// re-sending the finished query would hold the search off forever.
let (_, actions) = pass(&ctx, &mut tour, Vec::new(), time + 5.0);
assert_eq!(
actions.set_query, None,
"still typing after the last letter"
);
}
#[test]
fn leaving_the_typing_page_and_returning_types_it_again() {
let input = PAGES
.iter()
.position(|p| p.type_query.is_some())
.expect("a page that types");
let ctx = crate::test_ui::ctx();
let mut tour = entering(input);
pass(&ctx, &mut tour, Vec::new(), 0.0);
pass(&ctx, &mut tour, Vec::new(), 10.0);
assert!(tour.typing.is_some());
// Off the page and back on.
tour.page = input + 1;
pass(&ctx, &mut tour, Vec::new(), 10.1);
assert!(tour.typing.is_none(), "the typing outlived its page");
tour.page = input;
let (_, actions) = pass(&ctx, &mut tour, Vec::new(), 10.2);
assert_eq!(actions.set_query.as_deref(), Some(""), "it starts over");
}
#[test]
fn dismissing_returns_to_search_with_an_empty_box() {
for page in [0, PAGES.len() - 1] {
let ctx = crate::test_ui::ctx();
let mut tour = at(page);
let (out, _) = frame(&ctx, &mut tour, Vec::new());
let skip = crate::test_ui::painted_text_center(&out, "Skip").expect("a Skip button");
let (_, actions) = pass(&ctx, &mut tour, click_at(skip), 1.0);
assert!(actions.dismissed, "page {page} would not skip");
assert_eq!(actions.goto_tab, Some(Tab::Search));
assert_eq!(
actions.set_query.as_deref(),
Some(""),
"the demonstration query is not the user's"
);
}
}
/// The window is the one thing on screen that can be in the way of what the
/// tour is pointing at, so it has to be movable.
#[test]
fn the_window_can_be_dragged_out_of_the_way() {
let ctx = crate::test_ui::ctx();
let mut tour = at(0);
let title = PAGES[0].title;
let (out, _) = frame(&ctx, &mut tour, Vec::new());
let where_is = |out: &egui::FullOutput| {
painted(out)
.into_iter()
.find(|(text, _)| text == title)
.map(|(_, rect)| rect.min)
.expect("the title is painted")
};
let before = where_is(&out);
let grab = crate::test_ui::painted_text_center(&out, title).expect("the title bar");
let delta = egui::vec2(-180.0, -120.0);
let button = |pos, pressed| egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
};
pass(
&ctx,
&mut tour,
vec![egui::Event::PointerMoved(grab), button(grab, true)],
0.0,
);
pass(
&ctx,
&mut tour,
vec![egui::Event::PointerMoved(grab + delta)],
0.1,
);
let (out, _) = pass(&ctx, &mut tour, vec![button(grab + delta, false)], 0.2);
let after = where_is(&out);
assert!(
(after - before - delta).length() < 2.0,
"dragged by {delta:?} but the window went from {before:?} to {after:?}"
);
// And it stays there when the page changes, rather than snapping back.
// Measured on the window itself: the next page is a different width, so
// where its title starts says nothing about where the window is.
let centre = |ctx: &egui::Context| {
ctx.memory(|m| m.area_rect(egui::Id::new(WINDOW_ID)))
.expect("the tour's window")
.center()
};
let dragged_to = centre(&ctx);
let next = crate::test_ui::painted_text_center(&out, "Next").expect("a Next button");
pass(&ctx, &mut tour, click_at(next), 0.3);
pass(&ctx, &mut tour, Vec::new(), 0.4);
assert!(
(centre(&ctx) - dragged_to).length() < 2.0,
"the next page put the window back at {:?}, not {dragged_to:?}",
centre(&ctx)
);
}
/// The y of the footer row, probed down the middle column (only Skip
/// occupies it): wrapped text height moves the row from page to page.
fn footer_y(ctx: &egui::Context, page: usize) -> f32 {
for y in (100..650).step_by(2) {
let mut t = at(page);
let (_, actions) = frame(ctx, &mut t, click_at(egui::pos2(500.0, y as f32)));
if actions.dismissed {
return y as f32;
}
}
panic!("no Skip button down the middle of page {page}");
}
/// The stretch of x one button answers a click on.
#[derive(Debug, Clone, Copy)]
struct Span {
lo: f32,
hi: f32,
}
impl Span {
fn width(&self) -> f32 {
self.hi - self.lo
}
}
/// The three footer buttons, found by what clicking each one does. Must
/// run on a middle page: on the last, Finish and Skip both dismiss
/// without moving; on the first, Back is disabled.
fn footer_spans(ctx: &egui::Context, page: usize) -> [Option<Span>; 3] {
assert!(page > 0 && page + 1 < PAGES.len(), "probe a middle page");
let y = footer_y(ctx, page);
let mut spans: [Option<Span>; 3] = [None; 3];
for x in 150..850 {
let mut t = at(page);
let (_, actions) = frame(ctx, &mut t, click_at(egui::pos2(x as f32, y)));
let x = x as f32;
let which = if actions.dismissed {
1 // Skip
} else if t.page + 1 == page {
0 // Back
} else if t.page == page + 1 {
2 // Next
} else {
continue;
};
match &mut spans[which] {
Some(span) => span.hi = x,
slot => *slot = Some(Span { lo: x, hi: x }),
}
}
spans
}
/// `Ui::columns` lays a column out justified, so a button dropped
/// straight into one comes out as wide as the whole third — which is
/// what Back was until it was given a layout of its own.
#[test]
fn the_footer_runs_back_then_skip_then_next_at_the_same_size() {
let ctx = crate::test_ui::ctx();
let [back, skip, next] = footer_spans(&ctx, 1);
let back = back.expect("no Back button in the footer");
let skip = skip.expect("no Skip button in the footer");
let next = next.expect("no Next button in the footer");
assert!(
back.lo < skip.lo,
"Back ({back:?}) is not left of Skip ({skip:?})"
);
assert!(
skip.lo < next.lo,
"Skip ({skip:?}) is not left of Next ({next:?})"
);
assert!(
(back.width() - next.width()).abs() <= 2.0,
"Back is {} wide against Next's {}",
back.width(),
next.width()
);
// A stretched button fills its third, which no four-letter label does.
assert!(
back.width() < 80.0,
"Back is stretched to {} points",
back.width()
);
}
#[test]
fn the_first_page_cannot_go_back() {
let ctx = crate::test_ui::ctx();
let y = footer_y(&ctx, 0);
for x in (150..850).step_by(4) {
let mut t = at(0);
let _ = frame(&ctx, &mut t, click_at(egui::pos2(x as f32, y)));
assert!(
t.page == 0 || t.page == 1,
"clicking x={x} left page {}",
t.page
);
}
}
fn sweep(ctx: &egui::Context, page: usize) -> Vec<usize> {
let mut seen = Vec::new();
for y in (150..560).step_by(4) {
for x in (240..760).step_by(8) {
let mut t = at(page);
let (_, actions) = frame(ctx, &mut t, click_at(egui::pos2(x as f32, y as f32)));
if actions.dismissed {
seen.push(t.page);
}
}
}
seen
}
#[test]
fn skip_and_finish_both_dismiss() {
let ctx = crate::test_ui::ctx();
// Skip is on every page.
assert!(
!sweep(&ctx, 0).is_empty(),
"Skip never fired on the first page"
);
// Finish only on the last, where it replaces Next.
let last = PAGES.len() - 1;
assert!(
!sweep(&ctx, last).is_empty(),
"Finish never fired on the last page"
);
let ctx = crate::test_ui::ctx();
let mut tour = at(last);
let (out, _) = frame(&ctx, &mut tour, Vec::new());
assert!(
painted_text(&out).contains(&"Finish".to_string()),
"the last page still offers Next"
);
}
/// The window is measured on its first frame, and on that frame the viewport
/// is not necessarily the size it settles at — the real app opened the tour
/// pinned to the bottom-right corner because a position picked once was
/// picked against the wrong screen. Until the user drags it, it belongs in
/// the middle of whatever the window currently is.
#[test]
fn the_window_centres_itself_on_the_window_it_is_in() {
let ctx = crate::test_ui::ctx();
let mut tour = at(0);
let roots = roots();
let mut run = |size: egui::Vec2| {
let _ = ctx.run(raw_input_at(size, Vec::new(), 0.0), |ctx| {
tour.ui(ctx, &roots);
});
let rect = ctx
.memory(|m| m.area_rect(egui::Id::new(WINDOW_ID)))
.expect("the tour's window");
(
rect.center(),
egui::Rect::from_min_size(egui::Pos2::ZERO, size).center(),
)
};
// Measured against a viewport it never sees again.
run(egui::vec2(1920.0, 1080.0));
for _ in 0..3 {
let (window, screen) = run(SCREEN);
assert!(
(window - screen).length() < 2.0,
"the window sits at {window:?} in a screen centred on {screen:?}"
);
}
}