check / check (push) Successful in 1m34s
report and trees already checked every stdout write and the final flush. run now takes the stdout it hands to them, so tests pass a closed file or a failing writer instead of swapping os.Stdout: a closed stdout exits 1 with a one-line diagnostic, and the writer's error reaches the caller. README "Error handling" now states the two cases that never reach sfdupes as a failed write: a pipe reader that exits early ends the process with SIGPIPE, as with cat; and stdout closed with >&- is replaced by /dev/null by the Go runtime before main runs, so the run succeeds. Model: opus-5-5
263 lines
6.7 KiB
Go
263 lines
6.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"slices"
|
|
"testing"
|
|
)
|
|
|
|
// Signature hashes shared by the smoke-test records.
|
|
const (
|
|
f1Head = "f1h"
|
|
f1Tail = "f1t"
|
|
f1Content = "f1c"
|
|
f2Head = "f2h"
|
|
f2Tail = "f2t"
|
|
f2Content = "f2c"
|
|
)
|
|
|
|
// smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and
|
|
// /d/t2 are identical, /d/t3 differs from them only by one filename.
|
|
func smokeTreeRecs() []scanRec {
|
|
return []scanRec{
|
|
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t1/f1"},
|
|
{size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t1/sub/f2"},
|
|
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t2/f1"},
|
|
{size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t2/sub/f2"},
|
|
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t3/f1"},
|
|
{size: 100, head: f2Head, tail: f2Tail, content: f2Content,
|
|
path: "/d/t3/sub/f2renamed"},
|
|
}
|
|
}
|
|
|
|
// nodeByPath finds the directory node with the given path.
|
|
func nodeByPath(t *testing.T, dirs []*treeNode, path string) *treeNode {
|
|
t.Helper()
|
|
|
|
for _, d := range dirs {
|
|
if d.path == path {
|
|
return d
|
|
}
|
|
}
|
|
|
|
t.Fatalf("no directory node with path %q", path)
|
|
|
|
return nil
|
|
}
|
|
|
|
// groupPaths flattens tree groups into their member path lists.
|
|
func groupPaths(groups [][]*treeNode) [][]string {
|
|
out := make([][]string, 0, len(groups))
|
|
for _, g := range groups {
|
|
paths := make([]string, 0, len(g))
|
|
for _, n := range g {
|
|
paths = append(paths, n.path)
|
|
}
|
|
|
|
out = append(out, paths)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func TestBuildHierarchyCounts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
super, dirs := buildHierarchy(smokeTreeRecs())
|
|
super.compute()
|
|
|
|
d := nodeByPath(t, dirs, "/d")
|
|
if d.fileCount != 6 || d.totalSize != 9300 {
|
|
t.Errorf("/d: fileCount %d size %d, want 6 9300",
|
|
d.fileCount, d.totalSize)
|
|
}
|
|
|
|
t1 := nodeByPath(t, dirs, "/d/t1")
|
|
if t1.fileCount != 2 || t1.totalSize != 3100 {
|
|
t.Errorf("/d/t1: fileCount %d size %d, want 2 3100",
|
|
t1.fileCount, t1.totalSize)
|
|
}
|
|
|
|
sub := nodeByPath(t, dirs, "/d/t1/sub")
|
|
if sub.fileCount != 1 || sub.totalSize != 100 {
|
|
t.Errorf("/d/t1/sub: fileCount %d size %d, want 1 100",
|
|
sub.fileCount, sub.totalSize)
|
|
}
|
|
}
|
|
|
|
func TestBuildHierarchyRootPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The root directory's path is "/", never empty, and its
|
|
// children's paths start with a single slash.
|
|
_, dirs := buildHierarchy([]scanRec{{path: "/f"}, {path: "/srv/g"}})
|
|
|
|
got := make([]string, 0, len(dirs))
|
|
for _, d := range dirs {
|
|
got = append(got, d.path)
|
|
}
|
|
|
|
slices.Sort(got)
|
|
|
|
want := []string{"/", "/srv"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("directory paths = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRunTreesEscapesPaths(t *testing.T) {
|
|
t.Setenv(databaseEnv, seedDatabase(t, awkwardPairRecs()))
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := run([]string{cmdTrees}, &stdout, &stderr)
|
|
if code != exitOK {
|
|
t.Fatalf("run(trees) = %d, want %d; stderr: %s",
|
|
code, exitOK, stderr.String())
|
|
}
|
|
|
|
want := "first\tdupe\tfiles\tsize\n" +
|
|
`/d/\tone\ntwo\rthree\\four` + "\t/d/A\t1\t5\n"
|
|
if got := stdout.String(); got != want {
|
|
t.Errorf("stdout = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestTreeDigests(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
super, dirs := buildHierarchy(smokeTreeRecs())
|
|
super.compute()
|
|
|
|
t1 := nodeByPath(t, dirs, "/d/t1")
|
|
t2 := nodeByPath(t, dirs, "/d/t2")
|
|
t3 := nodeByPath(t, dirs, "/d/t3")
|
|
|
|
if t1.digest != t2.digest {
|
|
t.Error("identical trees /d/t1 and /d/t2 have different digests")
|
|
}
|
|
|
|
// t3 differs only in a filename; names are part of the digest.
|
|
if t1.digest == t3.digest {
|
|
t.Error("/d/t3 digest equals /d/t1 despite a renamed file")
|
|
}
|
|
|
|
sub1 := nodeByPath(t, dirs, "/d/t1/sub")
|
|
sub3 := nodeByPath(t, dirs, "/d/t3/sub")
|
|
|
|
if sub1.digest == sub3.digest {
|
|
t.Error("subdirs with differently-named files share a digest")
|
|
}
|
|
}
|
|
|
|
func TestTreeDigestContentSensitivity(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const sharedTail = "same"
|
|
|
|
recs := []scanRec{
|
|
{size: 10, head: sharedTail, tail: sharedTail, content: "c", path: "/r/a/f"},
|
|
{size: 10, head: "DIFF", tail: sharedTail, content: "c", path: "/r/b/f"},
|
|
}
|
|
|
|
super, dirs := buildHierarchy(recs)
|
|
super.compute()
|
|
|
|
a := nodeByPath(t, dirs, "/r/a")
|
|
b := nodeByPath(t, dirs, "/r/b")
|
|
|
|
if a.digest == b.digest {
|
|
t.Error("trees with different file content share a digest")
|
|
}
|
|
}
|
|
|
|
func TestCollectTreeGroupsMaximal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
super, dirs := buildHierarchy(smokeTreeRecs())
|
|
super.compute()
|
|
|
|
groups := collectTreeGroups(dirs, super)
|
|
|
|
want := [][]string{{"/d/t1", "/d/t2"}}
|
|
if got := groupPaths(groups); !slices.EqualFunc(got, want,
|
|
slices.Equal) {
|
|
t.Fatalf("groups = %v, want %v", got, want)
|
|
}
|
|
|
|
// The /d/t1/sub vs /d/t2/sub group must be suppressed as implied
|
|
// by its parents' group; the winning group reports one copy's
|
|
// recursive totals.
|
|
if groups[0][0].fileCount != 2 || groups[0][0].totalSize != 3100 {
|
|
t.Errorf("group totals: %d files %d bytes, want 2 3100",
|
|
groups[0][0].fileCount, groups[0][0].totalSize)
|
|
}
|
|
}
|
|
|
|
func TestCollectTreeGroupsDeterministic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
recs := smokeTreeRecs()
|
|
|
|
super, dirs := buildHierarchy(recs)
|
|
super.compute()
|
|
|
|
forward := groupPaths(collectTreeGroups(dirs, super))
|
|
|
|
reversed := slices.Clone(recs)
|
|
slices.Reverse(reversed)
|
|
|
|
superR, dirsR := buildHierarchy(reversed)
|
|
superR.compute()
|
|
|
|
backward := groupPaths(collectTreeGroups(dirsR, superR))
|
|
if !slices.EqualFunc(forward, backward, slices.Equal) {
|
|
t.Fatalf("output depends on record order: %v vs %v",
|
|
forward, backward)
|
|
}
|
|
}
|
|
|
|
func TestCollectTreeGroupsSiblings(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Identical sibling dirs share a parent, so their group cannot be
|
|
// implied by a parent group and must be reported.
|
|
recs := []scanRec{
|
|
{size: 10, head: "h", tail: "t", content: "c", path: "/p/x1/f"},
|
|
{size: 10, head: "h", tail: "t", content: "c", path: "/p/x2/f"},
|
|
}
|
|
|
|
super, dirs := buildHierarchy(recs)
|
|
super.compute()
|
|
|
|
got := groupPaths(collectTreeGroups(dirs, super))
|
|
|
|
want := [][]string{{"/p/x1", "/p/x2"}}
|
|
if !slices.EqualFunc(got, want, slices.Equal) {
|
|
t.Fatalf("groups = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCollectTreeGroupsDifferingParents(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// /p/a and /q/b contain identical x subtrees, but /p/a has an
|
|
// extra file, so the parents' digests differ and the x group must
|
|
// be reported.
|
|
recs := []scanRec{
|
|
{size: 10, head: "h", tail: "t", content: "c", path: "/p/a/x/f"},
|
|
{size: 99, head: "e", tail: "e", content: "e", path: "/p/a/extra"},
|
|
{size: 10, head: "h", tail: "t", content: "c", path: "/q/b/x/f"},
|
|
}
|
|
|
|
super, dirs := buildHierarchy(recs)
|
|
super.compute()
|
|
|
|
got := groupPaths(collectTreeGroups(dirs, super))
|
|
|
|
want := [][]string{{"/p/a/x", "/q/b/x"}}
|
|
if !slices.EqualFunc(got, want, slices.Equal) {
|
|
t.Fatalf("groups = %v, want %v", got, want)
|
|
}
|
|
}
|