test: expand unit test coverage for agent, llm, llmloop, and tool packages

Add integration-style tests with fake LLM clients for agent dispatch and
llmloop runner, plus new unit test files for gitcmd, session/history,
tool/code_comment, tool/filereader_read, and viewer/store packages.
This commit is contained in:
kite 2026-06-26 23:09:11 +08:00
parent 98fe309f03
commit 904ecd3ae1
9 changed files with 1604 additions and 0 deletions

View file

@ -1,15 +1,88 @@
package agent
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/open-code-review/open-code-review/internal/config/template"
"github.com/open-code-review/open-code-review/internal/config/toolsconfig"
"github.com/open-code-review/open-code-review/internal/llm"
"github.com/open-code-review/open-code-review/internal/model"
"github.com/open-code-review/open-code-review/internal/tool"
)
type fakeAgentClient struct {
responses []*llm.ChatResponse
calls int
}
func (f *fakeAgentClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
if f.calls >= len(f.responses) {
content := ""
return &llm.ChatResponse{
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &content}}},
Model: "fake",
}, nil
}
resp := f.responses[f.calls]
f.calls++
return resp, nil
}
func agentTaskDoneResponse() *llm.ChatResponse {
content := ""
return &llm.ChatResponse{
Choices: []llm.Choice{{
Message: llm.ResponseMessage{
Content: &content,
ToolCalls: []llm.ToolCall{{
ID: "call_done",
Type: "function",
Function: llm.FunctionCall{
Name: "task_done",
Arguments: `{}`,
},
}},
},
}},
Model: "fake",
Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 5},
}
}
func codeCommentResponse(path string) *llm.ChatResponse {
content := ""
args := map[string]any{
"path": path,
"comments": []any{
map[string]any{
"content": "potential null pointer",
"existing_code": "foo := bar.Baz()",
},
},
}
argsJSON, _ := json.Marshal(args)
return &llm.ChatResponse{
Choices: []llm.Choice{{
Message: llm.ResponseMessage{
Content: &content,
ToolCalls: []llm.ToolCall{{
ID: "call_comment",
Type: "function",
Function: llm.FunctionCall{
Name: "code_comment",
Arguments: string(argsJSON),
},
}},
},
}},
Model: "fake",
Usage: &llm.UsageInfo{PromptTokens: 50, CompletionTokens: 20},
}
}
func TestBuildFilterCommentsJSON(t *testing.T) {
tests := []struct {
name string
@ -302,3 +375,195 @@ func TestBuildToolDefs(t *testing.T) {
}
})
}
func TestFilterLargeDiffs(t *testing.T) {
a := New(Args{
Template: template.Template{MaxTokens: 100},
})
diffs := []model.Diff{
{NewPath: "small.go", Diff: "short diff"},
{NewPath: "large.go", Diff: strings.Repeat("word ", 500)},
}
kept := a.filterLargeDiffs(diffs)
if len(kept) != 1 {
t.Fatalf("expected 1 kept diff, got %d", len(kept))
}
if kept[0].NewPath != "small.go" {
t.Errorf("kept wrong file: %s", kept[0].NewPath)
}
}
func TestFilterLargeDiffs_ZeroMaxTokens(t *testing.T) {
a := New(Args{
Template: template.Template{MaxTokens: 0},
})
diffs := []model.Diff{{NewPath: "a.go", Diff: "some diff"}}
kept := a.filterLargeDiffs(diffs)
if len(kept) != 1 {
t.Errorf("expected all kept when MaxTokens=0, got %d", len(kept))
}
}
func TestCountReviewable(t *testing.T) {
a := New(Args{})
diffs := []model.Diff{
{NewPath: "main.go", Insertions: 10, Deletions: 2},
{NewPath: "deleted.go", IsDeleted: true, Deletions: 20},
{NewPath: "binary.bin", IsBinary: true},
{NewPath: "helper.go", Insertions: 5},
}
count := a.countReviewable(diffs)
if count != 2 {
t.Errorf("countReviewable = %d, want 2", count)
}
}
func TestBuildChangeFilesExcept(t *testing.T) {
a := New(Args{})
a.diffs = []model.Diff{
{NewPath: "main.go", OldPath: "main.go"},
{NewPath: "helper.go", OldPath: "helper.go", IsNew: true},
{NewPath: "removed.go", OldPath: "removed.go", IsDeleted: true},
{NewPath: "renamed.go", OldPath: "old_name.go"},
{NewPath: "bin.dat", OldPath: "bin.dat", IsBinary: true},
}
got := a.buildChangeFilesExcept("main.go")
if strings.Contains(got, "main.go") {
t.Error("excluded file should not appear")
}
if !strings.Contains(got, "ADDED") {
t.Error("expected ADDED status for new file")
}
if !strings.Contains(got, "DELETED") {
t.Error("expected DELETED status")
}
if !strings.Contains(got, "RENAMED") {
t.Error("expected RENAMED status")
}
if strings.Contains(got, "bin.dat") {
t.Error("binary files should be skipped")
}
}
func TestDispatchSubtasks_WithFakeLLM(t *testing.T) {
client := &fakeAgentClient{responses: []*llm.ChatResponse{
codeCommentResponse("main.go"),
agentTaskDoneResponse(),
}}
collector := tool.NewCommentCollector()
reg := tool.NewRegistry()
reg.Register(&tool.CodeCommentProvider{Collector: collector})
a := New(Args{
LLMClient: client,
Model: "fake",
CommentCollector: collector,
Tools: reg,
Template: template.Template{
MaxTokens: 100000,
MaxToolRequestTimes: 10,
MainTask: template.LlmConversation{
Messages: []template.ChatMessage{
{Role: "user", Content: "Review {{diff}} for {{current_file_path}}"},
},
},
},
MainToolDefs: []llm.ToolDef{
{Type: "function", Function: llm.FunctionDef{Name: "task_done", Description: "done"}},
{Type: "function", Function: llm.FunctionDef{Name: "code_comment", Description: "comment"}},
},
})
a.diffs = []model.Diff{
{NewPath: "main.go", OldPath: "main.go", Diff: "+new line", Insertions: 1},
}
a.currentDate = "2025-06-26 10:00"
comments, err := a.dispatchSubtasks(context.Background())
if err != nil {
t.Fatalf("dispatchSubtasks: %v", err)
}
if len(comments) != 1 {
t.Fatalf("expected 1 comment, got %d", len(comments))
}
if comments[0].Path != "main.go" {
t.Errorf("Path = %q, want main.go", comments[0].Path)
}
if !strings.Contains(comments[0].Content, "null pointer") {
t.Errorf("Content = %q", comments[0].Content)
}
}
func TestDispatchSubtasks_AllDeleted(t *testing.T) {
client := &fakeAgentClient{}
a := New(Args{
LLMClient: client,
Model: "fake",
Template: template.Template{
MaxTokens: 100000,
MaxToolRequestTimes: 5,
MainTask: template.LlmConversation{
Messages: []template.ChatMessage{
{Role: "user", Content: "Review {{diff}}"},
},
},
},
})
a.diffs = []model.Diff{
{NewPath: "removed.go", IsDeleted: true},
}
a.currentDate = "2025-06-26 10:00"
comments, err := a.dispatchSubtasks(context.Background())
if err != nil {
t.Fatalf("dispatchSubtasks: %v", err)
}
if len(comments) != 0 {
t.Errorf("expected 0 comments for deleted file, got %d", len(comments))
}
if client.calls != 0 {
t.Errorf("expected 0 LLM calls, got %d", client.calls)
}
}
func TestAgent_TokenAccumulation(t *testing.T) {
client := &fakeAgentClient{responses: []*llm.ChatResponse{
agentTaskDoneResponse(),
}}
a := New(Args{
LLMClient: client,
Model: "fake",
Template: template.Template{
MaxTokens: 100000,
MaxToolRequestTimes: 10,
MainTask: template.LlmConversation{
Messages: []template.ChatMessage{
{Role: "user", Content: "Review {{diff}}"},
},
},
},
})
a.diffs = []model.Diff{
{NewPath: "a.go", Diff: "+x", Insertions: 1},
}
a.currentDate = "2025-06-26 10:00"
_, err := a.dispatchSubtasks(context.Background())
if err != nil {
t.Fatal(err)
}
if a.TotalInputTokens() != 10 {
t.Errorf("TotalInputTokens = %d, want 10", a.TotalInputTokens())
}
if a.TotalOutputTokens() != 5 {
t.Errorf("TotalOutputTokens = %d, want 5", a.TotalOutputTokens())
}
}

