quick_search/crates/quicksearch-gui/src/duplicates_tab/tests.rs

663 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use super::*;
use crate::test_ui::{click_at, click_with, painted_text, painted_text_center, run_panel};
const SCREEN: egui::Vec2 = egui::vec2(1000.0, 700.0);
/// What `app.rs` passes; the banner quotes it back.
const HASH_LENGTH: usize = 8192;
fn group(paths: &[&str]) -> DuplicateGroup {
DuplicateGroup {
hash: vec![0xab; 32],
count: paths.len() as i64,
total_size: 100 * paths.len() as i64,
redundant_size: 100 * (paths.len() as i64 - 1),
members: paths
.iter()
.enumerate()
.map(|(i, p)| {
let name = p.rsplit('/').next().unwrap_or(p).to_string();
(i as i64, name, p.to_string(), 100u64, 1_700_000_000i64)
})
.collect(),
}
}
fn tab(groups: Vec<DuplicateGroup>, filters: DuplicatesConfig) -> DuplicatesTab {
let mut tab = DuplicatesTab::new(&filters);
tab.scan_limit = crate::backend::DUP_SCAN_LIMIT;
tab.state = DupState::Loaded(LoadedGroups::new(groups));
tab
}
fn loaded(paths: &[&str]) -> DuplicatesTab {
tab(vec![group(paths)], DuplicatesConfig::default())
}
fn frame(
ctx: &egui::Context,
tab: &mut DuplicatesTab,
busy: bool,
events: Vec<egui::Event>,
) -> (egui::FullOutput, DuplicatesActions) {
let mut actions = DuplicatesActions::default();
let out = run_panel(ctx, SCREEN, events, |ui| {
actions = tab.ui(ui, busy, HASH_LENGTH);
});
crate::test_ui::assert_no_tofu(ctx, &out);
(out, actions)
}
/// Right-clicks `needle`; the text painted once its menu is open.
fn context_menu_on(
ctx: &egui::Context,
tab: &mut DuplicatesTab,
busy: bool,
needle: &str,
) -> Vec<String> {
let (out, _) = frame(ctx, tab, busy, Vec::new());
let target = painted_text_center(&out, needle)
.unwrap_or_else(|| panic!("nothing painted for {needle:?}"));
frame(
ctx,
tab,
busy,
click_with(target, egui::PointerButton::Secondary),
);
// The menu is its own area, painted on the frame after the click.
painted_text(&frame(ctx, tab, busy, Vec::new()).0)
}
/// Clicks the group header open, so its member rows are on screen.
fn expand(ctx: &egui::Context, tab: &mut DuplicatesTab) {
let header = header_of(ctx, tab);
let (out, _) = frame(ctx, tab, false, Vec::new());
let pos = painted_text_center(&out, &header).expect("no header painted");
frame(ctx, tab, false, click_at(pos));
}
/// Click the menu entry labelled `label`, which the caller has already opened.
fn click_menu_entry(
ctx: &egui::Context,
tab: &mut DuplicatesTab,
label: &str,
) -> DuplicatesActions {
let (out, _) = frame(ctx, tab, false, Vec::new());
let entry =
painted_text_center(&out, label).unwrap_or_else(|| panic!("no {label:?} entry painted"));
frame(ctx, tab, false, click_at(entry)).1
}
/// The title line carries the group; find it without rebuilding its wording.
fn header_of(ctx: &egui::Context, tab: &mut DuplicatesTab) -> String {
let (out, _) = frame(ctx, tab, false, Vec::new());
headers(&out)
.into_iter()
.next()
.expect("no group header painted")
}
/// Every group header on screen, in listed order.
fn headers(out: &egui::FullOutput) -> Vec<String> {
painted_text(out)
.into_iter()
.filter(|t| t.contains("reclaimable"))
.collect()
}
const PATHS: [&str; 3] = ["/a/img.raw", "/b/img.raw", "/c/img.raw"];
// --- The verification, and how it is found ---------------------------------
#[test]
fn a_group_header_offers_the_verification() {
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
let header = header_of(&ctx, &mut tab);
let menu = context_menu_on(&ctx, &mut tab, false, &header);
assert!(
menu.contains(&VERIFY_LABEL.to_string()),
"the group's own row does not offer it: {menu:?}"
);
}
/// Clicking it asks for the whole group, not the one row it was asked from.
#[test]
fn verifying_asks_for_every_member_of_the_group() {
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
let header = header_of(&ctx, &mut tab);
context_menu_on(&ctx, &mut tab, false, &header);
let actions = click_menu_entry(&ctx, &mut tab, VERIFY_LABEL);
assert_eq!(
actions.verify,
Some(PATHS.iter().map(|p| p.to_string()).collect::<Vec<_>>())
);
}
/// There is one verification window, so a second run is refused where it is
/// asked for rather than replacing what someone is reading.
#[test]
fn a_second_verification_is_refused_while_the_window_is_open() {
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
let header = header_of(&ctx, &mut tab);
context_menu_on(&ctx, &mut tab, true, &header);
let (out, _) = frame(&ctx, &mut tab, true, Vec::new());
let entry = painted_text_center(&out, VERIFY_LABEL).expect("the entry should still be listed");
let (_, actions) = frame(&ctx, &mut tab, true, click_at(entry));
assert_eq!(actions.verify, None, "a disabled entry still fired");
}
/// Expanding a group and right-clicking one of its files offers the same
/// thing: the rows are what someone is looking at when the question occurs.
#[test]
fn a_member_row_offers_the_verification_too() {
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
expand(&ctx, &mut tab);
let menu = context_menu_on(&ctx, &mut tab, false, PATHS[1]);
assert!(
menu.contains(&VERIFY_LABEL.to_string()),
"an expanded member row does not offer it: {menu:?}"
);
assert!(
menu.contains(&"Open File".to_string()),
"the existing entries went missing: {menu:?}"
);
}
/// The caution is on screen, not behind a right-click nobody has made yet.
#[test]
fn the_listing_says_what_a_group_is_without_being_asked() {
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0).join(" ");
assert!(
painted.contains("suspected duplicates"),
"nothing warns that a group is a suspicion: {painted:?}"
);
assert!(
painted.contains("not proof"),
"the caution does not say what it is cautioning about: {painted:?}"
);
assert!(
painted.contains(VERIFY_LABEL.trim_end_matches('…')),
"the banner does not name the way to settle it: {painted:?}"
);
}
/// It quotes the setting, not a number written twice.
#[test]
fn the_banner_quotes_the_configured_sample_size() {
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
let out = run_panel(&ctx, SCREEN, Vec::new(), |ui| {
tab.ui(ui, false, 64 * 1024);
});
let painted = painted_text(&out).join(" ");
assert!(
painted.contains("first 65.5 KB"),
"the banner ignores hash_length: {painted:?}"
);
}
/// A tab with nothing to warn about does not warn.
#[test]
fn an_empty_result_says_so_rather_than_showing_an_empty_list() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(Vec::new(), DuplicatesConfig::default());
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(painted.contains(&NO_GROUPS.to_string()), "{painted:?}");
assert!(
!painted.iter().any(|t| t.contains("suspected duplicates")),
"an empty tab cautioned about nothing: {painted:?}"
);
}
// --- Ordering ---------------------------------------------------------------
/// A group named by its first member, with the waste that decides the
/// default order set by hand.
fn sized_group(name: &str, redundant: i64) -> DuplicateGroup {
let mut group = group(&[&format!("/a/{name}"), &format!("/b/{name}")]);
group.hash = name.as_bytes().to_vec();
group.redundant_size = redundant;
// The title is priced from the members, so the size has to agree with the
// waste the ordering is being tested on: two copies, one reclaimable.
for member in group.members.iter_mut() {
member.3 = redundant.max(0) as u64;
}
group
}
/// Each listed group's name (its first surviving member's), in order.
fn names(loaded: &LoadedGroups) -> Vec<String> {
loaded
.rows
.iter()
.map(|row| loaded.groups[row.group].members[row.members[0]].1.clone())
.collect()
}
/// The names of the groups, in the order the tab would list them.
fn listed(groups: Vec<DuplicateGroup>, sort: DupSort) -> Vec<String> {
let filters = DupFilters::new(&DuplicatesConfig::default());
let mut loaded = LoadedGroups::new(groups);
loaded.rebuild(sort, &filters, false);
names(&loaded)
}
#[test]
fn the_default_order_is_the_one_the_scan_returned() {
let groups = vec![
sized_group("big.txt", 900),
sized_group("small.jpg", 10),
sized_group("middling.txt", 100),
];
assert_eq!(
listed(groups, DupSort::Reclaimable),
["big.txt", "small.jpg", "middling.txt"],
"the query already ordered these; the tab must not reshuffle them"
);
}
#[test]
fn sorting_by_extension_gathers_the_extensions_together() {
let groups = vec![
sized_group("b.txt", 10),
sized_group("a.jpg", 20),
sized_group("a.txt", 30),
sized_group("b.jpg", 40),
];
assert_eq!(
listed(groups, DupSort::Extension),
// .jpg before .txt, and within each the bigger waste first.
["b.jpg", "a.jpg", "a.txt", "b.txt"]
);
}
/// Files without one are a category of their own, and not an interesting
/// one; they go after every extension rather than in front of "a".
#[test]
fn groups_without_an_extension_sort_last() {
let groups = vec![
sized_group("README", 900),
sized_group("notes.zzz", 10),
sized_group("Makefile", 800),
];
assert_eq!(
listed(groups, DupSort::Extension),
["notes.zzz", "README", "Makefile"]
);
}
#[test]
fn extension_matching_ignores_case() {
let groups = vec![
sized_group("shot.JPG", 10),
sized_group("scan.jpg", 20),
sized_group("note.txt", 30),
];
assert_eq!(
listed(groups, DupSort::Extension),
["scan.jpg", "shot.JPG", "note.txt"],
"one extension, spelled two ways, is still one extension"
);
}
/// Switching back is switching back, not a second arbitrary order.
#[test]
fn the_order_returns_when_the_choice_does() {
let groups = vec![
sized_group("big.txt", 900),
sized_group("small.jpg", 10),
sized_group("middling.txt", 100),
];
let filters = DupFilters::new(&DuplicatesConfig::default());
let mut loaded = LoadedGroups::new(groups);
loaded.rebuild(DupSort::Extension, &filters, false);
loaded.rebuild(DupSort::Reclaimable, &filters, false);
assert_eq!(names(&loaded), ["big.txt", "small.jpg", "middling.txt"]);
}
/// End to end: the choice reaches the list, and reordering what is already
/// loaded is not a reason to go back to the database.
#[test]
fn choosing_an_order_relists_without_rescanning() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(
vec![sized_group("b.txt", 900), sized_group("a.jpg", 10)],
DuplicatesConfig::default(),
);
let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let before = headers(&out);
assert!(before[0].contains("b.txt"), "{before:?}");
tab.sort = DupSort::Extension;
let (out, actions) = frame(&ctx, &mut tab, false, Vec::new());
let after = headers(&out);
assert!(after[0].contains("a.jpg"), "{after:?}");
assert!(
!actions.refresh,
"reordering what is loaded must not ask for another scan"
);
assert_eq!(before.len(), after.len(), "a group went missing");
}
// --- Exclusions -------------------------------------------------------------
fn excluding(patterns: &[&str]) -> DuplicatesConfig {
DuplicatesConfig {
exclude_patterns: patterns.iter().map(|p| p.to_string()).collect(),
hidden_groups: Vec::new(),
}
}
fn hiding(hashes: &[String]) -> DuplicatesConfig {
DuplicatesConfig {
exclude_patterns: Vec::new(),
hidden_groups: hashes.to_vec(),
}
}
/// An excluded copy stops being counted, and the group is re-priced around
/// the ones that are left: listing "3 ×" beside two paths would be a lie
/// about how much space deleting them saves.
#[test]
fn an_excluded_member_leaves_the_group_and_its_totals() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(vec![group(&PATHS)], excluding(&["/c/*"]));
let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let header = headers(&out)
.into_iter()
.next()
.expect("group went missing");
assert!(
header.starts_with("2 × img.raw:"),
"the count still includes the excluded copy: {header:?}"
);
assert!(
header.contains("100 B reclaimable (200 B total)"),
"the totals still include the excluded copy: {header:?}"
);
let painted = painted_text(&out);
assert!(
!painted.contains(&"/c/img.raw".to_string()),
"the excluded path is still listed: {painted:?}"
);
}
/// One surviving copy is not a duplicate of anything.
#[test]
fn a_group_down_to_one_survivor_is_not_listed() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(vec![group(&PATHS)], excluding(&["/b/*", "/c/*"]));
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(painted.contains(&ALL_FILTERED.to_string()), "{painted:?}");
assert!(
!painted.contains(&NO_GROUPS.to_string()),
"a filtered listing must not read as an index with no duplicates in it"
);
}
/// Verification is offered on what is on screen, not on what was excluded.
#[test]
fn verifying_a_filtered_group_reads_only_its_survivors() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(vec![group(&PATHS)], excluding(&["/c/*"]));
let header = header_of(&ctx, &mut tab);
context_menu_on(&ctx, &mut tab, false, &header);
let actions = click_menu_entry(&ctx, &mut tab, VERIFY_LABEL);
assert_eq!(
actions.verify,
Some(vec!["/a/img.raw".to_string(), "/b/img.raw".to_string()]),
"an excluded file was read anyway"
);
}
/// Adding one is an edit to the listing, not a reason to re-read the index.
#[test]
fn a_new_exclusion_relists_without_rescanning_and_is_persisted() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(vec![group(&PATHS)], DuplicatesConfig::default());
frame(&ctx, &mut tab, false, Vec::new());
tab.draft = "*.raw".to_string();
let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let add = painted_text_center(&out, "Add").expect("no Add button painted");
let (_, actions) = frame(&ctx, &mut tab, false, click_at(add));
assert!(!actions.refresh, "an exclusion asked for another scan");
assert_eq!(
actions.save_filters.map(|f| f.exclude_patterns),
Some(vec!["*.raw".to_string()]),
"the exclusion was not handed back to be saved"
);
// The click lands after the frame it was laid out for, so what it changed
// is on the next one.
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(
painted.contains(&ALL_FILTERED.to_string()),
"the group survived its own exclusion: {painted:?}"
);
assert!(
painted.contains(&"*.raw ×".to_string()),
"no chip to take it back off with: {painted:?}"
);
assert!(
!painted.contains(&"*.raw".to_string()),
"the editor kept the pattern it just added: {painted:?}"
);
}
/// …and taking the chip off puts the group back.
#[test]
fn removing_the_chip_brings_the_groups_back() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(vec![group(&PATHS)], excluding(&["*.raw"]));
let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let chip = painted_text_center(&out, "*.raw ×").expect("no chip painted");
let (out, actions) = frame(&ctx, &mut tab, false, click_at(chip));
assert_eq!(
actions.save_filters.map(|f| f.exclude_patterns),
Some(Vec::new())
);
assert_eq!(headers(&out).len(), 1, "the group did not come back");
}
/// Right-clicking a file is the shortest way to say "not this folder again".
#[test]
fn a_member_row_offers_the_two_exclusions_it_stands_for() {
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
expand(&ctx, &mut tab);
let menu = context_menu_on(&ctx, &mut tab, false, PATHS[1]);
assert!(
menu.contains(&"Exclude *.raw from duplicates".to_string()),
"no extension exclusion: {menu:?}"
);
assert!(
menu.iter().any(|t| t.contains("Exclude this folder")),
"no folder exclusion: {menu:?}"
);
let actions = click_menu_entry(&ctx, &mut tab, "Exclude *.raw from duplicates");
assert_eq!(
actions.save_filters.map(|f| f.exclude_patterns),
Some(vec!["*.raw".to_string()])
);
}
/// Only a hand-edited config can get here (the editor refuses one), and the
/// answer is to say so, not to quietly list files someone asked to never see.
#[test]
fn an_unparseable_pattern_is_reported_rather_than_silently_dropped() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(vec![group(&PATHS)], excluding(&["a[b"]));
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(
painted
.iter()
.any(|t| t.contains("Exclusions are not being applied")),
"a broken pattern set said nothing: {painted:?}"
);
assert_eq!(
headers(&frame(&ctx, &mut tab, false, Vec::new()).0).len(),
1,
"a broken pattern set hid the listing instead of listing it"
);
}
// --- Hidden groups ----------------------------------------------------------
#[test]
fn hiding_a_group_takes_it_off_the_list_and_is_persisted() {
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
let hash = group(&PATHS).hash_hex();
let header = header_of(&ctx, &mut tab);
context_menu_on(&ctx, &mut tab, false, &header);
let actions = click_menu_entry(&ctx, &mut tab, HIDE_LABEL);
assert_eq!(
actions.save_filters.map(|f| f.hidden_groups),
Some(vec![hash]),
"hiding is not remembered by content hash"
);
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(painted.contains(&ALL_FILTERED.to_string()), "{painted:?}");
assert!(
painted.iter().any(|t| t.contains("Hidden: 1 group")),
"nothing says a group is being kept off screen: {painted:?}"
);
}
/// A listing already on screen when the config named the group still drops it,
/// so the hiding survives a restart.
#[test]
fn a_group_hidden_in_the_config_is_not_listed() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(
vec![group(&PATHS), sized_group("keep.txt", 40)],
hiding(&[group(&PATHS).hash_hex()]),
);
let out = frame(&ctx, &mut tab, false, Vec::new()).0;
let listed = headers(&out);
assert_eq!(listed.len(), 1, "{listed:?}");
assert!(listed[0].contains("keep.txt"), "{listed:?}");
assert!(
painted_text(&out)
.iter()
.any(|t| t.contains("1 more hidden by your filters")),
"the listing does not account for what it dropped"
);
}
/// Hiding has to be reversible without editing a config file by hand.
#[test]
fn show_hidden_lists_them_again_and_offers_the_way_back() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(vec![group(&PATHS)], hiding(&[group(&PATHS).hash_hex()]));
let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let toggle = painted_text_center(&out, "Show hidden").expect("no Show hidden control");
let (out, _) = frame(&ctx, &mut tab, false, click_at(toggle));
let listed = headers(&out);
assert_eq!(listed.len(), 1, "showing hidden groups listed none");
assert!(
listed[0].starts_with("Hidden — "),
"a revealed group is not marked as one: {listed:?}"
);
context_menu_on(&ctx, &mut tab, false, &listed[0]);
let actions = click_menu_entry(&ctx, &mut tab, UNHIDE_LABEL);
assert_eq!(
actions.save_filters.map(|f| f.hidden_groups),
Some(Vec::new()),
"unhiding did not take it back out of the config"
);
}
/// Clear is the escape hatch for a hidden list nobody wants to unpick.
#[test]
fn clear_empties_the_hidden_list() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(
vec![group(&PATHS)],
hiding(&[group(&PATHS).hash_hex(), "00".repeat(32)]),
);
let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let clear = painted_text_center(&out, "Clear").expect("no Clear button");
let (out, actions) = frame(&ctx, &mut tab, false, click_at(clear));
assert_eq!(
actions.save_filters.map(|f| f.hidden_groups),
Some(Vec::new())
);
assert_eq!(headers(&out).len(), 1, "the group did not come back");
}
/// A hash spelled in capitals by a hand-edited config still means that group.
#[test]
fn hidden_hashes_are_matched_case_insensitively() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(
vec![group(&PATHS)],
hiding(&[group(&PATHS).hash_hex().to_uppercase()]),
);
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(painted.contains(&ALL_FILTERED.to_string()), "{painted:?}");
}
// --- The two together -------------------------------------------------------
/// The extension listing orders on what the titles say, which after an
/// exclusion is not what the scan ranked.
#[test]
fn the_extension_order_follows_the_recomputed_waste() {
// Three copies of one .txt (200 B reclaimable) against two of another
// (100 B), until an exclusion takes the third copy off the first.
let mut big = group(&["/a/big.txt", "/b/big.txt", "/keep-out/big.txt"]);
big.hash = b"big".to_vec();
let mut small = group(&["/a/small.txt", "/b/small.txt"]);
small.hash = b"small".to_vec();
assert_eq!(
listed(vec![big.clone(), small.clone()], DupSort::Extension),
["big.txt", "small.txt"],
"unfiltered, the three-copy group wastes more"
);
let filters = DupFilters::new(&excluding(&["/keep-out/*"]));
let mut loaded = LoadedGroups::new(vec![big, small]);
loaded.rebuild(DupSort::Extension, &filters, false);
assert_eq!(
names(&loaded),
["big.txt", "small.txt"],
"both waste 100 B now, so the hash settles it — but stably"
);
assert!(
loaded.rows.iter().all(|row| row.redundant == 100),
"the order was taken from the scan's price, not the listed one"
);
}
/// Every filter edit goes through the rebuild, so it must not run for
/// a frame that changed nothing.
#[test]
fn an_unchanged_listing_is_not_rebuilt() {
let filters = DupFilters::new(&DuplicatesConfig::default());
let mut loaded = LoadedGroups::new(vec![group(&PATHS)]);
loaded.rebuild(DupSort::Reclaimable, &filters, false);
let built = loaded.built_for;
loaded.rows.clear();
loaded.rebuild(DupSort::Reclaimable, &filters, false);
assert_eq!(loaded.built_for, built);
assert!(
loaded.rows.is_empty(),
"an unchanged listing was rebuilt anyway"
);
}