//! Drives the launcher UI against a fake backend: what's shown for each status, and which //! commands the buttons send. `ARTCRAFT_LAUNCHER_SNAPSHOTS=` also saves screenshots; run that //! with `-- --test-threads=1`, as rendering on several threads at once can crash Vulkan loaders //! older than 1.4.350 (KhronosGroup/Vulkan-Loader#1863). use std::cell::{Cell, RefCell}; use std::rc::Rc; use std::sync::OnceLock; use artcraft_launcher_core::details::AppDetails; use artcraft_launcher_core::protocol::{ Backend, CANCELLED, Capabilities, Command, Event, INSTALL_CANCELLED, JobPhase, LatestRelease, Origin, PausedDownload, Source, }; use artcraft_launcher_core::release::{Asset, Release}; use artcraft_launcher_core::status::InstallSource; use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version}; use artcraft_launcher_ui_egui::settings::ViewMode; use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; const NOW: i64 = 1_791_500_000; /// How the fake backend answers a request for an app's details pictures, whenever it is polled. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum Pictures { /// Every picture arrives. #[default] Online, /// Every picture fails, as with no connection. Offline, /// Nothing is answered yet: the requests wait until the mode changes. Silent, /// The second picture of each page fails (not published), the others arrive. SecondFails, } /// A small JPEG, made here so that no picture is kept in the repository for the tests. fn test_jpeg() -> Vec { static JPEG: OnceLock> = OnceLock::new(); JPEG.get_or_init(|| { let (w, h) = (320u32, 200u32); let rgb: Vec = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 255 / w) as u8, (y * 255 / h) as u8, 160])).collect(); let mut out = Vec::new(); image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80).encode(&rgb, w, h, image::ExtendedColorType::Rgb8).unwrap(); out }) .clone() } struct Fake { events: Vec, sent: Rc>>, manage: bool, pictures: Rc>, /// Apps whose pictures were asked for and not answered yet. asked: Vec, clock: Rc>, } impl Backend for Fake { fn send(&mut self, command: Command) { if let Command::FetchScreenshots { app } = &command { self.asked.push(app.clone()); } self.sent.borrow_mut().push(command); } fn poll(&mut self) -> Vec { let mut out = std::mem::take(&mut self.events); if self.pictures.get() != Pictures::Silent { for app in std::mem::take(&mut self.asked) { let Some(page) = AppDetails::by_id(&app) else { continue }; for (i, shot) in page.shots.iter().enumerate() { let result = match self.pictures.get() { Pictures::Offline => Err("couldn't reach raw.githubusercontent.com (no connection)".to_string()), Pictures::SecondFails if i == 1 => Err("this picture isn't published".to_string()), _ => Ok(test_jpeg()), }; out.push(Event::Screenshot { app: app.clone(), key: shot.key.to_string(), result }); } } } out } fn capabilities(&self) -> Capabilities { Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage } } fn platform(&self) -> Option { Some(Platform { os: Os::MacOs, arch: Arch::Arm64 }) } fn now(&self) -> i64 { self.clock.get() } } struct NoHost; impl Host for NoHost {} fn release(id: &str, v: &str, at: i64) -> Release { let name = format!("{id}-{v}-macos-universal.dmg"); Release { tag: format!("v{v}"), version: version::parse(v), name: format!("v{v}"), published_at: Some(at), html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"), notes: "## What's Changed\n* Faster things by @a in https://github.com/storytold/x/pull/1".into(), prerelease: false, assets: vec![Asset { name: name.clone(), size: 150 << 20, url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"), sha256: None, }], } } /// A realistic mix: updates, up-to-date apps, a download in progress, one app with no release. fn events() -> Vec { let mut ev = Vec::new(); let versions = [ ("photocraft", "0.5.0"), ("vectorcraft", "0.7.0"), ("filmcraft", "0.4.0"), ("lightcraft", "0.4.0"), ("pdfcraft", "0.4.0"), ("effectcraft", "0.6.0"), ("designcraft", "0.4.0"), ("wordcraft", "0.3.0"), ("deckcraft", "0.3.0"), ("gridcraft", "0.3.0"), ("cadcraft", "0.3.0"), ]; for (i, (id, v)) in versions.iter().enumerate() { ev.push(Event::Latest { app: (*id).into(), latest: LatestRelease::Found(release(id, v, NOW - 86_400 - i as i64 * 600)), from: Origin::ArtCraftApi, }); } ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None, from: Origin::ArtCraftApi }); let installed = [("photocraft", "0.4.1"), ("lightcraft", "0.4.0"), ("wordcraft", "0.3.0"), ("effectcraft", "0.5.0")]; for app in &SUITE { let inst = installed.iter().find(|(id, _)| *id == app.id).map(|(_, v)| Installed { version: version::parse(v), source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, }); ev.push(Event::Detected { app: app.id.into(), installed: inst }); } ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Downloading { done: 48 << 20, total: 131 << 20 } }); ev.push(Event::Paused { app: "deckcraft".into(), download: Some(PausedDownload { done: 30 << 20, total: 120 << 20 }) }); ev.push(Event::Progress { app: "gridcraft".into(), phase: JobPhase::Reconnecting { done: 10 << 20, total: 90 << 20 } }); ev.push(Event::ReadyToInstall { app: "cadcraft".into(), ready: true }); ev.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: None }); ev } fn harness(view: ViewMode, theme: egui::Theme, manage: bool) -> (Harness<'static, Option>, Rc>>) { harness_at(view, theme, manage, 1.0) } fn harness_at(view: ViewMode, theme: egui::Theme, manage: bool, scale: f32) -> (Harness<'static, Option>, Rc>>) { harness_with(events(), view, theme, manage, scale) } fn harness_with( events: Vec, view: ViewMode, theme: egui::Theme, manage: bool, scale: f32, ) -> (Harness<'static, Option>, Rc>>) { harness_full(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native, Rc::default()) } /// Like `harness_with`, with the details pictures answered as `pictures` says (changeable during the test). fn harness_pictures( events: Vec, view: ViewMode, theme: egui::Theme, manage: bool, scale: f32, pictures: Rc>, ) -> (Harness<'static, Option>, Rc>>) { harness_full(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native, pictures) } fn harness_with_title_bar( events: Vec, view: ViewMode, theme: egui::Theme, manage: bool, scale: f32, bar: artcraft_launcher_ui_egui::TitleBar, ) -> (Harness<'static, Option>, Rc>>) { harness_full(events, view, theme, manage, scale, bar, Rc::default()) } fn harness_full( events: Vec, view: ViewMode, theme: egui::Theme, manage: bool, scale: f32, bar: artcraft_launcher_ui_egui::TitleBar, pictures: Rc>, ) -> (Harness<'static, Option>, Rc>>) { struct HeaderHost(artcraft_launcher_ui_egui::TitleBar); impl Host for HeaderHost { fn title_bar(&self) -> artcraft_launcher_ui_egui::TitleBar { self.0 } } // Integration tests compile without cfg!(test), so system_lang() picks up the // developer's locale. Force English so labels match the assertions. artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN); let sent = Rc::new(RefCell::new(Vec::new())); let sent2 = sent.clone(); let mut backend = Some(Fake { events, sent: sent2, manage, pictures, asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) }); let settings = Settings { view, notify: false, ..Settings::default() }; // No `.wgpu()`: it gives every test its own GPU device, and those segfault on parallel test threads (see above). // The default renderer creates one only when a test renders. let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_pixels_per_point(scale).with_theme(theme).build_ui_state( move |ui, state: &mut Option| { if state.is_none() && let Some(b) = backend.take() { let mut s = settings.clone(); s.theme = if theme == egui::Theme::Dark { artcraft_launcher_ui_egui::theme::ThemeChoice::Dark } else { artcraft_launcher_ui_egui::theme::ThemeChoice::Light }; *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), s)); } if let Some(l) = state.as_mut() { l.logic(&ui.ctx().clone(), &mut HeaderHost(bar)); l.ui(ui, &mut HeaderHost(bar)); } }, None, ); (harness, sent) } fn snapshot(h: &mut Harness<'_, Option>, name: &str) { let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return }; let img = h.render().unwrap(); img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap(); } #[test] fn keeps_working_with_no_window_then_draws_in_a_new_one() { let sent = Rc::new(RefCell::new(Vec::new())); let backend = Fake { events: events(), sent: sent.clone(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) }; let windowless = egui::Context::default(); let mut ui = LauncherUi::new(&windowless, Box::new(backend), Settings { notify: false, ..Settings::default() }); // No window: the backend's news is still taken in. ui.logic(&windowless, &mut NoHost); assert!(sent.borrow().contains(&Command::CheckForUpdates { force: false })); assert_eq!(ui.update_count(), 2); assert_eq!(ui.settings.last_check, Some(NOW - 180)); let mut attached = false; let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state( move |egui_ui, state: &mut LauncherUi| { if !attached { attached = true; state.attach(egui_ui.ctx()); } state.logic(&egui_ui.ctx().clone(), &mut NoHost); state.ui(egui_ui, &mut NoHost); }, ui, ); h.run_steps(4); h.get_by_label("Update to 0.5.0"); h.get_by_label("Update to 0.6.0"); } #[test] fn grid_shows_every_status_and_installs_on_click() { let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true); h.run_steps(8); snapshot(&mut h, "grid-dark"); // Statuses the user asked for: available, installed and up to date, update with version. assert!(h.query_all_by_label("Available to install").count() >= 3); assert!(h.query_all_by_label("Installed · up to date").count() >= 2); h.get_by_label("Update to 0.5.0"); h.get_by_label("Update to 0.6.0"); h.get_by_label_contains("0.4.1 installed → 0.5.0 released"); // The window has no title bar of its own here (the web build, and the tests). assert!(h.query_by_label("Close").is_none()); assert!(h.query_all_by_label_contains("Discord").count() >= 2); // header link and footer link assert!(h.query_all_by_label("Coming soon").count() >= 1); h.get_by_label_contains("48 MB of 131 MB"); h.get_by_label("Update all (2)").click(); h.run_steps(2); let installs: Vec<_> = sent.borrow().iter().filter_map(|c| if let Command::Install { app } = c { Some(app.clone()) } else { None }).collect(); assert_eq!(installs.len(), 2, "{installs:?}"); assert!(installs.contains(&"photocraft".to_string()) && installs.contains(&"effectcraft".to_string())); } #[test] fn downloads_can_be_paused_resumed_and_discarded() { let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true); h.run_steps(4); // Bring the paused and reconnecting apps on screen. if let Some(l) = h.state_mut().as_mut() { l.settings.filter = artcraft_launcher_ui_egui::settings::Filter::NotInstalled; } h.run_steps(6); snapshot(&mut h, "grid-downloads-dark"); h.get_by_label("Download paused"); h.get_by_label("Reconnecting…"); h.get_by_label_contains("· 25%").click(); h.run_steps(2); assert!(sent.borrow().contains(&Command::Install { app: "deckcraft".into() })); h.get_by_label("Discard download").click(); h.run_steps(2); assert!(sent.borrow().contains(&Command::Cancel { app: "deckcraft".into() })); h.get_all_by_label("Pause download").next().unwrap().click(); h.run_steps(2); assert!(sent.borrow().iter().any(|c| matches!(c, Command::Pause { .. }))); } #[test] fn a_kept_download_offers_retry_install_and_download_again() { let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true); h.run_steps(4); if let Some(l) = h.state_mut().as_mut() { l.settings.filter = artcraft_launcher_ui_egui::settings::Filter::NotInstalled; } h.run_steps(6); // Below the fold in the restyled grid: bring the card on screen first. h.get_by_label("Retry install").scroll_to_me(); h.run_steps(4); snapshot(&mut h, "grid-retry-install-dark"); sent.borrow_mut().clear(); let retry = h.get_by_label("Retry install"); let row = retry.rect(); retry.click(); h.run_steps(2); assert_eq!(*sent.borrow(), [Command::Install { app: "cadcraft".into() }]); sent.borrow_mut().clear(); // The … menu beside it. let beside = |r: egui::Rect| row.y_range().contains(r.center().y) && (row.right()..row.right() + 60.0).contains(&r.left()); h.get_all_by_label("…").find(|n| beside(n.rect())).unwrap().click(); h.run_steps(3); snapshot(&mut h, "grid-retry-install-menu-dark"); h.get_by_label("Download again").click(); h.run_steps(2); assert_eq!(*sent.borrow(), [Command::Cancel { app: "cadcraft".into() }, Command::Install { app: "cadcraft".into() }]); // Apps without a kept download don't offer it. assert_eq!(h.query_all_by_label("Retry install").count(), 1); } #[test] fn a_dismissed_install_prompt_is_not_called_a_cancelled_download() { let finished = |app: &str, why: &str| Event::JobFinished { app: app.into(), result: Err(why.into()) }; // The password prompt was dismissed with the download kept (cadcraft) and without (pdfcraft); // a download was cancelled (vectorcraft). let mut ev = events(); ev.push(finished("cadcraft", INSTALL_CANCELLED)); ev.push(finished("pdfcraft", INSTALL_CANCELLED)); ev.push(finished("vectorcraft", CANCELLED)); let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0); h.run_steps(6); snapshot(&mut h, "grid-install-cancelled-dark"); for toast in ["Install cancelled. The download is kept for the next try.", "Install cancelled", "Download cancelled"] { assert_eq!(h.query_all_by_label(toast).count(), 1, "{toast}"); } } #[test] fn a_kept_update_names_its_version_and_an_up_to_date_app_is_not_downloaded_again() { let mut ev = events(); for app in ["effectcraft", "wordcraft"] { ev.push(Event::ReadyToInstall { app: app.into(), ready: true }); } let (mut h, sent) = harness_with(ev, ViewMode::Table, egui::Theme::Dark, true, 1.0); h.run_steps(8); snapshot(&mut h, "table-retry-update-dark"); sent.borrow_mut().clear(); assert!(h.query_by_label("Update to 0.6.0").is_none()); h.get_by_label("Retry update to 0.6.0").click(); h.run_steps(2); assert_eq!(*sent.borrow(), [Command::Install { app: "effectcraft".into() }]); // WordCraft is up to date: its menu offers Reinstall, which uses the kept file. let row = h.get_all_by_label("WordCraft").next().unwrap().rect().center().y; h.get_all_by_label("…").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click(); h.run_steps(3); h.get_by_label("Reinstall"); assert!(h.query_by_label("Download again").is_none()); } #[test] fn an_install_waiting_for_another_says_so_and_can_be_cancelled() { let mut events = events(); // Two updates reached the system's installer together: one runs, the other waits its turn. events.push(Event::Progress { app: "photocraft".into(), phase: JobPhase::Installing }); events.push(Event::Progress { app: "effectcraft".into(), phase: JobPhase::Waiting }); let (mut h, sent) = harness_with(events, ViewMode::Grid, egui::Theme::Dark, true, 1.0); h.run_steps(6); snapshot(&mut h, "grid-waiting-dark"); h.get_by_label("Installing…"); h.get_by_label("Waiting for another app…"); // Only the waiting one: the install that is running can't be called off. h.get_by_label("Cancel").click(); h.run_steps(2); let cancels: Vec<_> = sent.borrow().iter().filter(|c| matches!(c, Command::Cancel { .. })).cloned().collect(); assert_eq!(cancels, [Command::Cancel { app: "effectcraft".into() }]); } #[test] fn the_launcher_update_waiting_for_other_installs_can_be_cancelled() { let mut ev = events(); ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW))); ev.push(Event::Progress { app: "artcraft-launcher".into(), phase: JobPhase::Waiting }); let (mut h, sent) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0); h.run_steps(6); snapshot(&mut h, "grid-launcher-waiting-dark"); h.get_by_label("Waiting for other installs…"); sent.borrow_mut().clear(); h.get_by_label("Cancel update").click(); h.run_steps(2); assert_eq!(*sent.borrow(), [Command::Cancel { app: "artcraft-launcher".into() }]); } #[test] fn each_cancellation_says_what_it_called_off() { let finished = |app: &str, why: &str| Event::JobFinished { app: app.into(), result: Err(why.into()) }; let mut ev = events(); // An uninstall called off while it waited for the installer. ev.push(Event::Progress { app: "lightcraft".into(), phase: JobPhase::Removing }); ev.push(Event::Progress { app: "lightcraft".into(), phase: JobPhase::Waiting }); ev.push(finished("lightcraft", CANCELLED)); // The launcher's own update, cancelled while it waited. ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW))); ev.push(Event::Progress { app: "artcraft-launcher".into(), phase: JobPhase::Waiting }); ev.push(finished("artcraft-launcher", CANCELLED)); // A download cancelled mid-way. ev.push(finished("filmcraft", CANCELLED)); let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0); h.run_steps(6); for toast in ["Uninstall cancelled", "Update cancelled", "Download cancelled"] { assert_eq!(h.query_all_by_label(toast).count(), 1, "{toast}"); } } #[test] fn table_view_and_light_theme() { let (mut h, sent) = harness(ViewMode::Table, egui::Theme::Light, true); h.run_steps(8); snapshot(&mut h, "table-light"); h.get_by_label("Update to 0.5.0").click(); h.run_steps(2); assert!(sent.borrow().contains(&Command::Install { app: "photocraft".into() })); h.get_all_by_label("Open").next().unwrap().click(); h.run_steps(2); assert!(sent.borrow().iter().any(|c| matches!(c, Command::Launch { .. }))); } #[test] fn hiding_an_app_removes_it_until_it_is_unhidden() { let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true); h.run_steps(6); // PhotoCraft has an update, so hiding it also drops the Updates count. h.get_by_label_contains("Updates 2"); let row = h.get_all_by_label("PhotoCraft").next().unwrap().rect().center().y; h.get_all_by_label("…").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click(); h.run_steps(3); h.get_by_label("Hide App").click(); h.run_steps(4); assert!(h.query_by_label("PhotoCraft").is_none()); assert!(h.state().as_ref().unwrap().settings.hidden.contains("photocraft")); h.get_by_label_contains("Updates 1"); h.get_by_label_contains("Hidden Apps").click(); h.run_steps(4); h.get_by_label("PhotoCraft"); assert!(h.query_by_label("WordCraft").is_none()); let row = h.get_all_by_label("PhotoCraft").next().unwrap().rect().center().y; h.get_all_by_label("…").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click(); h.run_steps(3); h.get_by_label("Unhide App").click(); h.run_steps(4); h.get_by_label("PhotoCraft"); assert!(h.query_by_label_contains("Hidden Apps").is_none()); assert!(h.state().as_ref().unwrap().settings.hidden.is_empty()); assert_eq!(h.state().as_ref().unwrap().settings.filter, artcraft_launcher_ui_egui::settings::Filter::All); } #[test] fn switching_views_and_filters() { let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); h.run_steps(6); h.get_by_label_contains("Table").click(); h.run_steps(4); assert_eq!(h.state().as_ref().unwrap().settings.view, ViewMode::Table); h.get_by_label_contains("Updates").click(); h.run_steps(4); snapshot(&mut h, "table-updates-dark"); assert!(h.query_by_label("WordCraft").is_none()); h.get_by_label("PhotoCraft"); } /// How far the middle of these lines of text is below the middle of the app icon beside them, in points. fn below_icon_centre(h: &Harness<'_, Option>, lines: &[&str]) -> f32 { let text = lines.iter().map(|l| h.get_by_label(l).rect()).reduce(|a, b| a.union(b)).unwrap(); let icon = h .query_all(egui_kittest::kittest::by().role(egui::accesskit::Role::Image)) .map(|n| n.rect()) .filter(|r| r.right() <= text.left() && r.top() < text.bottom() && r.bottom() > text.top()) .max_by(|a, b| a.right().total_cmp(&b.right())) .unwrap(); assert!(text.left() - icon.right() < 24.0, "no icon beside {lines:?}"); text.center().y - icon.center().y } #[test] fn name_and_tagline_are_centred_on_the_icon() { for (scale, suffix) in [(1.0, "-1x"), (1.5, "-1.5x"), (2.0, "-2x")] { let (mut h, _) = harness_at(ViewMode::Grid, egui::Theme::Dark, true, scale); h.run_steps(8); snapshot(&mut h, &format!("grid-dark{suffix}")); let off = below_icon_centre(&h, &["PhotoCraft", "Image editing"]); assert!(off.abs() < 0.25, "grid at {scale}x: {off}"); let (mut h, _) = harness_at(ViewMode::Table, egui::Theme::Light, true, scale); h.run_steps(8); snapshot(&mut h, &format!("table-light{suffix}")); let off = below_icon_centre(&h, &["PhotoCraft", "Image editing"]); assert!(off.abs() < 0.25, "table at {scale}x: {off}"); } } /// The app names in the order the table shows them. fn order(h: &Harness<'_, Option>) -> Vec { let names: Vec<&str> = SUITE.iter().map(|a| a.name).collect(); let mut found: Vec<(f32, String)> = h .query_all(egui_kittest::kittest::by()) .filter_map(|n| { let node = n.accesskit_node(); let label = node.label().or_else(|| node.value())?; names.contains(&label.as_str()).then(|| (n.rect().top(), label)) }) .collect(); found.sort_by(|a, b| a.0.total_cmp(&b.0)); let mut names: Vec = found.into_iter().map(|(_, l)| l).collect(); // A label and its text can both carry the name. names.dedup(); names } #[test] fn every_table_column_sorts_both_ways() { let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true); h.run_steps(6); // Installed: highest first, apps that aren't installed last. h.get_by_label("Installed").click(); h.run_steps(3); let o = order(&h); assert_eq!(&o[..4], ["EffectCraft", "PhotoCraft", "LightCraft", "WordCraft"], "{o:?}"); // Reversed: lowest first, the empty cells still last. h.get_by_label("Installed").click(); h.run_steps(3); let o = order(&h); assert_eq!(&o[..4], ["WordCraft", "LightCraft", "PhotoCraft", "EffectCraft"], "{o:?}"); // Latest: highest version first, SoundCraft (no release) last. h.get_by_label("Latest").click(); h.run_steps(3); let o = order(&h); assert_eq!(o.first().map(String::as_str), Some("VectorCraft"), "{o:?}"); // 0.7.0 snapshot(&mut h, "table-sorted-latest"); // App: Z to A on the second click. h.get_by_label("App").click(); h.run_steps(3); h.get_by_label("App").click(); h.run_steps(3); let o = order(&h); assert_eq!(o.first().map(String::as_str), Some("WordCraft"), "{o:?}"); } #[test] fn settings_link_to_discord() { let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); h.run_steps(6); h.get_by_label("Settings").click(); h.run_steps(4); snapshot(&mut h, "settings-dark"); h.get_by_label("Questions, ideas and news from the ArtCraft team"); // The header button and the one in Settings. assert!(h.query_all_by_label_contains("Discord").count() >= 2); } #[test] fn the_uninstall_prompt_keeps_its_own_size_after_a_taller_dialog() { let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); h.run_steps(6); // Settings is the tallest dialog: open it and close it again. h.get_by_label("Settings").click(); h.run_steps(4); h.get_by_label("Done").click(); h.run_steps(3); // PhotoCraft is installed, so its … menu offers Uninstall. h.get_all_by_label("…").next().unwrap().click(); h.run_steps(3); h.get_by_label("Uninstall…").click(); h.run_steps(4); snapshot(&mut h, "uninstall-prompt-dark"); // The buttons follow the text closely, not somewhere down the height Settings had (#88). let text = h.get_by_label("The app will be moved to the Trash. Your documents and settings stay.").rect(); let button = h.get_by_label("Uninstall").rect(); let gap = button.top() - text.bottom(); assert!((0.0..48.0).contains(&gap), "the buttons sit {gap} px below the text"); h.get_by_label("Cancel").click(); h.run_steps(3); assert!(h.query_by_label("Uninstall").is_none()); } #[test] fn links_to_the_website_github_and_each_apps_pages() { let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); h.run_steps(6); // Footer: website, GitHub and Discord. for label in ["Website", "GitHub", "Discord"] { assert!(h.query_all_by_label(label).count() >= 1, "{label}"); } // Cards: every app links to its repo and to its own page on getartcraft.com (plus the footer's). let shown_cards = h.query_all_by_label("GitHub").count() - 1; let shown_sites = h.query_all_by_label("Website").count() - 1; assert!(shown_cards >= 13, "{shown_cards}"); assert_eq!(shown_sites, 13, "every app has a website page"); // The table's … menu has the app's website and GitHub repo. let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true); h.run_steps(6); h.get_all_by_label("…").nth(7).unwrap().click(); // WordCraft h.run_steps(3); h.get_by_label("GitHub repository"); // The footer's Website link and WordCraft's now-live one in the menu. assert!(h.query_all_by_label_contains("Website").count() >= 2); } /// Clicks the `n`th widget labelled `label` and returns the URL the app asked to open. fn click_nth_opens(h: &mut Harness<'_, Option>, label: &str, n: usize) -> Option { h.get_all_by_label(label).nth(n)?.click(); for _ in 0..3 { h.step(); let url = h.output().platform_output.commands.iter().find_map(|c| match c { egui::OutputCommand::OpenUrl(o) => Some(o.url.clone()), _ => None, }); if url.is_some() { return url; } } None } fn click_opens(h: &mut Harness<'_, Option>, label: &str) -> Option { click_nth_opens(h, label, 0) } /// Every URL the widgets labelled `label` open. fn all_opened(h: &mut Harness<'_, Option>, label: &str) -> Vec { let n = h.query_all_by_label(label).count(); (0..n).filter_map(|i| click_nth_opens(h, label, i)).collect() } #[test] fn links_open_the_right_pages() { let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); h.run_steps(6); assert_eq!(click_opens(&mut h, "Discord").as_deref(), Some("https://discord.gg/artcraft")); // The footer's ArtCraft links and each card's own pages. let github = all_opened(&mut h, "GitHub"); assert!(github.contains(&"https://github.com/storytold".to_string()), "{github:?}"); assert!(github.contains(&"https://github.com/storytold/photocraft".to_string()), "{github:?}"); let sites = all_opened(&mut h, "Website"); assert!(sites.contains(&"https://getartcraft.com".to_string()), "{sites:?}"); assert!(sites.contains(&"https://getartcraft.com/apps/photocraft".to_string()), "{sites:?}"); // Social icons in the footer. assert_eq!(click_opens(&mut h, "X").as_deref(), Some("https://x.com/get_artcraft")); assert_eq!(click_opens(&mut h, "YouTube").as_deref(), Some("https://www.youtube.com/@OfficialArtcraftStudios")); assert_eq!(click_opens(&mut h, "TikTok").as_deref(), Some("https://www.tiktok.com/@artcraft.studios")); assert_eq!(click_opens(&mut h, "Instagram").as_deref(), Some("https://www.instagram.com/officialartcraftstudios")); assert_eq!(click_opens(&mut h, "LinkedIn").as_deref(), Some("https://www.linkedin.com/company/artcraft-ai")); // The … menu. h.get_all_by_label("…").next().unwrap().click(); h.run_steps(3); assert_eq!(click_opens(&mut h, "All releases").as_deref(), Some("https://github.com/storytold/photocraft/releases")); } #[test] fn looks_again_at_installed_apps_every_few_minutes_not_every_frame() { let sent = Rc::new(RefCell::new(Vec::new())); let clock = Rc::new(Cell::new(NOW)); let mut backend = Some(Fake { events: events(), sent: sent.clone(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: clock.clone() }); let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state( move |ui, state: &mut Option| { if state.is_none() && let Some(b) = backend.take() { *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { notify: false, ..Settings::default() })); } if let Some(l) = state.as_mut() { l.logic(&ui.ctx().clone(), &mut NoHost); l.ui(ui, &mut NoHost); } }, None, ); let detects = |sent: &Rc>>| sent.borrow().iter().filter(|c| **c == Command::Detect).count(); h.run_steps(10); assert_eq!(detects(&sent), 0, "the backend's own startup detection is enough"); clock.set(NOW + 4 * 60); h.run_steps(10); assert_eq!(detects(&sent), 0, "not before five minutes"); clock.set(NOW + 5 * 60); h.run_steps(10); assert_eq!(detects(&sent), 1, "once at five minutes, not once per frame"); } #[test] fn the_launcher_updates_itself_and_quits_to_restart() { struct QuitHost(Rc>); impl Host for QuitHost { fn quit(&mut self) { self.0.set(true); } } let sent = Rc::new(RefCell::new(Vec::new())); let mut events = events(); events.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW))); let shared_events = Rc::new(RefCell::new(Vec::new())); struct Scripted { inner: Fake, later: Rc>>, } impl Backend for Scripted { fn send(&mut self, c: Command) { self.inner.send(c); } fn poll(&mut self) -> Vec { let mut out = self.inner.poll(); out.extend(self.later.borrow_mut().drain(..)); out } fn capabilities(&self) -> Capabilities { self.inner.capabilities() } fn platform(&self) -> Option { self.inner.platform() } fn now(&self) -> i64 { self.inner.now() } } let quit = Rc::new(Cell::new(false)); let mut host = QuitHost(quit.clone()); let mut backend = Some(Scripted { inner: Fake { events, sent: sent.clone(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) }, later: shared_events.clone(), }); let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state( move |ui, state: &mut Option| { if state.is_none() && let Some(b) = backend.take() { *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { notify: false, ..Settings::default() })); } if let Some(l) = state.as_mut() { l.logic(&ui.ctx().clone(), &mut host); l.ui(ui, &mut host); } }, None, ); h.run_steps(6); h.get_by_label_contains("ArtCraft Launcher 0.2.0 is available"); h.get_by_label("Update and restart").click(); h.run_steps(2); assert!(sent.borrow().contains(&Command::UpdateLauncher)); // Downloading: the banner shows progress instead of the button. shared_events .borrow_mut() .push(Event::Progress { app: "artcraft-launcher".into(), phase: JobPhase::Downloading { done: 5 << 20, total: 10 << 20 } }); h.run_steps(3); assert!(h.query_by_label("Update and restart").is_none()); assert!(!quit.get()); // In place: the launcher quits so the helper can start the new one. shared_events.borrow_mut().push(Event::LauncherRestarting); h.run_steps(3); assert!(quit.get()); } #[test] fn launcher_update_prompts_to_relaunch_with_updated_version() { let mut ev = events(); ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW))); let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0); h.run_steps(6); // Dialog pops up on launch with the update prompt h.get_by_label_contains("ArtCraft Launcher 0.2.0 is available"); h.get_by_label("A new version is available. Would you like to relaunch with the updated version?"); h.get_by_label("What's new"); // Clicking "Later" dismisses the modal dialog h.get_by_label("Later").click(); h.run_steps(3); assert!(h.query_by_label("A new version is available. Would you like to relaunch with the updated version?").is_none()); // Toolbar update banner is now visible with Update and restart h.get_by_label("Update and restart"); } #[test] fn the_current_desktop_setting_is_only_offered_where_the_os_has_desktops() { // NoHost has no desktops (Windows, Linux, web): no setting. let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); h.run_steps(6); h.get_by_label("Settings").click(); h.run_steps(4); assert!(h.query_by_label("Show the window on the current desktop").is_none()); // On by default for everyone; macOS shows it in Settings (DesktopHost::has_desktops). assert!(Settings::default().show_on_current_desktop); } #[test] fn feed_announcements_and_alerts_show_until_dismissed() { use artcraft_launcher_core::feed::Notice; let notice = |id: &str, title: &str, link: Option<&str>| Notice { id: id.into(), title: title.into(), body: format!("{title} body"), link: link.map(str::to_string), created_at: None, }; let sent = Rc::new(RefCell::new(Vec::new())); let mut evs = events(); evs.push(Event::Notices { announcements: vec![notice("a1", "CADCraft 1.0 is out", Some("https://getartcraft.com/news"))], alerts: vec![notice("x1", "Security update", Some("https://getartcraft.com/security"))], }); let mut backend = Some(Fake { events: evs, sent, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) }); let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state( move |ui, state: &mut Option| { if state.is_none() && let Some(b) = backend.take() { *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { notify: false, ..Settings::default() })); } if let Some(l) = state.as_mut() { l.logic(&ui.ctx().clone(), &mut NoHost); l.ui(ui, &mut NoHost); } }, None, ); h.run_steps(6); // The alert pops up first; OK acknowledges it for good. h.get_by_label("Security update"); h.get_by_label("Open link"); h.get_by_label("OK").click(); h.run_steps(4); assert!(h.query_by_label("Security update").is_none()); assert!(h.state().as_ref().unwrap().settings.dismissed_notices.contains("x1")); // The announcement stays inline until dismissed. h.get_by_label("CADCraft 1.0 is out"); h.get_by_label("Learn more"); h.get_all_by_label("Dismiss").next().unwrap().click(); h.run_steps(4); assert!(h.query_by_label("CADCraft 1.0 is out").is_none()); assert!(h.state().as_ref().unwrap().settings.dismissed_notices.contains("a1")); } #[test] fn automatic_checks_run_every_five_minutes() { use artcraft_launcher_ui_egui::settings::CheckEvery; assert_eq!(Settings::default().check_every.seconds(), Some(300)); // Older saved choices still load, and mean "automatically" now. assert_eq!(CheckEvery::Day.seconds(), Some(300)); assert_eq!(CheckEvery::Never.seconds(), None); } #[test] fn automatic_updates_queue_installs_for_outdated_apps_and_launcher() { let mut ev = events(); ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW))); let sent = Rc::new(RefCell::new(Vec::new())); let sent2 = sent.clone(); let mut backend = Some(Fake { events: ev, sent: sent2, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) }); let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state( move |ui, state: &mut Option| { if state.is_none() && let Some(b) = backend.take() { *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { auto_update: true, notify: false, ..Settings::default() })); } if let Some(l) = state.as_mut() { l.logic(&ui.ctx().clone(), &mut NoHost); l.ui(ui, &mut NoHost); } }, None, ); h.run_steps(4); let commands = sent.borrow().clone(); assert!(commands.contains(&Command::Install { app: "photocraft".into() }), "outdated photocraft should be queued for install: {commands:?}"); assert!(commands.contains(&Command::Install { app: "effectcraft".into() }), "outdated effectcraft should be queued for install: {commands:?}"); assert!(commands.contains(&Command::UpdateLauncher), "launcher update should be queued: {commands:?}"); } #[test] fn automatic_updates_are_disabled_by_default() { let mut ev = events(); ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW))); let sent = Rc::new(RefCell::new(Vec::new())); let sent2 = sent.clone(); let mut backend = Some(Fake { events: ev, sent: sent2, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) }); let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state( move |ui, state: &mut Option| { if state.is_none() && let Some(b) = backend.take() { *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { auto_update: false, notify: false, ..Settings::default() })); } if let Some(l) = state.as_mut() { l.logic(&ui.ctx().clone(), &mut NoHost); l.ui(ui, &mut NoHost); } }, None, ); h.run_steps(4); let commands = sent.borrow().clone(); assert!(!commands.contains(&Command::Install { app: "photocraft".into() }), "photocraft should not be auto-installed when auto_update is false"); assert!(!commands.contains(&Command::UpdateLauncher), "launcher should not be auto-updated when auto_update is false"); } #[test] fn the_footer_names_the_services_behind_what_is_on_screen() { let footer = |extra: Vec| { let mut evs = events(); evs.extend(extra); let sent = Rc::new(RefCell::new(Vec::new())); let mut backend = Some(Fake { events: evs, sent, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) }); let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state( move |ui, state: &mut Option| { if state.is_none() && let Some(b) = backend.take() { *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { notify: false, ..Settings::default() })); } if let Some(l) = state.as_mut() { l.logic(&ui.ctx().clone(), &mut NoHost); l.ui(ui, &mut NoHost); } }, None, ); h.run_steps(6); let label = h.get_by_label_contains("Checked ").accesskit_node().label().or_else(|| h.get_by_label_contains("Checked ").accesskit_node().value()); label.unwrap_or_default() }; assert_eq!(footer(Vec::new()), "Checked 3 min ago · using ArtCraft API"); let from_github = vec![Event::Latest { app: "wordcraft".into(), latest: LatestRelease::Found(release("wordcraft", "0.3.1", NOW)), from: Origin::GitHub }]; assert_eq!(footer(from_github), "Checked 3 min ago · using ArtCraft API and GitHub"); } #[test] fn web_build_offers_downloads() { let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, false); h.run_steps(8); snapshot(&mut h, "web-grid-dark"); assert!(h.query_all_by_label("Download for macOS").count() >= 8); assert!(h.query_by_label("Install").is_none()); } #[test] fn custom_title_bar_has_window_buttons() { struct FramelessHost; impl Host for FramelessHost { fn title_bar(&self) -> artcraft_launcher_ui_egui::TitleBar { artcraft_launcher_ui_egui::TitleBar::Custom } } let mut backend = Some(Fake { events: events(), sent: Rc::new(RefCell::new(Vec::new())), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)), }); let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state( move |ui, state: &mut Option| { if state.is_none() && let Some(b) = backend.take() { *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { notify: false, ..Settings::default() })); } if let Some(l) = state.as_mut() { l.logic(&ui.ctx().clone(), &mut FramelessHost); l.ui(ui, &mut FramelessHost); } }, None, ); h.run_steps(6); snapshot(&mut h, "grid-title-bar-dark"); h.get_by_label("Minimize"); h.get_by_label("Maximize"); h.get_by_label("Close").click(); h.step(); let closes = h.output().viewport_output.values().any(|v| v.commands.contains(&egui::ViewportCommand::Close)); assert!(closes, "the close button asks the window to close (the shell decides whether that hides it to the tray)"); } #[test] fn clicking_a_card_opens_its_details_drawer() { let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true); h.run_steps(8); // Every app has a page: a card of each generation opens one. h.get_by_label("VectorCraft details"); h.get_by_label("WordCraft details"); h.get_by_label("PhotoCraft details").click(); h.run_steps(10); snapshot(&mut h, "details-dark"); h.get_by_label_contains("The image editor you already know how to use"); h.get_by_label_contains("Curves and type layers · 1/4"); // The links the page offers as buttons of their own. h.get_by_label("Website ↗"); h.get_by_label("GitHub ↗"); h.get_by_label("Releases ↗"); // The picture arrows wrap around, and the thumbnails jump. h.get_by_label("Previous picture (←)").click(); h.run_steps(3); h.get_by_label_contains("Selections and masked adjustments · 4/4"); h.get_by_label("Next picture (→)").click(); h.run_steps(3); h.get_by_label_contains("Curves and type layers · 1/4"); // Esc closes it and the page is gone once it has slid out. h.key_press(egui::Key::Escape); h.run_steps(10); assert!(h.query_by_label_contains("The image editor you already know").is_none()); } #[test] fn details_drawer_in_the_light_theme() { let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Light, true); h.run_steps(8); h.get_by_label("PhotoCraft details").click(); h.run_steps(10); snapshot(&mut h, "details-light"); h.get_by_label_contains("The image editor you already know how to use"); } fn fetches(sent: &Rc>>, app: &str) -> usize { sent.borrow().iter().filter(|c| matches!(c, Command::FetchScreenshots { app: a } if a == app)).count() } #[test] fn opening_a_page_asks_for_its_pictures_once_and_only_while_it_lacks_some() { let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true); h.run_steps(8); assert_eq!(fetches(&sent, "photocraft"), 0, "nothing is fetched before a page is opened"); h.get_by_label("PhotoCraft details").click(); h.run_steps(10); assert_eq!(fetches(&sent, "photocraft"), 1); h.get_by_label("Enlarge picture"); // Closed and opened again: the pictures are in hand, so nothing is asked. h.key_press(egui::Key::Escape); h.run_steps(10); h.get_by_label("PhotoCraft details").click(); h.run_steps(10); assert_eq!(fetches(&sent, "photocraft"), 1); h.get_by_label("Enlarge picture"); // Another page asks for its own, and a page without pictures asks for nothing. h.key_press(egui::Key::Escape); h.run_steps(10); h.get_by_label("VectorCraft details").click(); h.run_steps(10); assert_eq!(fetches(&sent, "vectorcraft"), 1); h.key_press(egui::Key::Escape); h.run_steps(10); h.get_by_label("PdfCraft details").click(); h.run_steps(10); assert_eq!(fetches(&sent, "pdfcraft"), 0); } #[test] fn a_frame_stands_in_for_the_pictures_until_they_arrive() { let pictures = Rc::new(Cell::new(Pictures::Silent)); let (mut h, sent) = harness_pictures(events(), ViewMode::Grid, egui::Theme::Dark, true, 1.0, pictures.clone()); h.run_steps(8); h.get_by_label("PhotoCraft details").click(); h.run_steps(10); snapshot(&mut h, "details-loading-dark"); h.get_by_label("Loading pictures…"); assert!(h.query_by_label("Enlarge picture").is_none(), "there is nothing to enlarge yet"); // The rest of the page is there, and the arrows work on the frames. h.get_by_label_contains("The image editor you already know how to use"); h.get_by_label("Next picture (→)").click(); h.run_steps(3); h.get_by_label_contains("Levels with a live histogram · 2/4"); // Asking again meanwhile is not done: the answer is on its way. assert_eq!(fetches(&sent, "photocraft"), 1); pictures.set(Pictures::Online); h.run_steps(6); assert!(h.query_by_label("Loading pictures…").is_none()); h.get_by_label("Enlarge picture"); } #[test] fn offline_the_frame_says_so_and_opening_the_page_again_retries() { let pictures = Rc::new(Cell::new(Pictures::Offline)); let (mut h, sent) = harness_pictures(events(), ViewMode::Grid, egui::Theme::Light, true, 1.0, pictures.clone()); h.run_steps(8); h.get_by_label("PhotoCraft details").click(); h.run_steps(10); snapshot(&mut h, "details-offline-light"); h.get_by_label("Pictures couldn't be loaded. Reopen this page to try again."); assert!(h.query_by_label("Loading pictures…").is_none()); assert!(h.query_by_label("Enlarge picture").is_none()); h.get_by_label_contains("The image editor you already know how to use"); // Back online: the page is closed and opened again, which asks again. pictures.set(Pictures::Online); h.key_press(egui::Key::Escape); h.run_steps(10); h.get_by_label("PhotoCraft details").click(); h.run_steps(10); assert_eq!(fetches(&sent, "photocraft"), 2); h.get_by_label("Enlarge picture"); assert!(h.query_by_label("Pictures couldn't be loaded. Reopen this page to try again.").is_none()); } #[test] fn the_enlarged_view_shows_a_frame_for_a_picture_still_missing() { let pictures = Rc::new(Cell::new(Pictures::SecondFails)); let (mut h, _sent) = harness_pictures(events(), ViewMode::Grid, egui::Theme::Dark, true, 1.0, pictures); h.run_steps(8); h.get_by_label("PhotoCraft details").click(); h.run_steps(10); h.get_by_label("Enlarge picture").click(); h.run_steps(3); // The second picture is missing: flipping to it keeps the view open, with a frame; the others still show. h.key_press(egui::Key::ArrowRight); h.run_steps(3); // Once in the drawer behind (it shows the same picture) and once in the enlarged view. assert_eq!(h.query_all_by_label("Pictures couldn't be loaded. Reopen this page to try again.").count(), 2); assert_eq!(h.query_all_by_label("Close (Esc)").count(), 2, "the enlarged view is still open"); h.key_press(egui::Key::ArrowRight); h.run_steps(3); assert!(h.query_by_label("Pictures couldn't be loaded. Reopen this page to try again.").is_none(), "the third picture is fine"); } #[test] fn a_page_without_pictures_has_no_gallery() { let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true); h.run_steps(8); // PdfCraft's screenshots still show its old name (PrintCraft), so its page has none for now. h.get_by_label("PdfCraft details").click(); h.run_steps(10); snapshot(&mut h, "details-no-pictures-dark"); h.get_by_label_contains("The open-source PDF workbench."); h.get_by_label_contains("Search as you type"); assert!(h.query_by_label("Enlarge picture").is_none()); assert!(h.query_by_label("Next picture (→)").is_none()); } #[test] fn the_lead_picture_can_be_enlarged() { let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true); h.run_steps(8); h.get_by_label("PhotoCraft details").click(); h.run_steps(10); // The drawer has one "Close (Esc)"; the enlarged view adds its own. assert_eq!(h.query_all_by_label("Close (Esc)").count(), 1); h.get_by_label("Enlarge picture").click(); h.run_steps(4); snapshot(&mut h, "details-zoom-dark"); assert_eq!(h.query_all_by_label("Close (Esc)").count(), 2); // The keyboard flips through the pictures there too. h.key_press(egui::Key::ArrowRight); h.run_steps(3); h.get_by_label_contains("Levels with a live histogram · 2/4"); // Esc closes the enlarged picture first, and the drawer only the second time. h.key_press(egui::Key::Escape); h.run_steps(4); assert_eq!(h.query_all_by_label("Close (Esc)").count(), 1); h.get_by_label_contains("The image editor you already know how to use"); h.key_press(egui::Key::Escape); h.run_steps(10); assert!(h.query_by_label_contains("The image editor you already know").is_none()); } /// Every page, whatever it holds: no pictures (PdfCraft), one (DesignCraft) or several. The arrow keys are /// pressed more often than there are pictures, both ways, to wrap around the list and to do nothing on a /// page that has one or none; the enlarged view is opened wherever there is a picture to enlarge. #[test] fn every_page_opens_and_pages_through_its_pictures() { let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true); // Tall enough to show all thirteen cards at once. h.set_size(egui::vec2(1180.0, 3000.0)); h.run_steps(8); for d in &artcraft_launcher_core::details::DETAILS { let name = SUITE.iter().find(|a| a.id == d.id).map(|a| a.name).unwrap_or(d.id); h.get_by_label(&format!("{name} details")).click(); h.run_steps(8); h.get_by_label_contains(d.headline); let presses = d.shots.len() + 2; for key in [egui::Key::ArrowRight, egui::Key::ArrowLeft] { for _ in 0..presses { h.key_press(key); h.run_steps(1); } } // The picture can be enlarged exactly when the page has one. assert_eq!(h.query_by_label("Enlarge picture").is_some(), !d.shots.is_empty(), "{}", d.id); if !d.shots.is_empty() { h.get_by_label("Enlarge picture").click(); h.run_steps(3); for key in [egui::Key::ArrowRight, egui::Key::ArrowLeft] { for _ in 0..presses { h.key_press(key); h.run_steps(1); } } // A full lap in each direction brings it back to the first picture. Its caption is in the drawer // and in the enlarged view, and has a counter only when there are several pictures. let first = &d.shots[0]; let caption = if d.shots.len() > 1 { format!("{} · 1/{}", first.caption, d.shots.len()) } else { first.caption.to_string() }; assert!(h.query_all_by_label_contains(&caption).count() >= 1, "{}: {caption}", d.id); h.key_press(egui::Key::Escape); h.run_steps(3); } h.key_press(egui::Key::Escape); h.run_steps(10); assert!(h.query_by_label_contains(d.headline).is_none(), "{} did not close", d.id); } } /// The drawer and the enlarged picture at sizes far below what the desktop window allows (its minimum is /// 720 x 500, but the web build has no such floor) and far above, resized while they are open. #[test] fn the_drawer_and_the_enlarged_picture_survive_any_window_size() { let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true); h.run_steps(8); h.get_by_label("PhotoCraft details").click(); h.run_steps(10); let sizes = [(1180.0, 900.0), (720.0, 500.0), (480.0, 320.0), (320.0, 200.0), (120.0, 90.0), (40.0, 30.0), (4000.0, 2400.0)]; for zoom in [false, true] { if zoom { h.get_by_label("Enlarge picture").click(); h.run_steps(3); } for (w, ht) in sizes { h.set_size(egui::vec2(w, ht)); h.run_steps(3); } // Back to a normal size, still working. h.set_size(egui::vec2(1180.0, 900.0)); h.run_steps(4); if zoom { h.key_press(egui::Key::Escape); h.run_steps(3); } h.get_by_label_contains("The image editor you already know how to use"); } } #[test] fn the_rate_limit_notice_never_runs_under_the_footer_links() { for width in [720.0, 960.0, 1180.0] { let mut evs = events(); evs.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: Some(NOW + 1800) }); let mut backend = Some(Fake { events: evs, sent: Rc::new(RefCell::new(Vec::new())), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)), }); let mut h = Harness::builder().with_size(egui::vec2(width, 900.0)).build_ui_state( move |ui, state: &mut Option| { if state.is_none() && let Some(b) = backend.take() { *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { notify: false, ..Settings::default() })); } if let Some(l) = state.as_mut() { l.logic(&ui.ctx().clone(), &mut NoHost); l.ui(ui, &mut NoHost); } }, None, ); h.run_steps(6); snapshot(&mut h, &format!("footer-rate-limited-{width}")); // Full or short, the notice says when GitHub can be asked again. let notice = h.get_by_label_contains("in 30 min").rect(); let checked = h.get_by_label_contains("Checked ").rect(); // Cut short in a narrow window, but enough of it left to read. assert!(notice.width() >= 120.0 && notice.right() <= width, "{width}: notice {notice:?}"); // Everything on the footer's right: the three links, the social icons and (when there's // room) the platform. let mut right = Vec::new(); for label in ["Website", "GitHub", "Discord", "X", "YouTube", "TikTok", "Instagram", "LinkedIn"] { right.extend(h.query_all_by_label(label).map(|n| n.rect()).filter(|r| (r.center().y - notice.center().y).abs() < 12.0)); } assert_eq!(right.len(), 8, "{width}: {right:?}"); let platform = h.query_by_label_contains("Installing for").map(|n| n.rect()); assert_eq!(platform.is_some(), width > 720.0, "{width}: the platform label shows unless the window is narrow"); right.extend(platform); for r in right { assert!(!r.intersects(notice), "{width}: notice {notice:?} overlaps {r:?}"); assert!(!r.intersects(checked), "{width}: check time {checked:?} overlaps {r:?}"); } } } #[test] fn narrow_header_keeps_the_view_switch_clear_of_brand_text() { use artcraft_launcher_ui_egui::TitleBar; for theme in [egui::Theme::Dark, egui::Theme::Light] { for bar in [TitleBar::Native, TitleBar::MacOs, TitleBar::Custom] { let (mut h, _) = harness_with_title_bar(events(), ViewMode::Grid, theme, true, 1.0, bar); for width in (720..=1180).step_by(10).chain([720, 1180]).map(|w| w as f32) { h.set_size(egui::vec2(width, 900.0)); h.run_steps(6); for label in ["Grid", "Table", "Settings", "Check for updates"] { let rect = h.get_by_label(label).rect(); assert!(rect.left() >= 0.0 && rect.right() <= width, "{bar:?} {width}: {label} {rect:?}"); for label in ["Launcher", "Craft App Suite"] { if let Some(text) = h.query_by_label(label) { assert!(!rect.intersects(text.rect()), "{bar:?} {width}: {label} overlaps {rect:?}"); } } } if bar == TitleBar::Custom { for label in ["Minimize", "Maximize", "Close"] { let rect = h.get_by_label(label).rect(); assert!(rect.left() >= 0.0 && rect.right() <= width, "{width}: {label} {rect:?}"); } } if width == 1180.0 { h.get_by_label("Launcher"); h.get_by_label("Craft App Suite"); } if [720.0, 800.0, 1180.0].contains(&width) { snapshot(&mut h, &format!("header-{theme:?}-{bar:?}-{width}")); } } h.get_by_label("Table").click(); h.run_steps(2); assert_eq!(h.state().as_ref().unwrap().settings.view, ViewMode::Table); } } } /// A shell on an OS whose animation setting the test switches. struct OsMotion(Rc>); impl Host for OsMotion { fn reduce_motion(&self) -> bool { self.0.get() } } /// The grid in a window too short for it, at 60 frames a second, on an OS asking for fewer /// animations (`reduce`) or not. Nothing is downloading, so the window is still unless something /// animates. fn short_window(reduce: Rc>) -> Harness<'static, Option> { artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN); let events = events().into_iter().filter(|e| !matches!(e, Event::Progress { .. })).collect(); let mut backend = Some(Fake { events, sent: Rc::default(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) }); let mut host = OsMotion(reduce); let mut h = Harness::builder().with_size(egui::vec2(1180.0, 500.0)).with_step_dt(1.0 / 60.0).with_max_steps(120).build_ui_state( move |ui, state: &mut Option| { if state.is_none() && let Some(b) = backend.take() { let settings = Settings { view: ViewMode::Grid, notify: false, ..Settings::default() }; *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings)); } if let Some(l) = state.as_mut() { l.logic(&ui.ctx().clone(), &mut host); l.ui(ui, &mut host); } }, None, ); h.run_steps(8); h } /// How far down the window the grid's first card is. fn grid_top(h: &Harness<'_, Option>) -> f32 { h.get_by_label("Image editing").rect().top() } /// Turns the mouse wheel `lines` down over the grid, by way of the shell's `raw_input_hook`. The /// next frame takes it in. fn turn_wheel(h: &mut Harness<'_, Option>, lines: f32) { h.hover_at(egui::pos2(590.0, 300.0)); h.run_steps(2); let wheel = egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, -lines), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, }; let mut raw = egui::RawInput { events: vec![wheel], ..Default::default() }; if let Some(l) = h.state() { l.raw_input(&h.ctx, &mut raw); } for event in raw.events { h.event(event); } } #[test] fn the_os_animation_setting_is_followed_as_it_changes() { let reduce = Rc::new(Cell::new(true)); let mut h = short_window(reduce.clone()); assert_eq!(h.ctx.global_style().animation_time, 0.0); // Switched back on while the launcher runs: noticed within a couple of seconds. reduce.set(false); h.run_steps(150); assert_eq!(h.ctx.global_style().animation_time, egui::Style::default().animation_time); reduce.set(true); h.run_steps(150); assert_eq!(h.ctx.global_style().animation_time, 0.0); } #[test] fn with_animations_off_the_wheel_scrolls_at_once() { let mut h = short_window(Rc::new(Cell::new(true))); let notch = 3.0 * h.ctx.options(|o| o.input_options.line_scroll_speed); let start = grid_top(&h); turn_wheel(&mut h, 3.0); // The frame the wheel turned in, and the one that draws where it scrolled to. h.run_steps(2); let moved = start - grid_top(&h); assert!((moved - notch).abs() < 0.5, "moved {moved} of {notch}"); // Nothing is left to glide. h.run_steps(30); assert_eq!(start - grid_top(&h), moved); } #[test] fn with_animations_on_the_wheel_glides() { let mut h = short_window(Rc::new(Cell::new(false))); let notch = 3.0 * h.ctx.options(|o| o.input_options.line_scroll_speed); let start = grid_top(&h); turn_wheel(&mut h, 3.0); h.run_steps(2); let moved = start - grid_top(&h); assert!(moved > 0.0 && moved < notch / 2.0, "moved {moved} of {notch}"); // And is there within the second. h.run_steps(60); let moved = start - grid_top(&h); assert!((moved - notch).abs() < 0.5, "moved {moved} of {notch}"); } /// Moves the pointer from one card to the next. Returns how many frames are drawn before the /// window is still again. fn frames_to_hover_the_next_card(h: &mut Harness<'_, Option>) -> u64 { let cards: Vec = ["Image editing", "Vector illustration"].map(|t| h.get_by_label(t).rect().center()).to_vec(); // Still to begin with: the icons are in, the first card is lit and the scroll bar is showing. h.hover_at(cards[0]); h.run(); h.hover_at(cards[1]); h.run() } #[test] fn with_animations_off_hovering_a_card_shows_at_once() { let mut h = short_window(Rc::new(Cell::new(true))); let frames = frames_to_hover_the_next_card(&mut h); assert!(frames <= 5, "{frames} frames"); } #[test] fn with_animations_on_hovering_a_card_animates() { let mut h = short_window(Rc::new(Cell::new(false))); // The rule wipes in and the icon rises over 0.3 s, at 60 frames a second. let frames = frames_to_hover_the_next_card(&mut h); assert!(frames >= 10, "{frames} frames"); }