Add unit tests and test tasks

- Add main_test.go with tests for formatContextInfo, stripANSI,
  StatusInput parsing, getGitInfo, and getGiteaStatus
- Add test, test:cover, test:fixture tasks to Taskfile
- 45% code coverage
This commit is contained in:
2025-12-18 06:07:13 +01:00
parent db80a7da81
commit f1de3c2050
2 changed files with 229 additions and 14 deletions

View File

@ -15,34 +15,44 @@ tasks:
build: build:
desc: Build with stripped symbols desc: Build with stripped symbols
cmds: cmds:
- go build -ldflags="{{.LDFLAGS}}" -o {{.BINARY}} . - go build -ldflags="{{.LDFLAGS}}" -o bin/{{.BINARY}} .
sources: sources:
- "*.go" - "*.go"
- go.mod - go.mod
- go.sum - go.sum
generates: generates:
- "{{.BINARY}}" - "bin/{{.BINARY}}"
build:debug: build:debug:
desc: Build with debug symbols desc: Build with debug symbols
cmds: cmds:
- go build -o {{.BINARY}} . - go build -o bin/{{.BINARY}} .
run: run:
desc: Run with test fixture desc: Run with test fixture
deps: [build] deps: [build]
cmds: cmds:
- cat test/fixture.json | ./{{.BINARY}} - cat test/fixture.json | ./bin/{{.BINARY}}
test: 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 desc: Run with test fixture and show output
deps: [build] deps: [build]
cmds: cmds:
- echo "=== Output ===" - echo "=== Output ==="
- cat test/fixture.json | ./{{.BINARY}} - cat test/fixture.json | ./bin/{{.BINARY}}
- echo "" - echo ""
- echo "=== Timing (single run) ===" - 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: bench:
desc: Benchmark Go vs Shell (100 runs) desc: Benchmark Go vs Shell (100 runs)
@ -50,10 +60,10 @@ tasks:
cmds: cmds:
- | - |
echo "=== Pure Go (100 runs) ===" 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
echo "" # echo ""
echo "=== Shell (100 runs) ===" # 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 | ./statusline.sh >/dev/null; done
silent: false silent: false
bench:go: bench:go:
@ -62,7 +72,7 @@ tasks:
cmds: cmds:
- | - |
echo "=== Pure Go (100 runs) ===" 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: tidy:
desc: Run go mod tidy desc: Run go mod tidy
@ -92,18 +102,18 @@ tasks:
clean: clean:
desc: Remove built binary desc: Remove built binary
cmds: cmds:
- rm -f {{.BINARY}} - rm -rf bin/
size: size:
desc: Show binary size desc: Show binary size
deps: [build] deps: [build]
cmds: cmds:
- ls -lh {{.BINARY}} - ls -lh bin/{{.BINARY}}
install: install:
desc: Install to ~/.claude/ desc: Install to ~/.claude/
deps: [build] deps: [build]
cmds: cmds:
- mkdir -p ~/.claude - mkdir -p ~/.claude
- cp {{.BINARY}} ~/.claude/{{.BINARY}} - cp bin/{{.BINARY}} ~/.claude/{{.BINARY}}
- echo "Installed to ~/.claude/{{.BINARY}}" - echo "Installed to ~/.claude/{{.BINARY}}"

205
main_test.go Normal file
View File

@ -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
}