Files
sfdupes/report_test.go
T
sneak b80c7e805e
check / check (push) Successful in 1m0s
Add 64 KiB head/tail and content-hash duplicate ladder (closes #61)
Replace the 1 KiB end sampling with a ladder for same-size candidates:
SHA-256 of the first and last 64 KiB, then a content hash that is the
whole file below 50 MiB (proof of identity) and gigabyte-spaced 1 MiB
samples at or above (deliberately probabilistic). Two files are
duplicates only when size, head, tail, and content all agree.

The signature gains a content column; schema bumps to version 2, so a
version 1 database is rejected and must be rescanned (unavoidable — every
stored hash changed). Because report and trees group stored signatures
across separate scans, content is computed for every shared-size file,
not only within-run head/tail collisions; size remains the sole read
gate. README "Duplicate detection" documents each rung; tests cover the
window boundaries, the 50 MiB boundary, and a multi-gigabyte sampled
case with sparse temp files.

Model: opus-4-8
2026-09-22 13:58:57 +00:00

149 lines
3.9 KiB
Go

package main
import (
"slices"
"testing"
)
func TestCollectDupeGroups(t *testing.T) {
t.Parallel()
recs := []scanRec{
{size: 100, head: "h", tail: "t", path: "/z/b"},
{size: 100, head: "h", tail: "t", path: "/z/a"},
{size: 100, head: "h", tail: "t", path: "/z/c"},
{size: 4000, head: "H", tail: "T", path: "/big/2"},
{size: 4000, head: "H", tail: "T", path: "/big/1"},
// Same size as the /z group but a different head hash.
{size: 100, head: "other", tail: "t", path: "/z/d"},
// A singleton signature must not form a group.
{size: 7, head: "u", tail: "u", path: "/lonely"},
}
groups := collectDupeGroups(recs)
if len(groups) != 2 {
t.Fatalf("len(groups) = %d, want 2", len(groups))
}
if groups[0].size != 4000 ||
!slices.Equal(groups[0].paths, []string{"/big/1", "/big/2"}) {
t.Errorf("groups[0] = %+v, want size 4000, paths /big/1 /big/2",
groups[0])
}
if groups[1].size != 100 ||
!slices.Equal(groups[1].paths, []string{"/z/a", "/z/b", "/z/c"}) {
t.Errorf("groups[1] = %+v, want size 100, paths /z/a /z/b /z/c",
groups[1])
}
}
func TestCollectDupeGroupsContentSeparates(t *testing.T) {
t.Parallel()
// Same size, head, and tail, but different content hashes: the final
// rung keeps them apart, so no group forms. Matching content groups.
recs := []scanRec{
{size: 100, head: "h", tail: "t", content: "c1", path: "/a"},
{size: 100, head: "h", tail: "t", content: "c2", path: "/b"},
{size: 100, head: "h", tail: "t", content: "c1", path: "/c"},
}
groups := collectDupeGroups(recs)
if len(groups) != 1 {
t.Fatalf("len(groups) = %d, want 1 (only the matching content)",
len(groups))
}
if !slices.Equal(groups[0].paths, []string{"/a", "/c"}) {
t.Errorf("group paths = %q, want /a /c", groups[0].paths)
}
}
func TestCollectDupeGroupsMtimeExcluded(t *testing.T) {
t.Parallel()
// mtime is informational only; records differing only in mtime
// still group together.
recs := []scanRec{
{size: 9, mtime: 100, head: "h", tail: "t", path: "/m/1"},
{size: 9, mtime: 200, head: "h", tail: "t", path: "/m/2"},
}
groups := collectDupeGroups(recs)
if len(groups) != 1 {
t.Fatalf("len(groups) = %d, want 1", len(groups))
}
}
func TestCollectDupeGroupsTieBreak(t *testing.T) {
t.Parallel()
recs := []scanRec{
{size: 50, head: "b", tail: "b", path: "/beta/2"},
{size: 50, head: "b", tail: "b", path: "/beta/1"},
{size: 50, head: "a", tail: "a", path: "/alpha/2"},
{size: 50, head: "a", tail: "a", path: "/alpha/1"},
}
groups := collectDupeGroups(recs)
if len(groups) != 2 {
t.Fatalf("len(groups) = %d, want 2", len(groups))
}
// Equal sizes: ordered by first path ascending.
if groups[0].paths[0] != "/alpha/1" || groups[1].paths[0] != "/beta/1" {
t.Fatalf("tie-break order wrong: %q then %q",
groups[0].paths[0], groups[1].paths[0])
}
}
func TestCollectDupeGroupsDeterministic(t *testing.T) {
t.Parallel()
recs := []scanRec{
{size: 1, head: "a", tail: "a", path: "/p/1"},
{size: 1, head: "a", tail: "a", path: "/p/2"},
{size: 2, head: "b", tail: "b", path: "/q/1"},
{size: 2, head: "b", tail: "b", path: "/q/2"},
}
forward := collectDupeGroups(recs)
reversed := slices.Clone(recs)
slices.Reverse(reversed)
backward := collectDupeGroups(reversed)
if !slices.EqualFunc(forward, backward, func(a, b dupeGroup) bool {
return a.size == b.size && slices.Equal(a.paths, b.paths)
}) {
t.Fatalf("output depends on record order: %+v vs %+v",
forward, backward)
}
}
func TestHumanBytes(t *testing.T) {
t.Parallel()
cases := []struct {
n int64
want string
}{
{0, "0 B"},
{1, "1 B"},
{1023, "1023 B"},
{1024, "1.0 KiB"},
{1536, "1.5 KiB"},
{1 << 20, "1.0 MiB"},
{5 << 30, "5.0 GiB"},
{1 << 40, "1.0 TiB"},
{1 << 50, "1.0 PiB"},
{1 << 60, "1.0 EiB"},
}
for _, c := range cases {
if got := humanBytes(c.n); got != c.want {
t.Errorf("humanBytes(%d) = %q, want %q", c.n, got, c.want)
}
}
}