2026-08-09 16:25:43 -04:00
|
|
|
use super::*;
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
// Headless-safe: `keychain_active` only probes the OS keychain when
|
2026-08-09 16:25:43 -04:00
|
|
|
// `use_keychain` is set, and no test here sets it.
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_fresh_draft_is_not_dirty() {
|
2026-09-25 14:58:51 -04:00
|
|
|
let mut w = SettingsTab::default();
|
2026-08-09 16:25:43 -04:00
|
|
|
let cfg = Config::default();
|
|
|
|
|
assert!(!w.is_dirty(&cfg), "no draft at all");
|
2026-08-17 19:26:18 -04:00
|
|
|
w.stage(&cfg);
|
2026-08-09 16:25:43 -04:00
|
|
|
assert!(!w.is_dirty(&cfg));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn an_edited_draft_is_dirty_until_discarded() {
|
2026-09-25 14:58:51 -04:00
|
|
|
let mut w = SettingsTab::default();
|
2026-08-09 16:25:43 -04:00
|
|
|
let cfg = Config::default();
|
2026-08-17 19:26:18 -04:00
|
|
|
w.stage(&cfg);
|
2026-08-09 16:25:43 -04:00
|
|
|
w.draft.as_mut().unwrap().search.debounce_ms += 100;
|
|
|
|
|
assert!(w.is_dirty(&cfg));
|
2026-08-17 19:26:18 -04:00
|
|
|
w.discard();
|
|
|
|
|
assert!(w.draft.is_none());
|
2026-08-09 16:25:43 -04:00
|
|
|
assert!(!w.is_dirty(&cfg), "the draft is gone");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// The stale copies of live-config fields in the draft are not edits.
|
2026-08-09 16:25:43 -04:00
|
|
|
#[test]
|
|
|
|
|
fn live_security_and_mode_changes_are_not_dirty() {
|
2026-09-25 14:58:51 -04:00
|
|
|
let mut w = SettingsTab::default();
|
2026-08-09 16:25:43 -04:00
|
|
|
let mut cfg = Config::default();
|
2026-08-17 19:26:18 -04:00
|
|
|
w.stage(&cfg);
|
2026-08-09 16:25:43 -04:00
|
|
|
cfg.security.use_keychain = !cfg.security.use_keychain;
|
|
|
|
|
cfg.indexing.auto_index = !cfg.indexing.auto_index;
|
|
|
|
|
assert!(!w.is_dirty(&cfg));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 20:32:00 -04:00
|
|
|
/// The watch-cap modal's OK and the tour's dismissal can land while the tab
|
|
|
|
|
/// is open; neither is an edit, and the draft must not carry them back.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_watch_cap_acknowledgement_does_not_dirty_the_draft() {
|
|
|
|
|
let mut w = SettingsTab::default();
|
|
|
|
|
let mut cfg = Config::default();
|
|
|
|
|
cfg.paths.indexing_paths = vec!["/data".to_string()];
|
|
|
|
|
w.stage(&cfg);
|
|
|
|
|
|
|
|
|
|
cfg.ui.watch_cap_warned_roots.push("/data".to_string());
|
|
|
|
|
assert!(!w.is_dirty(&cfg), "the OK read as an unsaved edit");
|
|
|
|
|
cfg.ui.tutorial_seen = Some(true);
|
|
|
|
|
assert!(
|
|
|
|
|
!w.is_dirty(&cfg),
|
|
|
|
|
"the tour's dismissal read as an unsaved edit"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// A real edit still counts, with both acknowledgements standing.
|
|
|
|
|
w.draft.as_mut().unwrap().search.debounce_ms += 100;
|
|
|
|
|
assert!(w.is_dirty(&cfg));
|
|
|
|
|
let mut applied = w.draft_config().expect("a draft");
|
|
|
|
|
crate::app::pin_live_fields(&mut applied, &cfg);
|
|
|
|
|
assert_eq!(applied.ui.watch_cap_warned_roots, ["/data"]);
|
|
|
|
|
assert_eq!(applied.ui.tutorial_seen, Some(true));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Without the restage, an edit made on Manage Index in between would be
|
2026-09-25 14:58:51 -04:00
|
|
|
/// reverted by a later Apply: `pin_live_fields` does not cover the filters.
|
2026-08-09 16:25:43 -04:00
|
|
|
#[test]
|
2026-08-17 19:26:18 -04:00
|
|
|
fn a_draft_is_restaged_from_the_live_config_after_leaving() {
|
2026-09-25 14:58:51 -04:00
|
|
|
let mut w = SettingsTab::default();
|
2026-08-17 19:26:18 -04:00
|
|
|
let mut cfg = Config::default();
|
|
|
|
|
w.stage(&cfg);
|
|
|
|
|
w.discard();
|
2026-08-09 16:25:43 -04:00
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
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"
|
2026-08-09 16:25:43 -04:00
|
|
|
);
|
2026-08-17 19:26:18 -04:00
|
|
|
}
|
2026-08-09 16:25:43 -04:00
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// A key capture in progress cannot outlive the tab.
|
2026-08-17 19:26:18 -04:00
|
|
|
#[test]
|
|
|
|
|
fn leaving_the_tab_ends_a_shortcut_capture() {
|
2026-09-25 14:58:51 -04:00
|
|
|
let mut w = SettingsTab::default();
|
2026-08-17 19:26:18 -04:00
|
|
|
let cfg = Config::default();
|
|
|
|
|
w.stage(&cfg);
|
|
|
|
|
w.capturing_hotkey = true;
|
|
|
|
|
w.discard();
|
|
|
|
|
assert!(!w.capturing_hotkey());
|
2026-08-09 16:25:43 -04:00
|
|
|
}
|
|
|
|
|
|
2026-09-25 14:58:51 -04:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-09 16:25:43 -04:00
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Outside the tab's scroll area so it is never below the fold.
|
2026-08-09 16:25:43 -04:00
|
|
|
fn run_hotkey_edit(
|
|
|
|
|
ctx: &egui::Context,
|
|
|
|
|
setting: &mut String,
|
|
|
|
|
capturing: &mut bool,
|
|
|
|
|
events: Vec<egui::Event>,
|
|
|
|
|
) -> egui::FullOutput {
|
2026-09-25 14:58:51 -04:00
|
|
|
run(ctx, egui::vec2(600.0, 200.0), events, |ui| {
|
|
|
|
|
hotkey_edit(ui, setting, capturing);
|
|
|
|
|
})
|
2026-08-09 16:25:43 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_color_scheme_edit(
|
|
|
|
|
ctx: &egui::Context,
|
|
|
|
|
setting: &mut String,
|
|
|
|
|
events: Vec<egui::Event>,
|
|
|
|
|
) -> egui::FullOutput {
|
2026-09-25 14:58:51 -04:00
|
|
|
run(ctx, egui::vec2(600.0, 200.0), events, |ui| {
|
|
|
|
|
color_scheme_edit(ui, setting);
|
|
|
|
|
})
|
2026-08-09 16:25:43 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// What the dropdown shows and what it stores are not the same string.
|
2026-08-09 16:25:43 -04:00
|
|
|
#[test]
|
|
|
|
|
fn the_color_scheme_box_shows_and_sets_the_scheme() {
|
2026-08-17 22:30:43 -04:00
|
|
|
let ctx = crate::test_ui::ctx();
|
2026-08-09 16:25:43 -04:00
|
|
|
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() {
|
2026-08-17 22:30:43 -04:00
|
|
|
let ctx = crate::test_ui::ctx();
|
2026-08-09 16:25:43 -04:00
|
|
|
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,
|
2026-09-25 14:58:51 -04:00
|
|
|
key_press(egui::Key::G, CTRL_ALT),
|
2026-08-09 16:25:43 -04:00
|
|
|
);
|
|
|
|
|
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() {
|
2026-08-17 22:30:43 -04:00
|
|
|
let ctx = crate::test_ui::ctx();
|
2026-08-09 16:25:43 -04:00
|
|
|
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,
|
2026-09-25 14:58:51 -04:00
|
|
|
key_press(egui::Key::G, egui::Modifiers::NONE),
|
2026-08-09 16:25:43 -04:00
|
|
|
);
|
|
|
|
|
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,
|
2026-09-25 14:58:51 -04:00
|
|
|
key_press(egui::Key::Escape, egui::Modifiers::NONE),
|
2026-08-09 16:25:43 -04:00
|
|
|
);
|
|
|
|
|
assert_eq!(setting, "Ctrl+Shift+F", "Escape leaves the shortcut alone");
|
|
|
|
|
assert!(!capturing);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn clear_switches_the_shortcut_off() {
|
2026-08-17 22:30:43 -04:00
|
|
|
let ctx = crate::test_ui::ctx();
|
2026-08-09 16:25:43 -04:00
|
|
|
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, "");
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
// The button says what the state is rather than going blank.
|
2026-08-09 16:25:43 -04:00
|
|
|
let empty = run_hotkey_edit(&ctx, &mut setting, &mut capturing, vec![]);
|
|
|
|
|
assert!(painted_text(&empty).iter().any(|t| t == "None"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Until Apply, the draft and the live registration can disagree.
|
2026-08-09 16:25:43 -04:00
|
|
|
#[test]
|
|
|
|
|
fn an_unapplied_shortcut_says_it_is_not_in_force_yet() {
|
2026-08-17 22:30:43 -04:00
|
|
|
let ctx = crate::test_ui::ctx();
|
2026-09-25 14:58:51 -04:00
|
|
|
let note = |draft: &str, live: &str| {
|
|
|
|
|
let out = run(&ctx, egui::vec2(600.0, 200.0), vec![], |ui| {
|
|
|
|
|
hotkey_note(ui, draft, live)
|
2026-08-09 16:25:43 -04:00
|
|
|
});
|
|
|
|
|
painted_text(&out).join("\n")
|
|
|
|
|
};
|
2026-09-25 14:58:51 -04:00
|
|
|
assert!(note("Ctrl+Alt+K", "Ctrl+Shift+F").contains("Apply & Save"));
|
2026-08-23 00:33:53 -04:00
|
|
|
// Matching, and nothing registered in a test process.
|
2026-09-25 14:58:51 -04:00
|
|
|
assert_eq!(note("Ctrl+Shift+F", "Ctrl+Shift+F"), "");
|
2026-08-09 16:25:43 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
/// 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.
|
2026-09-25 14:58:51 -04:00
|
|
|
#[rustfmt::skip]
|
2026-08-25 01:04:36 -04:00
|
|
|
const ROWS: &[(Section, &str, &tips::Tip, Level)] = &[
|
2026-09-25 14:58:51 -04:00
|
|
|
(Section::Indexing, "Full reindex every", &tips::REINDEX_INTERVAL, Level::Advanced),
|
|
|
|
|
(Section::Indexing, "Include hidden files", &tips::INCLUDE_HIDDEN, Level::Everyday),
|
|
|
|
|
(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),
|
2026-09-26 16:29:36 -04:00
|
|
|
(Section::Processing, "Compact text index", &tips::COMPACT_INDEX, Level::Everyday),
|
2026-09-25 14:58:51 -04:00
|
|
|
(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),
|
2026-08-09 16:25:43 -04:00
|
|
|
];
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
/// 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.
|
2026-08-09 16:25:43 -04:00
|
|
|
#[test]
|
|
|
|
|
fn every_row_shows_its_own_tip() {
|
2026-08-25 01:04:36 -04:00
|
|
|
for (section, label, tip, _) in ROWS {
|
2026-08-17 22:30:43 -04:00
|
|
|
let ctx = crate::test_ui::ctx();
|
2026-09-25 14:58:51 -04:00
|
|
|
instant_tooltips(&ctx);
|
2026-08-09 16:25:43 -04:00
|
|
|
let mut cfg = Config::default();
|
2026-09-25 14:58:51 -04:00
|
|
|
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 })
|
2026-08-09 16:25:43 -04:00
|
|
|
})
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-25 14:58:51 -04:00
|
|
|
let pos = painted_text_center(&frame(vec![]), label)
|
|
|
|
|
.unwrap_or_else(|| panic!("{label} was not painted"));
|
2026-08-23 00:33:53 -04:00
|
|
|
// Enough of the body to be unique.
|
2026-08-09 16:25:43 -04:00
|
|
|
let opening: String = tip.body.chars().take(40).collect();
|
2026-09-25 14:58:51 -04:00
|
|
|
assert!(
|
|
|
|
|
hover_shows(&mut frame, pos, &opening),
|
|
|
|
|
"hovering {label:?} did not show {:?}",
|
|
|
|
|
tip.title
|
|
|
|
|
);
|
2026-08-09 16:25:43 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Hovering a setting's *name* explains it, not just its control.
|
2026-08-09 16:25:43 -04:00
|
|
|
#[test]
|
|
|
|
|
fn hovering_a_setting_label_explains_it() {
|
2026-08-17 22:30:43 -04:00
|
|
|
let ctx = crate::test_ui::ctx();
|
2026-09-25 14:58:51 -04:00
|
|
|
instant_tooltips(&ctx);
|
2026-10-11 20:32:00 -04:00
|
|
|
// Hash sample size is an advanced row, so the whole tab has to be
|
|
|
|
|
// showing them.
|
2026-08-25 01:04:36 -04:00
|
|
|
let cfg = Config {
|
|
|
|
|
ui: quicksearch_core::config::UiConfig {
|
|
|
|
|
show_advanced_settings: true,
|
|
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
..Config::default()
|
|
|
|
|
};
|
2026-09-25 14:58:51 -04:00
|
|
|
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);
|
2026-08-09 16:25:43 -04:00
|
|
|
})
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-11 20:32:00 -04:00
|
|
|
let target = painted_text_center(&frame(vec![]), "Hash sample size (bytes)")
|
|
|
|
|
.expect("the Hash sample size label was not painted");
|
2026-09-25 14:58:51 -04:00
|
|
|
assert!(
|
2026-10-11 20:32:00 -04:00
|
|
|
hover_shows(&mut frame, target, "How many bytes from the start"),
|
|
|
|
|
"no tooltip appeared over the Hash sample size label"
|
2026-09-25 14:58:51 -04:00
|
|
|
);
|
2026-08-09 16:25:43 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-08-17 19:26:18 -04:00
|
|
|
fn the_tab_renders_and_apply_reports_the_draft() {
|
2026-08-17 22:30:43 -04:00
|
|
|
let ctx = crate::test_ui::ctx();
|
2026-08-09 16:25:43 -04:00
|
|
|
let cfg = Config::default();
|
2026-09-25 14:58:51 -04:00
|
|
|
let mut w = SettingsTab::default();
|
2026-08-17 19:26:18 -04:00
|
|
|
w.stage(&cfg);
|
2026-08-09 16:25:43 -04:00
|
|
|
w.draft.as_mut().unwrap().search.debounce_ms += 100;
|
|
|
|
|
|
2026-09-25 14:58:51 -04:00
|
|
|
let frame = |w: &mut SettingsTab, events| {
|
2026-08-17 19:26:18 -04:00
|
|
|
let mut out = SettingsOutput::default();
|
2026-09-25 14:58:51 -04:00
|
|
|
let full = run(&ctx, egui::vec2(1000.0, 900.0), events, |ui| {
|
|
|
|
|
out = w.ui(ui, &cfg, None)
|
2026-08-17 19:26:18 -04:00
|
|
|
});
|
2026-08-09 16:25:43 -04:00
|
|
|
(out, full)
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-25 14:58:51 -04:00
|
|
|
let (untouched, full) = frame(&mut w, vec![]);
|
2026-08-17 19:26:18 -04:00
|
|
|
assert!(untouched.applied.is_none());
|
|
|
|
|
let target = painted_text_center(&full, "Apply & Save")
|
|
|
|
|
.expect("the Apply & Save button was not painted");
|
2026-09-25 14:58:51 -04:00
|
|
|
let (clicked, _) = frame(&mut w, click_at(target));
|
2026-08-09 16:25:43 -04:00
|
|
|
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"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-08-17 19:26:18 -04:00
|
|
|
|
|
|
|
|
fn run_columns(
|
|
|
|
|
ctx: &egui::Context,
|
|
|
|
|
current: &ColumnsConfig,
|
|
|
|
|
events: Vec<egui::Event>,
|
|
|
|
|
) -> (Option<ColumnsConfig>, egui::FullOutput) {
|
|
|
|
|
let mut picked = None;
|
2026-09-25 14:58:51 -04:00
|
|
|
let full = run(ctx, egui::vec2(700.0, 200.0), events, |ui| {
|
|
|
|
|
picked = columns_ui(ui, current);
|
2026-08-17 19:26:18 -04:00
|
|
|
});
|
|
|
|
|
(picked, full)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// The picker acts on the *live* config, so it reports a change the moment
|
|
|
|
|
/// a box moves, with no Apply.
|
2026-08-17 19:26:18 -04:00
|
|
|
#[test]
|
|
|
|
|
fn the_columns_block_reports_a_change_immediately() {
|
2026-08-17 22:30:43 -04:00
|
|
|
let ctx = crate::test_ui::ctx();
|
2026-08-17 19:26:18 -04:00
|
|
|
let current = ColumnsConfig::default();
|
|
|
|
|
assert!(!current.size, "the fixture assumes Size ships off");
|
|
|
|
|
|
|
|
|
|
let (quiet, full) = run_columns(&ctx, ¤t, 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, ¤t, 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
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// The path is shown checked and greyed rather than left out, so "why can
|
|
|
|
|
/// I not remove it?" has an answer on screen.
|
2026-08-17 19:26:18 -04:00
|
|
|
#[test]
|
|
|
|
|
fn the_columns_block_offers_every_column_but_the_path() {
|
2026-08-17 22:30:43 -04:00
|
|
|
let ctx = crate::test_ui::ctx();
|
2026-08-17 19:26:18 -04:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// `keychain_active` is passed straight through: nothing touches the OS keychain.
|
2026-08-17 19:26:18 -04:00
|
|
|
fn run_security(
|
|
|
|
|
ctx: &egui::Context,
|
|
|
|
|
current: &Config,
|
|
|
|
|
events: Vec<egui::Event>,
|
|
|
|
|
) -> (Option<SecurityAction>, egui::FullOutput) {
|
|
|
|
|
let mut action = None;
|
2026-09-25 14:58:51 -04:00
|
|
|
let full = run(ctx, egui::vec2(700.0, 300.0), events, |ui| {
|
|
|
|
|
action = security_ui(ui, current, false, Form { advanced: true });
|
2026-08-17 19:26:18 -04:00
|
|
|
});
|
|
|
|
|
(action, full)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// An unprotected index has no key, so the button is left out, not shown dead.
|
2026-08-17 19:26:18 -04:00
|
|
|
#[test]
|
|
|
|
|
fn the_key_button_appears_only_while_the_index_is_encrypted() {
|
2026-08-17 22:30:43 -04:00
|
|
|
let ctx = crate::test_ui::ctx();
|
2026-08-17 19:26:18 -04:00
|
|
|
let mut cfg = Config::default();
|
|
|
|
|
assert!(
|
|
|
|
|
!cfg.security.password_protected,
|
|
|
|
|
"the fixture assumes protection ships off"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let (_, full) = run_security(&ctx, &cfg, vec![]);
|
|
|
|
|
assert!(
|
2026-09-05 01:55:59 -04:00
|
|
|
painted_text_center(&full, "Show database key").is_none(),
|
2026-08-17 19:26:18 -04:00
|
|
|
"offered the key of an unencrypted index: {:?}",
|
|
|
|
|
painted_text(&full)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
cfg.security.password_protected = true;
|
|
|
|
|
let (_, full) = run_security(&ctx, &cfg, vec![]);
|
|
|
|
|
assert!(
|
2026-09-05 01:55:59 -04:00
|
|
|
painted_text_center(&full, "Show database key").is_some(),
|
2026-08-17 19:26:18 -04:00
|
|
|
"no key button while encrypted: {:?}",
|
|
|
|
|
painted_text(&full)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// The click only asks for the flow; the reveal lives in the app.
|
2026-08-17 19:26:18 -04:00
|
|
|
#[test]
|
|
|
|
|
fn clicking_the_key_button_reports_show_key() {
|
2026-08-17 22:30:43 -04:00
|
|
|
let ctx = crate::test_ui::ctx();
|
2026-08-17 19:26:18 -04:00
|
|
|
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");
|
2026-09-05 01:55:59 -04:00
|
|
|
let target = painted_text_center(&full, "Show database key").expect("no key button");
|
2026-08-17 19:26:18 -04:00
|
|
|
|
|
|
|
|
let (action, _) = run_security(&ctx, &cfg, click_at(target));
|
|
|
|
|
assert_eq!(action, Some(SecurityAction::ShowKey));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// The assertion that `app::pin_live_fields` covers the columns field.
|
2026-08-17 19:26:18 -04:00
|
|
|
#[test]
|
|
|
|
|
fn a_stale_draft_cannot_revert_the_columns() {
|
2026-09-25 14:58:51 -04:00
|
|
|
let mut w = SettingsTab::default();
|
2026-08-17 19:26:18 -04:00
|
|
|
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"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-08-25 01:04:36 -04:00
|
|
|
|
|
|
|
|
/// 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] {
|
2026-09-25 14:58:51 -04:00
|
|
|
let out = run_panel(&ctx, egui::vec2(600.0, 800.0), vec![], |ui| {
|
|
|
|
|
config_editor_ui(ui, &mut cfg, section, None, form)
|
|
|
|
|
});
|
2026-08-25 01:04:36 -04:00
|
|
|
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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 18:09:05 -04:00
|
|
|
/// 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
|
2026-09-25 14:58:51 -04:00
|
|
|
/// counts files or milliseconds and stays plain, the other half of the
|
|
|
|
|
/// assertion: the grouping is scoped to the fields that needed it.
|
2026-09-18 18:09:05 -04:00
|
|
|
#[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;
|
2026-09-25 14:58:51 -04:00
|
|
|
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 },
|
|
|
|
|
)
|
|
|
|
|
});
|
2026-09-18 18:09:05 -04:00
|
|
|
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}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 01:04:36 -04:00
|
|
|
/// 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() {
|
2026-09-25 14:58:51 -04:00
|
|
|
let mut w = SettingsTab::default();
|
2026-08-25 01:04:36 -04:00
|
|
|
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"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-09-04 21:39:54 -04:00
|
|
|
|
2026-09-06 19:43:57 -04:00
|
|
|
/// 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;
|
2026-09-25 14:58:51 -04:00
|
|
|
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}");
|
2026-09-06 19:43:57 -04:00
|
|
|
assert!(!err, "SystemOwned is the working state, not a warning");
|
2026-09-25 14:58:51 -04:00
|
|
|
let (desktop, err) = super::hotkey_status_line(Status::DesktopOnly).expect("a line");
|
2026-09-06 19:43:57 -04:00
|
|
|
assert!(desktop.contains("Set up system shortcut"), "{desktop}");
|
|
|
|
|
assert!(!err, "DesktopOnly is a pointer, not a failure");
|
2026-09-25 14:58:51 -04:00
|
|
|
let (error, err) =
|
|
|
|
|
super::hotkey_status_line(Status::Error("taken".to_string())).expect("a line");
|
2026-09-06 19:43:57 -04:00
|
|
|
assert!(error.contains("taken"));
|
|
|
|
|
assert_eq!(err, !cfg!(windows), "only non-Windows colours the failure");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 14:58:51 -04:00
|
|
|
/// 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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 21:39:54 -04:00
|
|
|
/// The panel that tells a user how to get a shortcut that also starts
|
2026-09-25 14:58:51 -04:00
|
|
|
/// QuickSearch has to show the command they must bind, with or without a
|
|
|
|
|
/// one-click desktop to lean on.
|
2026-09-04 21:39:54 -04:00
|
|
|
#[test]
|
|
|
|
|
fn the_shortcut_note_names_the_command_to_bind() {
|
|
|
|
|
let ctx = crate::test_ui::ctx();
|
2026-09-25 14:58:51 -04:00
|
|
|
let painted = shortcut_note_text(
|
|
|
|
|
&ctx,
|
|
|
|
|
"Ctrl+Shift+F",
|
|
|
|
|
crate::shortcut_setup::Desktop::Unsupported,
|
|
|
|
|
);
|
2026-09-04 21:39:54 -04:00
|
|
|
assert!(
|
|
|
|
|
painted.contains("--toggle"),
|
|
|
|
|
"the command to bind was not shown: {painted}"
|
|
|
|
|
);
|
|
|
|
|
assert!(painted.contains("Copy"), "no way to copy it: {painted}");
|
|
|
|
|
}
|
2026-09-05 01:55:59 -04:00
|
|
|
|
|
|
|
|
/// 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();
|
2026-09-25 14:58:51 -04:00
|
|
|
let note_when_installed = |installed: bool| {
|
|
|
|
|
ctx.data_mut(|d| {
|
|
|
|
|
d.insert_temp(
|
|
|
|
|
egui::Id::new("system-shortcut-state"),
|
|
|
|
|
super::SystemShortcutState {
|
|
|
|
|
installed,
|
|
|
|
|
feedback: None,
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-09-05 01:55:59 -04:00
|
|
|
});
|
2026-09-25 14:58:51 -04:00
|
|
|
shortcut_note_text(&ctx, "Ctrl+Shift+F", crate::shortcut_setup::Desktop::Gnome)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let painted = note_when_installed(false);
|
2026-09-05 01:55:59 -04:00
|
|
|
assert!(
|
|
|
|
|
painted.contains("Set up Ctrl+Shift+F system-wide"),
|
|
|
|
|
"no one-click button: {painted}"
|
|
|
|
|
);
|
2026-09-25 14:58:51 -04:00
|
|
|
assert!(
|
|
|
|
|
painted.contains("--toggle"),
|
|
|
|
|
"the fallback vanished: {painted}"
|
|
|
|
|
);
|
2026-09-05 01:55:59 -04:00
|
|
|
|
|
|
|
|
// Already installed: the button flips to removal.
|
2026-09-25 14:58:51 -04:00
|
|
|
let painted = note_when_installed(true);
|
2026-09-05 01:55:59 -04:00
|
|
|
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();
|
2026-09-25 14:58:51 -04:00
|
|
|
let painted = shortcut_note_text(&ctx, "", crate::shortcut_setup::Desktop::Gnome);
|
2026-09-05 01:55:59 -04:00
|
|
|
assert!(
|
|
|
|
|
!painted.contains("system-wide"),
|
|
|
|
|
"offered to bind nothing: {painted}"
|
|
|
|
|
);
|
2026-09-25 14:58:51 -04:00
|
|
|
assert!(
|
|
|
|
|
painted.contains("--toggle"),
|
|
|
|
|
"the manual flow vanished: {painted}"
|
|
|
|
|
);
|
2026-09-05 01:55:59 -04:00
|
|
|
}
|