From bf1463a85853cac14493573ff29714cb19be5f51 Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Sun, 3 May 2026 22:02:13 +0500 Subject: [PATCH] feat: unit-tests --- internal/hash/hash_test.go | 163 ++++++++++++++++++++ internal/hash/similarity_test.go | 51 +++++++ internal/load/load_test.go | 174 +++++++++++++++++++++ internal/report/report_test.go | 242 ++++++++++++++++++++++++++++++ internal/x/convx/safeconv_test.go | 32 ++++ main_test.go | 48 ++++++ 6 files changed, 710 insertions(+) create mode 100644 internal/hash/hash_test.go create mode 100644 internal/hash/similarity_test.go create mode 100644 internal/load/load_test.go create mode 100644 internal/report/report_test.go create mode 100644 internal/x/convx/safeconv_test.go create mode 100644 main_test.go diff --git a/internal/hash/hash_test.go b/internal/hash/hash_test.go new file mode 100644 index 0000000..e1dcd1b --- /dev/null +++ b/internal/hash/hash_test.go @@ -0,0 +1,163 @@ +package hash + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +func parseSingleFunc(t *testing.T, src string) (*token.FileSet, *ast.FuncDecl) { + t.Helper() + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "sample.go", src, 0) + if err != nil { + t.Fatalf("parse source: %v", err) + } + + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok { + return fset, fn + } + } + t.Fatal("source does not contain function declaration") + return nil, nil +} + +func hashFuncFromSource(t *testing.T, src string) FuncInfo { + t.Helper() + + fset, fn := parseSingleFunc(t, src) + return New(fset).HashFunc("sample", "sample.go", fn) +} + +func TestHashFuncNormalizesIdentifierAndLiteralNames(t *testing.T) { + a := hashFuncFromSource(t, `package sample +func validateUser(user string) error { + if user == "" { + return errors.New("empty user name") + } + return nil +}`) + + b := hashFuncFromSource(t, `package sample +func validateOrder(order string) error { + if order == "abc" { + return fmt.New("empty order code") + } + return nil +}`) + + if a.TopHash != b.TopHash { + t.Fatalf("expected structurally equivalent functions to have same top hash: %d != %d", a.TopHash, b.TopHash) + } +} + +func TestHashFuncPreservesOperators(t *testing.T) { + a := hashFuncFromSource(t, `package sample +func add(a, b int) int { + c := a + b + d := c + b + return d +}`) + + b := hashFuncFromSource(t, `package sample +func sub(a, b int) int { + c := a - b + d := c + b + return d +}`) + + if a.TopHash == b.TopHash { + t.Fatal("expected different operators to produce different top hashes") + } +} + +func TestHashFuncPreservesStatementOrder(t *testing.T) { + a := hashFuncFromSource(t, `package sample +func first() int { + a := 1 + if a > 0 { + return a + } + return 0 +}`) + + b := hashFuncFromSource(t, `package sample +func second() int { + if a > 0 { + return a + } + a := 1 + return 0 +}`) + + if a.TopHash == b.TopHash { + t.Fatal("expected statement order to affect top hash") + } +} + +func TestHashFuncInfoMetadata(t *testing.T) { + info := hashFuncFromSource(t, `package sample +func add(a, b int) int { + c := a + b + d := c + b + return d +}`) + + if info.Name != "sample.add" { + t.Fatalf("Name = %q, want %q", info.Name, "sample.add") + } + if info.File != "sample.go" { + t.Fatalf("File = %q, want sample.go", info.File) + } + if info.Line != 2 { + t.Fatalf("Line = %d, want 2", info.Line) + } + if info.NumStmts != 3 { + t.Fatalf("NumStmts = %d, want 3", info.NumStmts) + } + if len(info.StmtSeq) != info.NumStmts { + t.Fatalf("len(StmtSeq) = %d, want %d", len(info.StmtSeq), info.NumStmts) + } + if info.NumLines != 5 { + t.Fatalf("NumLines = %d, want 5", info.NumLines) + } +} + +func TestQualifiedName(t *testing.T) { + tests := []struct { + name string + src string + want string + }{ + { + name: "function", + src: `package sample +func Run() {}`, + want: "sample.Run", + }, + { + name: "value receiver", + src: `package sample +func (s Store) Run() {}`, + want: "sample.(Store).Run", + }, + { + name: "pointer receiver", + src: `package sample +func (s *Store) Run() {}`, + want: "sample.(*Store).Run", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, fn := parseSingleFunc(t, tt.src) + if got := qualifiedName("sample", fn); got != tt.want { + t.Fatalf("qualifiedName() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/hash/similarity_test.go b/internal/hash/similarity_test.go new file mode 100644 index 0000000..7d4e9c5 --- /dev/null +++ b/internal/hash/similarity_test.go @@ -0,0 +1,51 @@ +package hash + +import "testing" + +func TestSimilarity(t *testing.T) { + tests := []struct { + name string + a []uint64 + b []uint64 + want float64 + }{ + {name: "both empty", a: nil, b: nil, want: 1.0}, + {name: "one empty", a: []uint64{1}, b: nil, want: 0.0}, + {name: "identical", a: []uint64{1, 2, 3}, b: []uint64{1, 2, 3}, want: 1.0}, + {name: "one insertion", a: []uint64{1, 2, 3}, b: []uint64{1, 9, 2, 3}, want: 0.75}, + {name: "one substitution", a: []uint64{1, 2, 3, 4}, b: []uint64{1, 2, 9, 4}, want: 0.75}, + {name: "completely different same length", a: []uint64{1, 2, 3}, b: []uint64{4, 5, 6}, want: 0.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Similarity(&FuncInfo{StmtSeq: tt.a}, &FuncInfo{StmtSeq: tt.b}) + if got != tt.want { + t.Fatalf("Similarity(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestEditDistance(t *testing.T) { + tests := []struct { + name string + a []uint64 + b []uint64 + want int + }{ + {name: "same", a: []uint64{1, 2, 3}, b: []uint64{1, 2, 3}, want: 0}, + {name: "insert", a: []uint64{1, 3}, b: []uint64{1, 2, 3}, want: 1}, + {name: "delete", a: []uint64{1, 2, 3}, b: []uint64{1, 3}, want: 1}, + {name: "substitute", a: []uint64{1, 2, 3}, b: []uint64{1, 9, 3}, want: 1}, + {name: "empty", a: nil, b: []uint64{1, 2}, want: 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := editDistance(tt.a, tt.b); got != tt.want { + t.Fatalf("editDistance(%v, %v) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} diff --git a/internal/load/load_test.go b/internal/load/load_test.go new file mode 100644 index 0000000..0ee37f6 --- /dev/null +++ b/internal/load/load_test.go @@ -0,0 +1,174 @@ +package load + +import ( + "os" + "path/filepath" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func TestLoadDirectoryRecursively(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "a.go"), `package sample +func One() int { + a := 1 + b := 2 + return a + b +}`) + writeFile(t, filepath.Join(dir, "nested", "b.go"), `package sample +func Two() int { + a := 1 + b := 2 + return a + b +}`) + + result, err := Load([]string{dir}, false) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(result.Funcs) != 2 { + t.Fatalf("len(Funcs) = %d, want 2", len(result.Funcs)) + } + if result.Fset == nil { + t.Fatal("Fset is nil") + } +} + +func TestLoadExcludesTests(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "a.go"), `package sample +func One() int { + a := 1 + b := 2 + return a + b +}`) + writeFile(t, filepath.Join(dir, "a_test.go"), `package sample +func TestOne() int { + a := 1 + b := 2 + return a + b +}`) + + result, err := Load([]string{dir}, true) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(result.Funcs) != 1 { + t.Fatalf("len(Funcs) = %d, want 1", len(result.Funcs)) + } + if got := result.Funcs[0].Name; got != "sample.One" { + t.Fatalf("loaded function = %q, want sample.One", got) + } +} + +func TestLoadIncludesTestsWhenNotExcluded(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "a.go"), `package sample +func One() int { + a := 1 + b := 2 + return a + b +}`) + writeFile(t, filepath.Join(dir, "a_test.go"), `package sample +func TestOne() int { + a := 1 + b := 2 + return a + b +}`) + + result, err := Load([]string{dir}, false) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(result.Funcs) != 2 { + t.Fatalf("len(Funcs) = %d, want 2", len(result.Funcs)) + } +} + +func TestLoadSkipsShortFunctionsAndUnparseableFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "short.go"), `package sample +func Short() int { + return 1 +}`) + writeFile(t, filepath.Join(dir, "bad.go"), `package sample +func Broken(`) + writeFile(t, filepath.Join(dir, "good.go"), `package sample +func Good() int { + a := 1 + b := 2 + return a + b +}`) + + result, err := Load([]string{dir}, false) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(result.Funcs) != 1 { + t.Fatalf("len(Funcs) = %d, want 1", len(result.Funcs)) + } + if got := result.Funcs[0].Name; got != "sample.Good" { + t.Fatalf("loaded function = %q, want sample.Good", got) + } +} + +func TestLoadSkipsHiddenAndVendorDirectories(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "good.go"), `package sample +func Good() int { + a := 1 + b := 2 + return a + b +}`) + writeFile(t, filepath.Join(dir, ".hidden", "hidden.go"), `package hidden +func Hidden() int { + a := 1 + b := 2 + return a + b +}`) + writeFile(t, filepath.Join(dir, "vendor", "vendored.go"), `package vendor +func Vendored() int { + a := 1 + b := 2 + return a + b +}`) + + result, err := Load([]string{dir}, false) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(result.Funcs) != 1 { + t.Fatalf("len(Funcs) = %d, want 1", len(result.Funcs)) + } + if got := result.Funcs[0].Name; got != "sample.Good" { + t.Fatalf("loaded function = %q, want sample.Good", got) + } +} + +func TestLoadSingleFile(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "single.go") + writeFile(t, file, `package sample +func Single() int { + a := 1 + b := 2 + return a + b +}`) + + result, err := Load([]string{file}, false) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(result.Funcs) != 1 { + t.Fatalf("len(Funcs) = %d, want 1", len(result.Funcs)) + } +} diff --git a/internal/report/report_test.go b/internal/report/report_test.go new file mode 100644 index 0000000..b3f0f21 --- /dev/null +++ b/internal/report/report_test.go @@ -0,0 +1,242 @@ +package report + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/hashmap-kz/godedup/internal/hash" +) + +func funcInfo(name, file string, line, stmts, lines int, top uint64, seq ...uint64) hash.FuncInfo { + return hash.FuncInfo{ + Name: name, + File: file, + Line: line, + TopHash: top, + StmtSeq: seq, + NumStmts: stmts, + NumLines: lines, + } +} + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + if cfg.MinSimilarity != 0.85 { + t.Fatalf("MinSimilarity = %v, want 0.85", cfg.MinSimilarity) + } + if cfg.MinStmts != 3 { + t.Fatalf("MinStmts = %d, want 3", cfg.MinStmts) + } + if cfg.ExactOnly { + t.Fatal("ExactOnly = true, want false") + } +} + +func TestDetectExactClones(t *testing.T) { + funcs := []hash.FuncInfo{ + funcInfo("pkg.A", "a.go", 10, 3, 5, 100, 1, 2, 3), + funcInfo("pkg.B", "b.go", 20, 3, 5, 100, 1, 2, 3), + funcInfo("pkg.C", "c.go", 30, 3, 5, 200, 7, 8, 9), + } + + clones := Detect(funcs, Config{MinSimilarity: 0.85, MinStmts: 3}) + if len(clones) != 1 { + t.Fatalf("len(clones) = %d, want 1", len(clones)) + } + if !clones[0].Exact { + t.Fatal("clone is not exact") + } + if clones[0].Similarity != 1.0 { + t.Fatalf("Similarity = %v, want 1.0", clones[0].Similarity) + } + if len(clones[0].Funcs) != 2 { + t.Fatalf("len(Funcs) = %d, want 2", len(clones[0].Funcs)) + } +} + +func TestDetectExactOnlySkipsNearClones(t *testing.T) { + funcs := []hash.FuncInfo{ + funcInfo("pkg.A", "a.go", 10, 4, 5, 100, 1, 2, 3, 4), + funcInfo("pkg.B", "b.go", 20, 4, 5, 200, 1, 2, 9, 4), + } + + clones := Detect(funcs, Config{MinSimilarity: 0.75, MinStmts: 3, ExactOnly: true}) + if len(clones) != 0 { + t.Fatalf("len(clones) = %d, want 0", len(clones)) + } +} + +func TestDetectNearClones(t *testing.T) { + funcs := []hash.FuncInfo{ + funcInfo("pkg.A", "a.go", 10, 4, 5, 100, 1, 2, 3, 4), + funcInfo("pkg.B", "b.go", 20, 4, 5, 200, 1, 2, 9, 4), + funcInfo("pkg.C", "c.go", 30, 4, 5, 300, 8, 8, 8, 8), + } + + clones := Detect(funcs, Config{MinSimilarity: 0.75, MinStmts: 3}) + if len(clones) != 1 { + t.Fatalf("len(clones) = %d, want 1", len(clones)) + } + if clones[0].Exact { + t.Fatal("clone is exact, want near") + } + if clones[0].Similarity != 0.75 { + t.Fatalf("Similarity = %v, want 0.75", clones[0].Similarity) + } + if len(clones[0].Funcs) != 2 { + t.Fatalf("len(Funcs) = %d, want 2", len(clones[0].Funcs)) + } +} + +func TestDetectRespectsMinStmts(t *testing.T) { + funcs := []hash.FuncInfo{ + funcInfo("pkg.A", "a.go", 10, 2, 5, 100, 1, 2), + funcInfo("pkg.B", "b.go", 20, 2, 5, 100, 1, 2), + } + + clones := Detect(funcs, Config{MinSimilarity: 0.85, MinStmts: 3}) + if len(clones) != 0 { + t.Fatalf("len(clones) = %d, want 0", len(clones)) + } +} + +func TestDetectNearCloneStatementCountPrefilter(t *testing.T) { + funcs := []hash.FuncInfo{ + funcInfo("pkg.A", "a.go", 10, 3, 5, 100, 1, 2, 3), + funcInfo("pkg.B", "b.go", 20, 10, 12, 200, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10), + } + + clones := Detect(funcs, Config{MinSimilarity: 0.30, MinStmts: 3}) + if len(clones) != 0 { + t.Fatalf("len(clones) = %d, want 0", len(clones)) + } +} + +func TestPrintNoClones(t *testing.T) { + var buf bytes.Buffer + Print(&buf, nil, "") + if got := strings.TrimSpace(buf.String()); got != "godedup: no structural duplicates found" { + t.Fatalf("Print() = %q", got) + } +} + +func TestPrintHumanReadable(t *testing.T) { + clones := []Clone{{ + Exact: true, + Similarity: 1.0, + Funcs: []hash.FuncInfo{ + funcInfo("pkg.B", "/repo/b.go", 20, 3, 7, 100, 1, 2, 3), + funcInfo("pkg.A", "/repo/a.go", 10, 3, 7, 100, 1, 2, 3), + }, + }} + + var buf bytes.Buffer + Print(&buf, clones, "/repo") + got := buf.String() + for _, want := range []string{ + "godedup: found 1 clone group(s) (1 exact, 0 near)", + "=== clone group 1 [EXACT 100% similarity] ===", + "pkg.A", + "a.go:10 (3 stmts, 7 lines)", + "pkg.B", + "b.go:20 (3 stmts, 7 lines)", + "suggestion: extract shared logic into a common function", + } { + if !strings.Contains(got, want) { + t.Fatalf("Print() missing %q in:\n%s", want, got) + } + } + if strings.Index(got, "pkg.A") > strings.Index(got, "pkg.B") { + t.Fatalf("functions are not sorted by file/line:\n%s", got) + } +} + +func TestPrintTable(t *testing.T) { + clones := []Clone{{ + Exact: false, + Similarity: 0.91, + Funcs: []hash.FuncInfo{ + funcInfo("api.handleOrderCreate", "/repo/pkg/api/order.go", 51, 19, 47, 200, 1, 2, 9), + funcInfo("api.handleUserCreate", "/repo/pkg/api/user.go", 44, 18, 45, 100, 1, 2, 3), + }, + }} + + var buf bytes.Buffer + PrintTable(&buf, clones, "/repo") + got := buf.String() + for _, want := range []string{ + "GROUP", + "TYPE", + "SIM", + "FUNCTION", + "LOCATION", + "1 NEAR 91%", + "api.handleOrderCreate", + "pkg/api/order.go:51", + "api.handleUserCreate", + "pkg/api/user.go:44", + } { + if !strings.Contains(got, want) { + t.Fatalf("PrintTable() missing %q in:\n%s", want, got) + } + } +} + +func TestPrintJSON(t *testing.T) { + clones := []Clone{{ + Exact: true, + Similarity: 1.0, + Funcs: []hash.FuncInfo{ + funcInfo("pkg.A", "a.go", 10, 3, 7, 100, 1, 2, 3), + }, + }} + + var buf bytes.Buffer + PrintJSON(&buf, clones) + + var decoded []struct { + Exact bool `json:"exact"` + Similarity float64 `json:"similarity"` + Functions []struct { + Name string `json:"name"` + File string `json:"file"` + Line int `json:"line"` + Stmts int `json:"stmts"` + } `json:"functions"` + } + if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil { + t.Fatalf("PrintJSON produced invalid JSON: %v\n%s", err, buf.String()) + } + if len(decoded) != 1 { + t.Fatalf("len(decoded) = %d, want 1", len(decoded)) + } + if !decoded[0].Exact || decoded[0].Similarity != 1.0 { + t.Fatalf("decoded clone = %+v, want exact similarity 1.0", decoded[0]) + } + if got := decoded[0].Functions[0].Name; got != "pkg.A" { + t.Fatalf("function name = %q, want pkg.A", got) + } +} + +func TestRelativePath(t *testing.T) { + tests := []struct { + name string + path string + cwd string + want string + }{ + {name: "inside cwd", path: "/repo/internal/a.go", cwd: "/repo", want: "internal/a.go"}, + {name: "outside cwd", path: "/other/a.go", cwd: "/repo", want: "/other/a.go"}, + {name: "empty cwd", path: "/repo/a.go", cwd: "", want: "/repo/a.go"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := relativePath(tt.path, tt.cwd); got != tt.want { + t.Fatalf("relativePath(%q, %q) = %q, want %q", tt.path, tt.cwd, got, tt.want) + } + }) + } +} diff --git a/internal/x/convx/safeconv_test.go b/internal/x/convx/safeconv_test.go new file mode 100644 index 0000000..6cca1b6 --- /dev/null +++ b/internal/x/convx/safeconv_test.go @@ -0,0 +1,32 @@ +package convx + +import ( + "go/token" + "testing" +) + +func TestToUint64SignedIntegers(t *testing.T) { + tests := []struct { + name string + in int + want uint64 + }{ + {name: "positive", in: 42, want: 42}, + {name: "zero", in: 0, want: 0}, + {name: "negative clamps to zero", in: -7, want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ToUint64(tt.in); got != tt.want { + t.Fatalf("ToUint64(%d) = %d, want %d", tt.in, got, tt.want) + } + }) + } +} + +func TestToUint64AcceptsTokenToken(t *testing.T) { + if got := ToUint64(token.ADD); got != uint64(token.ADD) { + t.Fatalf("ToUint64(token.ADD) = %d, want %d", got, token.ADD) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..a7ea539 --- /dev/null +++ b/main_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestTrimSuffix(t *testing.T) { + tests := []struct { + name string + s string + suffix string + want string + }{ + {name: "has suffix", s: "./pkg/...", suffix: "/...", want: "./pkg"}, + {name: "without suffix", s: "./pkg", suffix: "/...", want: "./pkg"}, + {name: "shorter than suffix", s: ".", suffix: "/...", want: "."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := trimSuffix(tt.s, tt.suffix); got != tt.want { + t.Fatalf("trimSuffix(%q, %q) = %q, want %q", tt.s, tt.suffix, got, tt.want) + } + }) + } +} + +func TestExpandPaths(t *testing.T) { + tests := []struct { + name string + in []string + want []string + }{ + {name: "dot slash ellipsis", in: []string{"./..."}, want: []string{"."}}, + {name: "ellipsis", in: []string{"..."}, want: []string{"."}}, + {name: "strip trailing ellipsis", in: []string{"./pkg/..."}, want: []string{"./pkg"}}, + {name: "mixed", in: []string{"./...", "./cmd/...", "internal"}, want: []string{".", "./cmd", "internal"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := expandPaths(tt.in); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("expandPaths(%v) = %v, want %v", tt.in, got, tt.want) + } + }) + } +}