diff --git a/crates/ui-egui/tests/german.rs b/crates/ui-egui/tests/german.rs index 21283572..a620eff0 100644 --- a/crates/ui-egui/tests/german.rs +++ b/crates/ui-egui/tests/german.rs @@ -194,18 +194,9 @@ fn german_switches_persists_and_keeps_install_actions() { let slot = retry.query_all_by_label("Installieren").next().unwrap().rect().width(); for app in ["PhotoCraft", "VectorCraft"] { let menu = retry.get_by_label(&format!("Weitere Aktionen für {app}")).rect(); - // The main button is the one ending just left of the menu (or overlapping it); a stacked - // Open and Update sit half a button above and below the menu's middle. - let main = retry - .query_all_by_role(egui::accesskit::Role::Button) - .filter(|n| { - (n.rect().center().y - menu.center().y).abs() < STACKED_OFFSET - && n.rect().left() < menu.left() - && n.rect().right() > menu.left() - 20.0 - }) - .find(|n| n.rect().height() < 40.0) - .unwrap(); - assert!(main.rect().width() <= slot + 0.5, "{app}: {:?} wider than Installieren ({slot})", main.rect()); + let main = main_button_in_line(&retry, menu, app); + assert!(main.right() > menu.left() - 20.0, "{app}: {main:?} isn't beside the menu {menu:?}"); + assert!(main.width() <= slot + 0.5, "{app}: {main:?} wider than Installieren ({slot})"); } let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language, Vec::new()); web.run_steps(6); @@ -244,6 +235,14 @@ fn a_table_update_too_narrow_to_sit_side_by_side_stacks() { // Up-to-date LightCraft keeps a one-line row. let light = h.get_by_label("Weitere Aktionen für LightCraft").rect(); assert!(update.bottom() - open.top() > light.height() * 1.5, "PhotoCraft's row grew for two buttons"); + // The pair stays inside its own row: the next row's controls start below it. + let next = h + .query_all_by_label_contains("Weitere Aktionen für") + .map(|n| n.rect()) + .filter(|r| r.top() > menu.top()) + .min_by(|a, b| a.top().total_cmp(&b.top())) + .expect("a row below PhotoCraft's"); + assert!(update.bottom() < next.top(), "Update {update:?} runs into the next row {next:?}"); sent.borrow_mut().clear(); h.get_by_label(keep).click(); @@ -257,6 +256,24 @@ fn a_table_update_too_narrow_to_sit_side_by_side_stacks() { /// half a button (and half the seam) above and below it. Rows are further apart than this. const STACKED_OFFSET: f32 = 20.0; +/// The main button of the row whose `⋯` menu is `menu` (the one ending nearest the menu), after +/// checking it is in line with the menu: centred on it, or, stacked over its twin (the button of +/// the same width ending at the same place), the pair centred on it. Only a stacked pair may sit +/// off the menu's middle. +fn main_button_in_line(h: &Harness<'static, Option>, menu: egui::Rect, what: &str) -> egui::Rect { + let near: Vec = h + .query_all_by_role(egui::accesskit::Role::Button) + .map(|n| n.rect()) + .filter(|r| (r.center().y - menu.center().y).abs() < STACKED_OFFSET && r.left() < menu.left() && r.height() < 40.0) + .collect(); + let main = *near.iter().max_by(|a, b| a.right().total_cmp(&b.right())).unwrap_or_else(|| panic!("{what}: no main button by {menu:?}")); + let column: Vec<&egui::Rect> = near.iter().filter(|r| (r.right() - main.right()).abs() < 0.5 && (r.left() - main.left()).abs() < 0.5).collect(); + let (top, bottom) = column.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(t, b), r| (t.min(r.top()), b.max(r.bottom()))); + let middle = (top + bottom) / 2.0; + assert!((middle - menu.center().y).abs() < 1.0, "{what}: {column:?} not in line with the menu {menu:?}"); + main +} + /// At the launcher's narrowest, no main button outgrows its slot or crowds out the `⋯` menu (#956): /// a kept update, a kept install and a plain update, in both views, whichever label `language` has. fn main_buttons_fit_the_narrowest_window(language: &str) { @@ -269,12 +286,7 @@ fn main_buttons_fit_the_narrowest_window(language: &str) { let slot = h.query_all_by_label(t("Install")).next().unwrap().rect().width(); for app in ["PhotoCraft", "VectorCraft", "EffectCraft"] { let menu = h.get_by_label(&fmt(t("More actions for {name}"), &[("name", app)])).rect(); - let main = h - .query_all_by_role(egui::accesskit::Role::Button) - .filter(|n| (n.rect().center().y - menu.center().y).abs() < STACKED_OFFSET && n.rect().left() < menu.left()) - .max_by(|a, b| a.rect().right().total_cmp(&b.rect().right())) - .unwrap() - .rect(); + let main = main_button_in_line(&h, menu, &format!("{language} {view:?} {app}")); assert!(main.width() <= slot + 0.5, "{language} {view:?} {app}: {main:?} wider than Install ({slot})"); assert!(main.right() <= menu.left(), "{language} {view:?} {app}: {main:?} overlaps the menu {menu:?}"); // The table scrolls sideways, with a fixed slot; cards have to fit the window. diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index d3cf4ac5..9e11d1db 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -571,6 +571,31 @@ fn choosing_an_app_version_turns_its_main_action_into_install() { assert_eq!(h.query_all_by_label("Open").count(), opens + 1, "PhotoCraft shows Open"); } +#[test] +/// The version dialog's Install follows the same answer as the card's "Install version X": for a +/// copy the backend can't replace with the chosen version (installed outside the launcher, say), +/// it is greyed out, says why on hover, and clicking it sends no install. +fn the_version_dialog_wont_install_a_version_the_install_would_refuse() { + let pin = signed_photocraft("0.4.0"); + let (mut h, sent, queue) = version_menu(pin.clone(), vec![signed_photocraft("0.5.0"), pin]); + queue.borrow_mut().push(Event::Installable { app: "photocraft".into(), installable: false }); + h.query_all_by_role_and_label(egui::accesskit::Role::Button, "v0.4.0").next().unwrap().click(); + h.run_steps(8); + sent.borrow_mut().clear(); + let row = h.get_by_label("Close").rect().center().y; + let install = h + .get_all_by_label("Install version v0.4.0") + .find(|n| (n.rect().center().y - row).abs() < 4.0) + .expect("the dialog shows Install for the chosen version"); + assert!(install.accesskit_node().is_disabled(), "the install would be refused"); + install.hover(); + h.run_steps(4); + assert!(h.query_all_by_label_contains("can't install version v0.4.0 over this copy").next().is_some(), "it says why"); + h.get_all_by_label("Install version v0.4.0").find(|n| (n.rect().center().y - row).abs() < 4.0).unwrap().click(); + h.run_steps(4); + assert!(!sent.borrow().iter().any(|c| matches!(c, Command::Install { .. })), "{:?}", sent.borrow()); +} + #[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 @@ -1128,6 +1153,7 @@ fn a_newer_release_without_a_download_here_is_not_offered_as_an_update() { assert!(h.query_by_label(KEEP_PHOTOCRAFT).is_none(), "no split without an update"); h.get_by_label("Update to 0.6.0"); h.get_by_label("Update 1 app"); + h.get_by_label_contains("0.4.1 · latest 0.5.0"); // PhotoCraft's main button is a plain Open, which launches it. // Cards share a row, so PhotoCraft's Open is the one right before its own menu. let menu = h.get_by_label("More actions for PhotoCraft").rect(); @@ -1152,10 +1178,15 @@ fn the_backends_installability_answer_decides_an_update() { assert!(h.query_by_label("Update to 0.5.0").is_none(), "the backend can't install it"); assert!(h.query_by_label(KEEP_PHOTOCRAFT).is_none()); h.get_by_label("Update 1 app"); - // Still a sign the release is out: a quiet note beside the installed version. - h.get_by_label_contains("0.4.1 · latest 0.5.0"); + // Still a sign the release is out: a quiet note beside the installed version, saying why. + h.get_by_label_contains("0.4.1 · latest 0.5.0").hover(); + h.run_steps(4); + h.get_by_label_contains(OUT_OF_REACH_TIP); } +/// The tooltip on the note beside a copy behind a release it can't take. +const OUT_OF_REACH_TIP: &str = "0.5.0 is out, but ArtCraft Launcher can't install it over this copy."; + #[test] /// The note beside a copy behind a release it can't take shows in the table too. fn the_table_notes_a_newer_release_that_cant_install_here() { @@ -1163,7 +1194,33 @@ fn the_table_notes_a_newer_release_that_cant_install_here() { ev.push(Event::Installable { app: "photocraft".into(), installable: false }); let (mut h, _) = harness_with(ev, ViewMode::Table, egui::Theme::Dark, true, 1.0); h.run_steps(8); - h.get_by_label("0.4.1 · latest 0.5.0"); + h.get_by_label("0.4.1 · latest 0.5.0").hover(); + h.run_steps(4); + h.get_by_label_contains(OUT_OF_REACH_TIP); +} + +#[test] +/// A copy its repository updates (a Flatpak from FlatPark) knows nothing of GitHub's latest, so a +/// newer release there gets no "can't install" note: its repository may well have it. +fn a_repository_copy_gets_no_out_of_reach_note() { + let mut ev = events(); + ev.push(Event::Detected { + app: "lightcraft".into(), + installed: Some(Installed { + version: version::parse("0.3.0"), + source: InstallSource::Flatpak, + location: "ai.storyteller.lightcraft".into(), + package: None, + externally_managed: true, + origin: Some("flatpark".into()), + installation: Some("user".into()), + }), + }); + ev.push(Event::Installable { app: "lightcraft".into(), installable: false }); + let (mut h, _) = harness_with(ev, ViewMode::Table, egui::Theme::Dark, true, 1.0); + h.run_steps(8); + assert!(h.query_all_by_label("0.3.0").next().is_some(), "LightCraft's version shows"); + assert!(h.query_by_label_contains("0.3.0 · latest").is_none(), "no note for a repository copy"); } #[test] @@ -1173,7 +1230,7 @@ fn the_table_notes_a_newer_release_that_cant_install_here() { fn a_kept_version_gets_no_out_of_reach_note_and_says_why_it_cant_install() { let mut ev = events(); ev.push(Event::Installable { app: "photocraft".into(), installable: false }); - let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0); + let (mut h, sent) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0); h.run_steps(8); h.get_by_label_contains("0.4.1 · latest 0.5.0"); h.state_mut() @@ -1189,6 +1246,10 @@ fn a_kept_version_gets_no_out_of_reach_note_and_says_why_it_cant_install() { install.hover(); h.run_steps(4); h.get_by_label_contains("can't install version v0.5.0 over this copy"); + sent.borrow_mut().clear(); + h.get_by_label("Install version v0.5.0").click(); + h.run_steps(2); + assert_eq!(launches_and_installs(&sent, "photocraft"), (0, 0), "a greyed-out Install sends nothing: {:?}", sent.borrow()); } #[test] @@ -1223,11 +1284,17 @@ fn reinstall_needs_an_install_that_would_go_ahead() { for installable in [true, false] { let mut ev = events(); ev.push(Event::Installable { app: "lightcraft".into(), installable }); - let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0); + let (mut h, sent) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0); h.run_steps(8); h.get_by_label("More actions for LightCraft").click(); h.run_steps(3); assert_eq!(h.query_by_label("Reinstall").is_some(), installable, "installable: {installable}"); + if installable { + sent.borrow_mut().clear(); + h.get_by_label("Reinstall").click(); + h.run_steps(2); + assert_eq!(launches_and_installs(&sent, "lightcraft"), (0, 1), "Reinstall installs: {:?}", sent.borrow()); + } } }