package main import ( "bufio" "fmt" "slices" "strings" "testing" ) // mkRecord serializes one scan record in the on-the-wire format. func mkRecord(size, mtime int64, head, tail, path string) string { return fmt.Sprintf("%d\t%d\t%s\t%s\t%s\x00", size, mtime, head, tail, path) } func TestSplitNULScanner(t *testing.T) { t.Parallel() sc := bufio.NewScanner(strings.NewReader("a\x00bb\x00\x00tail")) sc.Split(splitNUL) var got []string for sc.Scan() { got = append(got, sc.Text()) } err := sc.Err() if err != nil { t.Fatalf("scanner error: %v", err) } want := []string{"a", "bb", "", "tail"} if !slices.Equal(got, want) { t.Fatalf("tokens = %q, want %q", got, want) } } func TestParseScanStream(t *testing.T) { t.Parallel() stream := mkRecord(10, 1, "h1", "t1", "/a/x") + "garbage-without-tabs\x00" + "notanumber\t1\th\tt\t/a/bad\x00" + mkRecord(20, 2, "h2", "t2", "/a/tab\tin\tname") + "30\t3\th3\tt3\t/trailing/no-nul" recs, malformed := parseScanStream(strings.NewReader(stream), "test") if malformed != 2 { t.Errorf("malformed = %d, want 2", malformed) } want := []scanRec{ {size: 10, head: "h1", tail: "t1", path: "/a/x"}, {size: 20, head: "h2", tail: "t2", path: "/a/tab\tin\tname"}, {size: 30, head: "h3", tail: "t3", path: "/trailing/no-nul"}, } if !slices.Equal(recs, want) { t.Fatalf("recs = %+v, want %+v", recs, want) } } func TestParseScanStreamPathWithNewline(t *testing.T) { t.Parallel() in := strings.NewReader(mkRecord(5, 9, "h", "t", "/a/new\nline")) recs, malformed := parseScanStream(in, "test") if malformed != 0 || len(recs) != 1 { t.Fatalf("got %d recs, %d malformed, want 1, 0", len(recs), malformed) } if recs[0].path != "/a/new\nline" { t.Fatalf("path = %q, want %q", recs[0].path, "/a/new\nline") } } func TestParseScanStreamEmpty(t *testing.T) { t.Parallel() recs, malformed := parseScanStream(strings.NewReader(""), "test") if len(recs) != 0 || malformed != 0 { t.Fatalf("got %d recs, %d malformed, want 0, 0", len(recs), malformed) } } 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 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) } } } func TestMalformedNote(t *testing.T) { t.Parallel() if got := malformedNote(0); got != "" { t.Errorf("malformedNote(0) = %q, want empty", got) } if got := malformedNote(3); got != " (3 malformed, skipped)" { t.Errorf("malformedNote(3) = %q", got) } }