Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion .claude/rules/testing.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,59 @@
# Testing Guidelines

This is a proof of concept, you don't need to implement unit tests.
## Running Unit Tests

```bash
# Run all unit tests
go test ./internal/...

# Run tests with verbose output
go test ./internal/... -v

# Run tests with coverage
go test ./internal/... -cover

# Run specific test
go test ./internal/services/... -run TestCreateSession
```

## Test Patterns

Follow the established patterns from existing tests:

```go
func TestFunctionName_Scenario(t *testing.T) {
// Create mocks
gitRepo := portsmocks.NewMockGitRepository(t)

// Setup expectations
gitRepo.EXPECT().Method(mock.Anything).Return(value, nil)

// Create service
service := NewService(gitRepo)

// Execute
result, err := service.Method(...)

// Assert
require.NoError(t, err)
assert.Equal(t, expected, result)
}
```

## Mocks

Mocks are generated using mockery. Config is in `.mockery.yaml`.

```bash
# Regenerate mocks after adding interfaces
mockery
```

## Test Coverage

Focus unit tests on:
- Pure functions (validation, sanitization, parsing)
- Service layer with mocked dependencies
- Error handling paths

Integration tests run in Docker (see testing_safety.md).
46 changes: 46 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Test

on:
pull_request:
branches: [main]
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'

- name: Download dependencies
run: go mod download

- name: Run tests
run: go test ./internal/... -v -race -coverprofile=coverage.out

- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: coverage.out
fail_ci_if_error: false
continue-on-error: true

build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'

- name: Build
run: go build -o rocha ./cmd
3 changes: 3 additions & 0 deletions .mockery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ packages:
ProcessInspector: {}
SessionReader: {}
SessionRepository: {}
SessionStateUpdater: {}
SessionWriter: {}
SoundPlayer: {}
TmuxClient: {}
TmuxSessionLifecycle: {}
TokenUsageReader: {}
github.com/renato0307/rocha/internal/services:
interfaces:
ClaudeDirResolver: {}
271 changes: 271 additions & 0 deletions internal/adapters/claude/session_parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
package claude

