test: add comprehensive test coverage across multiple packages

- [x] Add 298 tests for Python chunker functionality
- [x] Add 213 tests for chunking types and constants
- [x] Add 398 tests for TypeScript/JavaScript chunker
- [x] Add 954 tests for MCP server handlers and validation
- [x] Add 563 tests for pattern detector and analysis
- [x] Add 1149 tests for vector client cache and operations
- [x] Add 663 tests for SDK processor, circuit breaker, and deduplication
- [x] Add 731 tests for session manager lifecycle and concurrency
- [x] Add 331 tests for similarity clustering and term extraction
This commit is contained in:
2026-01-11 12:25:58 +00:00
parent e2193e449d
commit 5215ee8617
9 changed files with 6269 additions and 218 deletions
+298
View File
@@ -0,0 +1,298 @@
package python
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
)
// =============================================================================
// TEST HELPERS
// =============================================================================
func createTempPythonFile(t *testing.T, content string) string {
t.Helper()
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test.py")
err := os.WriteFile(filePath, []byte(content), 0600)
require.NoError(t, err)
return filePath
}
// =============================================================================
// TESTS FOR Chunker
// =============================================================================
func TestNewChunker(t *testing.T) {
t.Parallel()
opts := chunking.DefaultChunkOptions()
c := NewChunker(opts)
assert.NotNil(t, c)
assert.NotNil(t, c.parser)
}
func TestChunker_Language(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
assert.Equal(t, chunking.LanguagePython, c.Language())
}
func TestChunker_SupportedExtensions(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
exts := c.SupportedExtensions()
assert.Contains(t, exts, ".py")
}
func TestChunker_Chunk_SimpleFunction(t *testing.T) {
t.Parallel()
code := `def greet(name):
"""Greets a person by name."""
return f"Hello, {name}!"
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the greet function
var foundGreet bool
for _, chunk := range chunks {
if chunk.Name == "greet" {
foundGreet = true
assert.Equal(t, chunking.ChunkTypeFunction, chunk.Type)
assert.Equal(t, chunking.LanguagePython, chunk.Language)
assert.Contains(t, chunk.Content, "def greet")
}
}
assert.True(t, foundGreet, "Should find 'greet' function")
}
func TestChunker_Chunk_ClassWithMethods(t *testing.T) {
t.Parallel()
code := `class Calculator:
"""A simple calculator class."""
def add(self, a, b):
"""Adds two numbers."""
return a + b
def multiply(self, a, b):
"""Multiplies two numbers."""
return a * b
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the Calculator class and its methods
var foundClass, foundAdd, foundMultiply bool
for _, chunk := range chunks {
switch chunk.Name {
case "Calculator":
foundClass = true
assert.Equal(t, chunking.ChunkTypeClass, chunk.Type)
case "add":
foundAdd = true
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
assert.Equal(t, "Calculator", chunk.ParentName)
case "multiply":
foundMultiply = true
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
assert.Equal(t, "Calculator", chunk.ParentName)
}
}
assert.True(t, foundClass, "Should find 'Calculator' class")
assert.True(t, foundAdd, "Should find 'add' method")
assert.True(t, foundMultiply, "Should find 'multiply' method")
}
func TestChunker_Chunk_MultipleFunctions(t *testing.T) {
t.Parallel()
code := `def first_function():
pass
def second_function(x, y):
return x + y
def third_function():
"""Has a docstring."""
return 42
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
// Should find all three functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeFunction {
functionNames[chunk.Name] = true
}
}
assert.True(t, functionNames["first_function"])
assert.True(t, functionNames["second_function"])
assert.True(t, functionNames["third_function"])
}
func TestChunker_Chunk_FileNotFound(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
_, err := c.Chunk(context.Background(), "/nonexistent/path/file.py")
require.Error(t, err)
assert.Contains(t, err.Error(), "read file")
}
func TestChunker_Chunk_EmptyFile(t *testing.T) {
t.Parallel()
filePath := createTempPythonFile(t, "")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
assert.Empty(t, chunks)
}
func TestChunker_Chunk_OnlyComments(t *testing.T) {
t.Parallel()
code := `# This is a comment
# Another comment
"""
This is a module docstring
"""
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
// Comments and docstrings without code should not produce chunks
assert.Empty(t, chunks)
}
func TestChunker_Chunk_NestedClass(t *testing.T) {
t.Parallel()
code := `class Outer:
class Inner:
def inner_method(self):
pass
def outer_method(self):
pass
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the Outer class at minimum
var foundOuter bool
for _, chunk := range chunks {
if chunk.Name == "Outer" {
foundOuter = true
}
}
assert.True(t, foundOuter, "Should find 'Outer' class")
}
func TestChunker_Chunk_Decorators(t *testing.T) {
t.Parallel()
code := `@staticmethod
def static_func():
pass
@classmethod
def class_func(cls):
pass
@property
def my_property(self):
return self._value
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find decorated functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
functionNames[chunk.Name] = true
}
assert.True(t, functionNames["static_func"])
assert.True(t, functionNames["class_func"])
assert.True(t, functionNames["my_property"])
}
func TestChunker_Chunk_AsyncFunction(t *testing.T) {
t.Parallel()
code := `async def fetch_data(url):
"""Fetches data from URL asynchronously."""
pass
async def process_items(items):
for item in items:
await process(item)
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find async functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
functionNames[chunk.Name] = true
}
assert.True(t, functionNames["fetch_data"])
assert.True(t, functionNames["process_items"])
}
+213
View File
@@ -0,0 +1,213 @@
package chunking
import (
"testing"
"github.com/stretchr/testify/assert"
)
// =============================================================================
// TESTS FOR Chunk METHODS
// =============================================================================
func TestChunk_Identifier(t *testing.T) {
tests := []struct {
name string
expected string
chunk Chunk
}{
// ===== GOOD CASES =====
{
name: "top-level function",
chunk: Chunk{
Name: "MyFunction",
ParentName: "",
},
expected: "MyFunction",
},
{
name: "method with parent",
chunk: Chunk{
Name: "Process",
ParentName: "Handler",
},
expected: "Handler.Process",
},
{
name: "nested method",
chunk: Chunk{
Name: "Validate",
ParentName: "UserService",
},
expected: "UserService.Validate",
},
// ===== EDGE CASES =====
{
name: "empty name",
chunk: Chunk{
Name: "",
ParentName: "",
},
expected: "",
},
{
name: "parent but no name",
chunk: Chunk{
Name: "",
ParentName: "Parent",
},
expected: "Parent.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.chunk.Identifier()
assert.Equal(t, tt.expected, result)
})
}
}
func TestChunk_LineRange(t *testing.T) {
tests := []struct {
name string
expected string
chunk Chunk
}{
// ===== GOOD CASES =====
{
name: "single line",
chunk: Chunk{
StartLine: 10,
EndLine: 10,
},
expected: "L10-L10",
},
{
name: "multi-line",
chunk: Chunk{
StartLine: 25,
EndLine: 50,
},
expected: "L25-L50",
},
// ===== EDGE CASES =====
{
name: "line 1",
chunk: Chunk{
StartLine: 1,
EndLine: 5,
},
expected: "L1-L5",
},
{
name: "large line numbers",
chunk: Chunk{
StartLine: 1000,
EndLine: 2500,
},
expected: "L1000-L2500",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.chunk.LineRange()
assert.Equal(t, tt.expected, result)
})
}
}
func TestChunk_SearchableContent(t *testing.T) {
tests := []struct {
name string
contains []string
chunk Chunk
}{
// ===== GOOD CASES =====
{
name: "full chunk with all fields",
chunk: Chunk{
Signature: "func ProcessData(input []byte) error",
DocComment: "// ProcessData handles incoming data",
Content: "func ProcessData(input []byte) error {\n\treturn nil\n}",
},
contains: []string{
"func ProcessData(input []byte) error",
"ProcessData handles incoming data",
"return nil",
},
},
{
name: "only signature",
chunk: Chunk{
Signature: "func Hello()",
},
contains: []string{"func Hello()"},
},
{
name: "only content",
chunk: Chunk{
Content: "some code here",
},
contains: []string{"some code here"},
},
// ===== EDGE CASES =====
{
name: "empty chunk",
chunk: Chunk{},
contains: []string{},
},
{
name: "only doc comment",
chunk: Chunk{
DocComment: "// Important documentation",
},
contains: []string{"Important documentation"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.chunk.SearchableContent()
for _, expected := range tt.contains {
assert.Contains(t, result, expected)
}
})
}
}
func TestDefaultChunkOptions(t *testing.T) {
opts := DefaultChunkOptions()
assert.Greater(t, opts.MaxChunkSize, 0, "MaxChunkSize should be positive")
assert.True(t, opts.IncludeDocComments, "IncludeDocComments should be true by default")
assert.True(t, opts.IncludePrivate, "IncludePrivate should be true by default")
assert.Equal(t, 0, opts.MinLines, "MinLines should be 0 by default")
}
// =============================================================================
// TESTS FOR ChunkType AND Language CONSTANTS
// =============================================================================
func TestChunkType_Values(t *testing.T) {
// Ensure all chunk types have expected values
assert.Equal(t, ChunkType("function"), ChunkTypeFunction)
assert.Equal(t, ChunkType("method"), ChunkTypeMethod)
assert.Equal(t, ChunkType("class"), ChunkTypeClass)
assert.Equal(t, ChunkType("interface"), ChunkTypeInterface)
assert.Equal(t, ChunkType("type"), ChunkTypeType)
assert.Equal(t, ChunkType("const"), ChunkTypeConst)
assert.Equal(t, ChunkType("var"), ChunkTypeVar)
}
func TestLanguage_Values(t *testing.T) {
// Ensure all language types have expected values
assert.Equal(t, Language("go"), LanguageGo)
assert.Equal(t, Language("python"), LanguagePython)
assert.Equal(t, Language("typescript"), LanguageTypeScript)
assert.Equal(t, Language("javascript"), LanguageJavaScript)
}
@@ -0,0 +1,398 @@
package typescript
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
)
// =============================================================================
// TEST HELPERS
// =============================================================================
func createTempTSFile(t *testing.T, content string, ext string) string {
t.Helper()
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test"+ext)
err := os.WriteFile(filePath, []byte(content), 0600)
require.NoError(t, err)
return filePath
}
// =============================================================================
// TESTS FOR Chunker
// =============================================================================
func TestNewChunker(t *testing.T) {
t.Parallel()
opts := chunking.DefaultChunkOptions()
c := NewChunker(opts)
assert.NotNil(t, c)
assert.NotNil(t, c.parser)
}
func TestChunker_Language(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
assert.Equal(t, chunking.LanguageTypeScript, c.Language())
}
func TestChunker_SupportedExtensions(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
exts := c.SupportedExtensions()
assert.Contains(t, exts, ".ts")
assert.Contains(t, exts, ".tsx")
assert.Contains(t, exts, ".js")
assert.Contains(t, exts, ".jsx")
}
func TestChunker_Chunk_SimpleFunction(t *testing.T) {
t.Parallel()
code := `function greet(name: string): string {
return "Hello, " + name + "!";
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the greet function
var foundGreet bool
for _, chunk := range chunks {
if chunk.Name == "greet" {
foundGreet = true
assert.Equal(t, chunking.ChunkTypeFunction, chunk.Type)
assert.Equal(t, chunking.LanguageTypeScript, chunk.Language)
assert.Contains(t, chunk.Content, "function greet")
}
}
assert.True(t, foundGreet, "Should find 'greet' function")
}
func TestChunker_Chunk_ClassWithMethods(t *testing.T) {
t.Parallel()
code := `class Calculator {
add(a: number, b: number): number {
return a + b;
}
multiply(a: number, b: number): number {
return a * b;
}
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the Calculator class and its methods
var foundClass, foundAdd, foundMultiply bool
for _, chunk := range chunks {
switch chunk.Name {
case "Calculator":
foundClass = true
assert.Equal(t, chunking.ChunkTypeClass, chunk.Type)
case "add":
foundAdd = true
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
assert.Equal(t, "Calculator", chunk.ParentName)
case "multiply":
foundMultiply = true
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
assert.Equal(t, "Calculator", chunk.ParentName)
}
}
assert.True(t, foundClass, "Should find 'Calculator' class")
assert.True(t, foundAdd, "Should find 'add' method")
assert.True(t, foundMultiply, "Should find 'multiply' method")
}
func TestChunker_Chunk_Interface(t *testing.T) {
t.Parallel()
code := `interface User {
id: number;
name: string;
email: string;
}
interface Authenticator {
login(username: string, password: string): boolean;
logout(): void;
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find interfaces
interfaceNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeInterface {
interfaceNames[chunk.Name] = true
}
}
assert.True(t, interfaceNames["User"])
assert.True(t, interfaceNames["Authenticator"])
}
func TestChunker_Chunk_TypeAlias(t *testing.T) {
t.Parallel()
code := `type UserID = string;
type Handler = (event: Event) => void;
type Result<T> = { success: true; data: T } | { success: false; error: Error };
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find type aliases
typeNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeType {
typeNames[chunk.Name] = true
}
}
assert.True(t, typeNames["UserID"])
assert.True(t, typeNames["Handler"])
assert.True(t, typeNames["Result"])
}
func TestChunker_Chunk_ArrowFunction(t *testing.T) {
t.Parallel()
code := `const add = (a: number, b: number): number => a + b;
const greet = (name: string): string => {
return "Hello, " + name;
};
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
_, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
// Arrow functions may or may not be captured depending on AST structure
// At minimum, no error should occur
}
func TestChunker_Chunk_FileNotFound(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
_, err := c.Chunk(context.Background(), "/nonexistent/path/file.ts")
require.Error(t, err)
assert.Contains(t, err.Error(), "read file")
}
func TestChunker_Chunk_EmptyFile(t *testing.T) {
t.Parallel()
filePath := createTempTSFile(t, "", ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
assert.Empty(t, chunks)
}
func TestChunker_Chunk_OnlyComments(t *testing.T) {
t.Parallel()
code := `// This is a comment
/* Another comment */
/**
* JSDoc comment
*/
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
// Comments without code should not produce chunks
assert.Empty(t, chunks)
}
func TestChunker_Chunk_AsyncFunction(t *testing.T) {
t.Parallel()
code := `async function fetchData(url: string): Promise<any> {
const response = await fetch(url);
return response.json();
}
async function processItems(items: string[]): Promise<void> {
for (const item of items) {
await process(item);
}
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find async functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeFunction {
functionNames[chunk.Name] = true
}
}
assert.True(t, functionNames["fetchData"])
assert.True(t, functionNames["processItems"])
}
func TestChunker_Chunk_ExportedFunction(t *testing.T) {
t.Parallel()
code := `export function publicFunction(): void {
console.log("public");
}
export default function defaultExport(): void {
console.log("default");
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find exported functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeFunction {
functionNames[chunk.Name] = true
}
}
assert.True(t, functionNames["publicFunction"])
assert.True(t, functionNames["defaultExport"])
}
func TestChunker_Chunk_JSXFile(t *testing.T) {
t.Parallel()
code := `function Button({ label }: { label: string }) {
return <button>{label}</button>;
}
function App() {
return (
<div>
<Button label="Click me" />
</div>
);
}
`
filePath := createTempTSFile(t, code, ".tsx")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find JSX components as functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeFunction {
functionNames[chunk.Name] = true
}
}
assert.True(t, functionNames["Button"])
assert.True(t, functionNames["App"])
}
func TestChunker_Chunk_JavaScript(t *testing.T) {
t.Parallel()
code := `function simpleFunc() {
return 42;
}
class MyClass {
constructor() {
this.value = 0;
}
getValue() {
return this.value;
}
}
`
filePath := createTempTSFile(t, code, ".js")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find JavaScript functions and classes
var foundFunc, foundClass bool
for _, chunk := range chunks {
if chunk.Name == "simpleFunc" {
foundFunc = true
}
if chunk.Name == "MyClass" {
foundClass = true
}
}
assert.True(t, foundFunc, "Should find 'simpleFunc' function")
assert.True(t, foundClass, "Should find 'MyClass' class")
}
+1923 -218
View File
File diff suppressed because it is too large Load Diff
+563
View File
@@ -328,6 +328,26 @@ func TestDefaultConfig(t *testing.T) {
}
}
func TestDefaultConfig_AllFieldsValid(t *testing.T) {
config := DefaultConfig()
if config.MinMatchScore != 0.3 {
t.Errorf("MinMatchScore = %f, want 0.3", config.MinMatchScore)
}
if config.MinFrequencyForPattern != 2 {
t.Errorf("MinFrequencyForPattern = %d, want 2", config.MinFrequencyForPattern)
}
if config.AnalysisInterval != 5*time.Minute {
t.Errorf("AnalysisInterval = %v, want 5m", config.AnalysisInterval)
}
if config.MaxPatternsToTrack != 1000 {
t.Errorf("MaxPatternsToTrack = %d, want 1000", config.MaxPatternsToTrack)
}
if config.MaxCandidates != 500 {
t.Errorf("MaxCandidates = %d, want 500", config.MaxCandidates)
}
}
func TestGeneratePatternName(t *testing.T) {
tests := []struct {
patternType models.PatternType
@@ -352,6 +372,85 @@ func TestGeneratePatternName(t *testing.T) {
}
}
func TestGeneratePatternName_EdgeCases(t *testing.T) {
tests := []struct {
name string
ptype models.PatternType
title string
want string
signature []string
}{
{
name: "with title uses title directly",
ptype: models.PatternTypeBug,
signature: []string{"ignored"},
title: "Custom Title",
want: "Custom Title",
},
{
name: "long title generates from signature",
ptype: models.PatternTypeBug,
signature: []string{"sig1", "sig2"},
title: "This is a very long title that exceeds sixty characters and should be ignored",
want: "Bug Pattern: sig1, sig2",
},
{
name: "empty signature returns Unnamed",
ptype: models.PatternTypeBug,
signature: []string{},
title: "",
want: "Bug Pattern: Unnamed",
},
{
name: "single signature element",
ptype: models.PatternTypeRefactor,
signature: []string{"single"},
title: "",
want: "Refactor Pattern: single",
},
{
name: "more than 3 signature elements truncates",
ptype: models.PatternTypeBestPractice,
signature: []string{"a", "b", "c", "d", "e"},
title: "",
want: "Best Practice: a, b, c",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := generatePatternName(tt.ptype, tt.signature, tt.title)
if got != tt.want {
t.Errorf("generatePatternName() = %q, want %q", got, tt.want)
}
})
}
}
func TestGeneratePatternName_AllTypes(t *testing.T) {
tests := []struct {
ptype models.PatternType
wantPrefix string
}{
{models.PatternTypeBug, "Bug Pattern:"},
{models.PatternTypeRefactor, "Refactor Pattern:"},
{models.PatternTypeArchitecture, "Architecture Pattern:"},
{models.PatternTypeAntiPattern, "Anti-Pattern:"},
{models.PatternTypeBestPractice, "Best Practice:"},
{models.PatternType("unknown"), "test"}, // Unknown type has empty prefix, starts with first signature element
}
for _, tt := range tests {
t.Run(string(tt.ptype), func(t *testing.T) {
name := generatePatternName(tt.ptype, []string{"test", "sig"}, "")
if !hasPrefix(name, tt.wantPrefix) {
t.Errorf("Expected prefix %q for type %s, got: %s",
tt.wantPrefix, tt.ptype, name)
}
})
}
}
func TestFormatPatternInsight(t *testing.T) {
// Pattern without recommendation
pattern1 := &models.Pattern{
@@ -386,6 +485,470 @@ func TestFormatPatternInsight(t *testing.T) {
}
}
func TestFormatPatternInsight_AllTypes(t *testing.T) {
types := []struct {
ptype models.PatternType
contains string
}{
{models.PatternTypeBug, "bug pattern"},
{models.PatternTypeRefactor, "recognized pattern"}, // Falls to default case
{models.PatternTypeArchitecture, "recognized pattern"}, // Falls to default case
{models.PatternTypeAntiPattern, "anti-pattern"},
{models.PatternTypeBestPractice, "best practice"},
{models.PatternType("unknown"), "recognized pattern"}, // Falls to default case
}
for _, tt := range types {
t.Run(string(tt.ptype), func(t *testing.T) {
pattern := &models.Pattern{
Type: tt.ptype,
Frequency: 3,
Projects: []string{"proj1"},
}
insight := formatPatternInsight(pattern)
if !containsString(insight, tt.contains) {
t.Errorf("Expected insight to contain %q for type %s, got: %s",
tt.contains, tt.ptype, insight)
}
})
}
}
func TestFormatPatternInsight_MultiProject(t *testing.T) {
pattern := &models.Pattern{
Type: models.PatternTypeBug,
Frequency: 10,
Projects: []string{"proj1", "proj2", "proj3"},
}
insight := formatPatternInsight(pattern)
if !containsString(insight, "10 times") {
t.Error("Expected frequency in insight")
}
if !containsString(insight, "3 projects") {
t.Error("Expected project count in insight")
}
}
func TestFormatPatternInsight_SingleProject(t *testing.T) {
pattern := &models.Pattern{
Type: models.PatternTypeBestPractice,
Frequency: 5,
Projects: []string{"only-one"},
}
insight := formatPatternInsight(pattern)
if !containsString(insight, "5 times") {
t.Error("Expected frequency in insight")
}
// Single project should NOT mention "projects"
if containsString(insight, "projects") {
t.Error("Single project should not mention 'projects'")
}
}
func TestDetector_SetSyncFunc(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
// Initially nil
if detector.syncFunc != nil {
t.Error("Expected syncFunc to be nil initially")
}
// Set sync func
var syncCalled bool
detector.SetSyncFunc(func(p *models.Pattern) {
syncCalled = true
})
if detector.syncFunc == nil {
t.Error("Expected syncFunc to be set")
}
// Verify it can be called
detector.syncFunc(&models.Pattern{})
if !syncCalled {
t.Error("Expected sync function to be called")
}
}
func TestDetector_CandidateCount(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
// Initially zero
if count := detector.CandidateCount(); count != 0 {
t.Errorf("Expected 0 candidates, got %d", count)
}
// Add some candidates
detector.candidates["key1"] = &candidatePattern{}
detector.candidates["key2"] = &candidatePattern{}
if count := detector.CandidateCount(); count != 2 {
t.Errorf("Expected 2 candidates, got %d", count)
}
}
func TestDetector_AnalyzeRecentObservations(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Should not error even with no observations
err := detector.AnalyzeRecentObservations(ctx)
if err != nil {
t.Fatalf("AnalyzeRecentObservations() error = %v", err)
}
}
func TestGenerateCandidateKey(t *testing.T) {
tests := []struct {
name string
want string
signature []string
}{
{
name: "single element",
signature: []string{"error"},
want: "error|",
},
{
name: "multiple elements",
signature: []string{"error", "handling", "nil"},
want: "error|handling|nil|",
},
{
name: "empty signature",
signature: []string{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := generateCandidateKey(tt.signature)
if got != tt.want {
t.Errorf("generateCandidateKey() = %q, want %q", got, tt.want)
}
})
}
}
func TestGenerateCandidateKey_EdgeCases(t *testing.T) {
tests := []struct {
name string
want string
signature []string
}{
{
name: "nil signature",
signature: nil,
want: "",
},
{
name: "empty strings in signature",
signature: []string{"", ""},
want: "||",
},
{
name: "special characters",
signature: []string{"error|handling", "nil"},
want: "error|handling|nil|",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := generateCandidateKey(tt.signature)
if got != tt.want {
t.Errorf("generateCandidateKey() = %q, want %q", got, tt.want)
}
})
}
}
func TestItoa(t *testing.T) {
tests := []struct {
want string
input int
}{
{"0", 0},
{"1", 1},
{"10", 10},
{"123", 123},
{"-1", -1},
{"-123", -123},
{"1000000", 1000000},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := itoa(tt.input)
if got != tt.want {
t.Errorf("itoa(%d) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestItoa_EdgeCases(t *testing.T) {
tests := []struct {
want string
input int
}{
{"0", 0},
{"0", -0},
{"1", 1},
{"-1", -1},
{"9", 9},
{"10", 10},
{"99", 99},
{"100", 100},
{"999", 999},
{"1000", 1000},
{"-999", -999},
{"-1000", -1000},
{"2147483647", 2147483647}, // Max int32
{"-2147483647", -2147483647}, // Min int32 + 1
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := itoa(tt.input)
if got != tt.want {
t.Errorf("itoa(%d) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestDetectionResult_ZeroValue(t *testing.T) {
result := &DetectionResult{}
if result.MatchedPattern != nil {
t.Error("Zero value should have nil MatchedPattern")
}
if result.MatchScore != 0 {
t.Error("Zero value should have 0 MatchScore")
}
if result.IsNewPattern {
t.Error("Zero value should have false IsNewPattern")
}
}
func TestCandidatePattern_Fields(t *testing.T) {
candidate := &candidatePattern{
patternType: models.PatternTypeBug,
title: "Test Title",
signature: []string{"sig1", "sig2"},
observationIDs: []int64{1, 2, 3},
projects: []string{"proj1", "proj2"},
lastSeenEpoch: time.Now().UnixMilli(),
}
if candidate.patternType != models.PatternTypeBug {
t.Error("Wrong pattern type")
}
if candidate.title != "Test Title" {
t.Error("Wrong title")
}
if len(candidate.signature) != 2 {
t.Error("Wrong signature length")
}
if len(candidate.observationIDs) != 3 {
t.Error("Wrong observationIDs length")
}
if len(candidate.projects) != 2 {
t.Error("Wrong projects length")
}
}
func TestDetector_AnalyzeObservation_EmptySignature(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Create observation with empty concepts/title/narrative
obs := &models.Observation{
ID: 1,
SDKSessionID: "test-session",
Project: "test-project",
Scope: models.ScopeProject,
Type: models.ObsTypeBugfix,
// All fields that would create signature are empty
}
result, err := detector.AnalyzeObservation(ctx, obs)
if err != nil {
t.Fatalf("AnalyzeObservation() error = %v", err)
}
// Should return empty result for empty signature
if result.MatchedPattern != nil {
t.Error("Expected nil pattern for empty signature")
}
}
func TestDetector_AnalyzeObservation_CandidateEviction(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
config.MaxCandidates = 2 // Very small for testing
config.MinFrequencyForPattern = 10 // High so nothing gets promoted
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Add observations with different signatures until we exceed MaxCandidates
obs1 := createTestObservation(1, "First", []string{"first", "unique"})
obs2 := createTestObservation(2, "Second", []string{"second", "unique"})
obs3 := createTestObservation(3, "Third", []string{"third", "unique"})
// Analyze all observations
_, _ = detector.AnalyzeObservation(ctx, obs1)
time.Sleep(10 * time.Millisecond) // Small delay so timestamps differ
_, _ = detector.AnalyzeObservation(ctx, obs2)
time.Sleep(10 * time.Millisecond)
_, _ = detector.AnalyzeObservation(ctx, obs3)
// Should have at most MaxCandidates
if count := detector.CandidateCount(); count > config.MaxCandidates {
t.Errorf("Expected at most %d candidates, got %d", config.MaxCandidates, count)
}
}
func TestDetector_PromoteCandidateWithSyncFunc(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
config.MinFrequencyForPattern = 2
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Set up sync function to track calls
var syncedPattern *models.Pattern
detector.SetSyncFunc(func(p *models.Pattern) {
syncedPattern = p
})
// Create two similar observations to trigger pattern promotion
obs1 := createTestObservation(1, "Sync Test", []string{"sync", "test"})
obs2 := createTestObservation(2, "Sync Test", []string{"sync", "test"})
_, _ = detector.AnalyzeObservation(ctx, obs1)
result, _ := detector.AnalyzeObservation(ctx, obs2)
if result.MatchedPattern == nil {
t.Fatal("Expected pattern to be created")
}
if syncedPattern == nil {
t.Error("Expected sync function to be called")
}
if syncedPattern != nil && syncedPattern.Name != result.MatchedPattern.Name {
t.Errorf("Synced pattern name mismatch: got %s, want %s",
syncedPattern.Name, result.MatchedPattern.Name)
}
}
func TestDetector_AnalyzeObservation_UpdateExistingCandidate(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
config.MinFrequencyForPattern = 5 // High enough that we don't promote
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Create observations with same signature
obs1 := createTestObservation(1, "Update Test", []string{"update", "test"})
obs2 := createTestObservation(2, "Update Test", []string{"update", "test"})
obs2.Project = "different-project"
// Analyze first observation
_, _ = detector.AnalyzeObservation(ctx, obs1)
// Check candidate count
if count := detector.CandidateCount(); count != 1 {
t.Errorf("Expected 1 candidate after first obs, got %d", count)
}
// Analyze second observation
_, _ = detector.AnalyzeObservation(ctx, obs2)
// Still 1 candidate (same signature)
if count := detector.CandidateCount(); count != 1 {
t.Errorf("Expected 1 candidate after second obs, got %d", count)
}
// Check that candidate has both projects
key := generateCandidateKey([]string{"update", "test"})
candidate := detector.candidates[key]
if candidate == nil {
t.Fatal("Expected candidate to exist")
}
if len(candidate.projects) != 2 {
t.Errorf("Expected 2 projects, got %d", len(candidate.projects))
}
}
func TestDetector_GetPatternInsight_NotFound(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Try to get insight for non-existent pattern
_, err := detector.GetPatternInsight(ctx, 99999)
if err == nil {
t.Error("Expected error for non-existent pattern")
}
}
// Helper functions
func setupTestStore(t *testing.T) *gorm.Store {
File diff suppressed because it is too large Load Diff
+663
View File
@@ -1,9 +1,12 @@
package sdk
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
@@ -1178,3 +1181,663 @@ func TestSafeResolvePath(t *testing.T) {
})
}
}
// =============================================================================
// TESTS FOR CircuitBreaker
// =============================================================================
func TestNewCircuitBreaker(t *testing.T) {
cb := NewCircuitBreaker(5, 60)
assert.NotNil(t, cb)
assert.Equal(t, int64(5), cb.threshold)
assert.Equal(t, int64(60), cb.resetTimeout)
assert.Equal(t, "closed", cb.State())
}
func TestCircuitBreaker_Allow_Closed(t *testing.T) {
cb := NewCircuitBreaker(5, 60)
// Closed state should allow requests
assert.True(t, cb.Allow())
assert.True(t, cb.Allow())
}
func TestCircuitBreaker_Allow_Open(t *testing.T) {
cb := NewCircuitBreaker(2, 60) // Low threshold for testing
// Record enough failures to open the circuit
cb.RecordFailure()
cb.RecordFailure()
// Open state should block requests
assert.False(t, cb.Allow())
assert.Equal(t, "open", cb.State())
}
func TestCircuitBreaker_RecordSuccess(t *testing.T) {
cb := NewCircuitBreaker(2, 60)
// Record a failure
cb.RecordFailure()
assert.Equal(t, int64(1), cb.Metrics().Failures)
// Record success resets failures
cb.RecordSuccess()
assert.Equal(t, int64(0), cb.Metrics().Failures)
assert.Equal(t, "closed", cb.State())
}
func TestCircuitBreaker_RecordFailure_OpensCircuit(t *testing.T) {
cb := NewCircuitBreaker(3, 60)
// Record failures below threshold
cb.RecordFailure()
assert.Equal(t, "closed", cb.State())
cb.RecordFailure()
assert.Equal(t, "closed", cb.State())
// Third failure should open circuit
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
}
func TestCircuitBreaker_State(t *testing.T) {
cb := NewCircuitBreaker(1, 60)
// Initially closed
assert.Equal(t, "closed", cb.State())
// After failure, open
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
// After success, closed
cb.RecordSuccess()
assert.Equal(t, "closed", cb.State())
}
func TestCircuitBreaker_Metrics(t *testing.T) {
cb := NewCircuitBreaker(5, 120)
metrics := cb.Metrics()
assert.Equal(t, "closed", metrics.State)
assert.Equal(t, int64(0), metrics.Failures)
assert.Equal(t, int64(5), metrics.Threshold)
assert.Equal(t, int64(120), metrics.ResetTimeoutSecs)
assert.Equal(t, int64(0), metrics.LastFailureUnix)
// After failure
cb.RecordFailure()
metrics = cb.Metrics()
assert.Equal(t, int64(1), metrics.Failures)
assert.Greater(t, metrics.LastFailureUnix, int64(0))
}
func TestCircuitBreaker_Metrics_OpenWithReset(t *testing.T) {
cb := NewCircuitBreaker(1, 60)
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
metrics := cb.Metrics()
assert.Equal(t, "open", metrics.State)
assert.Greater(t, metrics.SecondsUntilReset, int64(0))
assert.LessOrEqual(t, metrics.SecondsUntilReset, int64(60))
}
// =============================================================================
// TESTS FOR RequestDeduplicator
// =============================================================================
func TestNewRequestDeduplicator(t *testing.T) {
d := NewRequestDeduplicator(300, 1000)
assert.NotNil(t, d)
assert.NotNil(t, d.seen)
assert.Equal(t, int64(300), d.ttlSecs)
assert.Equal(t, 1000, d.maxSize)
}
func TestRequestDeduplicator_IsDuplicate_NotSeen(t *testing.T) {
d := NewRequestDeduplicator(300, 1000)
// New hash is not a duplicate
assert.False(t, d.IsDuplicate("newhash"))
}
func TestRequestDeduplicator_IsDuplicate_AfterRecord(t *testing.T) {
d := NewRequestDeduplicator(300, 1000)
hash := "testhash"
// Record the hash
d.Record(hash)
// Now it should be a duplicate
assert.True(t, d.IsDuplicate(hash))
}
func TestRequestDeduplicator_Record(t *testing.T) {
d := NewRequestDeduplicator(300, 1000)
hash := "recordtest"
d.Record(hash)
// Check it was recorded
d.mu.RLock()
_, exists := d.seen[hash]
d.mu.RUnlock()
assert.True(t, exists)
}
func TestRequestDeduplicator_Record_Eviction(t *testing.T) {
// Small maxSize for testing eviction
d := NewRequestDeduplicator(0, 2) // TTL of 0 means everything is "old"
// Record until capacity
d.Record("hash1")
d.Record("hash2")
// Recording a third should trigger eviction (since TTL is 0)
d.Record("hash3")
// Should have cleaned up old entries
d.mu.RLock()
size := len(d.seen)
d.mu.RUnlock()
// Size should be limited (eviction occurred)
assert.LessOrEqual(t, size, 3)
}
func TestHashRequest(t *testing.T) {
tests := []struct {
name string
toolName string
input string
output string
compareWith []string
wantLen int
wantSame bool
}{
{
name: "basic hash",
toolName: "Read",
input: "file.txt",
output: "content",
wantLen: 16,
},
{
name: "consistent hashing",
toolName: "Edit",
input: "same input",
output: "same output",
wantLen: 16,
},
{
name: "long output truncation",
toolName: "Bash",
input: "command",
output: string(make([]byte, 5000)), // Very long output
wantLen: 16,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash := hashRequest(tt.toolName, tt.input, tt.output)
assert.Len(t, hash, tt.wantLen)
// Same inputs should produce same hash
hash2 := hashRequest(tt.toolName, tt.input, tt.output)
assert.Equal(t, hash, hash2)
})
}
}
func TestHashRequest_DifferentInputs(t *testing.T) {
// Different inputs should produce different hashes
hash1 := hashRequest("Read", "file1.txt", "content1")
hash2 := hashRequest("Read", "file2.txt", "content2")
assert.NotEqual(t, hash1, hash2)
}
func TestHashRequest_OutputTruncation(t *testing.T) {
// Hash should be the same for outputs that differ only after 1000 chars
longOutput1 := string(make([]byte, 1500))
longOutput2 := longOutput1[:1000] + "different suffix here"
hash1 := hashRequest("Read", "input", longOutput1)
hash2 := hashRequest("Read", "input", longOutput2)
// Since we only hash first 1000 chars, these should be the same
assert.Equal(t, hash1, hash2)
}
// =============================================================================
// TESTS FOR Processor methods
// =============================================================================
func TestProcessor_CircuitBreakerState(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(2, 60),
}
// Initially closed
assert.Equal(t, "closed", p.CircuitBreakerState())
// After enough failures, open
p.circuitBreaker.RecordFailure()
p.circuitBreaker.RecordFailure()
assert.Equal(t, "open", p.CircuitBreakerState())
// After success, closed
p.circuitBreaker.RecordSuccess()
assert.Equal(t, "closed", p.CircuitBreakerState())
}
func TestProcessor_CircuitBreakerMetrics(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 120),
}
metrics := p.CircuitBreakerMetrics()
assert.Equal(t, "closed", metrics.State)
assert.Equal(t, int64(0), metrics.Failures)
assert.Equal(t, int64(5), metrics.Threshold)
assert.Equal(t, int64(120), metrics.ResetTimeoutSecs)
// Record a failure and check metrics update
p.circuitBreaker.RecordFailure()
metrics = p.CircuitBreakerMetrics()
assert.Equal(t, int64(1), metrics.Failures)
assert.Greater(t, metrics.LastFailureUnix, int64(0))
}
// =============================================================================
// TESTS FOR Vector Sync Workers
// =============================================================================
func TestProcessor_StartAndStopVectorSyncWorkers(t *testing.T) {
var syncedObservations []*models.Observation
var mu sync.Mutex
p := &Processor{
vectorSyncChan: make(chan *models.Observation, MaxVectorSyncWorkers*2),
vectorSyncDone: make(chan struct{}),
syncObservationFunc: func(obs *models.Observation) {
mu.Lock()
syncedObservations = append(syncedObservations, obs)
mu.Unlock()
},
}
// Start workers
p.StartVectorSyncWorkers()
// Send some observations
obs1 := &models.Observation{SDKSessionID: "test1"}
obs2 := &models.Observation{SDKSessionID: "test2"}
p.vectorSyncChan <- obs1
p.vectorSyncChan <- obs2
// Give workers time to process
time.Sleep(50 * time.Millisecond)
// Stop workers
p.StopVectorSyncWorkers()
// Verify observations were synced
mu.Lock()
assert.Len(t, syncedObservations, 2)
mu.Unlock()
}
func TestProcessor_VectorSyncWorker_DrainOnShutdown(t *testing.T) {
var syncedCount int
var mu sync.Mutex
p := &Processor{
vectorSyncChan: make(chan *models.Observation, 10),
vectorSyncDone: make(chan struct{}),
syncObservationFunc: func(obs *models.Observation) {
mu.Lock()
syncedCount++
mu.Unlock()
},
}
// Queue observations before starting workers
for i := 0; i < 5; i++ {
p.vectorSyncChan <- &models.Observation{SDKSessionID: "pre-queued"}
}
// Start workers
p.StartVectorSyncWorkers()
// Stop immediately - workers should drain the queue
p.StopVectorSyncWorkers()
// All pre-queued items should have been processed
mu.Lock()
assert.Equal(t, 5, syncedCount)
mu.Unlock()
}
func TestProcessor_VectorSyncWorker_NilSyncFunc(t *testing.T) {
p := &Processor{
vectorSyncChan: make(chan *models.Observation, 10),
vectorSyncDone: make(chan struct{}),
syncObservationFunc: nil, // No sync function set
}
// Start workers
p.StartVectorSyncWorkers()
// Send observation - should not panic even with nil sync func
p.vectorSyncChan <- &models.Observation{SDKSessionID: "test"}
// Give it time to process
time.Sleep(50 * time.Millisecond)
// Stop workers - should not panic
p.StopVectorSyncWorkers()
}
// =============================================================================
// TESTS FOR CircuitBreaker Additional Behaviors
// =============================================================================
func TestCircuitBreaker_Allow_OpenBlocksRequests(t *testing.T) {
cb := NewCircuitBreaker(1, 60)
// Open the circuit
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
// All requests should be blocked
assert.False(t, cb.Allow())
assert.False(t, cb.Allow())
assert.False(t, cb.Allow())
}
func TestCircuitBreaker_MultipleFailures(t *testing.T) {
cb := NewCircuitBreaker(3, 60) // Higher threshold
// Record failures below threshold
cb.RecordFailure()
assert.Equal(t, "closed", cb.State())
assert.Equal(t, int64(1), cb.Metrics().Failures)
cb.RecordFailure()
assert.Equal(t, "closed", cb.State())
assert.Equal(t, int64(2), cb.Metrics().Failures)
// Third failure opens circuit
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
assert.Equal(t, int64(3), cb.Metrics().Failures)
}
func TestCircuitBreaker_SuccessResetsFailures(t *testing.T) {
cb := NewCircuitBreaker(5, 60)
// Record some failures
cb.RecordFailure()
cb.RecordFailure()
assert.Equal(t, int64(2), cb.Metrics().Failures)
// Success resets failures
cb.RecordSuccess()
assert.Equal(t, int64(0), cb.Metrics().Failures)
assert.Equal(t, "closed", cb.State())
}
func TestCircuitBreaker_Metrics_Comprehensive(t *testing.T) {
cb := NewCircuitBreaker(5, 120)
// Initial state
metrics := cb.Metrics()
assert.Equal(t, "closed", metrics.State)
assert.Equal(t, int64(0), metrics.Failures)
assert.Equal(t, int64(5), metrics.Threshold)
assert.Equal(t, int64(120), metrics.ResetTimeoutSecs)
assert.Equal(t, int64(0), metrics.LastFailureUnix)
assert.Equal(t, int64(0), metrics.SecondsUntilReset)
// After failures that open circuit
for i := 0; i < 5; i++ {
cb.RecordFailure()
}
metrics = cb.Metrics()
assert.Equal(t, "open", metrics.State)
assert.Equal(t, int64(5), metrics.Failures)
assert.Greater(t, metrics.LastFailureUnix, int64(0))
assert.Greater(t, metrics.SecondsUntilReset, int64(0))
}
// =============================================================================
// TESTS FOR MaxVectorSyncWorkers constant
// =============================================================================
func TestMaxVectorSyncWorkers(t *testing.T) {
assert.Equal(t, 8, MaxVectorSyncWorkers)
}
// =============================================================================
// ADDITIONAL EDGE CASE TESTS
// =============================================================================
func TestRequestDeduplicator_IsDuplicate_ExpiredEntry(t *testing.T) {
if testing.Short() {
t.Skip("Skipping time-dependent test in short mode")
}
// Use a 1-second TTL with enough margin
d := NewRequestDeduplicator(1, 100)
hash := "expiretest"
d.Record(hash)
// Initially duplicate
assert.True(t, d.IsDuplicate(hash))
// Wait for TTL to expire (2.5 seconds to ensure crossing second boundaries)
time.Sleep(2500 * time.Millisecond)
// Should no longer be considered duplicate
assert.False(t, d.IsDuplicate(hash))
}
// =============================================================================
// TESTS FOR ProcessObservation Early Returns
// =============================================================================
func TestProcessObservation_SkipTool(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
}
ctx := context.Background()
// TodoWrite should be skipped
err := p.ProcessObservation(ctx, "session-1", "project-1", "TodoWrite",
map[string]string{"content": "test"}, "success", 1, "/test/cwd")
assert.NoError(t, err)
// Glob should be skipped
err = p.ProcessObservation(ctx, "session-1", "project-1", "Glob",
map[string]string{"pattern": "*.go"}, []string{"main.go", "test.go"}, 1, "/test/cwd")
assert.NoError(t, err)
// AskUserQuestion should be skipped
err = p.ProcessObservation(ctx, "session-1", "project-1", "AskUserQuestion",
"question", "answer", 1, "/test/cwd")
assert.NoError(t, err)
}
func TestProcessObservation_SkipTrivial(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
}
ctx := context.Background()
// Short output should be skipped
err := p.ProcessObservation(ctx, "session-1", "project-1", "Read",
map[string]string{"file_path": "/test.go"}, "short", 1, "/test/cwd")
assert.NoError(t, err)
// "No matches found" should be skipped
err = p.ProcessObservation(ctx, "session-1", "project-1", "Grep",
map[string]string{"pattern": "test"}, "No matches found in the repository", 1, "/test/cwd")
assert.NoError(t, err)
}
func TestProcessObservation_SkipDuplicate(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
sem: make(chan struct{}, 4),
claudePath: "/nonexistent/path", // Will fail at CLI call stage
}
ctx := context.Background()
// Valid input that would be processed
input := map[string]string{"file_path": "/project/main.go"}
output := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello World\")\n}"
// First call should try to process (will fail because claudePath doesn't exist)
err := p.ProcessObservation(ctx, "session-1", "project-1", "Read", input, output, 1, "/test/cwd")
// Expect error because claudePath doesn't exist
assert.Error(t, err)
// Second call with same input should be skipped as duplicate
err = p.ProcessObservation(ctx, "session-1", "project-1", "Read", input, output, 1, "/test/cwd")
assert.NoError(t, err) // No error because it was skipped as duplicate
}
func TestProcessObservation_CircuitBreakerOpen(t *testing.T) {
cb := NewCircuitBreaker(1, 60) // Threshold of 1
cb.RecordFailure() // Open the circuit breaker
p := &Processor{
circuitBreaker: cb,
deduplicator: NewRequestDeduplicator(300, 1000),
}
ctx := context.Background()
// Valid input that would be processed
input := map[string]string{"file_path": "/project/main.go"}
output := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello World\")\n}"
err := p.ProcessObservation(ctx, "session-1", "project-1", "Read", input, output, 1, "/test/cwd")
assert.Error(t, err)
assert.Contains(t, err.Error(), "circuit breaker open")
}
func TestProcessObservation_ContextCancel(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
sem: make(chan struct{}, 1), // Small semaphore
claudePath: "/fake/claude",
}
// Fill the semaphore
p.sem <- struct{}{}
// Create a cancelled context
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
// Valid input that would be processed
input := map[string]string{"file_path": "/project/main.go"}
output := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello World\")\n}"
err := p.ProcessObservation(ctx, "session-1", "project-1", "Read", input, output, 1, "/test/cwd")
assert.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
}
// =============================================================================
// TESTS FOR ProcessSummary Early Returns
// =============================================================================
func TestProcessSummary_SkipEmptyRequest(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
}
ctx := context.Background()
// Empty request should be skipped (sessionDBID, sdkSessionID, project, userPrompt, lastUserMsg, lastAssistantMsg)
err := p.ProcessSummary(ctx, 1, "session-1", "project-1", "", "", "")
assert.NoError(t, err)
}
func TestProcessSummary_CircuitBreakerOpen(t *testing.T) {
cb := NewCircuitBreaker(1, 60)
cb.RecordFailure() // Open the circuit breaker
p := &Processor{
circuitBreaker: cb,
deduplicator: NewRequestDeduplicator(300, 1000),
sem: make(chan struct{}, 4),
claudePath: "/nonexistent/path",
}
ctx := context.Background()
// Meaningful assistant message (> 200 chars, contains code discussion)
assistantMsg := `I've updated the handler.go file to fix the authentication bug.
The function validateToken() was not checking token expiry correctly.
I've added a check for the exp claim and implemented proper error handling.
The changes have been tested and the build passes successfully.
Here's the implementation details and code review.`
// Valid request but circuit breaker is open
err := p.ProcessSummary(ctx, 1, "session-1", "project-1",
"Implement authentication", "User message", assistantMsg)
assert.Error(t, err)
assert.Contains(t, err.Error(), "claude CLI failed")
}
// =============================================================================
// TESTS FOR callClaudeCLI Error Paths
// =============================================================================
func TestCallClaudeCLI_PromptTooLarge(t *testing.T) {
p := &Processor{
claudePath: "/fake/claude",
}
ctx := context.Background()
// Create a prompt that exceeds MaxPromptSize
largePrompt := string(make([]byte, MaxPromptSize+1))
_, err := p.callClaudeCLI(ctx, largePrompt)
assert.Error(t, err)
assert.Contains(t, err.Error(), "prompt exceeds maximum size")
}
func TestCallClaudeCLI_BinaryNotFound(t *testing.T) {
p := &Processor{
claudePath: "/nonexistent/path/to/claude",
}
ctx := context.Background()
_, err := p.callClaudeCLI(ctx, "test prompt")
assert.Error(t, err)
assert.Contains(t, err.Error(), "claude CLI failed")
}
+731
View File
@@ -693,3 +693,734 @@ func TestToolInputResponse(t *testing.T) {
})
}
}
// =============================================================================
// TESTS FOR NewManager AND CLEANUP
// =============================================================================
// TestNewManager tests the NewManager function.
func TestNewManager(t *testing.T) {
t.Parallel()
// Test with nil session store (valid for testing)
manager := NewManager(nil)
assert.NotNil(t, manager)
assert.NotNil(t, manager.sessions)
assert.NotNil(t, manager.ProcessNotify)
assert.NotNil(t, manager.ctx)
assert.NotNil(t, manager.cancel)
assert.Equal(t, 0, manager.GetActiveSessionCount())
// Clean up - cancel context to stop cleanup goroutine
manager.cancel()
}
// TestNewManager_CleanupGoroutineStops tests that cleanup goroutine stops on cancel.
func TestNewManager_CleanupGoroutineStops(t *testing.T) {
t.Parallel()
manager := NewManager(nil)
// Give goroutine time to start
time.Sleep(10 * time.Millisecond)
// Cancel should stop the cleanup goroutine
manager.cancel()
// Context should be done
select {
case <-manager.ctx.Done():
// Expected
case <-time.After(100 * time.Millisecond):
t.Error("Context should be done after cancel")
}
}
// TestCleanupStaleSessions_NoSessions tests cleanup with no sessions.
func TestCleanupStaleSessions_NoSessions(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Should not panic with empty sessions
manager.cleanupStaleSessions()
assert.Equal(t, 0, manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions_FreshSession tests that fresh sessions are not cleaned.
func TestCleanupStaleSessions_FreshSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Add a fresh session
sessionCtx, sessionCancel := context.WithCancel(context.Background())
manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
StartTime: time.Now(), // Fresh
pendingMessages: []PendingMessage{},
ctx: sessionCtx,
cancel: sessionCancel,
}
manager.cleanupStaleSessions()
// Session should still exist (not stale)
assert.Equal(t, 1, manager.GetActiveSessionCount())
sessionCancel()
}
// TestCleanupStaleSessions_StaleSession tests that stale sessions are cleaned.
func TestCleanupStaleSessions_StaleSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Add a stale session
sessionCtx, sessionCancel := context.WithCancel(context.Background())
manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
StartTime: time.Now().Add(-SessionTimeout - time.Minute), // Stale
pendingMessages: []PendingMessage{},
ctx: sessionCtx,
cancel: sessionCancel,
}
manager.cleanupStaleSessions()
// Session should be deleted
assert.Equal(t, 0, manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions_StaleWithPending tests stale sessions with pending messages are not cleaned.
func TestCleanupStaleSessions_StaleWithPending(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Add a stale session with pending messages
sessionCtx, sessionCancel := context.WithCancel(context.Background())
defer sessionCancel()
manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
StartTime: time.Now().Add(-SessionTimeout - time.Minute), // Stale
pendingMessages: []PendingMessage{{Type: MessageTypeObservation}},
ctx: sessionCtx,
cancel: sessionCancel,
}
manager.cleanupStaleSessions()
// Session should NOT be deleted (has pending messages)
assert.Equal(t, 1, manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions_StaleWithActiveGenerator tests stale sessions with active generator are not cleaned.
func TestCleanupStaleSessions_StaleWithActiveGenerator(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Add a stale session with active generator
sessionCtx, sessionCancel := context.WithCancel(context.Background())
defer sessionCancel()
session := &ActiveSession{
SessionDBID: 1,
StartTime: time.Now().Add(-SessionTimeout - time.Minute), // Stale
pendingMessages: []PendingMessage{},
ctx: sessionCtx,
cancel: sessionCancel,
}
session.generatorActive.Store(true)
manager.sessions[1] = session
manager.cleanupStaleSessions()
// Session should NOT be deleted (generator is active)
assert.Equal(t, 1, manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions_MixedSessions tests cleanup with mixed fresh and stale sessions.
func TestCleanupStaleSessions_MixedSessions(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Fresh session
ctx1, cancel1 := context.WithCancel(context.Background())
defer cancel1()
manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
StartTime: time.Now(),
pendingMessages: []PendingMessage{},
ctx: ctx1,
cancel: cancel1,
}
// Stale session (should be deleted)
ctx2, cancel2 := context.WithCancel(context.Background())
manager.sessions[2] = &ActiveSession{
SessionDBID: 2,
StartTime: time.Now().Add(-SessionTimeout - time.Minute),
pendingMessages: []PendingMessage{},
ctx: ctx2,
cancel: cancel2,
}
// Stale session with pending (should NOT be deleted)
ctx3, cancel3 := context.WithCancel(context.Background())
defer cancel3()
manager.sessions[3] = &ActiveSession{
SessionDBID: 3,
StartTime: time.Now().Add(-SessionTimeout - time.Minute),
pendingMessages: []PendingMessage{{Type: MessageTypeObservation}},
ctx: ctx3,
cancel: cancel3,
}
manager.cleanupStaleSessions()
// Should have 2 sessions left (1 fresh, 1 stale with pending)
assert.Equal(t, 2, manager.GetActiveSessionCount())
// Verify which sessions remain
manager.mu.RLock()
_, has1 := manager.sessions[1]
_, has2 := manager.sessions[2]
_, has3 := manager.sessions[3]
manager.mu.RUnlock()
assert.True(t, has1, "Fresh session should remain")
assert.False(t, has2, "Stale session should be deleted")
assert.True(t, has3, "Stale session with pending should remain")
}
// TestCleanupLoop_ExitsOnCancel tests that cleanup loop exits when context is cancelled.
func TestCleanupLoop_ExitsOnCancel(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
manager.ctx = ctx
manager.cancel = cancel
// Start cleanup loop in goroutine
done := make(chan struct{})
go func() {
manager.cleanupLoop()
close(done)
}()
// Cancel immediately
cancel()
// Should exit quickly
select {
case <-done:
// Success - loop exited
case <-time.After(100 * time.Millisecond):
t.Error("Cleanup loop should exit when context is cancelled")
}
}
// =============================================================================
// TESTS FOR InitializeSession (without DB)
// =============================================================================
// TestInitializeSession_AlreadyActive tests reusing an already active session.
func TestInitializeSession_AlreadyActive(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add an active session
existingSession := &ActiveSession{
SessionDBID: 42,
ClaudeSessionID: "claude-existing",
Project: "test-project",
UserPrompt: "original prompt",
LastPromptNumber: 1,
StartTime: time.Now(),
pendingMessages: make([]PendingMessage, 0),
}
manager.sessions[42] = existingSession
// Initialize same session - should reuse
session, err := manager.InitializeSession(context.Background(), 42, "new prompt", 5)
assert.NoError(t, err)
assert.NotNil(t, session)
assert.Same(t, existingSession, session)
assert.Equal(t, "new prompt", session.UserPrompt)
assert.Equal(t, 5, session.LastPromptNumber)
}
// TestInitializeSession_AlreadyActive_EmptyPrompt tests reusing session with empty prompt.
func TestInitializeSession_AlreadyActive_EmptyPrompt(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add an active session
existingSession := &ActiveSession{
SessionDBID: 42,
UserPrompt: "original prompt",
LastPromptNumber: 1,
}
manager.sessions[42] = existingSession
// Initialize with empty prompt - should NOT update
session, err := manager.InitializeSession(context.Background(), 42, "", 0)
assert.NoError(t, err)
assert.NotNil(t, session)
assert.Equal(t, "original prompt", session.UserPrompt) // Unchanged
assert.Equal(t, 1, session.LastPromptNumber) // Unchanged
}
// TestInitializeSession_NoStore tests initialization without session store.
func TestInitializeSession_NoStore(t *testing.T) {
t.Parallel()
manager := &Manager{
sessionStore: nil, // No store
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Should fail gracefully with nil store (panic recovery not expected)
// This tests the guard against nil sessionStore
defer func() {
if r := recover(); r != nil {
_ = r // Expected panic when calling nil store - intentionally ignored
}
}()
_, _ = manager.InitializeSession(context.Background(), 999, "prompt", 1)
}
// TestInitializeSession_CallbackTriggered tests that created callback is triggered.
func TestInitializeSession_CallbackTriggered(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
var calledWithID int64
manager.SetOnSessionCreated(func(id int64) {
calledWithID = id
})
// Add session directly (simulating what would happen after DB fetch)
sessionCtx, sessionCancel := context.WithCancel(context.Background())
defer sessionCancel()
session := &ActiveSession{
SessionDBID: 100,
ClaudeSessionID: "test",
Project: "project",
StartTime: time.Now(),
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
ctx: sessionCtx,
cancel: sessionCancel,
}
manager.mu.Lock()
manager.sessions[100] = session
onCreated := manager.onCreated
manager.mu.Unlock()
// Trigger callback
if onCreated != nil {
onCreated(100)
}
assert.Equal(t, int64(100), calledWithID)
}
// =============================================================================
// TESTS FOR QueueObservation AND QueueSummarize (without DB)
// =============================================================================
// TestQueueObservation_ToExistingSession tests queuing to an existing session.
func TestQueueObservation_ToExistingSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue observation
err := manager.QueueObservation(context.Background(), 1, ObservationData{
ToolName: "Read",
ToolInput: map[string]string{"path": "/test"},
ToolResponse: "content",
PromptNumber: 1,
CWD: "/project",
})
assert.NoError(t, err)
assert.Equal(t, 1, manager.GetTotalQueueDepth())
// Verify message
messages := manager.DrainMessages(1)
assert.Len(t, messages, 1)
assert.Equal(t, MessageTypeObservation, messages[0].Type)
assert.Equal(t, "Read", messages[0].Observation.ToolName)
assert.Equal(t, "/project", messages[0].Observation.CWD)
}
// TestQueueObservation_NotifiesSession tests that notification is sent to session.
func TestQueueObservation_NotifiesSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session with notify channel
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue observation
err := manager.QueueObservation(context.Background(), 1, ObservationData{ToolName: "Test"})
assert.NoError(t, err)
// Should receive notification on session channel
select {
case <-session.notify:
// Success
default:
t.Error("Session should receive notification")
}
// Should receive notification on process channel
select {
case <-manager.ProcessNotify:
// Success
default:
t.Error("Manager ProcessNotify should receive notification")
}
}
// TestQueueSummarize_ToExistingSession tests queuing summarize to an existing session.
func TestQueueSummarize_ToExistingSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue summarize
err := manager.QueueSummarize(context.Background(), 1, "User asked question", "Assistant answered")
assert.NoError(t, err)
assert.Equal(t, 1, manager.GetTotalQueueDepth())
// Verify message
messages := manager.DrainMessages(1)
assert.Len(t, messages, 1)
assert.Equal(t, MessageTypeSummarize, messages[0].Type)
assert.Equal(t, "User asked question", messages[0].Summarize.LastUserMessage)
assert.Equal(t, "Assistant answered", messages[0].Summarize.LastAssistantMessage)
}
// TestQueueSummarize_NotifiesSession tests that notification is sent to session.
func TestQueueSummarize_NotifiesSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session with notify channel
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue summarize
err := manager.QueueSummarize(context.Background(), 1, "user", "assistant")
assert.NoError(t, err)
// Should receive notification on session channel
select {
case <-session.notify:
// Success
default:
t.Error("Session should receive notification")
}
// Should receive notification on process channel
select {
case <-manager.ProcessNotify:
// Success
default:
t.Error("Manager ProcessNotify should receive notification")
}
}
// TestQueueOperations_MultipleMessages tests queuing multiple messages.
func TestQueueOperations_MultipleMessages(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue multiple messages
for i := 0; i < 10; i++ {
if i%2 == 0 {
err := manager.QueueObservation(context.Background(), 1, ObservationData{
ToolName: "Tool" + string(rune('A'+i)),
})
assert.NoError(t, err)
} else {
err := manager.QueueSummarize(context.Background(), 1, "user", "assistant")
assert.NoError(t, err)
}
}
assert.Equal(t, 10, manager.GetTotalQueueDepth())
// Drain and verify
messages := manager.DrainMessages(1)
assert.Len(t, messages, 10)
}
// TestQueueOperations_NonBlockingNotification tests non-blocking notification behavior.
func TestQueueOperations_NonBlockingNotification(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session with full notify channel
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
// Fill the notify channel
session.notify <- struct{}{}
manager.sessions[1] = session
// Fill ProcessNotify channel
manager.ProcessNotify <- struct{}{}
// Queue should NOT block even with full channels
done := make(chan bool)
go func() {
err := manager.QueueObservation(context.Background(), 1, ObservationData{ToolName: "Test"})
assert.NoError(t, err)
done <- true
}()
select {
case <-done:
// Success - didn't block
case <-time.After(100 * time.Millisecond):
t.Error("Queue operation should not block even with full notification channels")
}
}
// TestConcurrentQueueAndCleanup tests concurrent queue operations and cleanup.
func TestConcurrentQueueAndCleanup(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add multiple sessions
for i := int64(1); i <= 5; i++ {
sessionCtx, sessionCancel := context.WithCancel(context.Background())
manager.sessions[i] = &ActiveSession{
SessionDBID: i,
StartTime: time.Now(),
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
ctx: sessionCtx,
cancel: sessionCancel,
}
}
var wg sync.WaitGroup
// Concurrent queue operations
for i := 0; i < 50; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
sessionID := int64((idx % 5) + 1)
if idx%2 == 0 {
_ = manager.QueueObservation(context.Background(), sessionID, ObservationData{ToolName: "Test"})
} else {
_ = manager.QueueSummarize(context.Background(), sessionID, "user", "assistant")
}
}(i)
}
// Concurrent cleanup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
manager.cleanupStaleSessions()
}()
}
// Concurrent reads
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = manager.GetActiveSessionCount()
_ = manager.GetTotalQueueDepth()
_ = manager.IsAnySessionProcessing()
_ = manager.GetAllSessions()
}()
}
wg.Wait()
// Should have all sessions (none are stale)
assert.Equal(t, 5, manager.GetActiveSessionCount())
// Should have 50 messages total
assert.Equal(t, 50, manager.GetTotalQueueDepth())
}
+331
View File
@@ -290,3 +290,334 @@ func TestClusterObservations_PreservesOrder(t *testing.T) {
require.NotEmpty(t, clustered)
assert.Equal(t, int64(1), clustered[0].ID, "First observation should be kept as first result")
}
// =============================================================================
// TESTS FOR OPTIMIZED CLUSTERING (triggered when len(observations) > 50)
// =============================================================================
func TestClusterObservationsOptimized_LargeSet(t *testing.T) {
t.Parallel()
// Create 60 observations to trigger optimized path (threshold is 50)
observations := make([]*models.Observation, 60)
// Create 30 pairs of similar observations
topics := []string{
"authentication", "authorization", "database", "caching", "logging",
"monitoring", "testing", "deployment", "scaling", "security",
"networking", "storage", "messaging", "scheduling", "configuration",
"validation", "serialization", "encryption", "compression", "indexing",
"backup", "recovery", "migration", "versioning", "documentation",
"profiling", "debugging", "tracing", "alerting", "reporting",
}
for i := 0; i < 30; i++ {
// First observation of pair
observations[i*2] = &models.Observation{
ID: int64(i*2 + 1),
Title: sql.NullString{String: topics[i] + " implementation", Valid: true},
Narrative: sql.NullString{String: "Detailed " + topics[i] + " system design", Valid: true},
}
// Second observation of pair (similar to first)
observations[i*2+1] = &models.Observation{
ID: int64(i*2 + 2),
Title: sql.NullString{String: topics[i] + " update", Valid: true},
Narrative: sql.NullString{String: "Updated " + topics[i] + " logic", Valid: true},
}
}
clustered := ClusterObservations(observations, 0.4)
// With similar pairs, we should get roughly 30 clusters (one per topic)
t.Logf("Clustered %d observations down to %d", len(observations), len(clustered))
assert.Less(t, len(clustered), 60, "Similar observations should be clustered together")
assert.GreaterOrEqual(t, len(clustered), 1, "Should have at least one cluster")
}
func TestClusterObservationsOptimized_AllUnique(t *testing.T) {
t.Parallel()
// Create 55 completely unique observations with NO shared terms
// Each observation has only its unique term (no common words like "topic" or "content")
uniqueTerms := []string{
"aardvark", "butterfly", "caterpillar", "dragonfly", "elephant",
"flamingo", "giraffe", "hippopotamus", "iguana", "jellyfish",
"kangaroo", "leopard", "mongoose", "nightingale", "octopus",
"penguin", "quail", "rhinoceros", "salamander", "toucan",
"umbrella", "vulture", "walrus", "xylophone", "yakking",
"zebra123", "astronomy99", "biology88", "chemistry77", "dynamics66",
"economics55", "forensics44", "genetics33", "hydraulics22", "immunology11",
"jurisprudence", "kinetics", "linguistics", "metallurgy", "neurology",
"oceanography", "pharmacology", "quantumphysics", "robotics", "sociology",
"thermodynamics", "ultrasound", "virology", "wavelength", "xenobiology",
"yeastculture", "zoology123", "algebra456", "botany789", "calculus012",
}
observations := make([]*models.Observation, 55)
for i := 0; i < 55; i++ {
// Each observation has ONLY its unique term - no shared words
observations[i] = &models.Observation{
ID: int64(i + 1),
Title: sql.NullString{String: uniqueTerms[i], Valid: true},
Narrative: sql.NullString{String: uniqueTerms[i], Valid: true},
}
}
clustered := ClusterObservations(observations, 0.4)
// All unique content should remain unclustered
assert.Len(t, clustered, 55, "All unique observations should be kept")
}
func TestClusterObservationsOptimized_SignaturePrefiltering(t *testing.T) {
t.Parallel()
// Test that signature prefiltering works correctly
// Create observations where some have very different signatures
observations := make([]*models.Observation, 60)
// First half: all identical (about "authentication") - should cluster to 1
for i := 0; i < 30; i++ {
observations[i] = &models.Observation{
ID: int64(i + 1),
Title: sql.NullString{String: "authentication security login", Valid: true},
Narrative: sql.NullString{String: "JWT tokens OAuth authentication", Valid: true},
}
}
// Second half: each completely unique with NO shared terms
diffTerms := []string{
"quantumphysics", "photosynthesis", "archaeologydig", "linguisticstudy", "astronomystar",
"paleontologyfossil", "oceanographywave", "entomologybug", "mycologyfungi", "herpetologysnake",
"ornithologybird", "ichthyologyfish", "seismologyquake", "volcanologylava", "meteorologyrain",
"cartographymap", "ethnographyculture", "philologyword", "numismaticscoin", "heraldryshield",
"genealogytree", "chronologytime", "typographyfont", "calligraphyink", "epigraphystone",
"papyrologytext", "codicologybook", "diplomaticseal", "sigillographywax", "sphragisticsring",
}
for i := 30; i < 60; i++ {
term := diffTerms[i-30]
// Each has ONLY its unique term - no shared words
observations[i] = &models.Observation{
ID: int64(i + 1),
Title: sql.NullString{String: term, Valid: true},
Narrative: sql.NullString{String: term, Valid: true},
}
}
clustered := ClusterObservations(observations, 0.5)
// Should have 31 clusters: 1 for all auth topics + 30 unique topics
t.Logf("Clustered %d observations down to %d", len(observations), len(clustered))
assert.Equal(t, 31, len(clustered), "Should have 31 clusters (1 auth + 30 unique)")
}
// =============================================================================
// TESTS FOR HELPER FUNCTIONS
// =============================================================================
func TestComputeTermSignature(t *testing.T) {
tests := []struct {
terms map[string]bool
compareTo map[string]bool
name string
expectZero bool
expectSame bool
}{
// ===== GOOD CASES =====
{
name: "single term",
terms: map[string]bool{"hello": true},
expectZero: false,
},
{
name: "multiple terms",
terms: map[string]bool{"hello": true, "world": true},
expectZero: false,
},
{
name: "identical terms produce same signature",
terms: map[string]bool{"alpha": true, "beta": true},
expectSame: true,
compareTo: map[string]bool{"alpha": true, "beta": true},
},
// ===== EDGE CASES =====
{
name: "empty set",
terms: map[string]bool{},
expectZero: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sig := computeTermSignature(tt.terms)
if tt.expectZero {
assert.Equal(t, uint64(0), sig, "Empty set should produce zero signature")
} else {
assert.NotEqual(t, uint64(0), sig, "Non-empty set should produce non-zero signature")
}
if tt.expectSame && tt.compareTo != nil {
sig2 := computeTermSignature(tt.compareTo)
assert.Equal(t, sig, sig2, "Identical term sets should produce identical signatures")
}
})
}
}
func TestComputeTermSignature_DifferentSets(t *testing.T) {
t.Parallel()
// Different term sets should usually produce different signatures
set1 := map[string]bool{"authentication": true, "security": true}
set2 := map[string]bool{"database": true, "migration": true}
sig1 := computeTermSignature(set1)
sig2 := computeTermSignature(set2)
// While hash collisions are possible, they should be rare
assert.NotEqual(t, sig1, sig2, "Different term sets should usually produce different signatures")
}
func TestPopCount64(t *testing.T) {
tests := []struct {
name string
input uint64
expected int
}{
// ===== GOOD CASES =====
{name: "zero", input: 0, expected: 0},
{name: "one", input: 1, expected: 1},
{name: "powers of two", input: 8, expected: 1},
{name: "all ones in byte", input: 0xFF, expected: 8},
{name: "alternating bits", input: 0xAAAAAAAAAAAAAAAA, expected: 32},
{name: "max uint64", input: 0xFFFFFFFFFFFFFFFF, expected: 64},
// ===== EDGE CASES =====
{name: "single high bit", input: 1 << 63, expected: 1},
{name: "sparse bits", input: 0x8000000000000001, expected: 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := popCount64(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsSimilarToAny_EmptyTerms(t *testing.T) {
t.Parallel()
// Observation with no extractable terms
emptyObs := &models.Observation{
ID: 1,
Title: sql.NullString{String: "", Valid: false},
Narrative: sql.NullString{String: "", Valid: false},
}
existing := []*models.Observation{
{
ID: 2,
Title: sql.NullString{String: "Some content here", Valid: true},
Narrative: sql.NullString{String: "More content", Valid: true},
},
}
// Should return false when new observation has no terms
assert.False(t, IsSimilarToAny(emptyObs, existing, 0.3))
}
func TestExtractObservationTerms_FilesModified(t *testing.T) {
t.Parallel()
obs := &models.Observation{
ID: 1,
Title: sql.NullString{String: "Code changes", Valid: true},
FilesModified: models.JSONStringArray{"/src/handler.go", "/pkg/models/user.go"},
}
terms := ExtractObservationTerms(obs)
// Should contain filenames from FilesModified
assert.Contains(t, terms, "handler.go")
assert.Contains(t, terms, "user.go")
}
func TestAddTerms_ShortWords(t *testing.T) {
t.Parallel()
terms := make(map[string]bool)
addTerms(terms, "I am a go developer")
// Short words (< 3 chars) should be excluded
assert.NotContains(t, terms, "i")
assert.NotContains(t, terms, "am")
assert.NotContains(t, terms, "a")
assert.NotContains(t, terms, "go") // Only 2 chars
// "developer" should be included
assert.Contains(t, terms, "developer")
}
func TestAddTerms_SpecialCharacters(t *testing.T) {
t.Parallel()
terms := make(map[string]bool)
addTerms(terms, "user_id authentication-flow JWT_token")
// Hyphens split words, but underscores are kept as part of the word
// (underscore is included in the tokenization regex)
assert.Contains(t, terms, "user_id")
assert.Contains(t, terms, "authentication")
assert.Contains(t, terms, "flow")
assert.Contains(t, terms, "jwt_token")
}
func TestJaccardSimilarity_SubsetSuperset(t *testing.T) {
t.Parallel()
subset := map[string]bool{"a": true, "b": true}
superset := map[string]bool{"a": true, "b": true, "c": true, "d": true}
// Subset similarity should be intersection/union = 2/4 = 0.5
result := JaccardSimilarity(subset, superset)
assert.InDelta(t, 0.5, result, 0.001)
}
func TestClusterObservations_HighThreshold(t *testing.T) {
t.Parallel()
// With a very high threshold, almost nothing should be clustered
observations := []*models.Observation{
{ID: 1, Title: sql.NullString{String: "authentication implementation", Valid: true}},
{ID: 2, Title: sql.NullString{String: "authentication update", Valid: true}},
{ID: 3, Title: sql.NullString{String: "authentication refactor", Valid: true}},
}
// With threshold of 0.9, even similar observations shouldn't cluster
clustered := ClusterObservations(observations, 0.9)
assert.Len(t, clustered, 3, "High threshold should prevent clustering")
}
func TestClusterObservations_LowThreshold(t *testing.T) {
t.Parallel()
// With a very low threshold, more things should be clustered
observations := []*models.Observation{
{ID: 1, Title: sql.NullString{String: "authentication implementation details", Valid: true}},
{ID: 2, Title: sql.NullString{String: "authentication security update", Valid: true}},
{ID: 3, Title: sql.NullString{String: "something completely different topic", Valid: true}},
}
// With threshold of 0.1, partial overlap should cluster
clustered := ClusterObservations(observations, 0.1)
// First two share "authentication", should likely cluster
assert.LessOrEqual(t, len(clustered), 3)
}