mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-11-02 18:07:04 +00:00
2940 lines
134 KiB
Rust
2940 lines
134 KiB
Rust
//! Drives the launcher UI against a fake backend: what's shown for each status, and which
|
||
//! commands the buttons send. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` 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<u8> {
|
||
static JPEG: OnceLock<Vec<u8>> = OnceLock::new();
|
||
JPEG.get_or_init(|| {
|
||
let (w, h) = (320u32, 200u32);
|
||
let rgb: Vec<u8> = (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<Event>,
|
||
sent: Rc<RefCell<Vec<Command>>>,
|
||
manage: bool,
|
||
pictures: Rc<Cell<Pictures>>,
|
||
/// Apps whose pictures were asked for and not answered yet.
|
||
asked: Vec<String>,
|
||
clock: Rc<Cell<i64>>,
|
||
}
|
||
|
||
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<Event> {
|
||
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<Platform> {
|
||
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
|
||
}
|
||
fn now(&self) -> i64 {
|
||
self.clock.get()
|
||
}
|
||
}
|
||
|
||
struct PlatformFake {
|
||
inner: Fake,
|
||
platform: Platform,
|
||
}
|
||
impl Backend for PlatformFake {
|
||
fn send(&mut self, command: Command) {
|
||
self.inner.send(command);
|
||
}
|
||
fn poll(&mut self) -> Vec<Event> {
|
||
self.inner.poll()
|
||
}
|
||
fn capabilities(&self) -> Capabilities {
|
||
self.inner.capabilities()
|
||
}
|
||
fn platform(&self) -> Option<Platform> {
|
||
Some(self.platform)
|
||
}
|
||
fn now(&self) -> i64 {
|
||
self.inner.now()
|
||
}
|
||
}
|
||
|
||
struct NoHost;
|
||
impl Host for NoHost {}
|
||
|
||
/// A harness that has the launcher's fonts, theme and image loaders before its first pass, and
|
||
/// never paints a synthetic cursor into saved images.
|
||
fn test_harness_builder<State>(theme: egui::Theme) -> egui_kittest::HarnessBuilder<State> {
|
||
let choice = if theme == egui::Theme::Dark {
|
||
artcraft_launcher_ui_egui::theme::ThemeChoice::Dark
|
||
} else {
|
||
artcraft_launcher_ui_egui::theme::ThemeChoice::Light
|
||
};
|
||
egui_kittest::HarnessBuilder::default()
|
||
// 0.37 defaults: strict accessible names and a panic on a missing glyph. Neither is
|
||
// relaxed here; fixed controls are painter-drawn and dynamic notes/feed text uses the
|
||
// attributed monochrome emoji fonts.
|
||
.with_accessibility_check(true)
|
||
.with_render_cursor(false)
|
||
.with_context_setup(move |ctx| {
|
||
artcraft_launcher_ui_egui::theme::install_fonts(ctx, None);
|
||
egui_extras::install_image_loaders(ctx);
|
||
artcraft_launcher_ui_egui::theme::apply(ctx, choice);
|
||
})
|
||
}
|
||
|
||
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<Event> {
|
||
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<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||
harness_at(view, theme, manage, 1.0)
|
||
}
|
||
|
||
fn harness_at(view: ViewMode, theme: egui::Theme, manage: bool, scale: f32) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||
harness_with(events(), view, theme, manage, scale)
|
||
}
|
||
|
||
fn harness_with(
|
||
events: Vec<Event>,
|
||
view: ViewMode,
|
||
theme: egui::Theme,
|
||
manage: bool,
|
||
scale: f32,
|
||
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||
harness_full(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native, Rc::default(), None, None)
|
||
}
|
||
|
||
/// Like `harness_with`, with the details pictures answered as `pictures` says (changeable during the test).
|
||
fn harness_pictures(
|
||
events: Vec<Event>,
|
||
view: ViewMode,
|
||
theme: egui::Theme,
|
||
manage: bool,
|
||
scale: f32,
|
||
pictures: Rc<Cell<Pictures>>,
|
||
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||
harness_full(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native, pictures, None, None)
|
||
}
|
||
|
||
fn harness_with_title_bar(
|
||
events: Vec<Event>,
|
||
view: ViewMode,
|
||
theme: egui::Theme,
|
||
manage: bool,
|
||
scale: f32,
|
||
bar: artcraft_launcher_ui_egui::TitleBar,
|
||
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||
harness_full(events, view, theme, manage, scale, bar, Rc::default(), None, None)
|
||
}
|
||
|
||
fn harness_with_discord(discord_url: Option<&'static str>) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||
harness_full(
|
||
events(),
|
||
ViewMode::Grid,
|
||
egui::Theme::Dark,
|
||
true,
|
||
1.0,
|
||
artcraft_launcher_ui_egui::TitleBar::Native,
|
||
Rc::default(),
|
||
None,
|
||
discord_url,
|
||
)
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)] // Test fixture inputs vary independently across existing cases.
|
||
/// Builds a harness with the full fixture matrix (events, view, theme, manage mode, scale, title
|
||
/// bar, pictures, platform and Discord URL), forces English labels, and returns the recorded
|
||
/// commands.
|
||
fn harness_full(
|
||
events: Vec<Event>,
|
||
view: ViewMode,
|
||
theme: egui::Theme,
|
||
manage: bool,
|
||
scale: f32,
|
||
bar: artcraft_launcher_ui_egui::TitleBar,
|
||
pictures: Rc<Cell<Pictures>>,
|
||
platform: Option<Platform>,
|
||
discord_url: Option<&'static str>,
|
||
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||
struct HeaderHost(artcraft_launcher_ui_egui::TitleBar, Option<&'static str>);
|
||
impl Host for HeaderHost {
|
||
fn title_bar(&self) -> artcraft_launcher_ui_egui::TitleBar {
|
||
self.0
|
||
}
|
||
fn discord_url(&self) -> &'static str {
|
||
self.1.unwrap_or(artcraft_launcher_core::catalog::DISCORD_URL)
|
||
}
|
||
}
|
||
// 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 fake = Fake { events, sent: sent2, manage, pictures, asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) };
|
||
let mut backend: Option<Box<dyn Backend>> = Some(match platform {
|
||
Some(platform) => Box::new(PlatformFake { inner: fake, platform }),
|
||
None => Box::new(fake),
|
||
});
|
||
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 = test_harness_builder(theme).with_size(egui::vec2(1180.0, 1200.0)).with_pixels_per_point(scale).with_theme(theme).build_ui_state(
|
||
move |ui, state: &mut Option<LauncherUi>| {
|
||
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(), b, s));
|
||
}
|
||
if let Some(l) = state.as_mut() {
|
||
l.logic(&ui.ctx().clone(), &mut HeaderHost(bar, discord_url));
|
||
l.ui(ui, &mut HeaderHost(bar, discord_url));
|
||
}
|
||
},
|
||
None,
|
||
);
|
||
(harness, sent)
|
||
}
|
||
|
||
/// Save a PNG under `ARTCRAFT_LAUNCHER_SNAPSHOTS` when that is set. `name` is a test-owned file
|
||
/// stem (the test names and sizes make each one unique), never a path: a separator is rejected so
|
||
/// no caller can write outside the evidence directory.
|
||
fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
|
||
let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };
|
||
assert!(
|
||
!name.is_empty() && !name.contains(['/', '\\']) && name != "." && name != "..",
|
||
"snapshot name must be one pathless file stem, got {name:?}"
|
||
);
|
||
let img = h.render().unwrap();
|
||
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
|
||
}
|
||
|
||
/// A managing macOS fixture with a fresh PhotoCraft release and a scripted folder picker, for
|
||
/// the install-folder flow.
|
||
fn folder_harness(picked: Rc<RefCell<Option<String>>>) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||
struct Picker(Rc<RefCell<Option<String>>>);
|
||
impl Host for Picker {
|
||
fn pick_folder(&mut self, _initial: Option<&str>) -> Option<String> {
|
||
self.0.borrow_mut().take()
|
||
}
|
||
}
|
||
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
|
||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||
let mut backend = Some(Fake {
|
||
events: vec![
|
||
Event::Latest { app: "photocraft".into(), latest: LatestRelease::Found(release("photocraft", "0.6.0", NOW)), from: Origin::GitHub },
|
||
Event::Detected { app: "photocraft".into(), installed: None },
|
||
],
|
||
sent: sent.clone(),
|
||
manage: true,
|
||
pictures: Rc::default(),
|
||
asked: vec![],
|
||
clock: Rc::new(Cell::new(NOW)),
|
||
});
|
||
let mut host = Picker(picked);
|
||
let harness = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
|
||
move |ui, state: &mut Option<LauncherUi>| {
|
||
if let Some(backend) = backend.take() {
|
||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(backend), Settings { notify: false, ..Default::default() }));
|
||
}
|
||
if let Some(launcher) = state {
|
||
launcher.logic(ui.ctx(), &mut host);
|
||
launcher.ui(ui, &mut host);
|
||
}
|
||
},
|
||
None,
|
||
);
|
||
(harness, sent)
|
||
}
|
||
|
||
fn install_commands(sent: &RefCell<Vec<Command>>) -> Vec<Command> {
|
||
sent.borrow().iter().filter(|c| !matches!(c, Command::CheckForUpdates { .. })).cloned().collect()
|
||
}
|
||
|
||
#[test]
|
||
fn app_and_launcher_offer_version_choices_without_starting_an_install() {
|
||
let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.run_steps(6);
|
||
h.get_by_label("More actions for PhotoCraft").click();
|
||
h.run_steps(3);
|
||
h.get_by_label("Choose version").click();
|
||
h.run_steps(3);
|
||
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Version");
|
||
assert!(sent.borrow().iter().any(|c| matches!(c, Command::ListVersions { app } if app == "photocraft")));
|
||
assert!(!sent.borrow().iter().any(|c| matches!(c, Command::Install { .. })));
|
||
h.get_by_label("Close").click();
|
||
h.run_steps(3);
|
||
h.get_by_label("Settings").click();
|
||
h.run_steps(4);
|
||
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Launcher version").click();
|
||
h.run_steps(3);
|
||
h.get_by_label("Latest release");
|
||
h.get_by_label("Nightly (none published yet)");
|
||
assert!(h.query_by_label("Launcher versions").is_none(), "version selection belongs in Settings, not another dialog");
|
||
assert!(sent.borrow().iter().any(|c| matches!(c, Command::ListVersions { app } if app == artcraft_launcher_core::catalog::LAUNCHER_ID)));
|
||
}
|
||
|
||
#[test]
|
||
fn version_choices_have_room_and_nightly_scrolls_with_the_versions() {
|
||
let releases: Vec<_> = (1..=25)
|
||
.rev()
|
||
.map(|v| {
|
||
let mut r = release("artcraft-launcher", &format!("0.{v}.0"), NOW);
|
||
r.assets[0].sha256 = Some("0".repeat(64));
|
||
r
|
||
})
|
||
.collect();
|
||
let mut ev = events();
|
||
ev.push(Event::Versions { app: artcraft_launcher_core::catalog::LAUNCHER_ID.into(), result: Ok(releases) });
|
||
let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0);
|
||
h.set_size(egui::vec2(1180.0, 1100.0));
|
||
h.run_steps(6);
|
||
h.get_by_label("Settings").click();
|
||
h.run_steps(4);
|
||
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Launcher version").click();
|
||
h.run_steps(5);
|
||
let nightly_before = h.get_by_label("Nightly (none published yet)").rect();
|
||
// The newest release carries the label; there is no separate "Latest release" row.
|
||
let first = h.get_by_label("v0.25.0 (latest release)").rect();
|
||
assert!(h.query_by_label("Latest release").is_none(), "the latest version is labelled in place");
|
||
assert!(nightly_before.height() >= 40.0, "dropdown rows need a comfortable target: {nightly_before:?}");
|
||
// Rows touch, so a selected row's highlight fills its whole slot and the spacing stays even.
|
||
assert!((first.top() - nightly_before.bottom()).abs() < 1.0, "rows sit at an even pitch: {nightly_before:?} {first:?}");
|
||
h.event(egui::Event::PointerMoved(first.center()));
|
||
h.event(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: egui::vec2(0.0, -250.0),
|
||
phase: egui::TouchPhase::Move,
|
||
source: egui::MouseWheelSource::Wheel,
|
||
modifiers: egui::Modifiers::NONE,
|
||
});
|
||
h.run_steps(30);
|
||
let nightly_after = h.get_by_label("Nightly (none published yet)").rect();
|
||
assert!(nightly_after.top() < nightly_before.top() - 80.0, "Nightly must scroll with release rows: {nightly_before:?} -> {nightly_after:?}");
|
||
}
|
||
|
||
/// A backend that answers ListVersions, unlike the shared Fake: the dialog clears the cached
|
||
/// list when it opens, so the choice only appears once the reply arrives.
|
||
struct VersionsFake {
|
||
events: Rc<RefCell<Vec<Event>>>,
|
||
sent: Rc<RefCell<Vec<Command>>>,
|
||
answer: Vec<Release>,
|
||
}
|
||
|
||
impl Backend for VersionsFake {
|
||
fn send(&mut self, command: Command) {
|
||
if let Command::ListVersions { app } = &command {
|
||
self.events.borrow_mut().push(Event::Versions { app: app.clone(), result: Ok(self.answer.clone()) });
|
||
}
|
||
self.sent.borrow_mut().push(command);
|
||
}
|
||
fn poll(&mut self) -> Vec<Event> {
|
||
std::mem::take(&mut *self.events.borrow_mut())
|
||
}
|
||
fn capabilities(&self) -> Capabilities {
|
||
Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }
|
||
}
|
||
fn platform(&self) -> Option<Platform> {
|
||
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
|
||
}
|
||
fn now(&self) -> i64 {
|
||
NOW
|
||
}
|
||
}
|
||
|
||
/// A PhotoCraft release with a published checksum, so the selector offers it.
|
||
fn signed_photocraft(v: &str) -> Release {
|
||
let mut r = release("photocraft", v, NOW);
|
||
r.assets[0].sha256 = Some("0".repeat(64));
|
||
r
|
||
}
|
||
|
||
type VersionsHarness = (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>, Rc<RefCell<Vec<Event>>>);
|
||
|
||
/// The suite (PhotoCraft 0.4.1 installed) with `latest` as PhotoCraft's checked release and
|
||
/// `answer` as its published versions, opened on PhotoCraft's expanded version menu.
|
||
fn version_menu(latest: Release, answer: Vec<Release>) -> VersionsHarness {
|
||
struct DummyHost;
|
||
impl Host for DummyHost {}
|
||
let mut ev = events();
|
||
ev.push(Event::Latest { app: "photocraft".into(), latest: LatestRelease::Found(latest), from: Origin::GitHub });
|
||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||
let queue = Rc::new(RefCell::new(ev));
|
||
let mut backend = Some(VersionsFake { events: queue.clone(), sent: sent.clone(), answer });
|
||
let mut h = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||
move |ui, state: &mut Option<LauncherUi>| {
|
||
if let Some(backend) = backend.take() {
|
||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(backend), Settings { notify: false, ..Default::default() }));
|
||
}
|
||
if let Some(launcher) = state {
|
||
launcher.logic(ui.ctx(), &mut DummyHost);
|
||
launcher.ui(ui, &mut DummyHost);
|
||
}
|
||
},
|
||
None,
|
||
);
|
||
h.run_steps(6);
|
||
h.get_by_label("More actions for PhotoCraft").click();
|
||
h.run_steps(3);
|
||
h.get_by_label("Choose version").click();
|
||
h.run_steps(4);
|
||
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Version").click();
|
||
h.run_steps(4);
|
||
(h, sent, queue)
|
||
}
|
||
|
||
#[test]
|
||
fn closing_the_dialog_without_installing_drops_the_chosen_version() {
|
||
// As on a real machine: the checked release is still the newest when the older one is picked.
|
||
let (mut h, sent, _) = version_menu(signed_photocraft("0.5.0"), vec![signed_photocraft("0.5.0"), signed_photocraft("0.4.0")]);
|
||
h.query_all_by_role_and_label(egui::accesskit::Role::Button, "v0.4.0").next().unwrap().click();
|
||
h.run_steps(4);
|
||
assert!(h.query_all_by_label("Install version v0.4.0").count() > 0, "the dialog offers the chosen version");
|
||
sent.borrow_mut().clear();
|
||
h.get_by_label("Close").click();
|
||
h.run_steps(4);
|
||
assert!(h.query_by_label("Version").is_none(), "Close closes the dialog");
|
||
assert!(h.query_by_label("Install version v0.4.0").is_none(), "the card goes back to its own action");
|
||
let configured = sent.borrow().iter().rev().find_map(|c| match c {
|
||
Command::Configure(o) => Some(o.versions.get("photocraft").cloned()),
|
||
_ => None,
|
||
});
|
||
assert_eq!(configured, Some(None), "the choice from before (latest release) is restored");
|
||
assert!(sent.borrow().iter().any(|c| matches!(c, Command::CheckForUpdates { force: true })), "the restored choice is checked again");
|
||
}
|
||
|
||
#[test]
|
||
fn choosing_an_app_version_turns_its_main_action_into_install() {
|
||
// A newer release takes the "latest release" label, so v0.4.0 is a plain pin.
|
||
let pin = signed_photocraft("0.4.0");
|
||
let (mut h, sent, queue) = version_menu(pin.clone(), vec![signed_photocraft("0.5.0"), pin]);
|
||
assert!(h.query_all_by_role_and_label(egui::accesskit::Role::Button, "v0.5.0 (latest release)").next().is_some());
|
||
sent.borrow_mut().clear();
|
||
h.query_all_by_role_and_label(egui::accesskit::Role::Button, "v0.4.0").next().unwrap().click();
|
||
h.run_steps(4);
|
||
assert!(
|
||
sent.borrow().iter().any(|c| matches!(c, Command::CheckForUpdates { force: true })),
|
||
"selection must refresh the release used by Install"
|
||
);
|
||
assert!(!sent.borrow().iter().any(|c| matches!(c, Command::Install { .. })), "selection itself must not install");
|
||
h.run_steps(4);
|
||
assert!(h.query_by_label("Version").is_some(), "choosing a version keeps the dialog open");
|
||
// The dialog's own Install sits on its Close button's row; the card shows the same label.
|
||
let row = h.get_by_label("Close").rect().center().y;
|
||
h.get_all_by_label("Install version v0.4.0")
|
||
.find(|n| (n.rect().center().y - row).abs() < 4.0)
|
||
.expect("the dialog offers Install for the chosen version")
|
||
.click();
|
||
h.run_steps(4);
|
||
assert!(sent.borrow().iter().any(|c| matches!(c, Command::Install { app } if app == "photocraft")));
|
||
assert!(!sent.borrow().iter().any(|c| matches!(c, Command::Launch { app } if app == "photocraft")));
|
||
assert!(h.query_by_label("Version").is_none(), "installing closes the dialog");
|
||
assert!(h.query_by_label("Install version v0.4.0").is_some(), "installing from the dialog keeps the chosen version");
|
||
// Once the pinned version is the installed copy, the card opens it again.
|
||
let opens = h.query_all_by_label("Open").count();
|
||
queue.borrow_mut().push(Event::Detected {
|
||
app: "photocraft".into(),
|
||
installed: Some(Installed {
|
||
version: version::parse("0.4.0"),
|
||
source: InstallSource::MacApp,
|
||
location: "/Applications/PhotoCraft.app".into(),
|
||
package: None,
|
||
}),
|
||
});
|
||
h.run_steps(4);
|
||
assert!(h.query_by_label("Install version v0.4.0").is_none(), "an installed pin is not offered again");
|
||
assert_eq!(h.query_all_by_label("Open").count(), opens + 1, "PhotoCraft shows Open");
|
||
}
|
||
|
||
#[test]
|
||
/// A custom-folder install opens a confirmation dialog with no command until Install; the
|
||
/// chosen root and per-app folder are persisted, Configure precedes Install, and Cancel keeps
|
||
/// no override.
|
||
fn a_fresh_install_confirms_the_folder_and_configures_before_installing() {
|
||
let picked = Rc::new(RefCell::new(None));
|
||
let (mut h, sent) = folder_harness(picked.clone());
|
||
h.run_steps(8);
|
||
h.get_by_label("Settings").click();
|
||
h.run_steps(4);
|
||
h.get_by_label("Browse…").scroll_to_me();
|
||
h.run_steps(4);
|
||
*picked.borrow_mut() = Some("/Volumes/Craft Apps".into());
|
||
h.get_by_label("Browse…").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().as_ref().unwrap().settings.install_locations.root.as_deref(), Some("/Volumes/Craft Apps"));
|
||
h.get_by_label("Done").click();
|
||
h.run_steps(3);
|
||
sent.borrow_mut().clear();
|
||
h.get_by_label("Install").scroll_to_me();
|
||
h.run_steps(3);
|
||
h.get_by_label("Install").click();
|
||
h.run_steps(4);
|
||
assert!(!sent.borrow().iter().any(|c| matches!(c, Command::Install { .. })));
|
||
h.get_by_label("Apps are placed inside this folder.");
|
||
h.check_accessibility();
|
||
snapshot(&mut h, "install-folder-confirmation");
|
||
*picked.borrow_mut() = Some("/Volumes/Photo".into());
|
||
h.get_by_label("Browse…").click();
|
||
h.run_steps(3);
|
||
assert!(h.state().as_ref().unwrap().settings.install_locations.per_app.is_empty());
|
||
h.get_by_label("Cancel").click();
|
||
h.run_steps(3);
|
||
assert!(h.state().as_ref().unwrap().settings.install_locations.per_app.is_empty());
|
||
h.get_by_label("Install").scroll_to_me();
|
||
h.run_steps(3);
|
||
h.get_by_label("Install").click();
|
||
h.run_steps(3);
|
||
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Install folder").click();
|
||
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Install folder").type_text("/Volumes/Typed Photo");
|
||
h.run_steps(3);
|
||
h.get_by_label("Browse…").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Install folder").value().as_deref(), Some("/Volumes/Typed Photo"));
|
||
sent.borrow_mut().clear();
|
||
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
|
||
h.run_steps(2);
|
||
let commands = install_commands(&sent);
|
||
assert!(matches!(commands.first(), Some(Command::Configure(options))
|
||
if options.install_locations.root.as_deref() == Some("/Volumes/Craft Apps")
|
||
&& options.install_locations.folder("photocraft") == Some("/Volumes/Typed Photo")));
|
||
assert_eq!(commands.get(1), Some(&Command::Install { app: "photocraft".into() }));
|
||
}
|
||
|
||
#[test]
|
||
fn use_default_removes_an_app_override_and_settings_can_reset_the_root() {
|
||
let picked = Rc::new(RefCell::new(Some("/Volumes/Photo".into())));
|
||
let (mut h, sent) = folder_harness(picked);
|
||
h.run_steps(8);
|
||
h.state_mut().as_mut().unwrap().settings.install_locations.root = Some("/Volumes/Craft Apps".into());
|
||
h.state_mut().as_mut().unwrap().settings.install_locations.per_app.insert("photocraft".into(), "/Volumes/Old Photo".into());
|
||
h.run_steps(2);
|
||
h.get_by_label("Install").scroll_to_me();
|
||
h.run_steps(3);
|
||
h.get_by_label("Install").click();
|
||
h.run_steps(3);
|
||
h.get_by_label("Use default").click();
|
||
h.run_steps(2);
|
||
sent.borrow_mut().clear();
|
||
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
|
||
h.run_steps(2);
|
||
assert!(h.state().as_ref().unwrap().settings.install_locations.per_app.is_empty());
|
||
assert!(
|
||
matches!(install_commands(&sent).first(), Some(Command::Configure(options)) if options.install_locations.folder("photocraft") == Some("/Volumes/Craft Apps"))
|
||
);
|
||
h.get_by_label("Settings").click();
|
||
h.run_steps(3);
|
||
h.get_by_label("Use default").scroll_to_me();
|
||
h.run_steps(3);
|
||
h.get_by_label("Use default").click();
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().as_ref().unwrap().settings.install_locations.root, None);
|
||
}
|
||
|
||
/// The install-folder fields' default comes from the host, so a person sees the real folder a
|
||
/// fresh install uses (here `/Applications`) instead of only "System default", and a saved root
|
||
/// override replaces it. The default is the same value the empty field's hint shows.
|
||
#[test]
|
||
fn the_install_folder_fields_default_to_the_hosts_answer() {
|
||
struct DefaultHost {
|
||
picked: Rc<RefCell<Option<String>>>,
|
||
initial: Rc<RefCell<Option<Option<String>>>>,
|
||
}
|
||
impl Host for DefaultHost {
|
||
fn pick_folder(&mut self, initial: Option<&str>) -> Option<String> {
|
||
*self.initial.borrow_mut() = Some(initial.map(str::to_string));
|
||
self.picked.borrow_mut().take()
|
||
}
|
||
fn default_install_folder(&mut self, _kind: artcraft_launcher_core::PackageKind, _arch: Option<Arch>) -> Option<String> {
|
||
Some("/Applications".into())
|
||
}
|
||
}
|
||
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
|
||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||
let mut backend = Some(Fake {
|
||
events: vec![
|
||
Event::Latest { app: "photocraft".into(), latest: LatestRelease::Found(release("photocraft", "0.6.0", NOW)), from: Origin::GitHub },
|
||
Event::Detected { app: "photocraft".into(), installed: None },
|
||
],
|
||
sent: sent.clone(),
|
||
manage: true,
|
||
pictures: Rc::default(),
|
||
asked: vec![],
|
||
clock: Rc::new(Cell::new(NOW)),
|
||
});
|
||
let picked = Rc::new(RefCell::new(None));
|
||
let initial = Rc::new(RefCell::new(None));
|
||
let mut host = DefaultHost { picked: picked.clone(), initial: initial.clone() };
|
||
let harness = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
|
||
move |ui, state: &mut Option<LauncherUi>| {
|
||
if let Some(backend) = backend.take() {
|
||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(backend), Settings { notify: false, ..Default::default() }));
|
||
}
|
||
if let Some(launcher) = state {
|
||
launcher.logic(ui.ctx(), &mut host);
|
||
launcher.ui(ui, &mut host);
|
||
}
|
||
},
|
||
None,
|
||
);
|
||
let mut h = harness;
|
||
h.run_steps(8);
|
||
// Settings' install folder starts at the host's default.
|
||
h.get_by_label("Settings").click();
|
||
h.run_steps(4);
|
||
h.get_by_label("Browse…").scroll_to_me();
|
||
h.run_steps(4);
|
||
h.get_by_label("Browse…").click();
|
||
h.run_steps(3);
|
||
assert_eq!(initial.borrow().as_ref(), Some(&Some("/Applications".into())));
|
||
h.get_by_label("Done").click();
|
||
h.run_steps(3);
|
||
// So does the per-app install dialog's folder, and a saved root override replaces it.
|
||
h.get_by_label("Install").scroll_to_me();
|
||
h.run_steps(3);
|
||
h.get_by_label("Install").click();
|
||
h.run_steps(3);
|
||
h.get_by_label("Browse…").scroll_to_me();
|
||
h.run_steps(3);
|
||
h.get_by_label("Browse…").click();
|
||
h.run_steps(3);
|
||
assert_eq!(initial.borrow().as_ref(), Some(&Some("/Applications".into())));
|
||
h.state_mut().as_mut().unwrap().settings.install_locations.root = Some("/Volumes/Craft Apps".into());
|
||
h.run_steps(3);
|
||
h.get_by_label("Browse…").click();
|
||
h.run_steps(3);
|
||
assert_eq!(initial.borrow().as_ref(), Some(&Some("/Volumes/Craft Apps".into())));
|
||
h.check_accessibility();
|
||
}
|
||
|
||
/// A host whose default folder names the package it was asked about (`Msi X86`), and answers
|
||
/// nothing when the architecture is unknown, as the desktop host does for an MSI.
|
||
/// What Browse was last opened at: `None` until it is clicked.
|
||
type BrowsedFrom = Rc<RefCell<Option<Option<String>>>>;
|
||
|
||
struct PackageHost {
|
||
initial: BrowsedFrom,
|
||
}
|
||
impl Host for PackageHost {
|
||
fn pick_folder(&mut self, initial: Option<&str>) -> Option<String> {
|
||
*self.initial.borrow_mut() = Some(initial.map(str::to_string));
|
||
None
|
||
}
|
||
fn default_install_folder(&mut self, kind: artcraft_launcher_core::PackageKind, arch: Option<Arch>) -> Option<String> {
|
||
Some(format!("{kind:?} {:?}", arch?))
|
||
}
|
||
}
|
||
|
||
/// A Windows launcher whose apps publish only the named assets, with `preferred` formats saved.
|
||
/// Returns the harness and what Browse was last opened at.
|
||
fn windows_package_harness(
|
||
apps: &[(&str, &[&str])],
|
||
preferred: Vec<artcraft_launcher_core::PackageKind>,
|
||
) -> (Harness<'static, Option<LauncherUi>>, BrowsedFrom) {
|
||
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
|
||
let mut events = Vec::new();
|
||
for (id, assets) in apps {
|
||
let mut latest = release(id, "0.6.0", NOW);
|
||
latest.assets = assets.iter().map(|name| Asset { name: (*name).into(), size: 1, url: format!("https://x/{name}"), sha256: None }).collect();
|
||
events.push(Event::Latest { app: (*id).into(), latest: LatestRelease::Found(latest), from: Origin::GitHub });
|
||
events.push(Event::Detected { app: (*id).into(), installed: None });
|
||
}
|
||
let fake = Fake { events, sent: Rc::default(), manage: true, pictures: Rc::default(), asked: vec![], clock: Rc::new(Cell::new(NOW)) };
|
||
let mut backend: Option<Box<dyn Backend>> = Some(Box::new(PlatformFake { inner: fake, platform: Platform { os: Os::Windows, arch: Arch::X64 } }));
|
||
let initial = Rc::new(RefCell::new(None));
|
||
let mut host = PackageHost { initial: initial.clone() };
|
||
let harness = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
|
||
move |ui, state: &mut Option<LauncherUi>| {
|
||
if let Some(backend) = backend.take() {
|
||
let settings = Settings { notify: false, preferred_kinds: preferred.clone(), ..Default::default() };
|
||
*state = Some(LauncherUi::new(ui.ctx(), backend, settings));
|
||
}
|
||
if let Some(launcher) = state {
|
||
launcher.logic(ui.ctx(), &mut host);
|
||
launcher.ui(ui, &mut host);
|
||
}
|
||
},
|
||
None,
|
||
);
|
||
(harness, initial)
|
||
}
|
||
|
||
/// The install dialog's default follows the package the backend will really install: Portable
|
||
/// ZIP is preferred, but this release ships only a 32-bit MSI, so the app goes into the 32-bit
|
||
/// Program Files and not the launcher's `apps` folder.
|
||
#[test]
|
||
fn the_install_folder_default_follows_the_package_the_backend_falls_back_to() {
|
||
let (mut h, initial) =
|
||
windows_package_harness(&[("photocraft", &["photocraft-0.6.0-windows-x86.msi"])], vec![artcraft_launcher_core::PackageKind::PortableZip]);
|
||
h.run_steps(8);
|
||
h.get_by_label("Install").scroll_to_me();
|
||
h.run_steps(3);
|
||
h.get_by_label("Install").click();
|
||
h.run_steps(3);
|
||
h.get_by_label("Browse…").click();
|
||
h.run_steps(3);
|
||
assert_eq!(initial.borrow().as_ref(), Some(&Some("Msi X86".into())));
|
||
}
|
||
|
||
/// Settings covers every app, so it shows a folder only when every app's package shares one: a
|
||
/// 32-bit-only app beside a 64-bit one leaves it at "System default".
|
||
#[test]
|
||
fn settings_shows_a_default_folder_only_when_every_app_shares_it() {
|
||
let open_settings_browse = |apps: &[(&str, &[&str])]| {
|
||
let (mut h, initial) = windows_package_harness(apps, vec![]);
|
||
h.run_steps(8);
|
||
h.get_by_label("Settings").click();
|
||
h.run_steps(4);
|
||
h.get_by_label("Browse…").scroll_to_me();
|
||
h.run_steps(4);
|
||
h.get_by_label("Browse…").click();
|
||
h.run_steps(3);
|
||
initial.take()
|
||
};
|
||
let x64 = ["photocraft-0.6.0-windows-x64.msi", "photocraft-0.6.0-windows-x86.msi"];
|
||
let shared = open_settings_browse(&[("photocraft", &x64), ("vectorcraft", &["vectorcraft-0.6.0-windows-x64.msi"])]);
|
||
assert_eq!(shared, Some(Some("Msi X64".into())));
|
||
let mixed = open_settings_browse(&[("photocraft", &x64), ("vectorcraft", &["vectorcraft-0.6.0-windows-x86.msi"])]);
|
||
assert_eq!(mixed, Some(None));
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn windows_package_choice_can_select_portable_and_configures_the_backend() {
|
||
let (mut h, sent) = harness_full(
|
||
events(),
|
||
ViewMode::Grid,
|
||
egui::Theme::Light,
|
||
true,
|
||
1.0,
|
||
artcraft_launcher_ui_egui::TitleBar::Native,
|
||
Rc::default(),
|
||
Some(Platform { os: Os::Windows, arch: Arch::X64 }),
|
||
None,
|
||
);
|
||
h.run_steps(8);
|
||
h.get_by_label("Settings").click();
|
||
h.run_steps(4);
|
||
h.get_by_value("Installer (.msi)").click();
|
||
h.run_steps(2);
|
||
h.get_by_label("Portable (.zip)").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().as_ref().unwrap().settings.preferred_kinds, [artcraft_launcher_core::PackageKind::PortableZip]);
|
||
assert!(
|
||
sent.borrow()
|
||
.iter()
|
||
.any(|command| matches!(command, Command::Configure(options) if options.kinds == [artcraft_launcher_core::PackageKind::PortableZip]))
|
||
);
|
||
}
|
||
|
||
/// The narrow 1000 x 700 native window that first showed it: a ComboBox grows to the width its
|
||
/// layout offers, so an unconstrained one started at the label's last glyph and painted over
|
||
/// "Install new apps as". The test reads the real widget rects, not a snapshot.
|
||
#[test]
|
||
fn a_narrow_settings_row_keeps_its_control_clear_of_its_label() {
|
||
let (mut h, _sent) = harness_full(
|
||
events(),
|
||
ViewMode::Grid,
|
||
egui::Theme::Light,
|
||
true,
|
||
1.0,
|
||
artcraft_launcher_ui_egui::TitleBar::Native,
|
||
Rc::default(),
|
||
Some(Platform { os: Os::Windows, arch: Arch::X64 }),
|
||
None,
|
||
);
|
||
h.set_size(egui::vec2(1000.0, 700.0));
|
||
h.run_steps(6);
|
||
h.get_by_label("Settings").click();
|
||
h.run_steps(6);
|
||
h.get_by_role_and_label(egui::accesskit::Role::Label, "Install new apps as").scroll_to_me();
|
||
h.run_steps(3);
|
||
let label = h.get_by_role_and_label(egui::accesskit::Role::Label, "Install new apps as").rect();
|
||
// Several combo boxes are on the settings page; the format chooser is the one nearest the
|
||
// label, whichever column the dialog's size puts it in.
|
||
let combo = h
|
||
.query_all_by_role(egui::accesskit::Role::ComboBox)
|
||
.min_by_key(|node| {
|
||
let r = node.rect();
|
||
((r.center().x - label.center().x).abs() + (r.center().y - label.center().y).abs()) as i32
|
||
})
|
||
.expect("the install-format chooser is on screen")
|
||
.rect();
|
||
assert!(!label.intersects(combo), "the label {label:?} overlaps its combo box {combo:?}");
|
||
assert!(
|
||
(label.center().y - combo.center().y).abs() < 2.0 && combo.left() >= label.right() + 4.0 || combo.top() >= label.bottom() + 4.0,
|
||
"the label {label:?} and its combo box {combo:?} must keep a clear gap, on one row or stacked"
|
||
);
|
||
}
|
||
|
||
/// Drives the real launcher lifecycle (`logic` then `ui`) in one `Context::run_ui` pass, optionally
|
||
/// asking egui for a discarded second pass over the same input.
|
||
fn drive_launcher(launcher: &mut LauncherUi, ctx: &egui::Context, events: Vec<egui::Event>, time: f64, two_passes: bool) {
|
||
let mut first = two_passes;
|
||
let input = egui::RawInput {
|
||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1180.0, 1200.0))),
|
||
time: Some(time),
|
||
events,
|
||
..Default::default()
|
||
};
|
||
let _ = ctx.run_ui(input, |root| {
|
||
if first {
|
||
first = false;
|
||
root.ctx().request_discard("test: force a second pass");
|
||
}
|
||
launcher.logic(&root.ctx().clone(), &mut NoHost);
|
||
launcher.ui(root, &mut NoHost);
|
||
});
|
||
}
|
||
|
||
/// Drives one pointer gesture as move, press and release over separate frames, optionally
|
||
/// asking for a discarded second pass on release.
|
||
fn click_steps(launcher: &mut LauncherUi, ctx: &egui::Context, at: egui::Pos2, time: f64, two_passes_on_release: bool) {
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||
drive_launcher(launcher, ctx, vec![egui::Event::PointerMoved(at)], time, false);
|
||
drive_launcher(launcher, ctx, vec![button(true)], time + 0.1, false);
|
||
drive_launcher(launcher, ctx, vec![button(false)], time + 0.2, two_passes_on_release);
|
||
}
|
||
|
||
/// Counts the `Install` commands among the recorded backend commands.
|
||
fn install_count(sent: &RefCell<Vec<Command>>) -> usize {
|
||
sent.borrow().iter().filter(|c| matches!(c, Command::Install { .. })).count()
|
||
}
|
||
|
||
/// The AccessKit target of a visible widget, as an assistive technology would address it.
|
||
fn accesskit_target(h: &Harness<'_, Option<LauncherUi>>, label: &str) -> (egui::accesskit::NodeId, egui::accesskit::TreeId) {
|
||
h.get_by_label(label).accesskit_node().locate()
|
||
}
|
||
|
||
/// The semantic click a screen reader emits for `target`, addressed exactly as the tree did.
|
||
fn accesskit_click(target: (egui::accesskit::NodeId, egui::accesskit::TreeId)) -> egui::Event {
|
||
egui::Event::AccessKitActionRequest(egui::accesskit::ActionRequest {
|
||
target_node: target.0,
|
||
target_tree: target.1,
|
||
action: egui::accesskit::Action::Click,
|
||
data: None,
|
||
})
|
||
}
|
||
|
||
/// Runs one visible frame with a semantic (AccessKit) click delivered on every egui pass, or over
|
||
/// one pass only when `two_passes` is false. This is the actual UI path: delivering the same click
|
||
/// on a discarded pass lets a button's own `clicked()` handler queue its action twice, so a call
|
||
/// site that bypasses `act_command`/`act` is caught instead of being hidden by egui's
|
||
/// first-pass-only pointer events.
|
||
///
|
||
/// An AccessKit request belongs to the `RawInput` of a pass, exactly as kittest queues it;
|
||
/// inserting it with `Context::input_mut` after `begin_pass` is too late in 0.37. `run_ui` also
|
||
/// spends the raw events on its first pass and re-runs the second pass without them, so the passes
|
||
/// are driven explicitly. They share one `cumulative_frame_nr` (the action boundary's visible-frame
|
||
/// identity): `run_ui` advances that counter once per visible frame, while `begin_pass`/`end_pass`
|
||
/// do not. The empty priming frame starts a fresh frame without drawing anything.
|
||
fn drive_accesskit_click(
|
||
launcher: &mut LauncherUi,
|
||
ctx: &egui::Context,
|
||
target: (egui::accesskit::NodeId, egui::accesskit::TreeId),
|
||
time: f64,
|
||
two_passes: bool,
|
||
) {
|
||
let screen_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1180.0, 1200.0));
|
||
let _ = ctx.run_ui(egui::RawInput { screen_rect: Some(screen_rect), time: Some(time), ..Default::default() }, |_| {});
|
||
let passes = if two_passes { 2 } else { 1 };
|
||
for pass in 0..passes {
|
||
ctx.begin_pass(egui::RawInput {
|
||
screen_rect: Some(screen_rect),
|
||
time: Some(time),
|
||
events: vec![accesskit_click(target)],
|
||
..Default::default()
|
||
});
|
||
ctx.root_ui(|root| {
|
||
if two_passes && pass == 0 {
|
||
root.ctx().request_discard("test: repeat the semantic click in this frame");
|
||
}
|
||
// Match Harness::build_ui_state's scope and frame: auto widget IDs include both.
|
||
// Drawing directly in root_ui addresses different nodes than the captured tree.
|
||
root.scope_builder(egui::UiBuilder::new(), |ui| {
|
||
egui::Frame::central_panel(ui.style()).outer_margin(8.0).inner_margin(0.0).show(ui, |ui| {
|
||
launcher.logic(&ui.ctx().clone(), &mut NoHost);
|
||
launcher.ui(ui, &mut NoHost);
|
||
});
|
||
});
|
||
});
|
||
let _ = ctx.end_pass();
|
||
}
|
||
}
|
||
|
||
/// The end-to-end shape of one pointer gesture per frame. A discarded second pass has no pointer
|
||
/// events (egui clears them after the first pass), so this alone cannot catch a call site that
|
||
/// bypasses the action boundary. The AccessKit-click tests below re-deliver the same semantic click
|
||
/// on every pass and therefore exercise the actual UI call sites twice in one visible frame.
|
||
#[test]
|
||
fn the_same_download_is_sent_once_across_discarded_passes_but_again_next_frame() {
|
||
let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.run_steps(8);
|
||
let at = h.get_by_label("Update to 0.5.0").rect().center();
|
||
let ctx = h.ctx.clone();
|
||
let mut launcher = h.state_mut().take().expect("the harness built the launcher");
|
||
|
||
sent.borrow_mut().clear();
|
||
click_steps(&mut launcher, &ctx, at, 100.0, true);
|
||
assert_eq!(install_count(&sent), 1, "one visible frame must send one install, not one per pass: {:?}", sent.borrow());
|
||
|
||
// A new frame is a new user gesture: the guard from the previous frame must not swallow it.
|
||
sent.borrow_mut().clear();
|
||
click_steps(&mut launcher, &ctx, at, 200.0, false);
|
||
assert_eq!(install_count(&sent), 1, "the next frame must accept the same action again: {:?}", sent.borrow());
|
||
}
|
||
|
||
#[test]
|
||
/// Clicking Open across a discarded second pass increments the launch counter once for the
|
||
/// visible frame.
|
||
fn opening_an_app_counts_once_across_discarded_passes() {
|
||
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.run_steps(8);
|
||
let at = h.get_all_by_label("Open").next().expect("an up-to-date app has an Open button").rect().center();
|
||
let ctx = h.ctx.clone();
|
||
let mut launcher = h.state_mut().take().expect("the harness built the launcher");
|
||
let before = launcher.settings.launches.values().copied().sum::<u32>();
|
||
click_steps(&mut launcher, &ctx, at, 100.0, true);
|
||
let after = launcher.settings.launches.values().copied().sum::<u32>();
|
||
assert_eq!(after, before + 1, "one launch gesture increments the count once");
|
||
}
|
||
|
||
#[test]
|
||
/// The real Update button, clicked through AccessKit on every pass, crosses the action boundary
|
||
/// once per frame and again on the next frame.
|
||
fn the_actual_update_button_queues_once_across_a_discarded_pass() {
|
||
let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.run_steps(8);
|
||
let target = accesskit_target(&h, "Update to 0.5.0");
|
||
let ctx = h.ctx.clone();
|
||
let mut launcher = h.state_mut().take().expect("the harness built the launcher");
|
||
sent.borrow_mut().clear();
|
||
drive_accesskit_click(&mut launcher, &ctx, target, 100.0, true);
|
||
assert_eq!(install_count(&sent), 1, "the real Update button must cross the action boundary once: {:?}", sent.borrow());
|
||
// The next visible frame is a new gesture, not a repeated pass.
|
||
sent.borrow_mut().clear();
|
||
drive_accesskit_click(&mut launcher, &ctx, target, 200.0, false);
|
||
assert_eq!(install_count(&sent), 1, "the next frame must accept the same action again: {:?}", sent.borrow());
|
||
}
|
||
|
||
#[test]
|
||
/// The real Open button, clicked through AccessKit on every pass, delivers one `Launch` and
|
||
/// increments the count once.
|
||
fn the_actual_open_button_launches_once_across_a_discarded_pass() {
|
||
let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.run_steps(8);
|
||
let target = h.get_all_by_label("Open").next().expect("an up-to-date app has an Open button").accesskit_node().locate();
|
||
let ctx = h.ctx.clone();
|
||
let mut launcher = h.state_mut().take().expect("the harness built the launcher");
|
||
let before = launcher.settings.launches.values().copied().sum::<u32>();
|
||
sent.borrow_mut().clear();
|
||
drive_accesskit_click(&mut launcher, &ctx, target, 100.0, true);
|
||
let launches = sent.borrow().iter().filter(|c| matches!(c, Command::Launch { .. })).count();
|
||
assert_eq!(launches, 1, "the real Open button must deliver one launch, not one per pass: {:?}", sent.borrow());
|
||
assert_eq!(launcher.settings.launches.values().copied().sum::<u32>(), before + 1, "the launch count increments once");
|
||
}
|
||
|
||
#[test]
|
||
/// A toast created by logic while no window ran is shown once on the first drawing pass and
|
||
/// expires relative to that pass.
|
||
fn a_job_finished_while_hidden_shows_one_fresh_toast_on_the_next_frame() {
|
||
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
|
||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||
let backend = Fake {
|
||
events: vec![Event::JobFinished { app: "photocraft".into(), result: Ok("PhotoCraft updated to 0.5.0".into()) }],
|
||
sent: sent.clone(),
|
||
manage: true,
|
||
pictures: Rc::default(),
|
||
asked: Vec::new(),
|
||
clock: Rc::new(Cell::new(NOW)),
|
||
};
|
||
// Build the harness with no launcher first: its construction pass runs at time 0, before a test
|
||
// can set `RawInput::time`, and would otherwise consume the hidden message.
|
||
let mut attached = false;
|
||
// Ask for a discarded second pass on the first drawing frame: the toast path must be
|
||
// idempotent across passes, not just across steps.
|
||
let mut discard_once = true;
|
||
let mut h = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||
move |ui, state: &mut Option<LauncherUi>| {
|
||
if let Some(l) = state {
|
||
if !attached {
|
||
attached = true;
|
||
l.attach(ui.ctx());
|
||
}
|
||
if discard_once {
|
||
discard_once = false;
|
||
ui.ctx().request_discard("test: discard the first drawing pass");
|
||
}
|
||
l.logic(&ui.ctx().clone(), &mut NoHost);
|
||
l.ui(ui, &mut NoHost);
|
||
}
|
||
},
|
||
None,
|
||
);
|
||
// The tray path: `logic` runs without any UI pass, with the input time of the last shown frame.
|
||
let windowless = egui::Context::default();
|
||
let mut launcher = LauncherUi::new(&windowless, Box::new(backend), Settings { notify: false, language: "en".into(), ..Settings::default() });
|
||
launcher.logic(&windowless, &mut NoHost);
|
||
*h.state_mut() = Some(launcher);
|
||
// The visible window's clock is far ahead of the logic-only tick's stale input time. The toast
|
||
// must be fresh on the first drawing pass and must expire relative to it.
|
||
h.input_mut().time = Some(1000.0);
|
||
h.run_steps(2);
|
||
assert_eq!(h.query_all_by_label("PhotoCraft updated to 0.5.0").count(), 1, "the hidden result is shown once");
|
||
h.run_steps(2);
|
||
assert_eq!(h.query_all_by_label("PhotoCraft updated to 0.5.0").count(), 1, "a discarded/next pass must not duplicate it");
|
||
// A stale expiry (old logic time + 4.5 s) would already have dropped it; at 1004.6 the real
|
||
// 1000.0 + 4.5 deadline has passed, so the toast goes.
|
||
h.input_mut().time = Some(1004.6);
|
||
h.run_steps(2);
|
||
assert!(h.query_by_label("PhotoCraft updated to 0.5.0").is_none(), "the toast expires relative to the drawing pass");
|
||
}
|
||
|
||
#[test]
|
||
/// At 560 px the toolbar's search, refresh, settings, sort and per-app menu keep their
|
||
/// accessible names, with no unnamed input widgets and a strict accessibility pass.
|
||
fn a_narrow_toolbar_names_its_controls_and_passes_strict_accessibility() {
|
||
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.set_size(egui::vec2(560.0, 900.0));
|
||
h.run_steps(6);
|
||
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search apps");
|
||
h.get_by_role_and_label(egui::accesskit::Role::Button, "Check for updates");
|
||
h.get_by_role_and_label(egui::accesskit::Role::Button, "Settings");
|
||
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Sort");
|
||
h.get_by_role_and_label(egui::accesskit::Role::Button, "More actions for PhotoCraft");
|
||
let unnamed = h.unnamed_widgets();
|
||
assert!(unnamed.is_empty(), "narrow toolbar has unnamed input widgets: {unnamed:?}");
|
||
h.check_accessibility();
|
||
}
|
||
|
||
#[test]
|
||
fn install_locations_survive_settings_save_and_older_settings_use_system_default() {
|
||
let old: Settings = serde_json::from_str(r#"{"auto_update":true}"#).unwrap();
|
||
assert!(old.auto_update);
|
||
assert_eq!(old.install_locations, Default::default());
|
||
let mut settings = old;
|
||
settings.install_locations.root = Some("/Volumes/Craft Apps".into());
|
||
settings.install_locations.per_app.insert("photocraft".into(), "/Volumes/Photo".into());
|
||
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
|
||
assert_eq!(restored.install_locations.folder("photocraft"), Some("/Volumes/Photo"));
|
||
assert_eq!(restored.install_locations.folder("filmcraft"), Some("/Volumes/Craft Apps"));
|
||
}
|
||
|
||
#[test]
|
||
/// Logic without a window still consumes backend events; attaching to a new window then draws
|
||
/// the update cards.
|
||
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 = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.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_eq!(h.query_all_by_label("Installed · up to date").count(), 0, "an installed app carries no badge");
|
||
h.get_by_label("Update to 0.5.0");
|
||
h.get_by_label("Update to 0.6.0");
|
||
// The version sits in the card's top strip, and the date under the description.
|
||
h.get_by_label("0.4.1 → 0.5.0");
|
||
assert!(h.query_all_by_label("v0.4.0").count() >= 2);
|
||
// Written like every other date ("Oct 7, 2026"); the day itself depends on the zone the test runs in.
|
||
let updated: Vec<String> = h.query_all_by_label_contains("Last updated: ").filter_map(|n| n.accesskit_node().value()).collect();
|
||
assert!(updated.len() >= 2, "{updated:?}");
|
||
assert!(updated.iter().all(|l| l.starts_with("Last updated: Oct ") && l.ends_with(", 2026")), "{updated:?}");
|
||
// 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() >= 1); // the footer icon (the header has none)
|
||
assert!(h.query_by_label("Join the ArtCraft community on Discord").is_none(), "no Discord link in the header");
|
||
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(3);
|
||
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().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]
|
||
/// A kept download offers Retry install (which only opens install) and menu Download again
|
||
/// (Cancel then Install); apps without a kept download do not offer it.
|
||
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(3);
|
||
assert!(sent.borrow().is_empty());
|
||
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().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_contains("More actions").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(3);
|
||
assert_eq!(*sent.borrow(), [Command::Cancel { app: "cadcraft".into() }]);
|
||
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().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]
|
||
/// A ready update names its version and installs on Retry, while an up-to-date app offers
|
||
/// Reinstall instead of Download again.
|
||
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_by_role_and_label(egui::accesskit::Role::Label, "WordCraft").rect().center().y;
|
||
h.get_all_by_label_contains("More actions").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();
|
||
h.run_steps(3);
|
||
h.get_by_label("Reinstall");
|
||
h.check_accessibility();
|
||
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]
|
||
/// The launcher banner's Cancel crosses the action boundary once per visible frame.
|
||
fn the_launcher_banner_cancel_is_sent_once_across_discarded_passes() {
|
||
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);
|
||
let at = h.get_by_label("Cancel update").rect().center();
|
||
let ctx = h.ctx.clone();
|
||
let mut launcher = h.state_mut().take().expect("the harness built the launcher");
|
||
sent.borrow_mut().clear();
|
||
click_steps(&mut launcher, &ctx, at, 100.0, true);
|
||
let cancels = sent.borrow().iter().filter(|c| matches!(c, Command::Cancel { .. })).count();
|
||
assert_eq!(cancels, 1, "the banner's Cancel must cross the action boundary once: {:?}", sent.borrow());
|
||
}
|
||
|
||
#[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]
|
||
/// Hide removes an app from All into Hidden Apps; Unhide restores it, clears the hidden set and
|
||
/// returns the filter to All.
|
||
fn hiding_an_app_removes_it_until_it_is_unhidden() {
|
||
let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true);
|
||
h.run_steps(6);
|
||
let row = h.get_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft").rect().center().y;
|
||
h.get_all_by_label_contains("More actions").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_role_and_label(egui::accesskit::Role::Label, "PhotoCraft").is_none());
|
||
assert!(h.state().as_ref().unwrap().settings.hidden.contains("photocraft"));
|
||
h.get_by_label("Hidden Apps 1").click();
|
||
h.run_steps(4);
|
||
h.get_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft");
|
||
assert!(h.query_by_role_and_label(egui::accesskit::Role::Label, "WordCraft").is_none());
|
||
let row = h.get_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft").rect().center().y;
|
||
h.get_all_by_label_contains("More actions").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_role_and_label(egui::accesskit::Role::Label, "PhotoCraft");
|
||
assert!(h.query_by_label("Hidden Apps 1").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]
|
||
/// The view switch stores Table, and the table names each app once in its wordmark rather than
|
||
/// again on the decorative icon.
|
||
fn switching_views() {
|
||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.run_steps(6);
|
||
h.get_by_label("Show apps as a table").click();
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().as_ref().unwrap().settings.view, ViewMode::Table);
|
||
h.get_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft");
|
||
h.get_by_role_and_label(egui::accesskit::Role::Label, "WordCraft");
|
||
// The row icon is decorative; the wordmark carries the name exactly once.
|
||
assert_eq!(h.query_all_by_role_and_label(egui::accesskit::Role::Image, "PhotoCraft").count(), 0, "the table icon must not repeat the wordmark");
|
||
assert_eq!(
|
||
h.query_all_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft").count(),
|
||
1,
|
||
"the table wordmark names PhotoCraft exactly once"
|
||
);
|
||
}
|
||
|
||
/// 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<LauncherUi>>, lines: &[&str]) -> f32 {
|
||
let text = lines.iter().map(|l| h.get_by_role_and_label(egui::accesskit::Role::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<LauncherUi>>) -> Vec<String> {
|
||
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<String> = 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);
|
||
// Status: apps with an update first (suite order breaks the tie), then the up to date ones.
|
||
h.get_by_label("Status").click();
|
||
h.run_steps(3);
|
||
let o = order(&h);
|
||
assert_eq!(&o[..4], ["PhotoCraft", "EffectCraft", "LightCraft", "WordCraft"], "{o:?}");
|
||
// Reversed: the apps not installed first, then up to date, then the updates last. The table
|
||
// keeps its column's order; only the grid puts installed apps above the rest.
|
||
h.get_by_label("Status").click();
|
||
h.run_steps(3);
|
||
let o = order(&h);
|
||
assert_eq!(&o[o.len().saturating_sub(4)..], ["LightCraft", "WordCraft", "PhotoCraft", "EffectCraft"], "{o:?}");
|
||
// The old Installed and Latest columns are gone; Version shows the number, and from and to for an update.
|
||
assert!(h.query_by_label("Latest").is_none());
|
||
h.get_by_label("Version");
|
||
assert!(h.query_all_by_label("0.4.0").count() >= 2);
|
||
h.get_by_label("0.4.1 → 0.5.0");
|
||
h.get_by_label("0.5.0 → 0.6.0");
|
||
snapshot(&mut h, "table-sorted-status");
|
||
// 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:?}");
|
||
}
|
||
|
||
/// The first close to the tray is explained once, by a system notification (#45, #215).
|
||
#[test]
|
||
fn the_first_close_to_the_tray_is_explained_once() {
|
||
struct Notices(Vec<(String, String)>);
|
||
impl Host for Notices {
|
||
fn notify(&mut self, title: &str, body: &str) {
|
||
self.0.push((title.to_string(), body.to_string()));
|
||
}
|
||
}
|
||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.run_steps(6);
|
||
let mut host = Notices(Vec::new());
|
||
let l = h.state_mut().as_mut().unwrap();
|
||
assert!(!l.settings.tray_hint_shown);
|
||
l.hidden_to_tray(&mut host);
|
||
l.hidden_to_tray(&mut host);
|
||
assert!(l.settings.tray_hint_shown);
|
||
assert_eq!(host.0.len(), 1, "{:?}", host.0);
|
||
assert_eq!(host.0[0].0, "ArtCraft Launcher is still running");
|
||
assert!(host.0[0].1.contains("tray icon"), "{}", host.0[0].1);
|
||
}
|
||
|
||
#[test]
|
||
fn community_apps_can_be_hidden_and_restored_in_settings() {
|
||
for view in [ViewMode::Grid, ViewMode::Table] {
|
||
let (mut h, _) = harness(view, egui::Theme::Dark, true);
|
||
h.run_steps(6);
|
||
assert!(!h.state().as_ref().unwrap().settings.hide_community_apps);
|
||
assert!(h.query_by_label("SolidCraft details").is_some());
|
||
h.get_by_label("Settings").click();
|
||
h.run_steps(4);
|
||
h.get_by_label("Hide community apps").click();
|
||
h.run_steps(2);
|
||
assert!(h.state().as_ref().unwrap().settings.hide_community_apps);
|
||
h.get_by_label("Done").click();
|
||
h.run_steps(4);
|
||
assert!(h.query_by_label("SolidCraft details").is_none());
|
||
assert!(h.query_by_label("PhotoCraft details").is_some());
|
||
h.get_by_label("Settings").click();
|
||
h.run_steps(4);
|
||
h.get_by_label("Hide community apps").click();
|
||
h.run_steps(2);
|
||
h.get_by_label("Done").click();
|
||
h.run_steps(4);
|
||
assert!(h.query_by_label("SolidCraft details").is_some());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn community_visibility_preference_round_trips_and_defaults_off_for_old_settings() {
|
||
let old: Settings = serde_json::from_str("{}").unwrap();
|
||
assert!(!old.hide_community_apps);
|
||
let settings = Settings { hide_community_apps: true, ..old };
|
||
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
|
||
assert!(restored.hide_community_apps);
|
||
}
|
||
|
||
#[test]
|
||
/// The Settings dialog announces its heading once and shows the community section's Discord
|
||
/// entry alongside the footer's.
|
||
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);
|
||
h.check_accessibility();
|
||
assert_eq!(
|
||
h.query_all_by_role_and_label(egui::accesskit::Role::Label, "Settings").count(),
|
||
1,
|
||
"the Settings hero icon must not repeat its heading"
|
||
);
|
||
snapshot(&mut h, "settings-dark");
|
||
h.get_by_label("Questions, ideas and news from the ArtCraft team");
|
||
// The footer icon and the button in Settings.
|
||
assert!(h.query_all_by_label_contains("Discord").count() >= 2);
|
||
}
|
||
|
||
#[test]
|
||
/// After the taller Settings dialog, the uninstall prompt keeps its own content-sized modal with
|
||
/// its buttons close under the text (#88), and Cancel closes it.
|
||
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_by_role_and_label(egui::accesskit::Role::Button, "More actions for PhotoCraft").click();
|
||
h.run_steps(3);
|
||
h.get_by_label("Uninstall…").click();
|
||
h.run_steps(4);
|
||
h.check_accessibility();
|
||
h.get_by_role_and_label(egui::accesskit::Role::Label, "Uninstall PhotoCraft?");
|
||
assert_eq!(h.query_all_by_label_contains("Uninstall PhotoCraft?").count(), 1, "the title icon is decorative, not a second copy of the heading");
|
||
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]
|
||
/// The footer links website and GitHub, cards link only GitHub, and the table menu adds the
|
||
/// app's website and repository.
|
||
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, 0, "the cards only link to GitHub; the website is the footer's icon");
|
||
// 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_contains("More actions").nth(8).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<LauncherUi>>, label: &str, n: usize) -> Option<String> {
|
||
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<LauncherUi>>, label: &str) -> Option<String> {
|
||
click_nth_opens(h, label, 0)
|
||
}
|
||
|
||
/// Every URL the widgets labelled `label` open.
|
||
fn all_opened(h: &mut Harness<'_, Option<LauncherUi>>, label: &str) -> Vec<String> {
|
||
let n = h.query_all_by_label(label).count();
|
||
(0..n).filter_map(|i| click_nth_opens(h, label, i)).collect()
|
||
}
|
||
|
||
#[test]
|
||
/// Footer, card and social links open their expected pages; one card menu's All releases action
|
||
/// opens a repository releases page.
|
||
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_eq!(sites, vec!["https://getartcraft.com".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_contains("More actions").next().unwrap().click();
|
||
h.run_steps(3);
|
||
// The first card is the first installed app by name.
|
||
let url = click_opens(&mut h, "All releases").unwrap_or_default();
|
||
assert!(url.starts_with("https://github.com/storytold/") && url.ends_with("/releases"), "{url}");
|
||
}
|
||
|
||
/// An asset URL can come straight from GitHub's release JSON; it must not reach the opener when
|
||
/// it carries an opener metacharacter, whether it is shown on a card or in the launcher dialog.
|
||
#[test]
|
||
fn hostile_release_asset_urls_never_reach_the_opener() {
|
||
const HOSTILE: &str = "https://github.com/storytold/photocraft/releases/download/v0.5.0/photocraft-0.5.0-macos-universal.dmg|calc.exe";
|
||
|
||
let mut ev = events();
|
||
for event in &mut ev {
|
||
if let Event::Latest { app, latest: LatestRelease::Found(release), .. } = event
|
||
&& app.as_str() == "photocraft"
|
||
&& let Some(asset) = release.assets.first_mut()
|
||
{
|
||
asset.url = HOSTILE.to_string();
|
||
}
|
||
}
|
||
let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, false, 1.0);
|
||
h.run_steps(8);
|
||
assert!(h.query_all_by_label("Download for macOS").count() >= 8, "the cards must offer their asset downloads");
|
||
let opened = all_opened(&mut h, "Download for macOS");
|
||
assert!(!opened.iter().any(|url| url.as_str() == HOSTILE), "a hostile card asset URL reached the opener: {opened:?}");
|
||
|
||
let mut ev = events();
|
||
let mut launcher_release = release("artcraft-launcher", "0.2.0", NOW);
|
||
launcher_release.assets[0].url = HOSTILE.to_string();
|
||
ev.push(Event::LauncherUpdate(launcher_release));
|
||
let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, false, 1.0);
|
||
h.run_steps(6);
|
||
assert_eq!(h.query_all_by_label("Download").count(), 1, "the launcher update dialog must be open");
|
||
let opened = all_opened(&mut h, "Download");
|
||
assert!(opened.is_empty(), "a hostile launcher-dialog asset URL reached the opener: {opened:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn discord_link_opens_app_when_discord_is_installed() {
|
||
let (mut h, _) = harness_with_discord(Some(artcraft_launcher_core::catalog::DISCORD_APP_URL));
|
||
h.run_steps(6);
|
||
assert_eq!(click_opens(&mut h, "Discord").as_deref(), Some("discord://-/invite/artcraft"));
|
||
}
|
||
|
||
#[test]
|
||
/// Background app detection remains on its own five-minute cadence; release polling is separate.
|
||
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 = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||
move |ui, state: &mut Option<LauncherUi>| {
|
||
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<RefCell<Vec<Command>>>| 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]
|
||
/// Update and restart queues `UpdateLauncher`, progress hides the button, and
|
||
/// `LauncherRestarting` asks the host to quit; the update title is announced once.
|
||
fn the_launcher_updates_itself_and_quits_to_restart() {
|
||
struct QuitHost(Rc<Cell<bool>>);
|
||
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<RefCell<Vec<Event>>>,
|
||
}
|
||
impl Backend for Scripted {
|
||
fn send(&mut self, c: Command) {
|
||
self.inner.send(c);
|
||
}
|
||
fn poll(&mut self) -> Vec<Event> {
|
||
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<Platform> {
|
||
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 = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||
move |ui, state: &mut Option<LauncherUi>| {
|
||
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_role_and_label(egui::accesskit::Role::Label, "ArtCraft Launcher 0.2.0 is available.");
|
||
assert_eq!(
|
||
h.query_all_by_label_contains("ArtCraft Launcher 0.2.0 is available").count(),
|
||
1,
|
||
"the update title is announced once, not once by the icon and once by the heading"
|
||
);
|
||
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]
|
||
/// A found launcher update opens the relaunch prompt once; Later dismisses it and leaves the
|
||
/// toolbar banner.
|
||
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_role_and_label(egui::accesskit::Role::Label, "ArtCraft Launcher 0.2.0 is available.");
|
||
assert_eq!(
|
||
h.query_all_by_label_contains("ArtCraft Launcher 0.2.0 is available").count(),
|
||
1,
|
||
"the update title is announced once, not once by the icon and once by the heading"
|
||
);
|
||
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]
|
||
/// A feed alert is acknowledged only by OK (persisted), while an inline announcement stays until
|
||
/// its own Dismiss is clicked.
|
||
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 = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||
move |ui, state: &mut Option<LauncherUi>| {
|
||
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_twelve_minutes() {
|
||
use artcraft_launcher_ui_egui::settings::CheckEvery;
|
||
assert_eq!(Settings::default().check_every.seconds(), Some(720));
|
||
// Older saved choices still load, and mean "automatically" now.
|
||
assert_eq!(CheckEvery::Day.seconds(), Some(720));
|
||
assert_eq!(CheckEvery::Never.seconds(), None);
|
||
}
|
||
|
||
#[test]
|
||
/// With auto-update on, outdated apps are queued for Install and the launcher for
|
||
/// `UpdateLauncher`.
|
||
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 = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||
move |ui, state: &mut Option<LauncherUi>| {
|
||
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]
|
||
/// With auto-update off, no app install or launcher update is queued.
|
||
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 = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||
move |ui, state: &mut Option<LauncherUi>| {
|
||
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]
|
||
/// The footer names the release origins actually in use, adding GitHub only when some release
|
||
/// came from there.
|
||
fn the_footer_names_the_services_behind_what_is_on_screen() {
|
||
let footer = |extra: Vec<Event>| {
|
||
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 = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||
move |ui, state: &mut Option<LauncherUi>| {
|
||
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]
|
||
/// A custom title bar shows Minimize, Maximize and Close, and Close requests the viewport close
|
||
/// command.
|
||
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 = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||
move |ui, state: &mut Option<LauncherUi>| {
|
||
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)");
|
||
}
|
||
|
||
/// Maximized, the window's top-right pixel is the screen's corner: there is no resize band there,
|
||
/// so it belongs to the close button, as it does with Windows' own caption buttons (#374).
|
||
#[test]
|
||
fn maximized_the_close_button_reaches_the_screens_corner() {
|
||
let (mut h, _) = harness_with_title_bar(events(), ViewMode::Grid, egui::Theme::Dark, true, 1.0, artcraft_launcher_ui_egui::TitleBar::Custom);
|
||
h.run_steps(6);
|
||
let set_maximized = |h: &mut Harness<'_, Option<LauncherUi>>, maximized: bool| {
|
||
h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().maximized = Some(maximized);
|
||
h.run_steps(2);
|
||
};
|
||
// Presses and releases at `pos`, and returns what the window was asked to do.
|
||
let click = |h: &mut Harness<'_, Option<LauncherUi>>, pos: egui::Pos2| -> Vec<egui::ViewportCommand> {
|
||
let mut asked = Vec::new();
|
||
for pressed in [true, false] {
|
||
let input = h.input_mut();
|
||
input.events.push(egui::Event::PointerMoved(pos));
|
||
input.events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||
h.step();
|
||
asked.extend(h.output().viewport_output.values().flat_map(|v| v.commands.iter().cloned()));
|
||
}
|
||
asked
|
||
};
|
||
// Windowed, the clickable area stops short of the button's top-right corner by the resize
|
||
// band; maximized, it reaches it.
|
||
let windowed = h.get_by_label("Close").rect();
|
||
set_maximized(&mut h, true);
|
||
let maximized = h.get_by_label("Close").rect();
|
||
assert!(maximized.top() < windowed.top() && maximized.right() > windowed.right(), "windowed {windowed:?}, maximized {maximized:?}");
|
||
// The button's own corner stands in for the screen's: the harness keeps the app 8 px in from
|
||
// the window. Windowed, it is beyond the band and egui's click tolerance; maximized, it closes.
|
||
let corner = egui::pos2(maximized.right() - 0.5, maximized.top() + 0.5);
|
||
let asked = click(&mut h, corner);
|
||
assert!(asked.contains(&egui::ViewportCommand::Close), "maximized: {asked:?}");
|
||
set_maximized(&mut h, false);
|
||
let asked = click(&mut h, corner);
|
||
assert!(!asked.contains(&egui::ViewportCommand::Close), "windowed: {asked:?}");
|
||
// And the window's own corner is still for resizing.
|
||
let asked = click(&mut h, egui::pos2(1180.0 - 0.5, 0.5));
|
||
assert!(asked.iter().any(|c| matches!(c, egui::ViewportCommand::BeginResize(_))), "{asked:?}");
|
||
}
|
||
|
||
#[test]
|
||
/// Clicking a card opens its details drawer with the page links, wrapping picture arrows and
|
||
/// Esc-to-close.
|
||
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);
|
||
h.check_accessibility();
|
||
assert_eq!(
|
||
h.query_all_by_role_and_label(egui::accesskit::Role::Image, "PhotoCraft").count(),
|
||
0,
|
||
"the hero icon is decorative; the wordmark carries the app name"
|
||
);
|
||
assert_eq!(
|
||
h.query_all_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft").count(),
|
||
2,
|
||
"exactly the grid card's wordmark and the details drawer's wordmark carry the app name"
|
||
);
|
||
snapshot(&mut h, "details-dark");
|
||
h.get_all_by_label_contains("The image editor you already know how to use").last().unwrap();
|
||
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);
|
||
// Only the card's own line is left once the page has slid out.
|
||
assert_eq!(h.query_all_by_label_contains("The image editor you already know").count(), 1);
|
||
}
|
||
|
||
#[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_all_by_label_contains("The image editor you already know how to use").last().unwrap();
|
||
}
|
||
|
||
fn fetches(sent: &Rc<RefCell<Vec<Command>>>, 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").scroll_to_me();
|
||
h.run_steps(3);
|
||
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").scroll_to_me();
|
||
h.run_steps(3);
|
||
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_all_by_label_contains("The image editor you already know how to use").last().unwrap();
|
||
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_all_by_label_contains("The image editor you already know how to use").last().unwrap();
|
||
// 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").scroll_to_me();
|
||
h.run_steps(3);
|
||
h.get_by_label("PdfCraft details").click();
|
||
h.run_steps(10);
|
||
snapshot(&mut h, "details-no-pictures-dark");
|
||
h.get_all_by_label_contains("The open-source PDF workbench.").last().unwrap();
|
||
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]
|
||
/// Enlarge adds a second, keyboard-navigable view over the drawer; Esc closes the enlarged
|
||
/// picture before the drawer.
|
||
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);
|
||
h.check_accessibility();
|
||
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_all_by_label_contains("The image editor you already know how to use").last().unwrap();
|
||
h.key_press(egui::Key::Escape);
|
||
h.run_steps(10);
|
||
// Only the card's own line is left once the page has slid out.
|
||
assert_eq!(h.query_all_by_label_contains("The image editor you already know").count(), 1);
|
||
}
|
||
|
||
/// 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")).scroll_to_me();
|
||
h.run_steps(3);
|
||
h.get_by_label(&format!("{name} details")).click();
|
||
h.run_steps(8);
|
||
h.get_all_by_label_contains(d.headline).last().unwrap();
|
||
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);
|
||
// The card keeps its own copy of the line; the page's is gone.
|
||
assert!(h.query_all_by_label_contains(d.headline).count() <= 1, "{} 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_all_by_label_contains("The image editor you already know how to use").last().unwrap();
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
/// At 720–1180 px the rate-limit notice stays readable and clear of the footer's checked time
|
||
/// and eight right-side links.
|
||
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 = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(width, 900.0)).build_ui_state(
|
||
move |ui, state: &mut Option<LauncherUi>| {
|
||
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 website, GitHub and Discord icons and the social icons.
|
||
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:?}");
|
||
assert!(h.query_by_label_contains("Installing for").is_none(), "{width}: the footer no longer names the 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]
|
||
/// At 720–1180 px refresh, search, sort and view share one unclipped toolbar row without
|
||
/// overlaps.
|
||
fn toolbar_keeps_navigation_refresh_search_and_view_on_one_unclipped_row() {
|
||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
for width in [720.0, 800.0, 1180.0] {
|
||
h.set_size(egui::vec2(width, 900.0));
|
||
h.run_steps(6);
|
||
let refresh = h.get_by_label("Check for updates").rect();
|
||
let search = h.query_all_by_role(egui::accesskit::Role::TextInput).next().unwrap().rect();
|
||
let view = h.get_by_label("Show apps as cards").rect();
|
||
let sort = h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Sort").rect();
|
||
assert!(refresh.top() > 48.0, "{width}: refresh belongs in the toolbar, not the header: {refresh:?}");
|
||
assert!((refresh.center().y - search.center().y).abs() < 2.0, "{width}: refresh and search must share a row");
|
||
assert!(refresh.right() <= search.left() + 1.0, "{width}: refresh overlaps search: {refresh:?}, {search:?}");
|
||
assert!(refresh.right() <= sort.left() + 1.0, "{width}: sort overlaps refresh: {refresh:?}, {sort:?}");
|
||
assert!(sort.right() <= search.left() + 1.0, "{width}: sort overlaps search: {sort:?}, {search:?}");
|
||
assert!(search.right() <= view.left() + 1.0 && view.right() <= width, "{width}: search overlaps view or view is clipped");
|
||
for (label, rect) in [("refresh", refresh), ("search", search), ("sort", sort), ("view", view)] {
|
||
assert!(rect.left() >= 0.0 && rect.right() <= width, "{width}: {label} is outside the window: {rect:?}");
|
||
}
|
||
assert!(!sort.intersects(search) && !view.intersects(search), "{width}: toolbar controls overlap");
|
||
h.check_accessibility();
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_toolbar_has_no_filter_dropdown() {
|
||
use artcraft_launcher_ui_egui::settings::Filter;
|
||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.run_steps(6);
|
||
for label in ["All 13", "Installed 4", "Updates 2", "Not installed 9"] {
|
||
assert!(h.query_by_value(label).is_none() && h.query_by_label(label).is_none(), "{label}");
|
||
}
|
||
// Only the sort dropdown is left.
|
||
assert_eq!(h.query_all_by_role(egui::accesskit::Role::ComboBox).count(), 1);
|
||
assert_eq!(h.state().as_ref().unwrap().settings.filter, Filter::All);
|
||
}
|
||
|
||
#[test]
|
||
fn saved_filters_are_migrated_on_ui_construction() {
|
||
use artcraft_launcher_ui_egui::settings::Filter;
|
||
|
||
for (saved, expected) in [
|
||
(r#"{"filter":"All"}"#, Filter::All),
|
||
(r#"{"filter":"Installed"}"#, Filter::All),
|
||
(r#"{"filter":"Updates"}"#, Filter::All),
|
||
(r#"{"filter":"NotInstalled"}"#, Filter::All),
|
||
(r#"{"filter":"Hidden"}"#, Filter::Hidden),
|
||
] {
|
||
let mut settings: Settings = serde_json::from_str(saved).unwrap();
|
||
settings.language = "en".into(); // Avoid the global system-locale probe in parallel tests.
|
||
settings.last_check = Some(NOW);
|
||
let backend = Fake {
|
||
events: Vec::new(),
|
||
sent: Rc::default(),
|
||
manage: true,
|
||
pictures: Rc::default(),
|
||
asked: Vec::new(),
|
||
clock: Rc::new(Cell::new(NOW)),
|
||
};
|
||
let ui = LauncherUi::new(&egui::Context::default(), Box::new(backend), settings);
|
||
assert_eq!(ui.settings.filter, expected, "{saved}");
|
||
assert_eq!(ui.settings.last_check, Some(NOW));
|
||
}
|
||
}
|
||
|
||
/// Checks that, at one window width, every header control stays inside the window and clear of
|
||
/// the brand text, that a custom title bar's buttons are there and inside, and snapshots the
|
||
/// three widths that matter visually.
|
||
fn assert_header_at_width(h: &mut Harness<'_, Option<LauncherUi>>, bar: artcraft_launcher_ui_egui::TitleBar, theme: egui::Theme, width: f32) {
|
||
for label in ["Show apps as cards", "Show apps as a 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:?}");
|
||
if let Some(text) = h.query_by_label("Craft App Suite") {
|
||
assert!(!rect.intersects(text.rect()), "{bar:?} {width}: Craft App Suite overlaps {label} {rect:?}");
|
||
}
|
||
}
|
||
if bar == artcraft_launcher_ui_egui::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 {
|
||
assert!(h.query_by_label("Launcher").is_none());
|
||
h.get_by_label("Craft App Suite");
|
||
}
|
||
if [720.0, 800.0, 1180.0].contains(&width) {
|
||
snapshot(h, &format!("header-{theme:?}-{bar:?}-{width}"));
|
||
}
|
||
}
|
||
|
||
#[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);
|
||
assert_header_at_width(&mut h, bar, theme, width);
|
||
}
|
||
h.get_by_label("Show apps as a 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<Cell<bool>>);
|
||
|
||
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<Cell<bool>>) -> Harness<'static, Option<LauncherUi>> {
|
||
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 =
|
||
test_harness_builder(egui::Theme::Dark).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<LauncherUi>| {
|
||
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<LauncherUi>>) -> 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<LauncherUi>>, 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,
|
||
source: egui::MouseWheelSource::Wheel,
|
||
};
|
||
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<LauncherUi>>) -> u64 {
|
||
let cards: Vec<egui::Pos2> = ["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");
|
||
}
|
||
|
||
#[test]
|
||
/// Command-plus-wheel over the main UI raises the global zoom factor.
|
||
fn control_wheel_zooms_the_main_ui() {
|
||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.run_steps(4);
|
||
let before = h.ctx.zoom_factor();
|
||
h.input_mut().events.push(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
phase: egui::TouchPhase::Move,
|
||
delta: egui::vec2(0.0, 100.0),
|
||
modifiers: egui::Modifiers::COMMAND,
|
||
source: egui::MouseWheelSource::Unknown,
|
||
});
|
||
h.step();
|
||
h.run_steps(2);
|
||
assert!(h.ctx.zoom_factor() > before);
|
||
}
|
||
|
||
#[test]
|
||
/// With release notes open, keyboard and wheel zoom change only the notes text (reset works)
|
||
/// while the global zoom stays put and the release heading appears once.
|
||
fn changelog_shortcuts_zoom_only_the_notes_text() {
|
||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.run_steps(4);
|
||
// The grid's default order is name A-Z, so pick the app explicitly: the first card is not
|
||
// PhotoCraft, and the heading assertion below is about this release's own page.
|
||
h.get_by_label("More actions for PhotoCraft").click();
|
||
h.run_steps(2);
|
||
h.get_by_label("Release notes").click();
|
||
h.run_steps(4);
|
||
assert_eq!(
|
||
h.query_all_by_role_and_label(egui::accesskit::Role::Image, "PhotoCraft 0.5.0").count(),
|
||
0,
|
||
"the Notes hero icon is decorative and must not repeat the release heading"
|
||
);
|
||
assert_eq!(
|
||
h.query_all_by_role_and_label(egui::accesskit::Role::Label, "PhotoCraft 0.5.0").count(),
|
||
1,
|
||
"the release heading appears exactly once"
|
||
);
|
||
let before = h.get_by_label("What's Changed").rect().height();
|
||
let global = h.ctx.zoom_factor();
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Plus);
|
||
h.run_steps(3);
|
||
assert_eq!(h.ctx.zoom_factor(), global, "zooming notes must not resize the main UI");
|
||
assert!(h.get_by_label("What's Changed").rect().height() > before);
|
||
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Num0);
|
||
h.run_steps(3);
|
||
assert!((h.get_by_label("What's Changed").rect().height() - before).abs() < 0.5);
|
||
h.input_mut().events.push(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
phase: egui::TouchPhase::Move,
|
||
delta: egui::vec2(0.0, 100.0),
|
||
modifiers: egui::Modifiers::COMMAND,
|
||
source: egui::MouseWheelSource::Unknown,
|
||
});
|
||
h.step();
|
||
h.run_steps(2);
|
||
assert_eq!(h.ctx.zoom_factor(), global);
|
||
let larger = h.get_by_label("What's Changed").rect().height();
|
||
assert!(larger > before);
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Minus);
|
||
h.run_steps(3);
|
||
assert!(h.get_by_label("What's Changed").rect().height() < larger);
|
||
snapshot(&mut h, "notes-zoomed");
|
||
h.get_by_label("Close").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.ctx.zoom_factor(), global);
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Equals);
|
||
h.run_steps(3);
|
||
assert!(h.ctx.zoom_factor() > global, "main-UI keyboard zoom should still work after closing notes");
|
||
}
|
||
|
||
#[test]
|
||
fn command_m_minimizes_the_mac_window_only() {
|
||
use artcraft_launcher_ui_egui::TitleBar;
|
||
for bar in [TitleBar::MacOs, TitleBar::Native, TitleBar::Custom] {
|
||
let (mut h, _) = harness_with_title_bar(events(), ViewMode::Grid, egui::Theme::Dark, true, 1.0, bar);
|
||
h.run_steps(4);
|
||
h.input_mut().events.push(egui::Event::Key {
|
||
key: egui::Key::M,
|
||
physical_key: None,
|
||
pressed: true,
|
||
repeat: false,
|
||
modifiers: egui::Modifiers::MAC_CMD,
|
||
});
|
||
h.step();
|
||
let minimized = h.output().viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Minimized(true))));
|
||
assert_eq!(minimized, bar == TitleBar::MacOs, "{bar:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_occupied_folder_asks_before_replacing_it() {
|
||
let path = "/Volumes/Craft Apps/PhotoCraft.app";
|
||
let mut ev = events();
|
||
ev.push(Event::Occupied { app: "photocraft".into(), path: path.into() });
|
||
ev.push(Event::JobFinished { app: "photocraft".into(), result: Err(format!("{path} already exists")) });
|
||
let (mut h, sent) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0);
|
||
h.run_steps(6);
|
||
h.get_by_label(&format!("{path} already exists. Installing PhotoCraft will overwrite it."));
|
||
assert!(h.query_by_label(&format!("{path} already exists")).is_none());
|
||
sent.borrow_mut().clear();
|
||
h.get_by_label("Replace").click();
|
||
h.run_steps(2);
|
||
assert_eq!(*sent.borrow(), [Command::Replace { app: "photocraft".into() }]);
|
||
assert!(h.query_by_label("Replace").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_not_installed_card_is_grey_until_the_pointer_is_over_it() {
|
||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.set_size(egui::vec2(1180.0, 1100.0));
|
||
h.run_steps(8);
|
||
let icon = h.get_by_label("CADCraft icon").rect();
|
||
// The most colourful pixel in the icon: how far apart its red, green and blue are.
|
||
let colour = |h: &mut Harness<'_, Option<LauncherUi>>| {
|
||
let image = h.render().unwrap();
|
||
let mut most = 0_i32;
|
||
for y in icon.top() as u32..icon.bottom() as u32 {
|
||
for x in icon.left() as u32..icon.right() as u32 {
|
||
let p = image.get_pixel(x, y).0;
|
||
let (hi, lo) = (p[0].max(p[1]).max(p[2]), p[0].min(p[1]).min(p[2]));
|
||
most = most.max(i32::from(hi) - i32::from(lo));
|
||
}
|
||
}
|
||
most
|
||
};
|
||
assert!(colour(&mut h) <= 8, "a not installed app's icon is grey");
|
||
h.hover_at(egui::pos2(icon.center().x + 120.0, icon.center().y + 60.0));
|
||
// The colour picture decodes on a background thread: give it real time, as a slow machine
|
||
// (CI's Windows runner) needs more than a few frames.
|
||
let mut shown = 0;
|
||
for _ in 0..100 {
|
||
h.run_steps(2);
|
||
shown = colour(&mut h);
|
||
if shown > 40 {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||
}
|
||
assert!(shown > 40, "the icon shows its colour under the pointer");
|
||
h.hover_at(egui::pos2(5.0, 400.0));
|
||
h.run_steps(8);
|
||
assert!(colour(&mut h) <= 8, "the colour goes again when the pointer leaves");
|
||
}
|
||
|
||
#[test]
|
||
fn the_sort_offers_name_both_ways_recently_updated_and_frequently_used() {
|
||
use artcraft_launcher_ui_egui::settings::SortKey;
|
||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.run_steps(6);
|
||
h.get_by_value("Name (A–Z)").click();
|
||
h.run_steps(3);
|
||
for label in ["Name (A–Z)", "Name (Z–A)", "Recently updated", "Frequently used"] {
|
||
h.get_by_label(label);
|
||
}
|
||
h.get_by_label("Name (Z–A)").click();
|
||
h.run_steps(4);
|
||
let s = &h.state().as_ref().unwrap().settings;
|
||
assert_eq!((s.sort, s.sort_descending), (SortKey::Name, true));
|
||
h.get_by_value("Name (Z–A)").click();
|
||
h.run_steps(3);
|
||
h.get_by_label("Frequently used").click();
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().as_ref().unwrap().settings.sort, SortKey::Frequent);
|
||
}
|
||
|
||
#[test]
|
||
fn opening_an_app_counts_and_frequently_used_puts_the_most_opened_first() {
|
||
use artcraft_launcher_ui_egui::settings::SortKey;
|
||
let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.run_steps(6);
|
||
h.state_mut().as_mut().unwrap().settings.sort = SortKey::Frequent;
|
||
h.run_steps(4);
|
||
// Installed apps in name order: Effect (updates), Light (Open), Photo (updates), Word (Open).
|
||
let opens: Vec<_> = h.get_all_by_label("Open").collect();
|
||
assert_eq!(opens.len(), 2);
|
||
opens[1].click(); // WordCraft
|
||
h.run_steps(3);
|
||
assert!(sent.borrow().contains(&Command::Launch { app: "wordcraft".into() }));
|
||
assert_eq!(h.state().as_ref().unwrap().settings.launches.get("wordcraft"), Some(&1));
|
||
h.run_steps(4);
|
||
let word = h.get_by_label("WordCraft details").rect();
|
||
let light = h.get_by_label("LightCraft details").rect();
|
||
assert!(word.min.y < light.min.y || (word.min.y == light.min.y && word.min.x < light.min.x), "{word:?} {light:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn an_apps_page_is_a_card_in_the_middle_with_next_and_previous_arrows_and_closes_from_outside() {
|
||
use artcraft_launcher_core::details::AppDetails;
|
||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||
h.run_steps(6);
|
||
let headline = |id: &str| AppDetails::by_id(id).unwrap().headline;
|
||
let showing = |h: &Harness<'_, Option<LauncherUi>>, id: &str| h.query_all_by_label_contains(headline(id)).count() >= 2;
|
||
h.get_by_label("PhotoCraft details").click();
|
||
h.run_steps(12);
|
||
assert!(showing(&h, "photocraft"));
|
||
// A card with a margin of window all round, not a panel against one edge.
|
||
let close = h.get_by_label("Close (Esc)").rect();
|
||
assert!(close.right() < 1180.0 - 24.0 && close.left() > 24.0, "{close:?}");
|
||
// Next goes to the following app in the grid's order (Word after Photo), and back again.
|
||
h.get_by_label("Next app").click();
|
||
h.run_steps(12);
|
||
assert!(showing(&h, "wordcraft"));
|
||
h.get_by_label("Previous app").click();
|
||
h.run_steps(12);
|
||
assert!(showing(&h, "photocraft"));
|
||
// A click on the card's own margin keeps it open (the card is 1116 wide, centred in 1180).
|
||
let margin = egui::pos2(36.0, 450.0);
|
||
h.hover_at(margin);
|
||
h.run_steps(2);
|
||
h.drag_at(margin);
|
||
h.run_steps(2);
|
||
h.drop_at(margin);
|
||
h.run_steps(12);
|
||
assert!(showing(&h, "photocraft"), "a click on the card closed it");
|
||
// A click on the dimmed window outside the card closes it.
|
||
h.hover_at(egui::pos2(10.0, 450.0));
|
||
h.run_steps(2);
|
||
h.drag_at(egui::pos2(10.0, 450.0));
|
||
h.run_steps(2);
|
||
h.drop_at(egui::pos2(10.0, 450.0));
|
||
h.run_steps(30);
|
||
assert!(!showing(&h, "photocraft"), "a click outside did not close it");
|
||
}
|