quick_search/crates/quicksearch-gui/src/settings_tab/tests.rs
Jeremy Karst 67dd461587
Some checks failed
CI / linux (push) Has been cancelled
CI / windows-cross (push) Has been cancelled
CI / release (push) Has been cancelled
Removal of positions index from FTS for dramatically reduced index size and improved indexing speed at the cost of slightly slower search of common terms.
2026-09-26 16:29:36 -04:00

719 lines
25 KiB
Rust

use super::*;
// Headless-safe: `keychain_active` only probes the OS keychain when
// `use_keychain` is set, and no test here sets it.
#[test]
fn a_fresh_draft_is_not_dirty() {
let mut w = SettingsTab::default();
let cfg = Config::default();
assert!(!w.is_dirty(&cfg), "no draft at all");
w.stage(&cfg);
assert!(!w.is_dirty(&cfg));
}
#[test]
fn an_edited_draft_is_dirty_until_discarded() {
let mut w = SettingsTab::default();
let cfg = Config::default();
w.stage(&cfg);
w.draft.as_mut().unwrap().search.debounce_ms += 100;
assert!(w.is_dirty(&cfg));
w.discard();
assert!(w.draft.is_none());
assert!(!w.is_dirty(&cfg), "the draft is gone");
}
/// The stale copies of live-config fields in the draft are not edits.
#[test]
fn live_security_and_mode_changes_are_not_dirty() {
let mut w = SettingsTab::default();
let mut cfg = Config::default();
w.stage(&cfg);
cfg.security.use_keychain = !cfg.security.use_keychain;
cfg.indexing.auto_index = !cfg.indexing.auto_index;
assert!(!w.is_dirty(&cfg));
}
/// Without the restage, an edit made on Manage Index in between would be
/// reverted by a later Apply: `pin_live_fields` does not cover the filters.
#[test]
fn a_draft_is_restaged_from_the_live_config_after_leaving() {
let mut w = SettingsTab::default();
let mut cfg = Config::default();
w.stage(&cfg);
w.discard();
cfg.indexing.ignore_patterns.push("*.tmp".to_string());
w.stage(&cfg);
assert!(!w.is_dirty(&cfg), "the fresh draft matches the live config");
assert_eq!(
w.draft_config().unwrap().indexing.ignore_patterns,
cfg.indexing.ignore_patterns,
"the filter added while the tab was away survives"
);
}
/// A key capture in progress cannot outlive the tab.
#[test]
fn leaving_the_tab_ends_a_shortcut_capture() {
let mut w = SettingsTab::default();
let cfg = Config::default();
w.stage(&cfg);
w.capturing_hotkey = true;
w.discard();
assert!(!w.capturing_hotkey());
}
use crate::test_ui::{
click_at, hover_shows, instant_tooltips, key_press, painted_text, painted_text_center,
run_panel,
};
/// One frame of `draw` at `size`, with the no-tofu check every test here gets.
fn run(
ctx: &egui::Context,
size: egui::Vec2,
events: Vec<egui::Event>,
draw: impl FnMut(&mut egui::Ui),
) -> egui::FullOutput {
let out = run_panel(ctx, size, events, draw);
crate::test_ui::assert_no_tofu(ctx, &out);
out
}
/// Outside the tab's scroll area so it is never below the fold.
fn run_hotkey_edit(
ctx: &egui::Context,
setting: &mut String,
capturing: &mut bool,
events: Vec<egui::Event>,
) -> egui::FullOutput {
run(ctx, egui::vec2(600.0, 200.0), events, |ui| {
hotkey_edit(ui, setting, capturing);
})
}
fn run_color_scheme_edit(
ctx: &egui::Context,
setting: &mut String,
events: Vec<egui::Event>,
) -> egui::FullOutput {
run(ctx, egui::vec2(600.0, 200.0), events, |ui| {
color_scheme_edit(ui, setting);
})
}
/// What the dropdown shows and what it stores are not the same string.
#[test]
fn the_color_scheme_box_shows_and_sets_the_scheme() {
let ctx = crate::test_ui::ctx();
let mut setting = "dark".to_string();
let closed = run_color_scheme_edit(&ctx, &mut setting, vec![]);
let target = painted_text_center(&closed, "Dark").expect("the current scheme was not painted");
run_color_scheme_edit(&ctx, &mut setting, click_at(target));
let open = run_color_scheme_edit(&ctx, &mut setting, vec![]);
let light = painted_text_center(&open, "Light").expect("the list did not open");
run_color_scheme_edit(&ctx, &mut setting, click_at(light));
assert_eq!(setting, "light", "picking Light stores the config value");
let after = run_color_scheme_edit(&ctx, &mut setting, vec![]);
assert!(
painted_text(&after).iter().any(|t| t == "Light"),
"the closed box still says what is in force: {:?}",
painted_text(&after)
);
}
#[test]
fn an_unknown_scheme_reads_as_dark() {
assert_eq!(scheme_label("dark"), "Dark");
assert_eq!(scheme_label("light"), "Light");
assert_eq!(scheme_label(" LIGHT "), "Light");
for nonsense in ["", "drak", "system", "auto"] {
assert_eq!(scheme_label(nonsense), "Dark", "{:?}", nonsense);
}
}
const CTRL_ALT: egui::Modifiers = egui::Modifiers {
alt: true,
ctrl: true,
shift: false,
mac_cmd: false,
command: true,
};
#[test]
fn the_shortcut_button_binds_what_was_pressed() {
let ctx = crate::test_ui::ctx();
let mut setting = "Ctrl+Shift+F".to_string();
let mut capturing = false;
let first = run_hotkey_edit(&ctx, &mut setting, &mut capturing, vec![]);
let button =
painted_text_center(&first, "Ctrl+Shift+F").expect("the current shortcut was not painted");
run_hotkey_edit(&ctx, &mut setting, &mut capturing, click_at(button));
assert!(capturing, "clicking the button starts a capture");
let waiting = run_hotkey_edit(&ctx, &mut setting, &mut capturing, vec![]);
assert!(
painted_text(&waiting)
.iter()
.any(|t| t.starts_with("Press a key")),
"a capturing button says so"
);
run_hotkey_edit(
&ctx,
&mut setting,
&mut capturing,
key_press(egui::Key::G, CTRL_ALT),
);
assert_eq!(setting, "Ctrl+Alt+G");
assert!(!capturing, "a bound press ends the capture");
}
#[test]
fn capture_ignores_what_it_cannot_bind_and_escape_cancels() {
let ctx = crate::test_ui::ctx();
let mut setting = "Ctrl+Shift+F".to_string();
let mut capturing = true;
// A bare letter: someone reaching for the modifier a moment late.
run_hotkey_edit(
&ctx,
&mut setting,
&mut capturing,
key_press(egui::Key::G, egui::Modifiers::NONE),
);
assert_eq!(setting, "Ctrl+Shift+F", "a bare key binds nothing");
assert!(capturing, "and does not end the capture");
run_hotkey_edit(
&ctx,
&mut setting,
&mut capturing,
key_press(egui::Key::Escape, egui::Modifiers::NONE),
);
assert_eq!(setting, "Ctrl+Shift+F", "Escape leaves the shortcut alone");
assert!(!capturing);
}
#[test]
fn clear_switches_the_shortcut_off() {
let ctx = crate::test_ui::ctx();
let mut setting = "Ctrl+Shift+F".to_string();
let mut capturing = false;
let first = run_hotkey_edit(&ctx, &mut setting, &mut capturing, vec![]);
let clear = painted_text_center(&first, "Clear").expect("Clear was not painted");
run_hotkey_edit(&ctx, &mut setting, &mut capturing, click_at(clear));
assert_eq!(setting, "");
// The button says what the state is rather than going blank.
let empty = run_hotkey_edit(&ctx, &mut setting, &mut capturing, vec![]);
assert!(painted_text(&empty).iter().any(|t| t == "None"));
}
/// Until Apply, the draft and the live registration can disagree.
#[test]
fn an_unapplied_shortcut_says_it_is_not_in_force_yet() {
let ctx = crate::test_ui::ctx();
let note = |draft: &str, live: &str| {
let out = run(&ctx, egui::vec2(600.0, 200.0), vec![], |ui| {
hotkey_note(ui, draft, live)
});
painted_text(&out).join("\n")
};
assert!(note("Ctrl+Alt+K", "Ctrl+Shift+F").contains("Apply & Save"));
// Matching, and nothing registered in a test process.
assert_eq!(note("Ctrl+Shift+F", "Ctrl+Shift+F"), "");
}
/// Every row with the tip it must show and who it is for, so a wrong tooltip
/// is impossible and the everyday/advanced split is written down once.
#[rustfmt::skip]
const ROWS: &[(Section, &str, &tips::Tip, Level)] = &[
(Section::Indexing, "Full reindex every", &tips::REINDEX_INTERVAL, Level::Advanced),
(Section::Indexing, "Follow symlinks", &tips::FOLLOW_SYMLINKS, Level::Advanced),
(Section::Indexing, "Include hidden files", &tips::INCLUDE_HIDDEN, Level::Everyday),
(Section::Processing, "Tokenizer", &tips::TOKENIZER, Level::Advanced),
(Section::Processing, "Hash sample size (bytes)", &tips::HASH_LENGTH, Level::Advanced),
(Section::Processing, "Max stored text (bytes)", &tips::MAX_STORED_TEXT, Level::Advanced),
(Section::Processing, "Max text file size (bytes)", &tips::MAX_TEXT_FILE_SIZE, Level::Advanced),
(Section::Processing, "Batch size", &tips::BATCH_SIZE, Level::Advanced),
(Section::Processing, "Max WAL size (bytes)", &tips::MAX_WAL_SIZE, Level::Advanced),
(Section::Processing, "Compact text index", &tips::COMPACT_INDEX, Level::Everyday),
(Section::Search, "Fuzzy search ON by default", &tips::FUZZY_DEFAULT, Level::Everyday),
(Section::Search, "Fuzzy edit distance", &tips::FUZZY_EDITS, Level::Advanced),
(Section::Search, "Display limit", &tips::DISPLAY_LIMIT, Level::Advanced),
(Section::Search, "Stream batch size", &tips::RESULTS_PER_PAGE, Level::Advanced),
(Section::Search, "Debounce (ms)", &tips::DEBOUNCE, Level::Advanced),
(Section::Search, "Live results", &tips::LIVE_RESULTS, Level::Everyday),
(Section::Search, "Search cache MiB (0 = auto)", &tips::SEARCH_CACHE, Level::Advanced),
];
/// Rendered without the tab's scroll area so nothing sits below the fold, and
/// with advanced on so every row is present to be hovered.
#[test]
fn every_row_shows_its_own_tip() {
for (section, label, tip, _) in ROWS {
let ctx = crate::test_ui::ctx();
instant_tooltips(&ctx);
let mut cfg = Config::default();
let mut frame = |events| {
run_panel(&ctx, egui::vec2(600.0, 800.0), events, |ui| {
config_editor_ui(ui, &mut cfg, *section, None, Form { advanced: true })
})
};
let pos = painted_text_center(&frame(vec![]), label)
.unwrap_or_else(|| panic!("{label} was not painted"));
// Enough of the body to be unique.
let opening: String = tip.body.chars().take(40).collect();
assert!(
hover_shows(&mut frame, pos, &opening),
"hovering {label:?} did not show {:?}",
tip.title
);
}
}
/// Hovering a setting's *name* explains it, not just its control.
#[test]
fn hovering_a_setting_label_explains_it() {
let ctx = crate::test_ui::ctx();
instant_tooltips(&ctx);
// Tokenizer is an advanced row, so the whole tab has to be showing them.
let cfg = Config {
ui: quicksearch_core::config::UiConfig {
show_advanced_settings: true,
..Default::default()
},
..Config::default()
};
let mut w = SettingsTab::default();
let mut frame = |events| {
run_panel(&ctx, egui::vec2(1000.0, 900.0), events, |ui| {
w.ui(ui, &cfg, None);
})
};
let target = painted_text_center(&frame(vec![]), "Tokenizer")
.expect("the Tokenizer label was not painted");
assert!(
hover_shows(&mut frame, target, "cut up so that it can be"),
"no tooltip appeared over the Tokenizer label"
);
}
#[test]
fn the_tab_renders_and_apply_reports_the_draft() {
let ctx = crate::test_ui::ctx();
let cfg = Config::default();
let mut w = SettingsTab::default();
w.stage(&cfg);
w.draft.as_mut().unwrap().search.debounce_ms += 100;
let frame = |w: &mut SettingsTab, events| {
let mut out = SettingsOutput::default();
let full = run(&ctx, egui::vec2(1000.0, 900.0), events, |ui| {
out = w.ui(ui, &cfg, None)
});
(out, full)
};
let (untouched, full) = frame(&mut w, vec![]);
assert!(untouched.applied.is_none());
let target = painted_text_center(&full, "Apply & Save")
.expect("the Apply & Save button was not painted");
let (clicked, _) = frame(&mut w, click_at(target));
let applied = clicked.applied.expect("the click did not report a config");
assert_eq!(
applied.search.debounce_ms,
Config::default().search.debounce_ms + 100,
"the click reported the edited draft"
);
}
fn run_columns(
ctx: &egui::Context,
current: &ColumnsConfig,
events: Vec<egui::Event>,
) -> (Option<ColumnsConfig>, egui::FullOutput) {
let mut picked = None;
let full = run(ctx, egui::vec2(700.0, 200.0), events, |ui| {
picked = columns_ui(ui, current);
});
(picked, full)
}
/// The picker acts on the *live* config, so it reports a change the moment
/// a box moves, with no Apply.
#[test]
fn the_columns_block_reports_a_change_immediately() {
let ctx = crate::test_ui::ctx();
let current = ColumnsConfig::default();
assert!(!current.size, "the fixture assumes Size ships off");
let (quiet, full) = run_columns(&ctx, &current, vec![]);
assert!(quiet.is_none(), "reported a change nobody made");
let target = painted_text_center(&full, "Size").expect("no Size checkbox");
let (picked, _) = run_columns(&ctx, &current, click_at(target));
let picked = picked.expect("the click reported nothing");
assert!(picked.size, "clicking Size did not switch it on");
assert_eq!(
picked,
ColumnsConfig {
size: true,
..current
}
);
}
/// The path is shown checked and greyed rather than left out, so "why can
/// I not remove it?" has an answer on screen.
#[test]
fn the_columns_block_offers_every_column_but_the_path() {
let ctx = crate::test_ui::ctx();
let (_, full) = run_columns(&ctx, &ColumnsConfig::default(), vec![]);
let painted = painted_text(&full);
for label in ["Name", "Path", "Content Match", "Size", "Modified", "Rank"] {
assert!(
painted.iter().any(|t| t == label),
"{label} missing: {painted:?}"
);
}
let target = painted_text_center(&full, "Path").expect("no Path entry");
let (picked, _) = run_columns(&ctx, &ColumnsConfig::default(), click_at(target));
assert!(picked.is_none(), "the path column was switched off");
}
/// `keychain_active` is passed straight through: nothing touches the OS keychain.
fn run_security(
ctx: &egui::Context,
current: &Config,
events: Vec<egui::Event>,
) -> (Option<SecurityAction>, egui::FullOutput) {
let mut action = None;
let full = run(ctx, egui::vec2(700.0, 300.0), events, |ui| {
action = security_ui(ui, current, false, Form { advanced: true });
});
(action, full)
}
/// An unprotected index has no key, so the button is left out, not shown dead.
#[test]
fn the_key_button_appears_only_while_the_index_is_encrypted() {
let ctx = crate::test_ui::ctx();
let mut cfg = Config::default();
assert!(
!cfg.security.password_protected,
"the fixture assumes protection ships off"
);
let (_, full) = run_security(&ctx, &cfg, vec![]);
assert!(
painted_text_center(&full, "Show database key").is_none(),
"offered the key of an unencrypted index: {:?}",
painted_text(&full)
);
cfg.security.password_protected = true;
let (_, full) = run_security(&ctx, &cfg, vec![]);
assert!(
painted_text_center(&full, "Show database key").is_some(),
"no key button while encrypted: {:?}",
painted_text(&full)
);
}
/// The click only asks for the flow; the reveal lives in the app.
#[test]
fn clicking_the_key_button_reports_show_key() {
let ctx = crate::test_ui::ctx();
let cfg = Config {
security: quicksearch_core::config::SecurityConfig {
password_protected: true,
..Default::default()
},
..Default::default()
};
let (quiet, full) = run_security(&ctx, &cfg, vec![]);
assert!(quiet.is_none(), "reported an action nobody clicked");
let target = painted_text_center(&full, "Show database key").expect("no key button");
let (action, _) = run_security(&ctx, &cfg, click_at(target));
assert_eq!(action, Some(SecurityAction::ShowKey));
}
/// The assertion that `app::pin_live_fields` covers the columns field.
#[test]
fn a_stale_draft_cannot_revert_the_columns() {
let mut w = SettingsTab::default();
let mut cfg = Config::default();
w.stage(&cfg);
// The header menu switches a column on while the tab is on screen.
cfg.search.columns.size = true;
assert!(!w.is_dirty(&cfg), "a live column change read as an edit");
let draft = w.draft_config().expect("a draft");
let mut applied = draft;
crate::app::pin_live_fields(&mut applied, &cfg);
assert!(
applied.search.columns.size,
"applying the stale draft reverted the column"
);
}
/// The hint is the only place the automatic ceiling is visible, and the only
/// thing that makes the override discoverable when the cap bites.
#[test]
fn the_search_cache_hint_explains_the_automatic_value() {
let mut cfg = Config::default();
assert_eq!(
search_cache_hint(&cfg, None),
None,
"with no file count there is no honest number to show"
);
// An explicit setting is not automatic, so there is nothing to explain.
cfg.search.cache_size_mib = 64;
assert_eq!(search_cache_hint(&cfg, Some(200_000)), None);
cfg.search.cache_size_mib = 0;
// Unencrypted: a fixed value, and the reason for it.
let plain = search_cache_hint(&cfg, Some(200_000)).expect("a hint");
assert!(plain.contains("16 MiB"), "{}", plain);
assert!(plain.contains("unencrypted"), "{}", plain);
// Encrypted and inside the cap: the derived value, and the file count it
// came from.
cfg.security.password_protected = true;
let keyed = search_cache_hint(&cfg, Some(200_000)).expect("a hint");
assert!(keyed.contains("32 MiB"), "{}", keyed);
assert!(keyed.contains("200,000"), "{}", keyed);
// Encrypted and past it: says so, and says what the index actually wants,
// or the override cannot be found by the people who need it.
let capped = search_cache_hint(&cfg, Some(2_000_000)).expect("a hint");
assert!(
capped.contains("128 MiB") && capped.contains("320 MiB"),
"the capped hint must name both the cap and the want: {}",
capped
);
}
/// The whole point: with advanced off, only the everyday rows are on screen,
/// and with it on nothing has gone missing. `ROWS` is the categorisation, so
/// this fails the moment a row is added without deciding who it is for.
#[test]
fn advanced_rows_are_hidden_until_asked_for() {
let painted_labels = |advanced: bool| -> Vec<&'static str> {
let ctx = crate::test_ui::ctx();
let mut cfg = Config::default();
let form = Form { advanced };
let mut shown = Vec::new();
for section in [Section::Indexing, Section::Processing, Section::Search] {
let out = run_panel(&ctx, egui::vec2(600.0, 800.0), vec![], |ui| {
config_editor_ui(ui, &mut cfg, section, None, form)
});
let text = painted_text(&out).join("\n");
for (row_section, label, _, _) in ROWS {
if *row_section == section && text.contains(*label) {
shown.push(*label);
}
}
}
shown
};
let everyday: Vec<&str> = ROWS
.iter()
.filter(|(_, _, _, level)| *level == Level::Everyday)
.map(|(_, label, _, _)| *label)
.collect();
let all: Vec<&str> = ROWS.iter().map(|(_, label, _, _)| *label).collect();
assert!(
!everyday.is_empty() && everyday.len() < all.len(),
"a split with nothing on one side is not a split: {} of {}",
everyday.len(),
all.len()
);
assert_eq!(
painted_labels(false),
everyday,
"the default view must show the everyday rows and only those"
);
assert_eq!(
painted_labels(true),
all,
"turning advanced on must bring every row back"
);
}
/// The byte fields are shown grouped: their values run to eight and ten
/// digits, where a misplaced zero is invisible. Everything else in the form
/// counts files or milliseconds and stays plain, the other half of the
/// assertion: the grouping is scoped to the fields that needed it.
#[test]
fn only_the_byte_fields_are_shown_with_thousands_separators() {
let ctx = crate::test_ui::ctx();
let mut cfg = Config::default();
// The top of its range, so the plain rendering is long enough that a
// separator would show up if one were being inserted.
cfg.processing.batch_size = 100_000;
let out = run_panel(&ctx, egui::vec2(600.0, 800.0), vec![], |ui| {
config_editor_ui(
ui,
&mut cfg,
Section::Processing,
None,
Form { advanced: true },
)
});
let text = painted_text(&out).join("\n");
// Hash sample size, max stored text, max text file size, max WAL size.
for grouped in ["8,192", "262,144", "52,428,800", "2,147,483,648"] {
assert!(
text.contains(grouped),
"{grouped} is not on screen; the byte fields read: {text}"
);
}
assert!(
text.contains("100000") && !text.contains("100,000"),
"batch size counts files, not bytes, and must stay plain: {text}"
);
}
/// Revealing a setting is not editing one: the toggle writes through
/// `SettingsOutput` and must never make the tab read as dirty, or looking at
/// an advanced setting would demand an Apply.
#[test]
fn showing_advanced_settings_is_not_an_unsaved_edit() {
let mut w = SettingsTab::default();
let mut cfg = Config::default();
assert!(!cfg.ui.show_advanced_settings, "hidden by default");
w.stage(&cfg);
// The checkbox writes straight to the live config, as the app does.
cfg.ui.show_advanced_settings = true;
assert!(!w.is_dirty(&cfg), "revealing rows read as an edit");
// And a draft staged while they were hidden must not put them away again.
let mut applied = w.draft_config().expect("a draft");
crate::app::pin_live_fields(&mut applied, &cfg);
assert!(
applied.ui.show_advanced_settings,
"applying the stale draft hid the advanced settings again"
);
}
/// Each registration state gets its sentence; only a real failure (and not
/// Windows' expected .lnk contention) earns the warning colour.
#[test]
fn the_hotkey_status_lines_match_their_states() {
use crate::hotkey::Status;
assert_eq!(super::hotkey_status_line(Status::Active), None);
assert_eq!(super::hotkey_status_line(Status::Disabled), None);
let (owned, err) = super::hotkey_status_line(Status::SystemOwned).expect("a line");
assert!(owned.contains("starts QuickSearch when closed"), "{owned}");
assert!(!err, "SystemOwned is the working state, not a warning");
let (desktop, err) = super::hotkey_status_line(Status::DesktopOnly).expect("a line");
assert!(desktop.contains("Set up system shortcut"), "{desktop}");
assert!(!err, "DesktopOnly is a pointer, not a failure");
let (error, err) =
super::hotkey_status_line(Status::Error("taken".to_string())).expect("a line");
assert!(error.contains("taken"));
assert_eq!(err, !cfg!(windows), "only non-Windows colours the failure");
}
/// The painted text of the shortcut note for `setting` on `desktop`.
fn shortcut_note_text(
ctx: &egui::Context,
setting: &str,
desktop: crate::shortcut_setup::Desktop,
) -> String {
let out = run_panel(ctx, egui::vec2(700.0, 300.0), vec![], |ui| {
super::shortcut_note_for(ui, setting, desktop)
});
painted_text(&out).join("\n")
}
/// The panel that tells a user how to get a shortcut that also starts
/// QuickSearch has to show the command they must bind, with or without a
/// one-click desktop to lean on.
#[test]
fn the_shortcut_note_names_the_command_to_bind() {
let ctx = crate::test_ui::ctx();
let painted = shortcut_note_text(
&ctx,
"Ctrl+Shift+F",
crate::shortcut_setup::Desktop::Unsupported,
);
assert!(
painted.contains("--toggle"),
"the command to bind was not shown: {painted}"
);
assert!(painted.contains("Copy"), "no way to copy it: {painted}");
}
/// On a desktop we can write, the note leads with the one-click button (the
/// probe is skipped by seeding the cached state, so the test stays
/// hermetic), and the manual command stays as the fallback.
#[test]
fn a_supported_desktop_gets_the_one_click_button() {
let ctx = crate::test_ui::ctx();
let note_when_installed = |installed: bool| {
ctx.data_mut(|d| {
d.insert_temp(
egui::Id::new("system-shortcut-state"),
super::SystemShortcutState {
installed,
feedback: None,
},
)
});
shortcut_note_text(&ctx, "Ctrl+Shift+F", crate::shortcut_setup::Desktop::Gnome)
};
let painted = note_when_installed(false);
assert!(
painted.contains("Set up Ctrl+Shift+F system-wide"),
"no one-click button: {painted}"
);
assert!(
painted.contains("--toggle"),
"the fallback vanished: {painted}"
);
// Already installed: the button flips to removal.
let painted = note_when_installed(true);
assert!(painted.contains("Remove"), "no removal offered: {painted}");
}
/// No usable binding means nothing to write: the one-click flow bows out
/// even on a supported desktop.
#[test]
fn no_binding_means_no_one_click_button() {
let ctx = crate::test_ui::ctx();
let painted = shortcut_note_text(&ctx, "", crate::shortcut_setup::Desktop::Gnome);
assert!(
!painted.contains("system-wide"),
"offered to bind nothing: {painted}"
);
assert!(
painted.contains("--toggle"),
"the manual flow vanished: {painted}"
);
}