View file

@ -0,0 +1,151 @@
package gitcmd
import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func initRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
run := func(args ...string) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test",
"GIT_AUTHOR_EMAIL=test@test.com",
"GIT_COMMITTER_NAME=test",
"GIT_COMMITTER_EMAIL=test@test.com",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run("init")
run("config", "user.email", "test@test.com")
run("config", "user.name", "test")
if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello\n"), 0644); err != nil {
t.Fatal(err)
}
run("add", "hello.txt")
run("commit", "-m", "init")
return dir
}
func TestRunner_New(t *testing.T) {
r := New(0)
if r == nil {
t.Fatal("New(0) returned nil")
}
if cap(r.sem) != defaultMaxConcurrent {
t.Errorf("default capacity = %d, want %d", cap(r.sem), defaultMaxConcurrent)
}
r2 := New(4)
if cap(r2.sem) != 4 {
t.Errorf("capacity = %d, want 4", cap(r2.sem))
}
}
func TestRunner_Run(t *testing.T) {
dir := initRepo(t)
r := New(2)
out, err := r.Run(context.Background(), dir, "log", "--oneline")
if err != nil {
t.Fatalf("Run error: %v", err)
}
if !strings.Contains(out, "init") {
t.Errorf("expected 'init' in output: %q", out)
}
}
func TestRunner_Run_InvalidCommand(t *testing.T) {
dir := initRepo(t)
r := New(2)
_, err := r.Run(context.Background(), dir, "nonexistent-subcommand")
if err == nil {
t.Error("expected error for invalid git subcommand")
}
}
func TestRunner_Output(t *testing.T) {
dir := initRepo(t)
r := New(2)
out, err := r.Output(context.Background(), dir, "rev-parse", "HEAD")
if err != nil {
t.Fatalf("Output error: %v", err)
}
hash := strings.TrimSpace(string(out))
if len(hash) != 40 {
t.Errorf("expected 40-char hash, got %q", hash)
}
}
func TestRunner_RunSplit(t *testing.T) {
dir := initRepo(t)
r := New(2)
stdout, stderr, err := r.RunSplit(context.Background(), dir, "status", "--short")
if err != nil {
t.Fatalf("RunSplit error: %v", err)
}
_ = stderr
if strings.Contains(stdout, "??") {
t.Errorf("unexpected untracked files in clean repo: %q", stdout)
}
}
func TestRunner_Stream(t *testing.T) {
dir := initRepo(t)
r := New(2)
var content string
err := r.Stream(context.Background(), dir, func(stdout io.Reader) error {
data, err := io.ReadAll(stdout)
if err != nil {
return err
}
content = string(data)
return nil
}, "show", "HEAD:hello.txt")
if err != nil {
t.Fatalf("Stream error: %v", err)
}
if content != "hello\n" {
t.Errorf("Stream content = %q, want %q", content, "hello\n")
}
}
func TestRunner_ContextCancelled(t *testing.T) {
r := New(1)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := r.Run(ctx, ".", "status")
if err == nil {
t.Error("expected error for cancelled context")
}
}
func TestRunner_AcquireTimeout(t *testing.T) {
r := New(1)
r.sem <- struct{}{}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err := r.Run(ctx, ".", "status")
if err == nil {
t.Error("expected timeout error when semaphore full")
}
}

View file

