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.
|
||||
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
|
||||
@ -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
|
||||
|
||||
### 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%
|
||||
- ✅ **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
|
||||
|
||||
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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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")
|
||||
}
|
||||
|
||||
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)
|
||||
func TestScaffoldCmd_Live(t *testing.T) {
|
||||
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) {
|
||||
viper.Reset()
|
||||
viper.SetDefault("log_level", "info")
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -64,3 +67,162 @@ func TestGitRunner(t *testing.T) {
|
||||
// Test IsRepo
|
||||
_ = 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
|
||||
func contains(s, substr string) bool {
|
||||
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) {
|
||||
tmpDir := t.TempDir()
|
||||
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