mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
mnemonic ralphised (#24)
* Make things 'betterer' across the board * fix: reorganize struct fields and config parameters for consistency - [x] Reorder Config struct fields alphabetically and by related functionality - [x] Reorganize Observation model fields with archival fields grouped together - [x] Reorder ObservationStore fields to group related members - [x] Reorder Store struct fields with health check caching grouped - [x] Reorganize HealthInfo and PoolMetrics struct field order - [x] Reorder maintenance Service struct fields logically - [x] Reorganize MCP server handler parameter structs alphabetically - [x] Reorder pattern detector candidate tracking fields - [x] Reorganize search Manager struct fields by functionality - [x] Reorder vector Client struct fields with mutex protections grouped - [x] Reorganize handler request/response struct fields - [x] Update handlers_test.go to expect wrapped response format - [x] Reorder middleware TokenAuth and rate limiter fields - [x] Reorganize Service struct fields with grouped functionality - [x] Fix RateLimiter field ordering for clarity - [x] Reorder CircuitBreaker metrics fields * fix(security): improve JSON output safety and path traversal protection - [x] Replace unsafe JSON string formatting with proper json.Marshal in export handler - [x] Remove escapeJSONString helper function in favor of standard JSON marshaling - [x] Add safeResolvePath function to validate paths and prevent directory traversal - [x] Apply path traversal validation in captureFileMtimes operations - [x] Cap result slice capacity in getRecentSearchQueries to prevent DoS via excessive allocation * fix(sdk): improve path traversal protection and allocation safety - [x] Enhance safeResolvePath with stricter validation using filepath.Rel - [x] Reject paths containing ".." after cleaning to prevent traversal - [x] Validate absolute paths are within cwd when cwd is specified - [x] Apply safeResolvePath validation to GetFileContent for consistency - [x] Add comprehensive test coverage for path traversal protection - [x] Fix allocation safety in getRecentSearchQueries by using constant capacity * feat(dashboard): add graph stats and vector metrics endpoints - [x] Add handleGraphStats endpoint for knowledge graph visualization - [x] Add handleVectorMetrics endpoint for vector database dashboard - [x] Improve update check error handling with JSON response - [x] Register new API routes for graph and vector metrics - [x] Migrate Font Awesome to npm package from CDN - [x] Fix observations API response type handling - [x] Update package version to v0.10.5-15-g385d05a * fixup! feat(dashboard): add graph stats and vector metrics endpoints * 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 * fix(pattern): add nil check and fmt import for GetPatternInsight - [x] Add `fmt` import for error formatting - [x] Add nil check for pattern before using it - [x] Remove duplicate comment line
This commit is contained in:
@@ -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"])
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
+1912
-207
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ package pattern
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -370,12 +371,16 @@ func (d *Detector) CandidateCount() int {
|
|||||||
return len(d.candidates)
|
return len(d.candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPatternInsight returns a formatted insight string for a pattern.
|
||||||
// GetPatternInsight returns a formatted insight string for a pattern.
|
// GetPatternInsight returns a formatted insight string for a pattern.
|
||||||
func (d *Detector) GetPatternInsight(ctx context.Context, patternID int64) (string, error) {
|
func (d *Detector) GetPatternInsight(ctx context.Context, patternID int64) (string, error) {
|
||||||
pattern, err := d.patternStore.GetPatternByID(ctx, patternID)
|
pattern, err := d.patternStore.GetPatternByID(ctx, patternID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if pattern == nil {
|
||||||
|
return "", fmt.Errorf("pattern not found: %d", patternID)
|
||||||
|
}
|
||||||
|
|
||||||
return formatPatternInsight(pattern), nil
|
return formatPatternInsight(pattern), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestGeneratePatternName(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
patternType models.PatternType
|
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) {
|
func TestFormatPatternInsight(t *testing.T) {
|
||||||
// Pattern without recommendation
|
// Pattern without recommendation
|
||||||
pattern1 := &models.Pattern{
|
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
|
// Helper functions
|
||||||
|
|
||||||
func setupTestStore(t *testing.T) *gorm.Store {
|
func setupTestStore(t *testing.T) *gorm.Store {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -593,3 +593,118 @@ func (s *Service) handleGetObservationByID(w http.ResponseWriter, r *http.Reques
|
|||||||
|
|
||||||
writeJSON(w, obs)
|
writeJSON(w, obs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGraphStats returns graph statistics for the dashboard.
|
||||||
|
// Uses relation data to compute knowledge graph metrics.
|
||||||
|
func (s *Service) handleGraphStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Get relation count (edges) - this represents the knowledge graph
|
||||||
|
edgeCount, err := s.relationStore.GetTotalRelationCount(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
edgeCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count by relation type
|
||||||
|
edgeTypes := make(map[string]int)
|
||||||
|
for _, t := range models.AllRelationTypes {
|
||||||
|
relations, err := s.relationStore.GetRelationsByType(r.Context(), t, 10000)
|
||||||
|
if err == nil {
|
||||||
|
edgeTypes[string(t)] = len(relations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique observation IDs involved in relations (approximate node count)
|
||||||
|
// For now, use edge count as a proxy - each edge has 2 nodes
|
||||||
|
nodeCount := 0
|
||||||
|
if edgeCount > 0 {
|
||||||
|
// Rough estimate: unique nodes ≈ edges * 1.5 (since nodes can have multiple edges)
|
||||||
|
nodeCount = int(float64(edgeCount) * 1.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate average degree
|
||||||
|
var avgDegree float64
|
||||||
|
if nodeCount > 0 {
|
||||||
|
avgDegree = float64(edgeCount*2) / float64(nodeCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graph is enabled if we have any edges (relations)
|
||||||
|
enabled := edgeCount > 0
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"enabled": enabled,
|
||||||
|
"nodeCount": nodeCount,
|
||||||
|
"edgeCount": edgeCount,
|
||||||
|
"avgDegree": avgDegree,
|
||||||
|
"maxDegree": 0,
|
||||||
|
"minDegree": 0,
|
||||||
|
"medianDegree": 0.0,
|
||||||
|
"edgeTypes": edgeTypes,
|
||||||
|
"config": map[string]any{
|
||||||
|
"maxHops": 2,
|
||||||
|
"branchFactor": 10,
|
||||||
|
"edgeWeight": 0.3,
|
||||||
|
"rebuildIntervalMin": 30,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVectorMetrics returns vector database metrics for the dashboard.
|
||||||
|
// Returns enabled: false if vector features are not available.
|
||||||
|
func (s *Service) handleVectorMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.vectorClient == nil {
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"enabled": false,
|
||||||
|
"message": "Vector database not initialized",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cache stats from vector client
|
||||||
|
cacheSize, cacheMax := s.vectorClient.CacheStats()
|
||||||
|
cacheStats := s.vectorClient.GetCacheStats()
|
||||||
|
count, _ := s.vectorClient.Count(r.Context())
|
||||||
|
|
||||||
|
uptime := time.Since(s.startTime).Round(time.Second).String()
|
||||||
|
|
||||||
|
// Calculate total queries from cache hits/misses
|
||||||
|
totalQueries := cacheStats.EmbeddingHits + cacheStats.EmbeddingMisses + cacheStats.ResultHits + cacheStats.ResultMisses
|
||||||
|
totalHits := cacheStats.EmbeddingHits + cacheStats.ResultHits
|
||||||
|
totalMisses := cacheStats.EmbeddingMisses + cacheStats.ResultMisses
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"enabled": true,
|
||||||
|
"queries": map[string]any{
|
||||||
|
"total": totalQueries,
|
||||||
|
"hubOnly": 0,
|
||||||
|
"hybrid": 0,
|
||||||
|
"onDemand": 0,
|
||||||
|
"graph": 0,
|
||||||
|
},
|
||||||
|
"latency": map[string]any{
|
||||||
|
"avg": "0ms",
|
||||||
|
"p50": "0ms",
|
||||||
|
"p95": "0ms",
|
||||||
|
"p99": "0ms",
|
||||||
|
"avgHub": "0ms",
|
||||||
|
"avgRecompute": "0ms",
|
||||||
|
},
|
||||||
|
"storage": map[string]any{
|
||||||
|
"totalDocuments": count,
|
||||||
|
"hubDocuments": 0,
|
||||||
|
"storedEmbeddings": count,
|
||||||
|
"savingsPercent": 0.0,
|
||||||
|
"recomputedTotal": 0,
|
||||||
|
},
|
||||||
|
"cache": map[string]any{
|
||||||
|
"hits": totalHits,
|
||||||
|
"misses": totalMisses,
|
||||||
|
"hitRate": cacheStats.HitRate(),
|
||||||
|
"size": cacheSize,
|
||||||
|
"maxSize": cacheMax,
|
||||||
|
},
|
||||||
|
"graph": map[string]any{
|
||||||
|
"traversals": 0,
|
||||||
|
"avgDepth": 0.0,
|
||||||
|
},
|
||||||
|
"uptime": uptime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package worker
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
@@ -13,7 +14,14 @@ import (
|
|||||||
func (s *Service) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
func (s *Service) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
info, err := s.updater.CheckForUpdate(r.Context())
|
info, err := s.updater.CheckForUpdate(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
// Return a proper JSON response for errors instead of 500
|
||||||
|
// This allows the frontend to handle it gracefully
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"available": false,
|
||||||
|
"current_version": s.version,
|
||||||
|
"error": err.Error(),
|
||||||
|
"rate_limited": strings.Contains(err.Error(), "403"),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, info)
|
writeJSON(w, info)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package sdk
|
package sdk
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||||
"github.com/stretchr/testify/assert"
|
"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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1198,6 +1198,8 @@ func (s *Service) setupRoutes() {
|
|||||||
// Vector management endpoints
|
// Vector management endpoints
|
||||||
s.router.Post("/api/vectors/rebuild", s.handleTriggerVectorRebuild)
|
s.router.Post("/api/vectors/rebuild", s.handleTriggerVectorRebuild)
|
||||||
s.router.Get("/api/vectors/health", s.handleVectorHealth)
|
s.router.Get("/api/vectors/health", s.handleVectorHealth)
|
||||||
|
s.router.Get("/api/vector/metrics", s.handleVectorMetrics)
|
||||||
|
s.router.Get("/api/graph/stats", s.handleGraphStats)
|
||||||
|
|
||||||
// Readiness check - returns 200 only when fully initialized
|
// Readiness check - returns 200 only when fully initialized
|
||||||
s.router.Get("/api/ready", s.handleReady)
|
s.router.Get("/api/ready", s.handleReady)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -290,3 +290,334 @@ func TestClusterObservations_PreservesOrder(t *testing.T) {
|
|||||||
require.NotEmpty(t, clustered)
|
require.NotEmpty(t, clustered)
|
||||||
assert.Equal(t, int64(1), clustered[0].ID, "First observation should be kept as first result")
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Claude Mnemonic Dashboard</title>
|
<title>Claude Mnemonic Dashboard</title>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 min-h-screen text-white">
|
<body class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 min-h-screen text-white">
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mnemonic-dashboard",
|
"name": "claude-mnemonic-dashboard",
|
||||||
"version": "v0.10.5-1-g7ab4b07-dirty",
|
"version": "v0.10.5-15-g385d05a-dirty",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "claude-mnemonic-dashboard",
|
"name": "claude-mnemonic-dashboard",
|
||||||
"version": "v0.10.5-1-g7ab4b07-dirty",
|
"version": "v0.10.5-15-g385d05a-dirty",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vis-data": "^7.1.9",
|
"vis-data": "^7.1.9",
|
||||||
"vis-network": "^9.1.9",
|
"vis-network": "^9.1.9",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mnemonic-dashboard",
|
"name": "claude-mnemonic-dashboard",
|
||||||
"version": "v0.10.5-1-g7ab4b07-dirty",
|
"version": "v0.10.5-15-g385d05a-dirty",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
createApp(App).mount('#app')
|
||||||
|
|||||||
+10
-1
@@ -89,10 +89,19 @@ async function fetchWithRetry<T>(url: string, options: FetchOptions = {}): Promi
|
|||||||
throw lastError!
|
throw lastError!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ObservationsResponse {
|
||||||
|
observations: Observation[]
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
hasMore: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchObservations(limit: number = 100, project?: string, signal?: AbortSignal): Promise<Observation[]> {
|
export async function fetchObservations(limit: number = 100, project?: string, signal?: AbortSignal): Promise<Observation[]> {
|
||||||
const params = new URLSearchParams({ limit: String(limit) })
|
const params = new URLSearchParams({ limit: String(limit) })
|
||||||
if (project) params.append('project', project)
|
if (project) params.append('project', project)
|
||||||
return fetchWithRetry<Observation[]>(`${API_BASE}/observations?${params}`, { signal })
|
const response = await fetchWithRetry<ObservationsResponse>(`${API_BASE}/observations?${params}`, { signal })
|
||||||
|
return response.observations || []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPrompts(limit: number = 100, project?: string, signal?: AbortSignal): Promise<UserPrompt[]> {
|
export async function fetchPrompts(limit: number = 100, project?: string, signal?: AbortSignal): Promise<UserPrompt[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user