@ -482,3 +482,90 @@ func TestAnthropicClient_NoExtraHeadersWhenEmpty(t *testing.T) {
// Verify the SDK constant is accessible (compile-time check).
var _ anthropic.CacheControlEphemeralParam = anthropic.NewCacheControlEphemeralParam()
func TestCountTokens(t *testing.T) {
tests := []struct {
name string
text string
want int
}{
{"empty", "", 0},
{"single word", "hello", 1},
{"sentence", "The quick brown fox jumps over the lazy dog.", 10},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CountTokens(tt.text)
if tt.want == 0 && got != 0 {
t.Errorf("CountTokens(%q) = %d, want 0", tt.text, got)
}
if tt.want > 0 && got == 0 {
t.Errorf("CountTokens(%q) = 0, expected > 0", tt.text)
}
})
}
}
func TestCountTokensForModel(t *testing.T) {
text := "Hello, world! This is a test."
base := CountTokensForModel(text, "gpt-4")
o1 := CountTokensForModel(text, "o1-mini")
if base == 0 {
t.Error("cl100k_base should produce non-zero tokens")
}
if o1 == 0 {
t.Error("o200k_base should produce non-zero tokens")
}
if CountTokensForModel("", "gpt-4") != 0 {
t.Error("empty text should return 0")
}
}
func TestEncodingForModel(t *testing.T) {
tests := []struct {
model string
want string
}{
{"gpt-4", "cl100k_base"},
{"claude-3-opus", "cl100k_base"},
{"", "cl100k_base"},
{"o1-preview", "o200k_base"},
{"o3-mini", "o200k_base"},
{"o4-mini", "o200k_base"},
{"GPT-O1", "o200k_base"},
}
for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
got := encodingForModel(tt.model)
if got != tt.want {
t.Errorf("encodingForModel(%q) = %q, want %q", tt.model, got, tt.want)
}
})
}
}
func TestStripThinkTags(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"no tags", "hello world", "hello world"},
{"open only", "<think>partial", "partial"},
{"close only", "partial</think>", "partial"},
{"both tags", "<think>reasoning here</think>answer", "reasoning hereanswer"},
{"multiple tags", "<think>a</think>b<think>c</think>d", "abcd"},
{"empty", "", ""},
{"tags only", "<think></think>", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := stripThinkTags(tt.input)
if got != tt.want {
t.Errorf("stripThinkTags(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

View file

@ -5,10 +5,237 @@ import (
"encoding/json"
"testing"
"github.com/open-code-review/open-code-review/internal/config/template"
"github.com/open-code-review/open-code-review/internal/llm"
"github.com/open-code-review/open-code-review/internal/session"
"github.com/open-code-review/open-code-review/internal/tool"
)
type fakeClient struct {
responses []*llm.ChatResponse
calls int
}
func (f *fakeClient) CompletionsWithCtx(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) {
if f.calls >= len(f.responses) {
content := ""
return &llm.ChatResponse{
Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &content}}},
Model: "fake",
}, nil
}
resp := f.responses[f.calls]
f.calls++
return resp, nil
}
func taskDoneResponse() *llm.ChatResponse {
content := ""
return &llm.ChatResponse{
Choices: []llm.Choice{{
Message: llm.ResponseMessage{
Content: &content,
ToolCalls: []llm.ToolCall{{
ID: "call_1",
Type: "function",
Function: llm.FunctionCall{
Name: "task_done",
Arguments: `{}`,
},
}},
},
}},
Model: "fake",
Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 5},
}
}
func fileReadToolCallResponse(callID, args string) *llm.ChatResponse {
content := ""
return &llm.ChatResponse{
Choices: []llm.Choice{{
Message: llm.ResponseMessage{
Content: &content,
ToolCalls: []llm.ToolCall{{
ID: callID,
Type: "function",
Function: llm.FunctionCall{
Name: "file_read",
Arguments: args,
},
}},
},
}},
Model: "fake",
Usage: &llm.UsageInfo{PromptTokens: 20, CompletionTokens: 10},
}
}
type fakeFileReadProvider struct {
result string
}
func (f *fakeFileReadProvider) Tool() tool.Tool { return tool.FileRead }
func (f *fakeFileReadProvider) Execute(_ context.Context, _ map[string]any) (string, error) {
return f.result, nil
}
func newTestDeps(client llm.LLMClient) Deps {
reg := tool.NewRegistry()
reg.Register(&fakeFileReadProvider{result: "package main\n"})
return Deps{
LLMClient: client,
Model: "fake",
Template: template.Template{MaxTokens: 100000, MaxToolRequestTimes: 10},
Tools: reg,
CommentCollector: tool.NewCommentCollector(),
Session: session.New("/tmp/test-repo", "main", "fake", session.SessionOptions{}),
}
}
func TestRunPerFile_TaskDoneImmediately(t *testing.T) {
client := &fakeClient{responses: []*llm.ChatResponse{taskDoneResponse()}}
deps := newTestDeps(client)
runner := NewRunner(deps)
msgs := []llm.Message{llm.NewTextMessage("user", "review this file")}
err := runner.RunPerFile(context.Background(), msgs, "main.go")
if err != nil {
t.Fatalf("RunPerFile: %v", err)
}
if client.calls != 1 {
t.Errorf("expected 1 LLM call, got %d", client.calls)
}
if runner.TotalInputTokens() != 10 {
t.Errorf("TotalInputTokens = %d, want 10", runner.TotalInputTokens())
}
if runner.TotalOutputTokens() != 5 {
t.Errorf("TotalOutputTokens = %d, want 5", runner.TotalOutputTokens())
}
}
func TestRunPerFile_ToolCallThenDone(t *testing.T) {
client := &fakeClient{responses: []*llm.ChatResponse{
fileReadToolCallResponse("call_1", `{"path":"main.go"}`),
taskDoneResponse(),
}}
deps := newTestDeps(client)
runner := NewRunner(deps)
msgs := []llm.Message{llm.NewTextMessage("user", "review")}
err := runner.RunPerFile(context.Background(), msgs, "main.go")
if err != nil {
t.Fatalf("RunPerFile: %v", err)
}
if client.calls != 2 {
t.Errorf("expected 2 LLM calls, got %d", client.calls)
}
toolCalls := runner.ToolCalls()
if toolCalls["file_read"] != 1 {
t.Errorf("file_read calls = %d, want 1", toolCalls["file_read"])
}
if runner.TotalInputTokens() != 30 {
t.Errorf("TotalInputTokens = %d, want 30", runner.TotalInputTokens())
}
}
func TestRunPerFile_ContextCancelled(t *testing.T) {
client := &fakeClient{responses: []*llm.ChatResponse{taskDoneResponse()}}
deps := newTestDeps(client)
runner := NewRunner(deps)
ctx, cancel := context.WithCancel(context.Background())
cancel()
msgs := []llm.Message{llm.NewTextMessage("user", "review")}
err := runner.RunPerFile(ctx, msgs, "main.go")
if err == nil {
t.Error("expected error for cancelled context")
}
}
func TestRunPerFile_UnknownTool(t *testing.T) {
content := ""
unknownToolResp := &llm.ChatResponse{
Choices: []llm.Choice{{
Message: llm.ResponseMessage{
Content: &content,
ToolCalls: []llm.ToolCall{{
ID: "call_x",
Type: "function",
Function: llm.FunctionCall{
Name: "nonexistent_tool",
Arguments: `{}`,
},
}},
},
}},
Model: "fake",
Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 5},
}
client := &fakeClient{responses: []*llm.ChatResponse{unknownToolResp, taskDoneResponse()}}
deps := newTestDeps(client)
runner := NewRunner(deps)
msgs := []llm.Message{llm.NewTextMessage("user", "review")}
err := runner.RunPerFile(context.Background(), msgs, "main.go")
if err != nil {
t.Fatalf("RunPerFile: %v", err)
}
if client.calls != 2 {
t.Errorf("expected 2 calls, got %d", client.calls)
}
}
func TestRunner_RecordWarning(t *testing.T) {
deps := newTestDeps(&fakeClient{})
runner := NewRunner(deps)
runner.RecordWarning("token_limit", "a.go", "approaching token limit")
runner.RecordWarning("parse_error", "b.go", "invalid JSON")
warnings := runner.Warnings()
if len(warnings) != 2 {
t.Fatalf("expected 2 warnings, got %d", len(warnings))
}
if warnings[0].Type != "token_limit" {
t.Errorf("Type = %q", warnings[0].Type)
}
if warnings[1].File != "b.go" {
t.Errorf("File = %q", warnings[1].File)
}
}
func TestRunner_RecordUsage(t *testing.T) {
deps := newTestDeps(&fakeClient{})
runner := NewRunner(deps)
runner.RecordUsage(&llm.UsageInfo{
PromptTokens: 100,
CompletionTokens: 50,
CacheReadTokens: 20,
CacheWriteTokens: 10,
})
runner.RecordUsage(nil)
if runner.TotalInputTokens() != 100 {
t.Errorf("input = %d", runner.TotalInputTokens())
}
if runner.TotalOutputTokens() != 50 {
t.Errorf("output = %d", runner.TotalOutputTokens())
}
if runner.TotalCacheReadTokens() != 20 {
t.Errorf("cache read = %d", runner.TotalCacheReadTokens())
}
if runner.TotalCacheWriteTokens() != 10 {
t.Errorf("cache write = %d", runner.TotalCacheWriteTokens())
}
if runner.TotalTokensUsed() != 150 {
t.Errorf("total = %d", runner.TotalTokensUsed())
}
}
func TestExecuteToolCall_CodeCommentOverridesHallucinatedPath(t *testing.T) {
collector := tool.NewCommentCollector()
reg := tool.NewRegistry()

View file

@ -0,0 +1,99 @@
package llmloop
import (
"errors"
"sync/atomic"
"testing"
"github.com/open-code-review/open-code-review/internal/model"
)
func TestNewCommentWorkerPool_Default(t *testing.T) {
p := NewCommentWorkerPool(0)
if cap(p.semaphore) != 8 {
t.Errorf("default capacity = %d, want 8", cap(p.semaphore))
}
}
func TestNewCommentWorkerPool_Custom(t *testing.T) {
p := NewCommentWorkerPool(4)
if cap(p.semaphore) != 4 {
t.Errorf("capacity = %d, want 4", cap(p.semaphore))
}
}
func TestCommentWorkerPool_SubmitAndAwait(t *testing.T) {
p := NewCommentWorkerPool(2)
p.Submit(func() ([]model.LlmComment, error) {
return []model.LlmComment{{Path: "a.go", Content: "issue 1"}}, nil
})
p.Submit(func() ([]model.LlmComment, error) {
return []model.LlmComment{{Path: "b.go", Content: "issue 2"}, {Path: "b.go", Content: "issue 3"}}, nil
})
results := p.Await()
if len(results) != 3 {
t.Fatalf("expected 3 results, got %d", len(results))
}
paths := map[string]bool{}
for _, r := range results {
paths[r.Path] = true
}
if !paths["a.go"] || !paths["b.go"] {
t.Errorf("unexpected paths: %v", results)
}
}
func TestCommentWorkerPool_ErrorDoesNotBlock(t *testing.T) {
p := NewCommentWorkerPool(2)
p.Submit(func() ([]model.LlmComment, error) {
return nil, errors.New("oops")
})
p.Submit(func() ([]model.LlmComment, error) {
return []model.LlmComment{{Path: "ok.go", Content: "fine"}}, nil
})
results := p.Await()
if len(results) != 1 {
t.Fatalf("expected 1 result after error, got %d", len(results))
}
if results[0].Path != "ok.go" {
t.Errorf("Path = %q", results[0].Path)
}
}
func TestCommentWorkerPool_Concurrency(t *testing.T) {
p := NewCommentWorkerPool(3)
var running atomic.Int32
var maxRunning atomic.Int32
for i := 0; i < 10; i++ {
p.Submit(func() ([]model.LlmComment, error) {
cur := running.Add(1)
for {
old := maxRunning.Load()
if cur <= old || maxRunning.CompareAndSwap(old, cur) {
break
}
}
running.Add(-1)
return nil, nil
})
}
p.Await()
if maxRunning.Load() > 3 {
t.Errorf("max concurrent = %d, expected <= 3", maxRunning.Load())
}
}
func TestCommentWorkerPool_AwaitEmpty(t *testing.T) {
p := NewCommentWorkerPool(2)
results := p.Await()
if results != nil {
t.Errorf("expected nil for no submissions, got %v", results)
}
}

View file

@ -0,0 +1,214 @@
package session
import (
"errors"
"testing"
"time"
"github.com/open-code-review/open-code-review/internal/llm"
)
func TestNew(t *testing.T) {
sh := New("/tmp/repo", "main", "gpt-4", SessionOptions{
ReviewMode: ReviewModeWorkspace,
DiffFrom: "a",
DiffTo: "b",
DiffCommit: "c",
})
if sh == nil {
t.Fatal("New returned nil")
}
if sh.SessionID == "" {
t.Error("SessionID should not be empty")
}
if sh.RepoDir != "/tmp/repo" {
t.Errorf("RepoDir = %q", sh.RepoDir)
}
if sh.GitBranch != "main" {
t.Errorf("GitBranch = %q", sh.GitBranch)
}
if sh.Model != "gpt-4" {
t.Errorf("Model = %q", sh.Model)
}
if sh.ReviewMode != ReviewModeWorkspace {
t.Errorf("ReviewMode = %q", sh.ReviewMode)
}
if sh.DiffFrom != "a" || sh.DiffTo != "b" || sh.DiffCommit != "c" {
t.Errorf("Diff fields mismatch")
}
if sh.StartTime.IsZero() {
t.Error("StartTime should be set")
}
if sh.FileSessions == nil {
t.Error("FileSessions map should be initialized")
}
}
func TestGetOrCreateFileSession(t *testing.T) {
sh := New("/tmp/repo", "main", "model", SessionOptions{})
fs1 := sh.GetOrCreateFileSession("main.go")
if fs1 == nil {
t.Fatal("nil FileSession")
}
if fs1.FilePath != "main.go" {
t.Errorf("FilePath = %q", fs1.FilePath)
}
fs2 := sh.GetOrCreateFileSession("main.go")
if fs1 != fs2 {
t.Error("expected same FileSession instance on second call")
}
fs3 := sh.GetOrCreateFileSession("other.go")
if fs3 == fs1 {
t.Error("different paths should yield different sessions")
}
}
func TestAppendTaskRecord(t *testing.T) {
sh := New("/tmp/repo", "main", "model", SessionOptions{})
fs := sh.GetOrCreateFileSession("file.go")
msgs := []llm.Message{llm.NewTextMessage("user", "hello")}
rec := fs.AppendTaskRecord(MainTask, msgs)
if rec == nil {
t.Fatal("nil TaskRecord")
}
if rec.Type != MainTask {
t.Errorf("Type = %v", rec.Type)
}
if rec.RequestNo != 1 {
t.Errorf("RequestNo = %d, want 1", rec.RequestNo)
}
rec2 := fs.AppendTaskRecord(MainTask, msgs)
if rec2.RequestNo != 2 {
t.Errorf("second RequestNo = %d, want 2", rec2.RequestNo)
}
rec3 := fs.AppendTaskRecord(PlanTask, msgs)
if rec3.RequestNo != 1 {
t.Errorf("PlanTask RequestNo = %d, want 1 (separate counter)", rec3.RequestNo)
}
}
func TestAppendTaskRecord_DefensiveCopy(t *testing.T) {
sh := New("/tmp/repo", "main", "model", SessionOptions{})
fs := sh.GetOrCreateFileSession("file.go")
msgs := []llm.Message{llm.NewTextMessage("user", "original")}
rec := fs.AppendTaskRecord(MainTask, msgs)
msgs[0] = llm.NewTextMessage("user", "mutated")
if rec.RequestMessages[0].ExtractText() == "mutated" {
t.Error("AppendTaskRecord should store a copy of messages")
}
}
func TestSetResponse(t *testing.T) {
sh := New("/tmp/repo", "main", "model", SessionOptions{})
fs := sh.GetOrCreateFileSession("file.go")
rec := fs.AppendTaskRecord(MainTask, []llm.Message{llm.NewTextMessage("user", "hi")})
content := "response text"
resp := &llm.ChatResponse{
Choices: []llm.Choice{{
Message: llm.ResponseMessage{
Content: &content,
},
}},
Model: "gpt-4",
Usage: &llm.UsageInfo{
PromptTokens: 100,
CompletionTokens: 50,
},
}
rec.SetResponse(resp, 2*time.Second)
if rec.Response == nil {
t.Fatal("Response should be set")
}
if rec.Response.Content != "response text" {
t.Errorf("Content = %q", rec.Response.Content)
}
if rec.Response.Model != "gpt-4" {
t.Errorf("Model = %q", rec.Response.Model)
}
if rec.Response.Usage.PromptTokens != 100 {
t.Errorf("PromptTokens = %d", rec.Response.Usage.PromptTokens)
}
if rec.Response.Usage.CompletionTokens != 50 {
t.Errorf("CompletionTokens = %d", rec.Response.Usage.CompletionTokens)
}
if rec.Duration != 2*time.Second {
t.Errorf("Duration = %v", rec.Duration)
}
}
func TestSetResponse_EmptyResponse(t *testing.T) {
sh := New("/tmp/repo", "main", "model", SessionOptions{})
fs := sh.GetOrCreateFileSession("file.go")
rec := fs.AppendTaskRecord(MainTask, []llm.Message{llm.NewTextMessage("user", "hi")})
rec.SetResponse(nil, time.Second)
if rec.Error == "" {
t.Error("expected error for nil response")
}
}
func TestSetError(t *testing.T) {
sh := New("/tmp/repo", "main", "model", SessionOptions{})
fs := sh.GetOrCreateFileSession("file.go")
rec := fs.AppendTaskRecord(MainTask, []llm.Message{llm.NewTextMessage("user", "hi")})
rec.SetError(errors.New("timeout"), 5*time.Second)
if rec.Error != "timeout" {
t.Errorf("Error = %q, want %q", rec.Error, "timeout")
}
if rec.Duration != 5*time.Second {
t.Errorf("Duration = %v", rec.Duration)
}
}
func TestLLMFailures(t *testing.T) {
sh := New("/tmp/repo", "main", "model", SessionOptions{})
if sh.LLMFailures() != 0 {
t.Errorf("initial failures = %d", sh.LLMFailures())
}
fs := sh.GetOrCreateFileSession("a.go")
rec := fs.AppendTaskRecord(MainTask, nil)
rec.SetError(errors.New("fail1"), time.Second)
rec2 := fs.AppendTaskRecord(MainTask, nil)
rec2.SetError(errors.New("fail2"), time.Second)
if sh.LLMFailures() != 2 {
t.Errorf("failures = %d, want 2", sh.LLMFailures())
}
}
func TestAddToolResult(t *testing.T) {
sh := New("/tmp/repo", "main", "model", SessionOptions{})
fs := sh.GetOrCreateFileSession("file.go")
rec := fs.AppendTaskRecord(MainTask, nil)
rec.AddToolResult("file_read", `{"path":"main.go"}`, "package main")
if len(rec.ToolResults) != 1 {
t.Fatalf("len = %d", len(rec.ToolResults))
}
tr := rec.ToolResults[0]
if tr.ToolName != "file_read" {
t.Errorf("ToolName = %q", tr.ToolName)
}
if tr.Arguments != `{"path":"main.go"}` {
t.Errorf("Arguments = %q", tr.Arguments)
}
if tr.Result != "package main" {
t.Errorf("Result = %q", tr.Result)
}
}

View file

@ -0,0 +1,190 @@
package tool
import (
"context"
"testing"
)
func TestParseComments(t *testing.T) {
tests := []struct {
name string
args map[string]any
wantCount int
wantErr bool
}{
{
name: "valid comments array",
args: map[string]any{
"path": "main.go",
"comments": []any{
map[string]any{"content": "issue 1", "existing_code": "old"},
map[string]any{"content": "issue 2", "suggestion_code": "new"},
},
},
wantCount: 2,
},
{
name: "comments as JSON string",
args: map[string]any{
"path": "main.go",
"comments": `[{"content":"from string"}]`,
},
wantCount: 1,
},
{
name: "missing path skips comment",
args: map[string]any{
"comments": []any{
map[string]any{"content": "no path"},
},
},
wantCount: 0,
},
{
name: "missing content skips comment",
args: map[string]any{
"path": "file.go",
"comments": []any{
map[string]any{"existing_code": "has no content"},
},
},
wantCount: 0,
},
{
name: "empty comments array returns error",
args: map[string]any{"path": "x.go", "comments": []any{}},
wantErr: true,
},
{
name: "no comments key returns error",
args: map[string]any{"path": "x.go"},
wantErr: true,
},
{
name: "invalid JSON string returns error",
args: map[string]any{"path": "x.go", "comments": "not json"},
wantErr: true,
},
{
name: "thinking field preserved",
args: map[string]any{
"path": "a.go",
"comments": []any{
map[string]any{"content": "c", "thinking": "my reasoning"},
},
},
wantCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
comments, errMsg := ParseComments(tt.args)
if tt.wantErr {
if errMsg == "" {
t.Error("expected error message, got empty")
}
return
}
if errMsg != "" {
t.Fatalf("unexpected error: %s", errMsg)
}
if len(comments) != tt.wantCount {
t.Errorf("len(comments) = %d, want %d", len(comments), tt.wantCount)
}
})
}
}
func TestParseComments_Fields(t *testing.T) {
args := map[string]any{
"path": "src/app.ts",
"comments": []any{
map[string]any{
"content": "fix null check",
"existing_code": "if (x == null)",
"suggestion_code": "if (x === null)",
"thinking": "strict equality is safer",
},
},
}
comments, errMsg := ParseComments(args)
if errMsg != "" {
t.Fatal(errMsg)
}
if len(comments) != 1 {
t.Fatal("expected 1 comment")
}
c := comments[0]
if c.Path != "src/app.ts" {
t.Errorf("Path = %q", c.Path)
}
if c.Content != "fix null check" {
t.Errorf("Content = %q", c.Content)
}
if c.ExistingCode != "if (x == null)" {
t.Errorf("ExistingCode = %q", c.ExistingCode)
}
if c.SuggestionCode != "if (x === null)" {
t.Errorf("SuggestionCode = %q", c.SuggestionCode)
}
if c.Thinking != "strict equality is safer" {
t.Errorf("Thinking = %q", c.Thinking)
}
}
func TestCodeCommentProvider_Execute(t *testing.T) {
t.Run("adds comments to collector", func(t *testing.T) {
collector := NewCommentCollector()
p := &CodeCommentProvider{Collector: collector}
result, err := p.Execute(context.Background(), map[string]any{
"path": "main.go",
"comments": []any{
map[string]any{"content": "issue 1"},
map[string]any{"content": "issue 2"},
},
})
if err != nil {
t.Fatal(err)
}
if result != CommentSucceed {
t.Errorf("result = %q, want %q", result, CommentSucceed)
}
if len(collector.Comments()) != 2 {
t.Errorf("collector has %d comments, want 2", len(collector.Comments()))
}
})
t.Run("nil collector returns error message", func(t *testing.T) {
p := &CodeCommentProvider{Collector: nil}
result, err := p.Execute(context.Background(), map[string]any{
"path": "main.go",
"comments": []any{map[string]any{"content": "x"}},
})
if err != nil {
t.Fatal(err)
}
if result == CommentSucceed {
t.Error("expected error message for nil collector")
}
})
t.Run("invalid args returns error message", func(t *testing.T) {
collector := NewCommentCollector()
p := &CodeCommentProvider{Collector: collector}
result, err := p.Execute(context.Background(), map[string]any{})
if err != nil {
t.Fatal(err)
}
if result == CommentSucceed {
t.Error("expected error message for empty args")
}
})
t.Run("tool type is CodeComment", func(t *testing.T) {
p := &CodeCommentProvider{}
if p.Tool() != CodeComment {
t.Errorf("Tool() = %v, want CodeComment", p.Tool())
}
})
}

