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, 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::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::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, ) -> (Option, 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, ¤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 } ); } /// 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, ) -> (Option, 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}" ); }