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 { 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, 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::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::>() }) .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 = 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 = 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 = (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; 3] { assert!(page > 0 && page + 1 < PAGES.len(), "probe a middle page"); let y = footer_y(ctx, page); let mut spans: [Option; 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 { 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:?}" ); } }