quick_search/crates/quicksearch-gui/src/duplicates_tab/tests.rs
Jeremy Karst be30ddc9af
All checks were successful
CI / linux (push) Successful in 16m22s
CI / windows-cross (push) Successful in 5m9s
CI / release (push) Successful in 15s
Tail-check duplicates, re-measure benchmarks and fix CI's release-only test failures
- Duplicates tab: groups are confirmed by each file's last 8 KiB and
  keyed by head+tail; unchecked files are never shown. False-positive
  duplicates drop from 3.4% to 0.1%, and indexing is unchanged.
- Encrypted search cache cap raised to 192 MiB at 150 B per file: a
  keystroke at 1M files takes 59 ms, down from 99 ms.
- Case-insensitive filename matching uses a memchr2 scan (1.18x).
- Benches run again on the compact index, seed with the indexer's 2 GiB
  WAL cap (peak scratch 6.2 GB -> 2.45 GB), and every recorded figure
  is re-measured.
- A fresh index no longer logs a rebuild warning.
- CI: the sandbox memory test's allocation survives release builds, and
  --toggle retries an interrupted read.
2026-10-12 00:14:10 -04:00

797 lines
28 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 std::sync::atomic::AtomicBool;
use quicksearch_core::config::Config;
use quicksearch_core::search::{split_by_tail, HeadGroup};
use quicksearch_core::testutil::Scratch;
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],
tail: [0xcd; 32],
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.state = DupState::Loaded(LoadedGroups::new(groups, None));
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:?}"
);
}
/// Whether the listing was cut short is the fetch's to say: the tail check
/// can leave a full fetch listing fewer groups than it asked for.
#[test]
fn a_full_fetch_says_it_was_limited_however_many_are_listed() {
let ctx = crate::test_ui::ctx();
let limited = |limited_to: Option<u32>| {
let mut tab = DuplicatesTab::new(&DuplicatesConfig::default());
tab.state = DupState::Loaded(LoadedGroups::new(vec![group(&PATHS)], limited_to));
painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0)
.iter()
.any(|t| t.contains("Limited to the 500 largest groups"))
};
assert!(limited(Some(500)), "one group left of a full fetch");
assert!(!limited(None), "a fetch short of its limit was everything");
}
// --- 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();
// The tab prices a group from its members: 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, None);
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, None);
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()])
);
}
/// The folder's glob syntax is escaped: unescaped, `[FLAC]` is a character
/// class that excludes `Music F` and `Music L` and keeps the folder itself.
#[test]
fn excluding_a_bracketed_folder_excludes_only_it() {
const BRACKETED: [&str; 3] = [
"/m/Music [FLAC]/a.flac",
"/m/Music F/a.flac",
"/m/Music L/a.flac",
];
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&BRACKETED);
expand(&ctx, &mut tab);
context_menu_on(&ctx, &mut tab, false, BRACKETED[0]);
let actions = click_menu_entry(&ctx, &mut tab, "Exclude this folder from duplicates");
let folder = std::path::Path::new(BRACKETED[0]).parent().unwrap();
assert_eq!(
actions.save_filters.map(|f| f.exclude_patterns),
Some(vec![crate::ui_util::dir_ignore_pattern(folder)])
);
let header = header_of(&ctx, &mut tab);
assert!(
header.starts_with("2 × a.flac:"),
"the wrong copies were excluded: {header:?}"
);
}
/// 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 identity = group(&PATHS).identity();
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![identity]),
"hiding is not remembered by head and tail"
);
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).identity()]),
);
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).identity()]));
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).identity(), "00".repeat(64)]),
);
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");
}
/// An identity spelled in capitals by a hand-edited config still means that
/// group.
#[test]
fn hidden_identities_are_matched_case_insensitively() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(
vec![group(&PATHS)],
hiding(&[group(&PATHS).identity().to_uppercase()]),
);
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(painted.contains(&ALL_FILTERED.to_string()), "{painted:?}");
}
/// A head hash alone, as earlier versions saved a hidden group, names no
/// group now: every listed group is a head and a tail.
#[test]
fn a_head_only_entry_hides_nothing() {
let ctx = crate::test_ui::ctx();
let head_only = group(&PATHS).identity()[..64].to_string();
let mut tab = tab(vec![group(&PATHS)], hiding(&[head_only]));
let out = frame(&ctx, &mut tab, false, Vec::new()).0;
assert_eq!(headers(&out).len(), 1, "an old entry hid a group");
}
/// The head the files below were "indexed" with.
const TAIL: usize = 16;
/// Two sets of copies under `dir` that start alike and end apart, as the tail
/// check lists them: `a1.bin` and `a2.bin`, then `b1.bin` and `b2.bin`.
fn sharing_a_head(dir: &std::path::Path) -> Vec<DuplicateGroup> {
let members = ["a1.bin", "a2.bin", "b1.bin", "b2.bin"]
.iter()
.enumerate()
.map(|(i, name)| {
let mut body = vec![b'h'; 3 * TAIL];
body[3 * TAIL - 1] = name.as_bytes()[0];
let path = dir.join(name);
std::fs::write(&path, &body).unwrap();
let path = path.to_str().unwrap().to_string();
let size = body.len() as u64;
(i as i64, name.to_string(), path, size, 1_700_000_000)
})
.collect();
let head = HeadGroup {
hash: vec![0xab; 32],
members,
};
split_by_tail(vec![head], TAIL, |_| false, &AtomicBool::new(false))
}
/// Hides whichever group is listed first; what the tab hands back to save.
fn hide_the_first(ctx: &egui::Context, tab: &mut DuplicatesTab) -> DuplicatesConfig {
let header = header_of(ctx, tab);
context_menu_on(ctx, tab, false, &header);
click_menu_entry(ctx, tab, HIDE_LABEL)
.save_filters
.expect("the hide was not handed back to be saved")
}
/// One head hash, two real sets of copies: hiding one leaves the other.
#[test]
fn hiding_one_of_two_groups_sharing_a_head_leaves_the_other() {
let ctx = crate::test_ui::ctx();
let dir = Scratch::dir("dup-tab-shared-head");
let groups = sharing_a_head(&dir);
assert_eq!(groups.len(), 2, "the tail check did not split them");
let mut tab = tab(groups.clone(), DuplicatesConfig::default());
let saved = hide_the_first(&ctx, &mut tab);
assert_eq!(saved.hidden_groups, [groups[0].identity()]);
let listed = headers(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert_eq!(listed.len(), 1, "{listed:?}");
assert!(
listed[0].contains("b1.bin"),
"the wrong one is listed: {listed:?}"
);
}
/// The hidden set is written to the config and read back at the next start,
/// where the identity it holds must still name that group, and only it.
#[test]
fn a_hidden_group_stays_hidden_through_a_saved_config() {
let ctx = crate::test_ui::ctx();
let dir = Scratch::dir("dup-tab-hidden-config");
let groups = sharing_a_head(&dir);
let mut before = tab(groups.clone(), DuplicatesConfig::default());
let path = dir.join("config.toml");
let config = Config {
duplicates: hide_the_first(&ctx, &mut before),
source: Some(path.clone()),
..Config::default()
};
config.save().unwrap();
let loaded = Config::load_from(&path).unwrap();
assert_eq!(loaded.duplicates.hidden_groups, [groups[0].identity()]);
let mut after = tab(groups, loaded.duplicates);
let listed = headers(&frame(&ctx, &mut after, false, Vec::new()).0);
assert_eq!(listed.len(), 1, "{listed:?}");
assert!(listed[0].contains("b1.bin"), "{listed:?}");
}
// --- 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], None);
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)], None);
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"
);
}