diff --git a/Taskfile.yml b/Taskfile.yml index 019aee1..8678e2d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -15,34 +15,44 @@ tasks: build: desc: Build with stripped symbols cmds: - - go build -ldflags="{{.LDFLAGS}}" -o {{.BINARY}} . + - go build -ldflags="{{.LDFLAGS}}" -o bin/{{.BINARY}} . sources: - "*.go" - go.mod - go.sum generates: - - "{{.BINARY}}" + - "bin/{{.BINARY}}" build:debug: desc: Build with debug symbols cmds: - - go build -o {{.BINARY}} . + - go build -o bin/{{.BINARY}} . run: desc: Run with test fixture deps: [build] cmds: - - cat test/fixture.json | ./{{.BINARY}} + - cat test/fixture.json | ./bin/{{.BINARY}} test: + desc: Run Go unit tests + cmds: + - go test -v ./... + + test:cover: + desc: Run tests with coverage + cmds: + - go test -cover ./... + + test:fixture: desc: Run with test fixture and show output deps: [build] cmds: - echo "=== Output ===" - - cat test/fixture.json | ./{{.BINARY}} + - cat test/fixture.json | ./bin/{{.BINARY}} - echo "" - echo "=== Timing (single run) ===" - - time sh -c 'cat test/fixture.json | ./{{.BINARY}} > /dev/null' + - time sh -c 'cat test/fixture.json | ./bin/{{.BINARY}} > /dev/null' bench: desc: Benchmark Go vs Shell (100 runs) @@ -50,10 +60,10 @@ tasks: cmds: - | echo "=== Pure Go (100 runs) ===" - time for i in $(seq 1 100); do cat test/fixture.json | ./{{.BINARY}} >/dev/null; done - echo "" - echo "=== Shell (100 runs) ===" - time for i in $(seq 1 100); do cat test/fixture.json | ./statusline.sh >/dev/null; done + time for i in $(seq 1 100); do cat test/fixture.json | ./bin/{{.BINARY}} >/dev/null; done + # echo "" + # echo "=== Shell (100 runs) ===" + # time for i in $(seq 1 100); do cat test/fixture.json | ./statusline.sh >/dev/null; done silent: false bench:go: @@ -62,7 +72,7 @@ tasks: cmds: - | echo "=== Pure Go (100 runs) ===" - time for i in $(seq 1 100); do cat test/fixture.json | ./{{.BINARY}} >/dev/null; done + time for i in $(seq 1 100); do cat test/fixture.json | ./bin/{{.BINARY}} >/dev/null; done tidy: desc: Run go mod tidy @@ -92,18 +102,18 @@ tasks: clean: desc: Remove built binary cmds: - - rm -f {{.BINARY}} + - rm -rf bin/ size: desc: Show binary size deps: [build] cmds: - - ls -lh {{.BINARY}} + - ls -lh bin/{{.BINARY}} install: desc: Install to ~/.claude/ deps: [build] cmds: - mkdir -p ~/.claude - - cp {{.BINARY}} ~/.claude/{{.BINARY}} + - cp bin/{{.BINARY}} ~/.claude/{{.BINARY}} - echo "Installed to ~/.claude/{{.BINARY}}" diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..2af0365 --- /dev/null +++ b/main_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +func TestFormatContextInfo_NilUsage(t *testing.T) { + result := formatContextInfo(200000, nil) + expected := "0/200k" + if result != expected { + t.Errorf("formatContextInfo(200000, nil) = %q, want %q", result, expected) + } +} + +func TestFormatContextInfo_WithUsage(t *testing.T) { + usage := &struct { + InputTokens int `json:"input_tokens"` + CacheCreationTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` + }{ + InputTokens: 8500, + CacheCreationTokens: 5000, + CacheReadInputTokens: 2000, + } + result := formatContextInfo(200000, usage) + expected := "15k/200k" + if result != expected { + t.Errorf("formatContextInfo(200000, usage) = %q, want %q", result, expected) + } +} + +func TestFormatContextInfo_SmallValues(t *testing.T) { + usage := &struct { + InputTokens int `json:"input_tokens"` + CacheCreationTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` + }{ + InputTokens: 500, + CacheCreationTokens: 0, + CacheReadInputTokens: 0, + } + result := formatContextInfo(100000, usage) + expected := "0k/100k" + if result != expected { + t.Errorf("formatContextInfo(100000, usage) = %q, want %q", result, expected) + } +} + +func TestStripANSI(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "no ansi", + input: "hello world", + expected: "hello world", + }, + { + name: "single color", + input: "\033[32mgreen\033[0m", + expected: "green", + }, + { + name: "multiple colors", + input: "\033[31mred\033[0m \033[32mgreen\033[0m", + expected: "red green", + }, + { + name: "bold", + input: "\033[1mbold\033[0m", + expected: "bold", + }, + { + name: "complex", + input: "\033[32m●\033[0m \033[35mOpus\033[0m \033[1;32m➜\033[0m", + expected: "● Opus ➜", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := stripANSI(tt.input) + if result != tt.expected { + t.Errorf("stripANSI(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestStatusInputParsing(t *testing.T) { + jsonData := `{ + "model": {"display_name": "Opus 4.5"}, + "workspace": {"current_dir": "/root/projects/statusline"}, + "context_window": { + "context_window_size": 200000, + "current_usage": { + "input_tokens": 5000, + "cache_creation_input_tokens": 1000, + "cache_read_input_tokens": 500 + } + } + }` + + var data StatusInput + err := json.Unmarshal([]byte(jsonData), &data) + if err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if data.Model.DisplayName != "Opus 4.5" { + t.Errorf("Model.DisplayName = %q, want %q", data.Model.DisplayName, "Opus 4.5") + } + + if data.Workspace.CurrentDir != "/root/projects/statusline" { + t.Errorf("Workspace.CurrentDir = %q, want %q", data.Workspace.CurrentDir, "/root/projects/statusline") + } + + if data.ContextWindow.ContextWindowSize != 200000 { + t.Errorf("ContextWindow.ContextWindowSize = %d, want %d", data.ContextWindow.ContextWindowSize, 200000) + } + + if data.ContextWindow.CurrentUsage == nil { + t.Fatal("ContextWindow.CurrentUsage is nil") + } + + if data.ContextWindow.CurrentUsage.InputTokens != 5000 { + t.Errorf("CurrentUsage.InputTokens = %d, want %d", data.ContextWindow.CurrentUsage.InputTokens, 5000) + } +} + +func TestStatusInputParsing_NilUsage(t *testing.T) { + jsonData := `{ + "model": {"display_name": "Sonnet"}, + "workspace": {"current_dir": "/tmp"}, + "context_window": { + "context_window_size": 100000 + } + }` + + var data StatusInput + err := json.Unmarshal([]byte(jsonData), &data) + if err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if data.ContextWindow.CurrentUsage != nil { + t.Errorf("ContextWindow.CurrentUsage should be nil, got %+v", data.ContextWindow.CurrentUsage) + } +} + +func TestGetGitInfo_CurrentRepo(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Skipf("Could not get working directory: %v", err) + } + + result := getGitInfo(cwd) + + // Should return something like " git:(master)" or " git:(master) ✗" + if result == "" { + t.Skip("Not in a git repository") + } + + if !contains(result, "git:(") { + t.Errorf("getGitInfo(%q) = %q, expected to contain 'git:('", cwd, result) + } +} + +func TestGetGitInfo_NonRepo(t *testing.T) { + result := getGitInfo("/tmp") + + // /tmp is unlikely to be a git repo + if result != "" && !contains(result, "git:(") { + t.Errorf("getGitInfo(/tmp) = %q, expected empty or valid git info", result) + } +} + +func TestGetGiteaStatus(t *testing.T) { + result := getGiteaStatus() + + // Should return either green or red dot + greenDot := green + "●" + reset + redDot := red + "●" + reset + + if result != greenDot && result != redDot { + t.Errorf("getGiteaStatus() = %q, expected green or red dot", result) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}