Add test suite covering parsing, grouping, digests, and scan passes
Covers splitNUL framing, parseScanStream (malformed records, tabs and newlines in paths, unterminated trailing record), duplicate grouping with ordering/tie-break/determinism, humanBytes units, Merkle digest equality and name/content sensitivity, maximal-tree suppression (including sibling and differing-parent cases), hashHeadTail edge sizes and error paths, walkPass .zfs/symlink skipping, statPass vanished files, and an end-to-end walk/stat/hash pipeline test of the README smoke-test scenario. 64% statement coverage.
This commit is contained in:
220
trees_test.go
Normal file
220
trees_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Signature hashes shared by the smoke-test records.
|
||||
const (
|
||||
f1Head = "f1h"
|
||||
f1Tail = "f1t"
|
||||
f2Head = "f2h"
|
||||
f2Tail = "f2t"
|
||||
)
|
||||
|
||||
// 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, path: "/d/t1/f1"},
|
||||
{size: 100, head: f2Head, tail: f2Tail, path: "/d/t1/sub/f2"},
|
||||
{size: 3000, head: f1Head, tail: f1Tail, path: "/d/t2/f1"},
|
||||
{size: 100, head: f2Head, tail: f2Tail, path: "/d/t2/sub/f2"},
|
||||
{size: 3000, head: f1Head, tail: f1Tail, path: "/d/t3/f1"},
|
||||
{size: 100, head: f2Head, tail: f2Tail, 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 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, path: "/r/a/f"},
|
||||
{size: 10, head: "DIFF", tail: sharedTail, 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", path: "/p/x1/f"},
|
||||
{size: 10, head: "h", tail: "t", 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", path: "/p/a/x/f"},
|
||||
{size: 99, head: "e", tail: "e", path: "/p/a/extra"},
|
||||
{size: 10, head: "h", tail: "t", 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user