import (
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestGetTodayUsage_EmptyDirectory(t *testing.T) {
tempDir := t.TempDir()
parser := NewSessionParserWithDir(tempDir)

usage, err := parser.GetTodayUsage()

require.NoError(t, err)
assert.Empty(t, usage)
}

func TestGetTodayUsage_NonExistentDirectory(t *testing.T) {
parser := NewSessionParserWithDir("/non/existent/path")

usage, err := parser.GetTodayUsage()

require.NoError(t, err)
assert.Empty(t, usage)
}

func TestGetTodayUsage_ParsesTokenUsage(t *testing.T) {
tempDir := t.TempDir()
projectDir := filepath.Join(tempDir, "test-project")
require.NoError(t, os.MkdirAll(projectDir, 0755))

// Create JSONL file with today's timestamp
now := time.Now()
timestamp := now.Format(time.RFC3339)
content := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":5}}}
`
jsonlPath := filepath.Join(projectDir, "session.jsonl")
require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644))

parser := NewSessionParserWithDir(tempDir)

usage, err := parser.GetTodayUsage()

require.NoError(t, err)
require.Len(t, usage, 1)
assert.Equal(t, 100, usage[0].InputTokens)
assert.Equal(t, 50, usage[0].OutputTokens)
assert.Equal(t, 10, usage[0].CacheCreation)
assert.Equal(t, 5, usage[0].CacheRead)
}

func TestGetTodayUsage_FiltersOldEntries(t *testing.T) {
tempDir := t.TempDir()
projectDir := filepath.Join(tempDir, "test-project")
require.NoError(t, os.MkdirAll(projectDir, 0755))

// Create entries from today and yesterday
now := time.Now()
todayTimestamp := now.Format(time.RFC3339)
yesterdayTimestamp := now.AddDate(0, 0, -1).Format(time.RFC3339)

content := `{"type":"assistant","timestamp":"` + yesterdayTimestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}}
{"type":"assistant","timestamp":"` + todayTimestamp + `","message":{"usage":{"input_tokens":200,"output_tokens":100}}}
`
jsonlPath := filepath.Join(projectDir, "session.jsonl")
require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644))

parser := NewSessionParserWithDir(tempDir)

usage, err := parser.GetTodayUsage()

require.NoError(t, err)
require.Len(t, usage, 1)
assert.Equal(t, 200, usage[0].InputTokens)
}

func TestGetTodayUsage_SkipsNonAssistantMessages(t *testing.T) {
tempDir := t.TempDir()
projectDir := filepath.Join(tempDir, "test-project")
require.NoError(t, os.MkdirAll(projectDir, 0755))

now := time.Now()
timestamp := now.Format(time.RFC3339)
content := `{"type":"user","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100}}}
{"type":"system","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":200}}}
{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":300,"output_tokens":150}}}
`
jsonlPath := filepath.Join(projectDir, "session.jsonl")
require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644))

parser := NewSessionParserWithDir(tempDir)

usage, err := parser.GetTodayUsage()

require.NoError(t, err)
require.Len(t, usage, 1)
assert.Equal(t, 300, usage[0].InputTokens)
}

func TestGetTodayUsage_SkipsEntriesWithoutUsage(t *testing.T) {
tempDir := t.TempDir()
projectDir := filepath.Join(tempDir, "test-project")
require.NoError(t, os.MkdirAll(projectDir, 0755))

now := time.Now()
timestamp := now.Format(time.RFC3339)
content := `{"type":"assistant","timestamp":"` + timestamp + `","message":{}}
{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}}
{"type":"assistant","timestamp":"` + timestamp + `"}
`
jsonlPath := filepath.Join(projectDir, "session.jsonl")
require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644))

parser := NewSessionParserWithDir(tempDir)

usage, err := parser.GetTodayUsage()

require.NoError(t, err)
require.Len(t, usage, 1)
assert.Equal(t, 100, usage[0].InputTokens)
}

func TestGetTodayUsage_HandlesInvalidJSON(t *testing.T) {
tempDir := t.TempDir()
projectDir := filepath.Join(tempDir, "test-project")
require.NoError(t, os.MkdirAll(projectDir, 0755))

now := time.Now()
timestamp := now.Format(time.RFC3339)
content := `not valid json
{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}}
{invalid
`
jsonlPath := filepath.Join(projectDir, "session.jsonl")
require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644))

parser := NewSessionParserWithDir(tempDir)

usage, err := parser.GetTodayUsage()

require.NoError(t, err)
require.Len(t, usage, 1)
assert.Equal(t, 100, usage[0].InputTokens)
}

func TestGetTodayUsage_HandlesInvalidTimestamp(t *testing.T) {
tempDir := t.TempDir()
projectDir := filepath.Join(tempDir, "test-project")
require.NoError(t, os.MkdirAll(projectDir, 0755))

now := time.Now()
timestamp := now.Format(time.RFC3339)
content := `{"type":"assistant","timestamp":"invalid-timestamp","message":{"usage":{"input_tokens":100}}}
{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":200,"output_tokens":100}}}
`
jsonlPath := filepath.Join(projectDir, "session.jsonl")
require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644))

parser := NewSessionParserWithDir(tempDir)

usage, err := parser.GetTodayUsage()

require.NoError(t, err)
require.Len(t, usage, 1)
assert.Equal(t, 200, usage[0].InputTokens)
}

func TestGetTodayUsage_ProcessesMultipleProjects(t *testing.T) {
tempDir := t.TempDir()
now := time.Now()
timestamp := now.Format(time.RFC3339)

// Create two project directories
project1 := filepath.Join(tempDir, "project1")
project2 := filepath.Join(tempDir, "project2")
require.NoError(t, os.MkdirAll(project1, 0755))
require.NoError(t, os.MkdirAll(project2, 0755))

content1 := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}}
`
content2 := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":200,"output_tokens":100}}}
`
require.NoError(t, os.WriteFile(filepath.Join(project1, "session.jsonl"), []byte(content1), 0644))
require.NoError(t, os.WriteFile(filepath.Join(project2, "session.jsonl"), []byte(content2), 0644))

parser := NewSessionParserWithDir(tempDir)

usage, err := parser.GetTodayUsage()

require.NoError(t, err)
require.Len(t, usage, 2)
// Check total tokens
totalInput := 0
for _, u := range usage {
totalInput += u.InputTokens
}
assert.Equal(t, 300, totalInput)
}

func TestGetTodayUsage_ProcessesMultipleJSONLFiles(t *testing.T) {
tempDir := t.TempDir()
projectDir := filepath.Join(tempDir, "test-project")
require.NoError(t, os.MkdirAll(projectDir, 0755))

now := time.Now()
timestamp := now.Format(time.RFC3339)

content1 := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}}
`
content2 := `{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":200,"output_tokens":100}}}
`
require.NoError(t, os.WriteFile(filepath.Join(projectDir, "session1.jsonl"), []byte(content1), 0644))
require.NoError(t, os.WriteFile(filepath.Join(projectDir, "session2.jsonl"), []byte(content2), 0644))

parser := NewSessionParserWithDir(tempDir)

usage, err := parser.GetTodayUsage()

require.NoError(t, err)
require.Len(t, usage, 2)
}

func TestGetTodayUsage_SkipsNonDirectories(t *testing.T) {
tempDir := t.TempDir()

// Create a file (not a directory) in the projects dir
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "not-a-dir.txt"), []byte("hello"), 0644))

parser := NewSessionParserWithDir(tempDir)

usage, err := parser.GetTodayUsage()

require.NoError(t, err)
assert.Empty(t, usage)
}

func TestGetTodayUsage_SkipsEmptyLines(t *testing.T) {
tempDir := t.TempDir()
projectDir := filepath.Join(tempDir, "test-project")
require.NoError(t, os.MkdirAll(projectDir, 0755))

now := time.Now()
timestamp := now.Format(time.RFC3339)
content := `

{"type":"assistant","timestamp":"` + timestamp + `","message":{"usage":{"input_tokens":100,"output_tokens":50}}}

`
jsonlPath := filepath.Join(projectDir, "session.jsonl")
require.NoError(t, os.WriteFile(jsonlPath, []byte(content), 0644))

parser := NewSessionParserWithDir(tempDir)

usage, err := parser.GetTodayUsage()

require.NoError(t, err)
require.Len(t, usage, 1)
assert.Equal(t, 100, usage[0].InputTokens)
}

func TestNewSessionParser_DefaultDirectory(t *testing.T) {
parser := NewSessionParser()

// Just verify it doesn't panic and creates a parser
assert.NotNil(t, parser)
}
Loading