test: add unit tests for cmd, config, git, linter, logger, prompts, todo, and workon
- Introduce tests for analyze, recipe, workon commands - Expand scaffold tests with language detection and context harvesting - Add tests for config getters, git utilities (tags, logs, diff) - Enhance linter with primary language detection tests - Cover logger level setting branches - New prompts loading tests with local/global fallback - Todo bootstrap and structure tests - Comprehensive workon flow tests including file moves, git integration, README updates - Update README coverage from 54% to 62%
This commit is contained in:
parent
65f67ff7b1
commit
5c9689e4da
@ -3,7 +3,7 @@
|
|||||||
Grokkit is a fast Go CLI integrating Grok AI with git workflows and general chat/edit functionality.
|
Grokkit is a fast Go CLI integrating Grok AI with git workflows and general chat/edit functionality.
|
||||||
|
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[]()
|
[]()
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
@ -188,7 +188,7 @@ Generate shell completions for faster command entry:
|
|||||||
- ✅ **Automated Workon** - AI-powered todo/fix workflow with branch management, work plan generation, and completion tracking
|
- ✅ **Automated Workon** - AI-powered todo/fix workflow with branch management, work plan generation, and completion tracking
|
||||||
|
|
||||||
### Quality & Testing
|
### Quality & Testing
|
||||||
- ✅ **Test coverage 54%+** - Comprehensive unit tests including all command message builders
|
- ✅ **Test coverage 60%+** - Comprehensive unit tests including all command message builders
|
||||||
- ✅ **Coverage gate in CI** - Builds fail if coverage drops below 50%
|
- ✅ **Coverage gate in CI** - Builds fail if coverage drops below 50%
|
||||||
- ✅ **CI/CD with Gitea Actions** - Tests must pass before lint and build jobs run
|
- ✅ **CI/CD with Gitea Actions** - Tests must pass before lint and build jobs run
|
||||||
- ✅ **Golangci-lint configured** - `.golangci.yml` with govet, errcheck, staticcheck, and more
|
- ✅ **Golangci-lint configured** - `.golangci.yml` with govet, errcheck, staticcheck, and more
|
||||||
|
|||||||
238
cmd/analyze_test.go
Normal file
238
cmd/analyze_test.go
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAnalyzeCmd — FAST DEFAULT TEST (runs on make test / go test ./cmd)
|
||||||
|
func TestAnalyzeCmd(t *testing.T) {
|
||||||
|
t.Log("✓ Fast analyze unit test (no Grok API call)")
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, c := range rootCmd.Commands() {
|
||||||
|
if c.Use == "analyze [flags]" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("analyze command not registered on rootCmd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeFlagRegistration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
flagName string
|
||||||
|
shorthand string
|
||||||
|
}{
|
||||||
|
{"output flag", "output", "o"},
|
||||||
|
{"yes flag", "yes", "y"},
|
||||||
|
{"dir flag", "dir", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
flag := analyzeCmd.Flags().Lookup(tt.flagName)
|
||||||
|
if flag == nil {
|
||||||
|
t.Fatalf("flag %q not found on analyze command", tt.flagName)
|
||||||
|
}
|
||||||
|
if tt.shorthand != "" && flag.Shorthand != tt.shorthand {
|
||||||
|
t.Errorf("flag %q shorthand = %q, want %q", tt.flagName, flag.Shorthand, tt.shorthand)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverSourceFiles(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create some source files
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "helper.go"), []byte("package main"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Create a non-source file (should be ignored)
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("notes"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := discoverSourceFiles(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discoverSourceFiles failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) < 2 {
|
||||||
|
t.Errorf("expected at least 2 Go files, got %d: %v", len(files), files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify .txt was not included
|
||||||
|
for _, f := range files {
|
||||||
|
if strings.HasSuffix(f, ".txt") {
|
||||||
|
t.Errorf("unexpected .txt file in results: %s", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverSourceFilesSkipsDotDirs(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a hidden directory with a Go file
|
||||||
|
hiddenDir := filepath.Join(tmpDir, ".hidden")
|
||||||
|
if err := os.MkdirAll(hiddenDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(hiddenDir, "secret.go"), []byte("package hidden"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create vendor directory with a Go file
|
||||||
|
vendorDir := filepath.Join(tmpDir, "vendor")
|
||||||
|
if err := os.MkdirAll(vendorDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(vendorDir, "dep.go"), []byte("package dep"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a normal Go file
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := discoverSourceFiles(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discoverSourceFiles failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
if strings.Contains(f, ".hidden") {
|
||||||
|
t.Errorf("should not include files from hidden dirs: %s", f)
|
||||||
|
}
|
||||||
|
if strings.Contains(f, "vendor") {
|
||||||
|
t.Errorf("should not include files from vendor dir: %s", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Errorf("expected 1 file (main.go only), got %d: %v", len(files), files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverSourceFilesSkipsBuildAndDist(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
for _, dir := range []string{"build", "dist", "node_modules"} {
|
||||||
|
d := filepath.Join(tmpDir, dir)
|
||||||
|
if err := os.MkdirAll(d, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(d, "file.go"), []byte("package x"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := discoverSourceFiles(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discoverSourceFiles failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Errorf("expected 1 file, got %d: %v", len(files), files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverSourceFilesEmptyDir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
files, err := discoverSourceFiles(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discoverSourceFiles failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 0 {
|
||||||
|
t.Errorf("expected 0 files for empty dir, got %d", len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildProjectContext(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
files := []string{
|
||||||
|
filepath.Join(tmpDir, "main.go"),
|
||||||
|
filepath.Join(tmpDir, "cmd", "root.go"),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := buildProjectContext(tmpDir, files)
|
||||||
|
|
||||||
|
if !strings.Contains(ctx, "Project Root:") {
|
||||||
|
t.Error("expected context to contain 'Project Root:'")
|
||||||
|
}
|
||||||
|
if !strings.Contains(ctx, "Total source files discovered: 2") {
|
||||||
|
t.Error("expected context to contain file count")
|
||||||
|
}
|
||||||
|
if !strings.Contains(ctx, "Key files") {
|
||||||
|
t.Error("expected context to contain 'Key files'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviewLines(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
n int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fewer lines than limit",
|
||||||
|
content: "line1\nline2\nline3\n",
|
||||||
|
n: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "more lines than limit",
|
||||||
|
content: "a\nb\nc\nd\ne\nf\ng\nh\n",
|
||||||
|
n: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty content",
|
||||||
|
content: "",
|
||||||
|
n: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero lines requested",
|
||||||
|
content: "line1\nline2\n",
|
||||||
|
n: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// previewLines prints to stdout; just verify it doesn't panic
|
||||||
|
previewLines(tt.content, tt.n)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildProjectContextLimitsSize(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Generate many files to test the 2500 byte limit
|
||||||
|
var files []string
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
files = append(files, filepath.Join(tmpDir, "file_with_a_long_name_for_testing_"+strings.Repeat("x", 20)+".go"))
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := buildProjectContext(tmpDir, files)
|
||||||
|
|
||||||
|
// Should be reasonably bounded (the 2500 byte check truncates the file list)
|
||||||
|
if len(ctx) > 4000 {
|
||||||
|
t.Errorf("context unexpectedly large: %d bytes", len(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
149
cmd/recipe_test.go
Normal file
149
cmd/recipe_test.go
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveRecipePathDirectFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
recipePath := filepath.Join(tmpDir, "my-recipe.md")
|
||||||
|
if err := os.WriteFile(recipePath, []byte("# Recipe\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := resolveRecipePath(recipePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveRecipePath(%q) failed: %v", recipePath, err)
|
||||||
|
}
|
||||||
|
if got != recipePath {
|
||||||
|
t.Errorf("resolveRecipePath() = %q, want %q", got, recipePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRecipePathDirectFileMissing(t *testing.T) {
|
||||||
|
_, err := resolveRecipePath("/nonexistent/recipe.md")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing recipe file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRecipePathProjectLocal(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Create .grokkit/recipes with a recipe, and a .git dir for project root detection
|
||||||
|
recipeDir := filepath.Join(tmpDir, ".grokkit", "recipes")
|
||||||
|
if err := os.MkdirAll(recipeDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recipePath := filepath.Join(recipeDir, "refactor.md")
|
||||||
|
if err := os.WriteFile(recipePath, []byte("# Refactor\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := resolveRecipePath("refactor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveRecipePath('refactor') failed: %v", err)
|
||||||
|
}
|
||||||
|
if got != recipePath {
|
||||||
|
t.Errorf("resolveRecipePath() = %q, want %q", got, recipePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRecipePathNotFound(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Create .git so findProjectRoot works, but no recipes
|
||||||
|
if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override HOME to empty dir so global fallback also fails
|
||||||
|
oldHome := os.Getenv("HOME")
|
||||||
|
defer func() { _ = os.Setenv("HOME", oldHome) }()
|
||||||
|
_ = os.Setenv("HOME", tmpDir)
|
||||||
|
|
||||||
|
_, err := resolveRecipePath("nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent recipe, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindProjectRoot(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
marker string
|
||||||
|
}{
|
||||||
|
{"git repo", ".git"},
|
||||||
|
{"gitignore", ".gitignore"},
|
||||||
|
{"grokkit dir", ".grokkit"},
|
||||||
|
{"go module", "go.mod"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
|
||||||
|
// Create a subdirectory and chdir into it
|
||||||
|
subDir := filepath.Join(tmpDir, "sub", "deep")
|
||||||
|
if err := os.MkdirAll(subDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chdir(subDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Place the marker at the root
|
||||||
|
markerPath := filepath.Join(tmpDir, tt.marker)
|
||||||
|
if tt.marker == ".git" || tt.marker == ".grokkit" {
|
||||||
|
if err := os.MkdirAll(markerPath, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := os.WriteFile(markerPath, []byte(""), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := findProjectRoot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findProjectRoot() failed: %v", err)
|
||||||
|
}
|
||||||
|
if root != tmpDir {
|
||||||
|
t.Errorf("findProjectRoot() = %q, want %q", root, tmpDir)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindProjectRootNotInProject(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Empty dir — no markers at all. This will walk up to / and fail.
|
||||||
|
_, err := findProjectRoot()
|
||||||
|
if err == nil {
|
||||||
|
// Might succeed if a parent dir has .git — that's OK in CI
|
||||||
|
t.Log("findProjectRoot() succeeded (likely found .git in a parent dir)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,8 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@ -16,6 +18,83 @@ func TestScaffoldCmd(t *testing.T) {
|
|||||||
assert.True(t, true, "command is registered and basic structure is intact")
|
assert.True(t, true, "command is registered and basic structure is intact")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetectLanguage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
override string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"Go file", "main.go", "", "Go"},
|
||||||
|
{"Python file", "script.py", "", "Python"},
|
||||||
|
{"JS file", "app.js", "", "TypeScript"},
|
||||||
|
{"TS file", "app.ts", "", "TypeScript"},
|
||||||
|
{"C file", "main.c", "", "C"},
|
||||||
|
{"C++ file", "main.cpp", "", "C++"},
|
||||||
|
{"Java file", "Main.java", "", "Java"},
|
||||||
|
{"unknown extension", "data.xyz", "", "code"},
|
||||||
|
{"override takes precedence", "main.go", "Rust", "Rust"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := detectLanguage(tt.path, tt.override)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("detectLanguage(%q, %q) = %q, want %q", tt.path, tt.override, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHarvestContext(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create the target file
|
||||||
|
targetPath := filepath.Join(tmpDir, "target.go")
|
||||||
|
if err := os.WriteFile(targetPath, []byte("package main\n\nfunc Target() {}\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a sibling .go file (should be included)
|
||||||
|
siblingPath := filepath.Join(tmpDir, "sibling.go")
|
||||||
|
if err := os.WriteFile(siblingPath, []byte("package main\n\nfunc Sibling() {}\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a non-.go sibling (should not be included)
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("notes"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := harvestContext(targetPath, "Go")
|
||||||
|
|
||||||
|
if !strings.Contains(ctx, "sibling.go") {
|
||||||
|
t.Error("expected sibling.go in context")
|
||||||
|
}
|
||||||
|
if !strings.Contains(ctx, "func Sibling()") {
|
||||||
|
t.Error("expected sibling content in context")
|
||||||
|
}
|
||||||
|
if strings.Contains(ctx, "target.go") {
|
||||||
|
t.Error("target file should not appear in its own context")
|
||||||
|
}
|
||||||
|
if strings.Contains(ctx, "notes.txt") {
|
||||||
|
t.Error("non-matching extension should not appear in context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHarvestContextEmptyDir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
targetPath := filepath.Join(tmpDir, "lonely.go")
|
||||||
|
if err := os.WriteFile(targetPath, []byte("package main"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := harvestContext(targetPath, "Go")
|
||||||
|
if ctx != "" {
|
||||||
|
t.Errorf("expected empty context for solo file, got: %q", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestScaffoldCmd_Live — LIVE INTEGRATION TEST (only runs when you ask)
|
// TestScaffoldCmd_Live — LIVE INTEGRATION TEST (only runs when you ask)
|
||||||
func TestScaffoldCmd_Live(t *testing.T) {
|
func TestScaffoldCmd_Live(t *testing.T) {
|
||||||
if !testing.Short() {
|
if !testing.Short() {
|
||||||
|
|||||||
107
cmd/workon_test.go
Normal file
107
cmd/workon_test.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWorkonCmd — FAST DEFAULT TEST (runs on make test / go test ./cmd)
|
||||||
|
func TestWorkonCmd(t *testing.T) {
|
||||||
|
t.Log("✓ Fast workon unit test (no Grok API call)")
|
||||||
|
|
||||||
|
// Verify the command is registered
|
||||||
|
found := false
|
||||||
|
for _, c := range rootCmd.Commands() {
|
||||||
|
if c.Use == "workon <todo_item_title>" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("workon command not registered on rootCmd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkonFlagRegistration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
flagName string
|
||||||
|
shorthand string
|
||||||
|
}{
|
||||||
|
{"message flag", "message", "M"},
|
||||||
|
{"fix flag", "fix", "f"},
|
||||||
|
{"complete flag", "complete", "c"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
flag := workonCmd.Flags().Lookup(tt.flagName)
|
||||||
|
if flag == nil {
|
||||||
|
t.Fatalf("flag %q not found on workon command", tt.flagName)
|
||||||
|
}
|
||||||
|
if flag.Shorthand != tt.shorthand {
|
||||||
|
t.Errorf("flag %q shorthand = %q, want %q", tt.flagName, flag.Shorthand, tt.shorthand)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkonFlagExclusivity(t *testing.T) {
|
||||||
|
// Run in a temp dir so todo.Bootstrap() doesn't leave orphan dirs in cmd/
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "complete alone is valid",
|
||||||
|
args: []string{"workon", "test-item", "-c"},
|
||||||
|
expectErr: false, // will fail at Run level (no git repo), but not at flag validation
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complete with fix is valid",
|
||||||
|
args: []string{"workon", "test-item", "-c", "-f"},
|
||||||
|
expectErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complete with message is invalid",
|
||||||
|
args: []string{"workon", "test-item", "-c", "-M", "some msg"},
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
rootCmd.SetArgs(tt.args)
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
|
||||||
|
if tt.expectErr && err == nil {
|
||||||
|
t.Error("expected error, got nil")
|
||||||
|
}
|
||||||
|
// Note: non-error cases may still fail (no git, no API key, etc.)
|
||||||
|
// We only assert that the flag validation error fires when expected
|
||||||
|
if tt.expectErr && err != nil {
|
||||||
|
// Verify it's the right error
|
||||||
|
if err.Error() != "-c cannot be combined with -m" {
|
||||||
|
// The error might be wrapped
|
||||||
|
t.Logf("got error: %v (flag validation may have been triggered)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkonRequiresArgs(t *testing.T) {
|
||||||
|
rootCmd.SetArgs([]string{"workon"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when no args provided, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -136,6 +136,26 @@ func TestGetTimeout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetIDECommand(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
|
||||||
|
t.Run("empty by default", func(t *testing.T) {
|
||||||
|
got := GetIDECommand()
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("GetIDECommand() default = %q, want empty", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns configured value", func(t *testing.T) {
|
||||||
|
viper.Set("commands.workon.ide", "code")
|
||||||
|
got := GetIDECommand()
|
||||||
|
if got != "code" {
|
||||||
|
t.Errorf("GetIDECommand() = %q, want 'code'", got)
|
||||||
|
}
|
||||||
|
viper.Set("commands.workon.ide", "") // reset
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetLogLevel(t *testing.T) {
|
func TestGetLogLevel(t *testing.T) {
|
||||||
viper.Reset()
|
viper.Reset()
|
||||||
viper.SetDefault("log_level", "info")
|
viper.SetDefault("log_level", "info")
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -64,3 +67,162 @@ func TestGitRunner(t *testing.T) {
|
|||||||
// Test IsRepo
|
// Test IsRepo
|
||||||
_ = runner.IsRepo() // Just ensure it doesn't panic
|
_ = runner.IsRepo() // Just ensure it doesn't panic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initTaggedGitRepo creates a temp git repo with an initial commit and a tag.
|
||||||
|
func initTaggedGitRepo(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cmds := [][]string{
|
||||||
|
{"git", "init"},
|
||||||
|
{"git", "config", "user.email", "test@test.com"},
|
||||||
|
{"git", "config", "user.name", "Test"},
|
||||||
|
}
|
||||||
|
for _, args := range cmds {
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git setup failed (%v): %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initial commit and tag
|
||||||
|
if err := os.WriteFile(tmpDir+"/init.txt", []byte("init"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"git", "add", "."},
|
||||||
|
{"git", "commit", "-m", "initial commit"},
|
||||||
|
{"git", "tag", "v0.1.0"},
|
||||||
|
} {
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git setup (%v) failed: %s", args, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tmpDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestTag(t *testing.T) {
|
||||||
|
tmpDir := initTaggedGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
tag, err := LatestTag()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LatestTag() failed: %v", err)
|
||||||
|
}
|
||||||
|
if tag != "v0.1.0" {
|
||||||
|
t.Errorf("LatestTag() = %q, want 'v0.1.0'", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestTagMultiple(t *testing.T) {
|
||||||
|
tmpDir := initTaggedGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Add a second commit and tag
|
||||||
|
if err := os.WriteFile("second.txt", []byte("second"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exec.Command("git", "add", ".").Run()
|
||||||
|
exec.Command("git", "commit", "-m", "second commit").Run()
|
||||||
|
exec.Command("git", "tag", "v0.2.0").Run()
|
||||||
|
|
||||||
|
tag, err := LatestTag()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LatestTag() failed: %v", err)
|
||||||
|
}
|
||||||
|
if tag != "v0.2.0" {
|
||||||
|
t.Errorf("LatestTag() = %q, want 'v0.2.0'", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviousTag(t *testing.T) {
|
||||||
|
tmpDir := initTaggedGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Add second commit and tag
|
||||||
|
if err := os.WriteFile("second.txt", []byte("second"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exec.Command("git", "add", ".").Run()
|
||||||
|
exec.Command("git", "commit", "-m", "second commit").Run()
|
||||||
|
exec.Command("git", "tag", "v0.2.0").Run()
|
||||||
|
|
||||||
|
prev, err := PreviousTag("v0.2.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PreviousTag() failed: %v", err)
|
||||||
|
}
|
||||||
|
if prev != "v0.1.0" {
|
||||||
|
t.Errorf("PreviousTag('v0.2.0') = %q, want 'v0.1.0'", prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogSince(t *testing.T) {
|
||||||
|
tmpDir := initTaggedGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Add commits after the tag
|
||||||
|
if err := os.WriteFile("new.txt", []byte("new"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exec.Command("git", "add", ".").Run()
|
||||||
|
exec.Command("git", "commit", "-m", "feat: add new feature").Run()
|
||||||
|
|
||||||
|
log, err := LogSince("v0.1.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LogSince() failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(log, "feat: add new feature") {
|
||||||
|
t.Errorf("LogSince() missing expected commit message, got: %q", log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiff(t *testing.T) {
|
||||||
|
tmpDir := initTaggedGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
t.Run("no changes returns empty", func(t *testing.T) {
|
||||||
|
out, err := Diff([]string{"diff"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Diff() failed: %v", err)
|
||||||
|
}
|
||||||
|
if out != "" {
|
||||||
|
t.Errorf("Diff() with no changes = %q, want empty", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with unstaged changes returns diff", func(t *testing.T) {
|
||||||
|
if err := os.WriteFile("init.txt", []byte("modified"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := Diff([]string{"diff"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Diff() failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "modified") {
|
||||||
|
t.Errorf("Diff() missing change content, got: %q", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -317,6 +317,91 @@ func TestLanguageStructure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetectPrimaryLanguage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
files []string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty list returns unknown",
|
||||||
|
files: []string{},
|
||||||
|
expected: "unknown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all Go files",
|
||||||
|
files: []string{"main.go", "handler.go", "util.go"},
|
||||||
|
expected: "go",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all Python files",
|
||||||
|
files: []string{"app.py", "utils.py"},
|
||||||
|
expected: "python",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed with Go bias",
|
||||||
|
files: []string{"main.go", "helper.go", "script.py", "app.js", "style.ts"},
|
||||||
|
expected: "go",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported files only returns unknown",
|
||||||
|
files: []string{"notes.txt", "data.csv", "readme.md"},
|
||||||
|
expected: "unknown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "C files normalized",
|
||||||
|
files: []string{"main.c", "util.c", "helper.c"},
|
||||||
|
expected: "c",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create real temp files so DetectLanguage can stat them
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
var paths []string
|
||||||
|
for _, f := range tt.files {
|
||||||
|
path := filepath.Join(tmpDir, f)
|
||||||
|
if err := os.WriteFile(path, []byte("content"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := DetectPrimaryLanguage(paths)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("DetectPrimaryLanguage() = %q, want %q", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSupportedLanguages(t *testing.T) {
|
||||||
|
langs := SupportedLanguages()
|
||||||
|
if len(langs) == 0 {
|
||||||
|
t.Fatal("SupportedLanguages() returned empty list")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be lowercase
|
||||||
|
for _, l := range langs {
|
||||||
|
if l == "" {
|
||||||
|
t.Error("SupportedLanguages() contains empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should contain "go"
|
||||||
|
foundGo := false
|
||||||
|
for _, l := range langs {
|
||||||
|
if l == "go" {
|
||||||
|
foundGo = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundGo {
|
||||||
|
t.Errorf("SupportedLanguages() missing 'go', got: %v", langs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function
|
// Helper function
|
||||||
func contains(s, substr string) bool {
|
func contains(s, substr string) bool {
|
||||||
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
|
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
|
||||||
|
|||||||
@ -105,6 +105,24 @@ func TestSetLevel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetLevelAllBranches(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
oldHome := os.Getenv("HOME")
|
||||||
|
setHome(t, tmpDir)
|
||||||
|
defer func() { _ = os.Setenv("HOME", oldHome) }()
|
||||||
|
|
||||||
|
if err := Init("info"); err != nil {
|
||||||
|
t.Fatalf("Init() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
levels := []string{"debug", "info", "warn", "error"}
|
||||||
|
for _, lvl := range levels {
|
||||||
|
t.Run(lvl, func(t *testing.T) {
|
||||||
|
SetLevel(lvl) // should not panic
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWith(t *testing.T) {
|
func TestWith(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
oldHome := os.Getenv("HOME")
|
oldHome := os.Getenv("HOME")
|
||||||
|
|||||||
162
internal/prompts/analyze_test.go
Normal file
162
internal/prompts/analyze_test.go
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
package prompts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadAnalysisPromptProjectLocal(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
promptDir := filepath.Join(tmpDir, ".grokkit", "prompts")
|
||||||
|
if err := os.MkdirAll(promptDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := "You are a Go expert educator."
|
||||||
|
if err := os.WriteFile(filepath.Join(promptDir, "go.md"), []byte(expected), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path, content, err := LoadAnalysisPrompt(tmpDir, "go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnalysisPrompt failed: %v", err)
|
||||||
|
}
|
||||||
|
if content != expected {
|
||||||
|
t.Errorf("content = %q, want %q", content, expected)
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(path) || path == "" {
|
||||||
|
// path should be meaningful
|
||||||
|
t.Logf("returned path: %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAnalysisPromptGlobalFallback(t *testing.T) {
|
||||||
|
projectDir := t.TempDir() // no .grokkit/prompts here
|
||||||
|
globalDir := t.TempDir()
|
||||||
|
|
||||||
|
promptDir := filepath.Join(globalDir, ".config", "grokkit", "prompts")
|
||||||
|
if err := os.MkdirAll(promptDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := "You are a Python expert."
|
||||||
|
if err := os.WriteFile(filepath.Join(promptDir, "python.md"), []byte(expected), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override HOME to point to our temp global dir
|
||||||
|
oldHome := os.Getenv("HOME")
|
||||||
|
defer func() { _ = os.Setenv("HOME", oldHome) }()
|
||||||
|
_ = os.Setenv("HOME", globalDir)
|
||||||
|
|
||||||
|
path, content, err := LoadAnalysisPrompt(projectDir, "python")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnalysisPrompt (global fallback) failed: %v", err)
|
||||||
|
}
|
||||||
|
if content != expected {
|
||||||
|
t.Errorf("content = %q, want %q", content, expected)
|
||||||
|
}
|
||||||
|
_ = path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAnalysisPromptProjectOverridesGlobal(t *testing.T) {
|
||||||
|
projectDir := t.TempDir()
|
||||||
|
globalDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create both project-local and global prompts
|
||||||
|
localPromptDir := filepath.Join(projectDir, ".grokkit", "prompts")
|
||||||
|
if err := os.MkdirAll(localPromptDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(localPromptDir, "go.md"), []byte("local prompt"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
globalPromptDir := filepath.Join(globalDir, ".config", "grokkit", "prompts")
|
||||||
|
if err := os.MkdirAll(globalPromptDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(globalPromptDir, "go.md"), []byte("global prompt"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldHome := os.Getenv("HOME")
|
||||||
|
defer func() { _ = os.Setenv("HOME", oldHome) }()
|
||||||
|
_ = os.Setenv("HOME", globalDir)
|
||||||
|
|
||||||
|
_, content, err := LoadAnalysisPrompt(projectDir, "go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnalysisPrompt failed: %v", err)
|
||||||
|
}
|
||||||
|
if content != "local prompt" {
|
||||||
|
t.Errorf("expected project-local to override global, got: %q", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAnalysisPromptNotFound(t *testing.T) {
|
||||||
|
projectDir := t.TempDir()
|
||||||
|
emptyHome := t.TempDir()
|
||||||
|
|
||||||
|
oldHome := os.Getenv("HOME")
|
||||||
|
defer func() { _ = os.Setenv("HOME", oldHome) }()
|
||||||
|
_ = os.Setenv("HOME", emptyHome)
|
||||||
|
|
||||||
|
_, _, err := LoadAnalysisPrompt(projectDir, "rust")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when no prompt found, got nil")
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Errorf("expected os.ErrNotExist, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAnalysisPromptDefaultsToGo(t *testing.T) {
|
||||||
|
projectDir := t.TempDir()
|
||||||
|
promptDir := filepath.Join(projectDir, ".grokkit", "prompts")
|
||||||
|
if err := os.MkdirAll(promptDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(promptDir, "go.md"), []byte("go prompt"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
language string
|
||||||
|
}{
|
||||||
|
{"empty string defaults to go", ""},
|
||||||
|
{"unknown defaults to go", "unknown"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, content, err := LoadAnalysisPrompt(projectDir, tt.language)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnalysisPrompt(%q) failed: %v", tt.language, err)
|
||||||
|
}
|
||||||
|
if content != "go prompt" {
|
||||||
|
t.Errorf("expected go prompt, got: %q", content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAnalysisPromptNormalizesCase(t *testing.T) {
|
||||||
|
projectDir := t.TempDir()
|
||||||
|
promptDir := filepath.Join(projectDir, ".grokkit", "prompts")
|
||||||
|
if err := os.MkdirAll(promptDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(promptDir, "python.md"), []byte("python prompt"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, content, err := LoadAnalysisPrompt(projectDir, "Python")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnalysisPrompt with uppercase failed: %v", err)
|
||||||
|
}
|
||||||
|
if content != "python prompt" {
|
||||||
|
t.Errorf("expected case-insensitive match, got: %q", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
123
internal/todo/todo_test.go
Normal file
123
internal/todo/todo_test.go
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
package todo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBootstrapCreatesStructure(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
if err := Bootstrap(); err != nil {
|
||||||
|
t.Fatalf("Bootstrap failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify directories
|
||||||
|
for _, dir := range []string{"todo/queued", "todo/doing", "todo/completed"} {
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected %s to exist: %v", dir, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Errorf("expected %s to be a directory", dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify README
|
||||||
|
data, err := os.ReadFile("todo/README.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected todo/README.md to exist: %v", err)
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
if !strings.Contains(content, "## Queued") {
|
||||||
|
t.Error("README missing ## Queued heading")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "## Completed") {
|
||||||
|
t.Error("README missing ## Completed heading")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapIdempotent(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Run twice
|
||||||
|
if err := Bootstrap(); err != nil {
|
||||||
|
t.Fatalf("first Bootstrap failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := Bootstrap(); err != nil {
|
||||||
|
t.Fatalf("second Bootstrap failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify structure still intact
|
||||||
|
for _, dir := range []string{"todo/queued", "todo/doing", "todo/completed"} {
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
t.Errorf("expected %s to exist after second Bootstrap: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapPreservesExistingReadme(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Create structure with custom README
|
||||||
|
if err := os.MkdirAll("todo", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
customContent := "# My Custom TODO\n\nCustom content here.\n"
|
||||||
|
if err := os.WriteFile("todo/README.md", []byte(customContent), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Bootstrap(); err != nil {
|
||||||
|
t.Fatalf("Bootstrap failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify custom README was not overwritten
|
||||||
|
data, err := os.ReadFile("todo/README.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(data) != customContent {
|
||||||
|
t.Errorf("Bootstrap overwrote existing README.\ngot: %q\nwant: %q", string(data), customContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapCreatesReadmeWithCorrectPermissions(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
if err := Bootstrap(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(filepath.Join("todo", "README.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
perm := info.Mode().Perm()
|
||||||
|
if perm != 0644 {
|
||||||
|
t.Errorf("README permissions = %o, want 0644", perm)
|
||||||
|
}
|
||||||
|
}
|
||||||
474
internal/workon/workon_test.go
Normal file
474
internal/workon/workon_test.go
Normal file
@ -0,0 +1,474 @@
|
|||||||
|
package workon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunRejectsEmptyTitle(t *testing.T) {
|
||||||
|
err := Run("", "", false, false)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty title, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "todo_item_title is required") {
|
||||||
|
t.Errorf("unexpected error message: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveQueuedToDoing(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Set up todo structure
|
||||||
|
if err := os.MkdirAll("todo/queued", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll("todo/doing", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("todo/queued/test-item.md", []byte("# Test Item\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := moveQueuedToDoing("test-item"); err != nil {
|
||||||
|
t.Fatalf("moveQueuedToDoing failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify source is gone
|
||||||
|
if _, err := os.Stat("todo/queued/test-item.md"); !os.IsNotExist(err) {
|
||||||
|
t.Error("expected queued file to be removed")
|
||||||
|
}
|
||||||
|
// Verify destination exists
|
||||||
|
if _, err := os.Stat("todo/doing/test-item.md"); err != nil {
|
||||||
|
t.Errorf("expected doing file to exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveQueuedToDoingMissingFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
if err := os.MkdirAll("todo/queued", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll("todo/doing", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := moveQueuedToDoing("nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing queued file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateFixFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpDir, "my-fix.md")
|
||||||
|
|
||||||
|
if err := createFixFile(path, "my-fix"); err != nil {
|
||||||
|
t.Fatalf("createFixFile failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read created file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
if !strings.Contains(content, "# my-fix") {
|
||||||
|
t.Errorf("expected title heading, got: %s", content)
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "## Work Plan") {
|
||||||
|
t.Errorf("expected Work Plan heading, got: %s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFileContent(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
t.Run("existing file", func(t *testing.T) {
|
||||||
|
path := filepath.Join(tmpDir, "exists.md")
|
||||||
|
expected := "hello world"
|
||||||
|
if err := os.WriteFile(path, []byte(expected), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := readFileContent(path)
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("readFileContent = %q, want %q", got, expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file returns empty string", func(t *testing.T) {
|
||||||
|
got := readFileContent(filepath.Join(tmpDir, "missing.md"))
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("readFileContent for missing file = %q, want empty", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteItemMovesFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Set up todo structure with a doing item
|
||||||
|
if err := os.MkdirAll("todo/doing", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll("todo/completed", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("todo/doing/test-item.md", []byte("# Test\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeItem will try to git commit — that will fail in a non-git temp dir,
|
||||||
|
// but we can verify the file move happened before the commit step
|
||||||
|
_ = completeItem("test-item", true)
|
||||||
|
|
||||||
|
// For a fix, file should be moved regardless of commit outcome
|
||||||
|
if _, err := os.Stat("todo/completed/test-item.md"); err != nil {
|
||||||
|
t.Errorf("expected completed file to exist: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("todo/doing/test-item.md"); !os.IsNotExist(err) {
|
||||||
|
t.Error("expected doing file to be removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateReadmeIndex(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
if err := os.MkdirAll("todo", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
readme := `# Todo Index
|
||||||
|
|
||||||
|
## Queued
|
||||||
|
|
||||||
|
* [1] [my-feature](queued/my-feature.md): My feature
|
||||||
|
* [2] [other-item](queued/other-item.md): Other item
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
* [old-item](./completed/old-item.md) : old-item *(done)*
|
||||||
|
`
|
||||||
|
if err := os.WriteFile("todo/README.md", []byte(readme), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := updateReadmeIndex("my-feature"); err != nil {
|
||||||
|
t.Fatalf("updateReadmeIndex failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile("todo/README.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result := string(data)
|
||||||
|
|
||||||
|
// Should have removed the queued entry
|
||||||
|
if strings.Contains(result, "queued/my-feature.md") {
|
||||||
|
t.Error("expected my-feature to be removed from Queued section")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should still have the other queued item
|
||||||
|
if !strings.Contains(result, "queued/other-item.md") {
|
||||||
|
t.Error("expected other-item to remain in Queued section")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have added to Completed section
|
||||||
|
if !strings.Contains(result, "completed/my-feature.md") {
|
||||||
|
t.Error("expected my-feature to appear in Completed section")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old completed item should still be there
|
||||||
|
if !strings.Contains(result, "completed/old-item.md") {
|
||||||
|
t.Error("expected old-item to remain in Completed section")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateReadmeIndexMissingReadme(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
err := updateReadmeIndex("anything")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when README doesn't exist, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initTempGitRepo creates a temp dir with a git repo and returns it.
|
||||||
|
// The caller is responsible for chdir-ing back.
|
||||||
|
func initTempGitRepo(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cmds := [][]string{
|
||||||
|
{"git", "init"},
|
||||||
|
{"git", "config", "user.email", "test@test.com"},
|
||||||
|
{"git", "config", "user.name", "Test"},
|
||||||
|
}
|
||||||
|
for _, args := range cmds {
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git init setup failed (%v): %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tmpDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateGitBranchNew(t *testing.T) {
|
||||||
|
tmpDir := initTempGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Need at least one commit for branches to work
|
||||||
|
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exec.Command("git", "add", ".").Run()
|
||||||
|
exec.Command("git", "commit", "-m", "initial").Run()
|
||||||
|
|
||||||
|
if err := createGitBranch("feature/test-branch"); err != nil {
|
||||||
|
t.Fatalf("createGitBranch failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we're on the new branch
|
||||||
|
out, err := exec.Command("git", "branch", "--show-current").Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git branch --show-current failed: %v", err)
|
||||||
|
}
|
||||||
|
branch := strings.TrimSpace(string(out))
|
||||||
|
if branch != "feature/test-branch" {
|
||||||
|
t.Errorf("expected branch 'feature/test-branch', got %q", branch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateGitBranchExisting(t *testing.T) {
|
||||||
|
tmpDir := initTempGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Initial commit + create branch
|
||||||
|
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exec.Command("git", "add", ".").Run()
|
||||||
|
exec.Command("git", "commit", "-m", "initial").Run()
|
||||||
|
exec.Command("git", "checkout", "-b", "fix/existing").Run()
|
||||||
|
exec.Command("git", "checkout", "-").Run() // back to main/master
|
||||||
|
|
||||||
|
// Should checkout existing branch, not error
|
||||||
|
if err := createGitBranch("fix/existing"); err != nil {
|
||||||
|
t.Fatalf("createGitBranch for existing branch failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, _ := exec.Command("git", "branch", "--show-current").Output()
|
||||||
|
branch := strings.TrimSpace(string(out))
|
||||||
|
if branch != "fix/existing" {
|
||||||
|
t.Errorf("expected branch 'fix/existing', got %q", branch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitChangesInGitRepo(t *testing.T) {
|
||||||
|
tmpDir := initTempGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Initial commit
|
||||||
|
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exec.Command("git", "add", ".").Run()
|
||||||
|
exec.Command("git", "commit", "-m", "initial").Run()
|
||||||
|
|
||||||
|
// Create a todo file to commit
|
||||||
|
if err := os.MkdirAll("todo/doing", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("todo/doing/test.md", []byte("# Test\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := commitChanges("test commit message"); err != nil {
|
||||||
|
t.Fatalf("commitChanges failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the commit was created
|
||||||
|
out, err := exec.Command("git", "log", "--oneline", "-1").Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git log failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "test commit message") {
|
||||||
|
t.Errorf("expected commit message in log, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitChangesNothingToCommit(t *testing.T) {
|
||||||
|
tmpDir := initTempGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Initial commit
|
||||||
|
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exec.Command("git", "add", ".").Run()
|
||||||
|
exec.Command("git", "commit", "-m", "initial").Run()
|
||||||
|
|
||||||
|
// Create empty todo dir but no files to stage
|
||||||
|
if err := os.MkdirAll("todo", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should fail — nothing to commit
|
||||||
|
err := commitChanges("empty commit")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when nothing to commit, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteItemWithGit(t *testing.T) {
|
||||||
|
tmpDir := initTempGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Initial commit
|
||||||
|
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exec.Command("git", "add", ".").Run()
|
||||||
|
exec.Command("git", "commit", "-m", "initial").Run()
|
||||||
|
|
||||||
|
// Set up todo structure
|
||||||
|
for _, d := range []string{"todo/doing", "todo/completed"} {
|
||||||
|
if err := os.MkdirAll(d, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("todo/doing/my-fix.md", []byte("# Fix\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Stage and commit the doing file first so git tracks it
|
||||||
|
exec.Command("git", "add", "todo/").Run()
|
||||||
|
exec.Command("git", "commit", "-m", "add doing item").Run()
|
||||||
|
|
||||||
|
// Now complete as a fix (no README update)
|
||||||
|
if err := completeItem("my-fix", true); err != nil {
|
||||||
|
t.Fatalf("completeItem failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify moved
|
||||||
|
if _, err := os.Stat("todo/completed/my-fix.md"); err != nil {
|
||||||
|
t.Errorf("expected completed file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("todo/doing/my-fix.md"); !os.IsNotExist(err) {
|
||||||
|
t.Error("expected doing file to be gone")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify commit
|
||||||
|
out, _ := exec.Command("git", "log", "--oneline", "-1").Output()
|
||||||
|
if !strings.Contains(string(out), "Complete work on my-fix") {
|
||||||
|
t.Errorf("expected completion commit, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteItemNonFixUpdatesReadme(t *testing.T) {
|
||||||
|
tmpDir := initTempGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Initial commit
|
||||||
|
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exec.Command("git", "add", ".").Run()
|
||||||
|
exec.Command("git", "commit", "-m", "initial").Run()
|
||||||
|
|
||||||
|
// Set up todo structure with README
|
||||||
|
for _, d := range []string{"todo/doing", "todo/completed"} {
|
||||||
|
if err := os.MkdirAll(d, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readme := "# Todo\n\n## Queued\n\n* [my-todo](queued/my-todo.md): My todo\n\n## Completed\n\n"
|
||||||
|
if err := os.WriteFile("todo/README.md", []byte(readme), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("todo/doing/my-todo.md", []byte("# My Todo\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exec.Command("git", "add", "todo/").Run()
|
||||||
|
exec.Command("git", "commit", "-m", "add todo item").Run()
|
||||||
|
|
||||||
|
// Complete as a non-fix (should update README)
|
||||||
|
if err := completeItem("my-todo", false); err != nil {
|
||||||
|
t.Fatalf("completeItem (non-fix) failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile("todo/README.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result := string(data)
|
||||||
|
|
||||||
|
if strings.Contains(result, "queued/my-todo.md") {
|
||||||
|
t.Error("expected my-todo removed from Queued")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "completed/my-todo.md") {
|
||||||
|
t.Error("expected my-todo added to Completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCnaddIfAvailable(t *testing.T) {
|
||||||
|
// Just exercise the function — it should not panic regardless
|
||||||
|
// of whether cnadd is installed
|
||||||
|
runCnaddIfAvailable("test-item")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenIDEIfConfigured(t *testing.T) {
|
||||||
|
// With no IDE configured (default), this should silently skip
|
||||||
|
openIDEIfConfigured()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user