From 38d66c9cf3b1ff957beeae5a489172953a4a06b4 Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Wed, 4 Mar 2026 09:05:44 -0500 Subject: [PATCH 1/2] test: improve render coverage from 7.8% to 55.0% --- .github/workflows/ci.yml | 2 +- render/clone_animation_test.go | 83 ++++++++++ render/depgraph_test.go | 105 +++++++++++++ render/skyline_test.go | 276 +++++++++++++++++++++++++++++++++ 4 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 render/clone_animation_test.go create mode 100644 render/depgraph_test.go create mode 100644 render/skyline_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca2270e..843f670 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: run: | total=$(go tool cover -func=coverage.out | awk '/^total:/ {gsub("%","",$3); print $3}') # Floor target: 90%. Codex PRs will incrementally raise this value. - min=30.0 + min=35.0 awk -v t="$total" -v m="$min" 'BEGIN { if (t+0 < m+0) { printf "Coverage %.1f%% is below floor %.1f%%\n", t, m diff --git a/render/clone_animation_test.go b/render/clone_animation_test.go new file mode 100644 index 0000000..e94d08c --- /dev/null +++ b/render/clone_animation_test.go @@ -0,0 +1,83 @@ +package render + +import ( + "bytes" + "strings" + "testing" +) + +func TestTruncate(t *testing.T) { + tests := []struct { + name string + input string + max int + expected string + }{ + {name: "short string unchanged", input: "repo", max: 10, expected: "repo"}, + {name: "same length unchanged", input: "abcdefghij", max: 10, expected: "abcdefghij"}, + {name: "long string truncated", input: "very-long-repository-name", max: 10, expected: "very-lon.."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncate(tt.input, tt.max) + if got != tt.expected { + t.Fatalf("truncate(%q, %d) = %q, want %q", tt.input, tt.max, got, tt.expected) + } + }) + } +} + +func TestNewCloneAnimation(t *testing.T) { + var buf bytes.Buffer + a := NewCloneAnimation(&buf, "repo") + if a == nil { + t.Fatal("expected animation instance") + } + if a.w != &buf { + t.Fatal("expected writer to be set") + } + if a.repoName != "repo" { + t.Fatalf("expected repoName to be %q, got %q", "repo", a.repoName) + } +} + +func TestCloneAnimationRenderClampsProgress(t *testing.T) { + tests := []struct { + name string + progress int + expectedProgress string + }{ + {name: "negative becomes zero", progress: -10, expectedProgress: "0%"}, + {name: "over hundred becomes hundred", progress: 120, expectedProgress: "100%"}, + {name: "middle value", progress: 45, expectedProgress: "45%"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + a := NewCloneAnimation(&buf, "example/repo") + a.Render(tt.progress) + + out := buf.String() + if !strings.Contains(out, "\r\033[K") { + t.Fatalf("expected cursor clear sequence, got %q", out) + } + if !strings.Contains(out, tt.expectedProgress) { + t.Fatalf("expected output to contain %q, got %q", tt.expectedProgress, out) + } + }) + } +} + +func TestCloneAnimationBuildFrame(t *testing.T) { + a := NewCloneAnimation(&bytes.Buffer{}, "very-very-long-repository-name-that-needs-truncation") + frame := a.buildFrame(50) + + checks := []string{"50%", "..", "πŸ—ΊοΈ"} + for _, check := range checks { + if !strings.Contains(frame, check) { + t.Fatalf("expected frame to contain %q, got %q", check, frame) + } + } +} diff --git a/render/depgraph_test.go b/render/depgraph_test.go new file mode 100644 index 0000000..58132d4 --- /dev/null +++ b/render/depgraph_test.go @@ -0,0 +1,105 @@ +package render + +import ( + "bytes" + "strings" + "testing" + + "codemap/scanner" +) + +func TestTitleCase(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {name: "empty", input: "", expected: ""}, + {name: "single word", input: "hello", expected: "Hello"}, + {name: "multiple words", input: "hello world", expected: "Hello World"}, + {name: "extra spaces", input: " hello world ", expected: "Hello World"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := titleCase(tt.input) + if got != tt.expected { + t.Fatalf("titleCase(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestGetSystemName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {name: "skips generic prefix", input: "src/payment_service", expected: "Payment Service"}, + {name: "supports windows separators", input: "internal\\auth-module", expected: "Auth Module"}, + {name: "falls back to last segment", input: "src", expected: "Src"}, + {name: "root marker", input: ".", expected: "."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getSystemName(tt.input) + if got != tt.expected { + t.Fatalf("getSystemName(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestDepgraphNoFiles(t *testing.T) { + project := scanner.DepsProject{ + Root: t.TempDir(), + Files: nil, + } + + var buf bytes.Buffer + Depgraph(&buf, project) + + output := buf.String() + if !strings.Contains(output, "No source files found.") { + t.Fatalf("expected no files message, got:\n%s", output) + } +} + +func TestDepgraphRendersExternalDepsAndSummary(t *testing.T) { + project := scanner.DepsProject{ + Root: t.TempDir(), + Files: []scanner.FileAnalysis{ + { + Path: "src/main.go", + Functions: []string{"main"}, + }, + }, + ExternalDeps: map[string][]string{ + "go": {"github.com/acme/module/v2", "github.com/acme/pkg", "github.com/acme/pkg"}, + "javascript": {"react", "react"}, + }, + } + + var buf bytes.Buffer + Depgraph(&buf, project) + output := buf.String() + + expectedSnippets := []string{ + "Dependency Flow", + "Go: module, pkg", + "JavaScript: react", + "Src", + "+1 standalone files", + "1 files", + "1 functions", + "0 deps", + } + + for _, snippet := range expectedSnippets { + if !strings.Contains(output, snippet) { + t.Fatalf("expected output to contain %q, got:\n%s", snippet, output) + } + } +} diff --git a/render/skyline_test.go b/render/skyline_test.go new file mode 100644 index 0000000..70e7462 --- /dev/null +++ b/render/skyline_test.go @@ -0,0 +1,276 @@ +package render + +import ( + "bytes" + "math/rand/v2" + "strings" + "testing" + "time" + + "codemap/scanner" + + tea "github.com/charmbracelet/bubbletea" +) + +func resetSkylineRNG() { + rng = rand.New(rand.NewPCG(42, 0)) +} + +func TestFilterCodeFiles(t *testing.T) { + tests := []struct { + name string + files []scanner.FileInfo + expected int + }{ + { + name: "returns only code files when present", + files: []scanner.FileInfo{ + {Path: "main.go", Ext: ".go"}, + {Path: "photo.png", Ext: ".png"}, + {Path: "Dockerfile"}, + }, + expected: 2, + }, + { + name: "returns original files when no code files found", + files: []scanner.FileInfo{ + {Path: "image.png", Ext: ".png"}, + {Path: "font.woff", Ext: ".woff"}, + }, + expected: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filterCodeFiles(tt.files) + if len(got) != tt.expected { + t.Fatalf("filterCodeFiles() len = %d, want %d", len(got), tt.expected) + } + }) + } +} + +func TestAggregateByExtension(t *testing.T) { + files := []scanner.FileInfo{ + {Path: "a/main.go", Ext: ".go", Size: 100}, + {Path: "a/util.go", Ext: ".go", Size: 50}, + {Path: "b/app.ts", Ext: ".ts", Size: 120}, + {Path: "Makefile", Ext: "", Size: 80}, + } + + agg := aggregateByExtension(files) + if len(agg) != 3 { + t.Fatalf("aggregateByExtension() len = %d, want 3", len(agg)) + } + + if agg[0].ext != ".go" || agg[0].size != 150 || agg[0].count != 2 { + t.Fatalf("unexpected first aggregate: %+v", agg[0]) + } + + seenMakefile := false + for _, a := range agg { + if a.ext == "Makefile" { + seenMakefile = true + break + } + } + if !seenMakefile { + t.Fatal("expected aggregate entry for Makefile") + } +} + +func TestGetBuildingChar(t *testing.T) { + tests := []struct { + name string + ext string + expected rune + }{ + {name: "go", ext: ".go", expected: 'β–“'}, + {name: "javascript", ext: ".js", expected: 'β–‘'}, + {name: "ruby", ext: ".rb", expected: 'β–’'}, + {name: "makefile", ext: "makefile", expected: 'β–ˆ'}, + {name: "default", ext: ".unknown", expected: 'β–“'}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getBuildingChar(tt.ext) + if got != tt.expected { + t.Fatalf("getBuildingChar(%q) = %q, want %q", tt.ext, got, tt.expected) + } + }) + } +} + +func TestCreateBuildings(t *testing.T) { + resetSkylineRNG() + + tests := []struct { + name string + sorted []extAgg + width int + wantNil bool + maxTotalW int + wantNonZero bool + }{ + {name: "empty input", sorted: nil, width: 80, wantNil: true}, + { + name: "fits within width", + sorted: []extAgg{ + {ext: ".go", size: 1000, count: 3}, + {ext: ".ts", size: 700, count: 2}, + {ext: ".py", size: 300, count: 1}, + }, + width: 80, + maxTotalW: 72, + wantNonZero: true, + }, + { + name: "trims buildings for narrow width", + sorted: []extAgg{ + {ext: ".go", size: 1000, count: 3}, + {ext: ".ts", size: 900, count: 2}, + {ext: ".py", size: 800, count: 1}, + {ext: ".rb", size: 700, count: 1}, + }, + width: 22, + maxTotalW: 14, + wantNonZero: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetSkylineRNG() + got := createBuildings(tt.sorted, tt.width) + if tt.wantNil { + if got != nil { + t.Fatal("expected nil buildings") + } + return + } + if tt.wantNonZero && len(got) == 0 { + t.Fatal("expected non-empty buildings") + } + + totalWidth := 0 + for _, b := range got { + totalWidth += buildingWidth + b.gap + if b.height < minHeight || b.height > maxHeight { + t.Fatalf("building height out of range: %d", b.height) + } + } + if totalWidth > tt.maxTotalW { + t.Fatalf("total building width = %d, want <= %d", totalWidth, tt.maxTotalW) + } + }) + } +} + +func TestSkylineNoFiles(t *testing.T) { + project := scanner.Project{Root: t.TempDir(), Name: "Demo", Files: nil} + var buf bytes.Buffer + + Skyline(&buf, project, false) + + out := buf.String() + if !strings.Contains(out, "No source files to display") { + t.Fatalf("expected no files message, got:\n%s", out) + } +} + +func TestRenderStaticIncludesTitleAndStats(t *testing.T) { + resetSkylineRNG() + + arranged := []building{{ + height: 6, + char: 'β–“', + color: Cyan, + ext: ".go", + extLabel: ".go", + count: 2, + size: 300, + gap: 1, + }} + + codeFiles := []scanner.FileInfo{{Path: "main.go", Size: 300, Ext: ".go"}} + sorted := []extAgg{{ext: ".go", size: 300, count: 1}} + + var buf bytes.Buffer + renderStatic(&buf, arranged, 40, 10, 8, 24, 16, codeFiles, "Demo", sorted) + + out := buf.String() + checks := []string{"─── Demo ───", "1 languages", "1 files", "300.0B"} + for _, check := range checks { + if !strings.Contains(out, check) { + t.Fatalf("expected output to contain %q, got:\n%s", check, out) + } + } +} + +func TestAnimationModelUpdateAndView(t *testing.T) { + resetSkylineRNG() + + m := animationModel{ + arranged: []building{{height: 5, char: 'β–“', color: Cyan, extLabel: ".go", gap: 1}}, + width: 30, + leftMargin: 3, + sceneLeft: 1, + sceneRight: 20, + sceneWidth: 19, + starPositions: [][2]int{{0, 2}, {1, 6}}, + moonCol: 10, + maxBuildingHeight: 5, + phase: 1, + visibleRows: 1, + } + + updated, cmd := m.Update(tickMsg(time.Now())) + if cmd == nil { + t.Fatal("expected tick command after tick update") + } + m1 := updated.(animationModel) + if m1.visibleRows <= m.visibleRows { + t.Fatalf("expected visibleRows to increase, got %d -> %d", m.visibleRows, m1.visibleRows) + } + + out := m1.View() + if !strings.Contains(out, "β–€") { + t.Fatalf("expected skyline ground in view, got:\n%s", out) + } + + updated, cmd = m1.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatal("expected quit command on key press") + } + m2 := updated.(animationModel) + if !m2.done { + t.Fatal("expected model to be marked done after key press") + } +} + +func TestMinMax(t *testing.T) { + tests := []struct { + name string + a int + b int + wantMax int + wantMin int + }{ + {name: "a greater", a: 8, b: 3, wantMax: 8, wantMin: 3}, + {name: "b greater", a: 2, b: 9, wantMax: 9, wantMin: 2}, + {name: "equal", a: 5, b: 5, wantMax: 5, wantMin: 5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := max(tt.a, tt.b); got != tt.wantMax { + t.Fatalf("max(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.wantMax) + } + if got := min(tt.a, tt.b); got != tt.wantMin { + t.Fatalf("min(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.wantMin) + } + }) + } +} From dfcbede0629a9718833344c8ab7c267f7ac52412 Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Wed, 4 Mar 2026 11:16:03 -0500 Subject: [PATCH 2/2] test: resolve PR feedback and CI collisions --- .github/workflows/ci.yml | 4 ++-- render/depgraph_test.go | 8 ++++---- render/skyline_test.go | 16 ++++++++-------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 843f670..fde0c39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.23' run: | total=$(go tool cover -func=coverage.out | awk '/^total:/ {gsub("%","",$3); print $3}') - # Floor target: 90%. Codex PRs will incrementally raise this value. + # Current enforced coverage floor. Codex PRs raise this incrementally toward 90%. min=35.0 awk -v t="$total" -v m="$min" 'BEGIN { if (t+0 < m+0) { @@ -71,7 +71,7 @@ jobs: label: coverage message: ${{ env.COVERAGE }}% valColorRange: ${{ env.COVERAGE }} - minColorRange: 30 + minColorRange: 35 maxColorRange: 90 - name: Upload coverage diff --git a/render/depgraph_test.go b/render/depgraph_test.go index 58132d4..0cabb75 100644 --- a/render/depgraph_test.go +++ b/render/depgraph_test.go @@ -8,7 +8,7 @@ import ( "codemap/scanner" ) -func TestTitleCase(t *testing.T) { +func TestDepgraphTitleCase(t *testing.T) { tests := []struct { name string input string @@ -30,7 +30,7 @@ func TestTitleCase(t *testing.T) { } } -func TestGetSystemName(t *testing.T) { +func TestDepgraphGetSystemName(t *testing.T) { tests := []struct { name string input string @@ -52,7 +52,7 @@ func TestGetSystemName(t *testing.T) { } } -func TestDepgraphNoFiles(t *testing.T) { +func TestDepgraphNoSourceFiles(t *testing.T) { project := scanner.DepsProject{ Root: t.TempDir(), Files: nil, @@ -67,7 +67,7 @@ func TestDepgraphNoFiles(t *testing.T) { } } -func TestDepgraphRendersExternalDepsAndSummary(t *testing.T) { +func TestDepgraphRendersExternalDepsAndSummarySection(t *testing.T) { project := scanner.DepsProject{ Root: t.TempDir(), Files: []scanner.FileAnalysis{ diff --git a/render/skyline_test.go b/render/skyline_test.go index 70e7462..23ace2b 100644 --- a/render/skyline_test.go +++ b/render/skyline_test.go @@ -16,7 +16,7 @@ func resetSkylineRNG() { rng = rand.New(rand.NewPCG(42, 0)) } -func TestFilterCodeFiles(t *testing.T) { +func TestSkylineFilterCodeFiles(t *testing.T) { tests := []struct { name string files []scanner.FileInfo @@ -51,7 +51,7 @@ func TestFilterCodeFiles(t *testing.T) { } } -func TestAggregateByExtension(t *testing.T) { +func TestSkylineAggregateByExtension(t *testing.T) { files := []scanner.FileInfo{ {Path: "a/main.go", Ext: ".go", Size: 100}, {Path: "a/util.go", Ext: ".go", Size: 50}, @@ -80,7 +80,7 @@ func TestAggregateByExtension(t *testing.T) { } } -func TestGetBuildingChar(t *testing.T) { +func TestSkylineGetBuildingChar(t *testing.T) { tests := []struct { name string ext string @@ -103,7 +103,7 @@ func TestGetBuildingChar(t *testing.T) { } } -func TestCreateBuildings(t *testing.T) { +func TestSkylineCreateBuildings(t *testing.T) { resetSkylineRNG() tests := []struct { @@ -168,7 +168,7 @@ func TestCreateBuildings(t *testing.T) { } } -func TestSkylineNoFiles(t *testing.T) { +func TestSkylineNoSourceFilesMessage(t *testing.T) { project := scanner.Project{Root: t.TempDir(), Name: "Demo", Files: nil} var buf bytes.Buffer @@ -180,7 +180,7 @@ func TestSkylineNoFiles(t *testing.T) { } } -func TestRenderStaticIncludesTitleAndStats(t *testing.T) { +func TestSkylineRenderStaticIncludesTitleAndStats(t *testing.T) { resetSkylineRNG() arranged := []building{{ @@ -209,7 +209,7 @@ func TestRenderStaticIncludesTitleAndStats(t *testing.T) { } } -func TestAnimationModelUpdateAndView(t *testing.T) { +func TestSkylineAnimationModelUpdateAndView(t *testing.T) { resetSkylineRNG() m := animationModel{ @@ -250,7 +250,7 @@ func TestAnimationModelUpdateAndView(t *testing.T) { } } -func TestMinMax(t *testing.T) { +func TestSkylineMinMax(t *testing.T) { tests := []struct { name string a int