View file

@ -0,0 +1,130 @@
package tool
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestFileReader_Read_Workspace(t *testing.T) {
dir := t.TempDir()
content := "line1\nline2\nline3\n"
if err := os.WriteFile(filepath.Join(dir, "test.go"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
got, err := fr.Read(context.Background(), "test.go")
if err != nil {
t.Fatalf("Read() error: %v", err)
}
if got != content {
t.Errorf("Read() = %q, want %q", got, content)
}
}
func TestFileReader_Read_WorkspaceNotFound(t *testing.T) {
dir := t.TempDir()
fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
_, err := fr.Read(context.Background(), "missing.go")
if err == nil {
t.Error("expected error for missing file")
}
}
func TestFileReader_Read_PathTraversal(t *testing.T) {
dir := t.TempDir()
fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
_, err := fr.Read(context.Background(), "../../../etc/passwd")
if err == nil {
t.Error("expected error for path traversal")
}
}
func TestFileReader_Read_SymlinkOutsideRepo(t *testing.T) {
dir := t.TempDir()
outside := t.TempDir()
secretFile := filepath.Join(outside, "secret.txt")
if err := os.WriteFile(secretFile, []byte("sensitive"), 0644); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "link.txt")
if err := os.Symlink(secretFile, link); err != nil {
t.Skipf("symlinks not supported: %v", err)
}
fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
_, err := fr.Read(context.Background(), "link.txt")
if err == nil {
t.Error("expected error for symlink pointing outside repo")
}
}
func TestFileReader_ReadLines_Workspace(t *testing.T) {
dir := t.TempDir()
content := "aaa\nbbb\nccc\nddd\n"
if err := os.WriteFile(filepath.Join(dir, "lines.txt"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
t.Run("all lines", func(t *testing.T) {
lines, total, err := fr.ReadLines(context.Background(), "lines.txt", 1, 100)
if err != nil {
t.Fatal(err)
}
if total != 5 {
t.Errorf("total = %d, want 5", total)
}
if len(lines) != 5 {
t.Errorf("lines count = %d, want 5", len(lines))
}
})
t.Run("start from line 2 with limit", func(t *testing.T) {
lines, total, err := fr.ReadLines(context.Background(), "lines.txt", 2, 2)
if err != nil {
t.Fatal(err)
}
if total != 5 {
t.Errorf("total = %d, want 5", total)
}
if len(lines) != 2 {
t.Fatalf("lines count = %d, want 2", len(lines))
}
if lines[0] != "bbb" || lines[1] != "ccc" {
t.Errorf("lines = %v, want [bbb ccc]", lines)
}
})
t.Run("path traversal rejected", func(t *testing.T) {
_, _, err := fr.ReadLines(context.Background(), "../../etc/passwd", 1, 10)
if err == nil {
t.Error("expected error for path traversal")
}
})
}
func TestFileReader_Read_SubdirectoryFile(t *testing.T) {
dir := t.TempDir()
sub := filepath.Join(dir, "src", "pkg")
if err := os.MkdirAll(sub, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "main.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
fr := &FileReader{RepoDir: dir, Mode: ModeWorkspace}
got, err := fr.Read(context.Background(), "src/pkg/main.go")
if err != nil {
t.Fatalf("Read() error: %v", err)
}
if got != "package main" {
t.Errorf("Read() = %q, want %q", got, "package main")
}
}

View file

@ -0,0 +1,241 @@
package viewer
import (
"os"
"path/filepath"
"testing"
"time"
)
func writeJSONL(t *testing.T, path string, lines ...string) {
t.Helper()
var content string
for _, l := range lines {
content += l + "\n"
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func TestDiscoverRepos_Empty(t *testing.T) {
root := t.TempDir()
repos, err := DiscoverRepos(root)
if err != nil {
t.Fatal(err)
}
if len(repos) != 0 {
t.Errorf("expected 0 repos, got %d", len(repos))
}
}
func TestDiscoverRepos_NonExistentDir(t *testing.T) {
repos, err := DiscoverRepos("/nonexistent/path/abc123")
if err != nil {
t.Fatal(err)
}
if repos != nil {
t.Errorf("expected nil for non-existent dir, got %v", repos)
}
}
func TestDiscoverRepos_SkipsFiles(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "stray.txt"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
repos, err := DiscoverRepos(root)
if err != nil {
t.Fatal(err)
}
if len(repos) != 0 {
t.Errorf("expected 0 repos, got %d", len(repos))
}
}
func TestDiscoverRepos_FindsRepos(t *testing.T) {
root := t.TempDir()
repoA := filepath.Join(root, "repo-a")
repoB := filepath.Join(root, "repo-b")
if err := os.MkdirAll(repoA, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(repoB, 0755); err != nil {
t.Fatal(err)
}
writeJSONL(t, filepath.Join(repoA, "session1.jsonl"),
`{"type":"session_start","timestamp":"2025-01-01T10:00:00Z"}`)
writeJSONL(t, filepath.Join(repoA, "session2.jsonl"),
`{"type":"session_start","timestamp":"2025-01-02T10:00:00Z"}`)
writeJSONL(t, filepath.Join(repoB, "session3.jsonl"),
`{"type":"session_start","timestamp":"2025-01-03T10:00:00Z"}`)
repos, err := DiscoverRepos(root)
if err != nil {
t.Fatal(err)
}
if len(repos) != 2 {
t.Fatalf("expected 2 repos, got %d", len(repos))
}
if repos[0].EncodedPath != "repo-b" {
t.Errorf("expected most recent repo first, got %q", repos[0].EncodedPath)
}
if repos[1].SessionCount != 2 {
t.Errorf("repo-a session count = %d, want 2", repos[1].SessionCount)
}
}
func TestDiscoverRepos_SkipsDirsWithNoJSONL(t *testing.T) {
root := t.TempDir()
emptyRepo := filepath.Join(root, "empty-repo")
if err := os.MkdirAll(emptyRepo, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(emptyRepo, "readme.txt"), []byte("hi"), 0644); err != nil {
t.Fatal(err)
}
repos, err := DiscoverRepos(root)
if err != nil {
t.Fatal(err)
}
if len(repos) != 0 {
t.Errorf("expected 0 repos for dir with no .jsonl, got %d", len(repos))
}
}
func TestListSessions(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "myrepo")
if err := os.MkdirAll(repoDir, 0755); err != nil {
t.Fatal(err)
}
writeJSONL(t, filepath.Join(repoDir, "aaa.jsonl"),
`{"type":"session_start","timestamp":"2025-03-01T09:00:00Z","cwd":"/home/user/proj","gitBranch":"main","model":"gpt-4","reviewMode":"workspace"}`,
`{"type":"session_end","duration_seconds":120.5,"files_reviewed":["a.go","b.go"],"llm_failures":1}`)
writeJSONL(t, filepath.Join(repoDir, "bbb.jsonl"),
`{"type":"session_start","timestamp":"2025-03-02T10:00:00Z","cwd":"/home/user/proj","gitBranch":"feat","model":"claude","reviewMode":"commit","diffCommit":"abc123"}`,
`{"type":"session_end","duration_seconds":60.0,"files_reviewed":["c.go"],"llm_failures":0}`)
// Non-jsonl file should be skipped
if err := os.WriteFile(filepath.Join(repoDir, "notes.txt"), []byte("ignored"), 0644); err != nil {
t.Fatal(err)
}
sessions, err := ListSessions(root, "myrepo")
if err != nil {
t.Fatal(err)
}
if len(sessions) != 2 {
t.Fatalf("expected 2 sessions, got %d", len(sessions))
}
// Should be sorted newest first
if sessions[0].SessionID != "bbb" {
t.Errorf("expected newest session first, got %q", sessions[0].SessionID)
}
if sessions[0].Model != "claude" {
t.Errorf("Model = %q", sessions[0].Model)
}
if sessions[0].ReviewMode != "commit" {
t.Errorf("ReviewMode = %q", sessions[0].ReviewMode)
}
if sessions[0].DiffCommit != "abc123" {
t.Errorf("DiffCommit = %q", sessions[0].DiffCommit)
}
if sessions[0].DurationSec != 60.0 {
t.Errorf("DurationSec = %f", sessions[0].DurationSec)
}
if sessions[0].FileCount != 1 {
t.Errorf("FileCount = %d", sessions[0].FileCount)
}
if sessions[1].SessionID != "aaa" {
t.Errorf("second session = %q", sessions[1].SessionID)
}
if sessions[1].LLMFailures != 1 {
t.Errorf("LLMFailures = %d", sessions[1].LLMFailures)
}
}
func TestPeekSession(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.jsonl")
writeJSONL(t, path,
`{"type":"session_start","timestamp":"2025-06-15T14:30:00Z","cwd":"/repo","gitBranch":"dev","model":"gpt-4o","reviewMode":"range","diffFrom":"a1b2","diffTo":"c3d4"}`,
`{"type":"llm_request","filePath":"main.go","taskType":"main_task","request_no":1}`,
`{"type":"llm_response","filePath":"main.go","taskType":"main_task","content":"looks good"}`,
`{"type":"session_end","duration_seconds":45.2,"files_reviewed":["main.go","util.go"],"llm_failures":2}`)
s, err := peekSession(path)
if err != nil {
t.Fatal(err)
}
expected := time.Date(2025, 6, 15, 14, 30, 0, 0, time.UTC)
if !s.Timestamp.Equal(expected) {
t.Errorf("Timestamp = %v, want %v", s.Timestamp, expected)
}
if s.CWD != "/repo" {
t.Errorf("CWD = %q", s.CWD)
}
if s.GitBranch != "dev" {
t.Errorf("GitBranch = %q", s.GitBranch)
}
if s.Model != "gpt-4o" {
t.Errorf("Model = %q", s.Model)
}
if s.ReviewMode != "range" {
t.Errorf("ReviewMode = %q", s.ReviewMode)
}
if s.DiffFrom != "a1b2" {
t.Errorf("DiffFrom = %q", s.DiffFrom)
}
if s.DiffTo != "c3d4" {
t.Errorf("DiffTo = %q", s.DiffTo)
}
if s.DurationSec != 45.2 {
t.Errorf("DurationSec = %f", s.DurationSec)
}
if len(s.FilesReviewed) != 2 {
t.Errorf("FilesReviewed = %v", s.FilesReviewed)
}
if s.LLMFailures != 2 {
t.Errorf("LLMFailures = %d", s.LLMFailures)
}
if s.FileCount != 2 {
t.Errorf("FileCount = %d", s.FileCount)
}
}
func TestPeekSession_MissingFile(t *testing.T) {
_, err := peekSession("/nonexistent/path/session.jsonl")
if err == nil {
t.Error("expected error for missing file")
}
}
func TestPeekSession_NoSessionEnd(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "partial.jsonl")
writeJSONL(t, path,
`{"type":"session_start","timestamp":"2025-01-01T00:00:00Z","cwd":"/x","model":"m"}`)
s, err := peekSession(path)
if err != nil {
t.Fatal(err)
}
if s.CWD != "/x" {
t.Errorf("CWD = %q", s.CWD)
}
if s.DurationSec != 0 {
t.Errorf("DurationSec should be 0 without session_end, got %f", s.DurationSec)
}
if s.FileCount != 0 {
t.Errorf("FileCount should be 0 without session_end, got %d", s.FileCount)
}
}