mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
5335a8a7a6
* 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
1013 lines
29 KiB
Go
1013 lines
29 KiB
Go
package pattern
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
|
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
|
)
|
|
|
|
func TestNewDetector(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)
|
|
if detector == nil {
|
|
t.Fatal("Expected non-nil detector")
|
|
}
|
|
|
|
if detector.config.MinMatchScore != config.MinMatchScore {
|
|
t.Errorf("Expected MinMatchScore %f, got %f",
|
|
config.MinMatchScore, detector.config.MinMatchScore)
|
|
}
|
|
}
|
|
|
|
func TestDetector_StartStop(t *testing.T) {
|
|
store := setupTestStore(t)
|
|
defer store.Close()
|
|
|
|
patternStore := gorm.NewPatternStore(store)
|
|
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
|
|
config := DefaultConfig()
|
|
config.AnalysisInterval = 100 * time.Millisecond // Short interval for testing
|
|
|
|
detector := NewDetector(patternStore, observationStore, config)
|
|
|
|
// Start
|
|
detector.Start()
|
|
|
|
// Wait a bit to ensure background goroutine is running
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Stop
|
|
detector.Stop()
|
|
|
|
// Verify we can stop without hanging
|
|
// (if this test hangs, the Stop logic is broken)
|
|
}
|
|
|
|
func TestDetector_AnalyzeObservation_NewCandidate(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()
|
|
|
|
// Create an observation
|
|
obs := createTestObservation(1, "nil-handling", []string{"nil", "error-handling"})
|
|
|
|
// Analyze first observation
|
|
result, err := detector.AnalyzeObservation(ctx, obs)
|
|
if err != nil {
|
|
t.Fatalf("AnalyzeObservation() error = %v", err)
|
|
}
|
|
|
|
// First observation should create a candidate, not a pattern
|
|
if result.MatchedPattern != nil {
|
|
t.Errorf("Expected no pattern match for first observation")
|
|
}
|
|
if result.IsNewPattern {
|
|
t.Errorf("Expected IsNewPattern to be false for first observation")
|
|
}
|
|
}
|
|
|
|
func TestDetector_AnalyzeObservation_PromoteToPattern(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()
|
|
|
|
// Create two similar observations
|
|
obs1 := createTestObservation(1, "Nil pointer handling", []string{"nil", "error-handling"})
|
|
obs2 := createTestObservation(2, "Nil pointer handling", []string{"nil", "error-handling"})
|
|
|
|
// Analyze first observation
|
|
_, err := detector.AnalyzeObservation(ctx, obs1)
|
|
if err != nil {
|
|
t.Fatalf("AnalyzeObservation(obs1) error = %v", err)
|
|
}
|
|
|
|
// Analyze second observation - should promote to pattern
|
|
result, err := detector.AnalyzeObservation(ctx, obs2)
|
|
if err != nil {
|
|
t.Fatalf("AnalyzeObservation(obs2) error = %v", err)
|
|
}
|
|
|
|
if result.MatchedPattern == nil {
|
|
t.Fatal("Expected pattern to be created after second occurrence")
|
|
}
|
|
if !result.IsNewPattern {
|
|
t.Errorf("Expected IsNewPattern to be true for newly promoted pattern")
|
|
}
|
|
if result.MatchedPattern.Frequency != 2 {
|
|
t.Errorf("Expected frequency 2, got %d", result.MatchedPattern.Frequency)
|
|
}
|
|
}
|
|
|
|
func TestDetector_AnalyzeObservation_MatchExisting(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 an existing pattern
|
|
pattern := &models.Pattern{
|
|
Name: "Existing Pattern",
|
|
Type: models.PatternTypeBug,
|
|
Signature: []string{"nil", "error-handling", "pointer"},
|
|
Frequency: 5,
|
|
Projects: []string{"proj1"},
|
|
ObservationIDs: []int64{1, 2, 3, 4, 5},
|
|
Status: models.PatternStatusActive,
|
|
Confidence: 0.7,
|
|
LastSeenAt: time.Now().Format(time.RFC3339),
|
|
LastSeenEpoch: time.Now().UnixMilli(),
|
|
CreatedAt: time.Now().Format(time.RFC3339),
|
|
CreatedAtEpoch: time.Now().UnixMilli(),
|
|
}
|
|
_, _ = patternStore.StorePattern(ctx, pattern)
|
|
|
|
// Create observation with similar signature
|
|
obs := createTestObservation(10, "Nil check", []string{"nil", "error-handling"})
|
|
|
|
// Analyze - should match existing pattern
|
|
result, err := detector.AnalyzeObservation(ctx, obs)
|
|
if err != nil {
|
|
t.Fatalf("AnalyzeObservation() error = %v", err)
|
|
}
|
|
|
|
if result.MatchedPattern == nil {
|
|
t.Fatal("Expected to match existing pattern")
|
|
}
|
|
if result.IsNewPattern {
|
|
t.Errorf("Expected IsNewPattern to be false for existing pattern")
|
|
}
|
|
if result.MatchScore < 0.3 {
|
|
t.Errorf("Expected match score >= 0.3, got %f", result.MatchScore)
|
|
}
|
|
}
|
|
|
|
func TestDetector_AnalyzeObservation_NoMatch(t *testing.T) {
|
|
store := setupTestStore(t)
|
|
defer store.Close()
|
|
|
|
patternStore := gorm.NewPatternStore(store)
|
|
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
|
|
config := DefaultConfig()
|
|
config.MinMatchScore = 0.5 // Higher threshold
|
|
|
|
detector := NewDetector(patternStore, observationStore, config)
|
|
ctx := context.Background()
|
|
|
|
// Create an existing pattern with specific signature
|
|
pattern := &models.Pattern{
|
|
Name: "Specific Pattern",
|
|
Type: models.PatternTypeBug,
|
|
Signature: []string{"database", "connection", "pool"},
|
|
Frequency: 3,
|
|
Projects: []string{"proj1"},
|
|
ObservationIDs: []int64{1, 2, 3},
|
|
Status: models.PatternStatusActive,
|
|
Confidence: 0.6,
|
|
LastSeenAt: time.Now().Format(time.RFC3339),
|
|
LastSeenEpoch: time.Now().UnixMilli(),
|
|
CreatedAt: time.Now().Format(time.RFC3339),
|
|
CreatedAtEpoch: time.Now().UnixMilli(),
|
|
}
|
|
_, _ = patternStore.StorePattern(ctx, pattern)
|
|
|
|
// Create observation with completely different signature
|
|
obs := createTestObservation(10, "UI Component", []string{"frontend", "react", "component"})
|
|
|
|
// Analyze - should not match
|
|
result, err := detector.AnalyzeObservation(ctx, obs)
|
|
if err != nil {
|
|
t.Fatalf("AnalyzeObservation() error = %v", err)
|
|
}
|
|
|
|
if result.MatchedPattern != nil {
|
|
t.Errorf("Expected no match for unrelated observation")
|
|
}
|
|
}
|
|
|
|
func TestDetector_CandidateCleanup(t *testing.T) {
|
|
store := setupTestStore(t)
|
|
defer store.Close()
|
|
|
|
patternStore := gorm.NewPatternStore(store)
|
|
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
|
|
config := DefaultConfig()
|
|
config.MinFrequencyForPattern = 3 // Higher threshold
|
|
|
|
detector := NewDetector(patternStore, observationStore, config)
|
|
|
|
// Add an old candidate manually
|
|
oldKey := "old|candidate|"
|
|
detector.candidates[oldKey] = &candidatePattern{
|
|
signature: []string{"old", "candidate"},
|
|
observationIDs: []int64{1},
|
|
projects: []string{"proj1"},
|
|
patternType: models.PatternTypeBug,
|
|
title: "Old Candidate",
|
|
lastSeenEpoch: time.Now().Add(-8 * 24 * time.Hour).UnixMilli(), // 8 days ago
|
|
}
|
|
|
|
// Add a recent candidate
|
|
recentKey := "recent|candidate|"
|
|
detector.candidates[recentKey] = &candidatePattern{
|
|
signature: []string{"recent", "candidate"},
|
|
observationIDs: []int64{2},
|
|
projects: []string{"proj1"},
|
|
patternType: models.PatternTypeBug,
|
|
title: "Recent Candidate",
|
|
lastSeenEpoch: time.Now().UnixMilli(),
|
|
}
|
|
|
|
// Run cleanup
|
|
detector.cleanupOldCandidates()
|
|
|
|
// Old candidate should be removed
|
|
if _, exists := detector.candidates[oldKey]; exists {
|
|
t.Errorf("Expected old candidate to be cleaned up")
|
|
}
|
|
|
|
// Recent candidate should remain
|
|
if _, exists := detector.candidates[recentKey]; !exists {
|
|
t.Errorf("Expected recent candidate to remain")
|
|
}
|
|
}
|
|
|
|
func TestDetector_GetPatternInsight(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 pattern with recommendation
|
|
pattern := &models.Pattern{
|
|
Name: "Insight Test Pattern",
|
|
Type: models.PatternTypeBestPractice,
|
|
Signature: []string{"test"},
|
|
Recommendation: sql.NullString{String: "Always write tests first", Valid: true},
|
|
Frequency: 12,
|
|
Projects: []string{"proj1", "proj2", "proj3"},
|
|
ObservationIDs: []int64{1},
|
|
Status: models.PatternStatusActive,
|
|
Confidence: 0.8,
|
|
LastSeenAt: time.Now().Format(time.RFC3339),
|
|
LastSeenEpoch: time.Now().UnixMilli(),
|
|
CreatedAt: time.Now().Format(time.RFC3339),
|
|
CreatedAtEpoch: time.Now().UnixMilli(),
|
|
}
|
|
id, _ := patternStore.StorePattern(ctx, pattern)
|
|
|
|
// Get insight
|
|
insight, err := detector.GetPatternInsight(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("GetPatternInsight() error = %v", err)
|
|
}
|
|
|
|
// Verify insight contains expected elements
|
|
if insight == "" {
|
|
t.Error("Expected non-empty insight")
|
|
}
|
|
if !containsString(insight, "12 times") {
|
|
t.Errorf("Expected insight to contain frequency, got: %s", insight)
|
|
}
|
|
if !containsString(insight, "3 projects") {
|
|
t.Errorf("Expected insight to contain project count, got: %s", insight)
|
|
}
|
|
if !containsString(insight, "Always write tests first") {
|
|
t.Errorf("Expected insight to contain recommendation, got: %s", insight)
|
|
}
|
|
}
|
|
|
|
func TestDefaultConfig(t *testing.T) {
|
|
config := DefaultConfig()
|
|
|
|
if config.MinMatchScore <= 0 || config.MinMatchScore > 1 {
|
|
t.Errorf("Invalid MinMatchScore: %f", config.MinMatchScore)
|
|
}
|
|
if config.MinFrequencyForPattern < 1 {
|
|
t.Errorf("Invalid MinFrequencyForPattern: %d", config.MinFrequencyForPattern)
|
|
}
|
|
if config.AnalysisInterval <= 0 {
|
|
t.Errorf("Invalid AnalysisInterval: %v", config.AnalysisInterval)
|
|
}
|
|
if config.MaxPatternsToTrack <= 0 {
|
|
t.Errorf("Invalid MaxPatternsToTrack: %d", config.MaxPatternsToTrack)
|
|
}
|
|
}
|
|
|
|
func TestDefaultConfig_AllFieldsValid(t *testing.T) {
|
|
config := DefaultConfig()
|
|
|
|
if config.MinMatchScore != 0.3 {
|
|
t.Errorf("MinMatchScore = %f, want 0.3", config.MinMatchScore)
|
|
}
|
|
if config.MinFrequencyForPattern != 2 {
|
|
t.Errorf("MinFrequencyForPattern = %d, want 2", config.MinFrequencyForPattern)
|
|
}
|
|
if config.AnalysisInterval != 5*time.Minute {
|
|
t.Errorf("AnalysisInterval = %v, want 5m", config.AnalysisInterval)
|
|
}
|
|
if config.MaxPatternsToTrack != 1000 {
|
|
t.Errorf("MaxPatternsToTrack = %d, want 1000", config.MaxPatternsToTrack)
|
|
}
|
|
if config.MaxCandidates != 500 {
|
|
t.Errorf("MaxCandidates = %d, want 500", config.MaxCandidates)
|
|
}
|
|
}
|
|
|
|
func TestGeneratePatternName(t *testing.T) {
|
|
tests := []struct {
|
|
patternType models.PatternType
|
|
title string
|
|
wantPrefix string
|
|
signature []string
|
|
}{
|
|
{patternType: models.PatternTypeBug, title: "", wantPrefix: "Bug Pattern:", signature: []string{"nil", "error"}},
|
|
{patternType: models.PatternTypeRefactor, title: "", wantPrefix: "Refactor Pattern:", signature: []string{"extract"}},
|
|
{patternType: models.PatternTypeArchitecture, title: "", wantPrefix: "Architecture Pattern:", signature: []string{"service"}},
|
|
{patternType: models.PatternTypeAntiPattern, title: "", wantPrefix: "Anti-Pattern:", signature: []string{"god-class"}},
|
|
{patternType: models.PatternTypeBestPractice, title: "", wantPrefix: "Best Practice:", signature: []string{"testing"}},
|
|
{patternType: models.PatternTypeBug, title: "Short Title", wantPrefix: "Short Title", signature: []string{}}, // Use title directly
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
name := generatePatternName(tt.patternType, tt.signature, tt.title)
|
|
if !hasPrefix(name, tt.wantPrefix) {
|
|
t.Errorf("generatePatternName(%v, %v, %q) = %q, want prefix %q",
|
|
tt.patternType, tt.signature, tt.title, name, tt.wantPrefix)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGeneratePatternName_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ptype models.PatternType
|
|
title string
|
|
want string
|
|
signature []string
|
|
}{
|
|
{
|
|
name: "with title uses title directly",
|
|
ptype: models.PatternTypeBug,
|
|
signature: []string{"ignored"},
|
|
title: "Custom Title",
|
|
want: "Custom Title",
|
|
},
|
|
{
|
|
name: "long title generates from signature",
|
|
ptype: models.PatternTypeBug,
|
|
signature: []string{"sig1", "sig2"},
|
|
title: "This is a very long title that exceeds sixty characters and should be ignored",
|
|
want: "Bug Pattern: sig1, sig2",
|
|
},
|
|
{
|
|
name: "empty signature returns Unnamed",
|
|
ptype: models.PatternTypeBug,
|
|
signature: []string{},
|
|
title: "",
|
|
want: "Bug Pattern: Unnamed",
|
|
},
|
|
{
|
|
name: "single signature element",
|
|
ptype: models.PatternTypeRefactor,
|
|
signature: []string{"single"},
|
|
title: "",
|
|
want: "Refactor Pattern: single",
|
|
},
|
|
{
|
|
name: "more than 3 signature elements truncates",
|
|
ptype: models.PatternTypeBestPractice,
|
|
signature: []string{"a", "b", "c", "d", "e"},
|
|
title: "",
|
|
want: "Best Practice: a, b, c",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := generatePatternName(tt.ptype, tt.signature, tt.title)
|
|
if got != tt.want {
|
|
t.Errorf("generatePatternName() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGeneratePatternName_AllTypes(t *testing.T) {
|
|
tests := []struct {
|
|
ptype models.PatternType
|
|
wantPrefix string
|
|
}{
|
|
{models.PatternTypeBug, "Bug Pattern:"},
|
|
{models.PatternTypeRefactor, "Refactor Pattern:"},
|
|
{models.PatternTypeArchitecture, "Architecture Pattern:"},
|
|
{models.PatternTypeAntiPattern, "Anti-Pattern:"},
|
|
{models.PatternTypeBestPractice, "Best Practice:"},
|
|
{models.PatternType("unknown"), "test"}, // Unknown type has empty prefix, starts with first signature element
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(string(tt.ptype), func(t *testing.T) {
|
|
name := generatePatternName(tt.ptype, []string{"test", "sig"}, "")
|
|
if !hasPrefix(name, tt.wantPrefix) {
|
|
t.Errorf("Expected prefix %q for type %s, got: %s",
|
|
tt.wantPrefix, tt.ptype, name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFormatPatternInsight(t *testing.T) {
|
|
// Pattern without recommendation
|
|
pattern1 := &models.Pattern{
|
|
Type: models.PatternTypeBug,
|
|
Frequency: 5,
|
|
Projects: []string{"proj1"},
|
|
}
|
|
insight1 := formatPatternInsight(pattern1)
|
|
if !containsString(insight1, "5 times") {
|
|
t.Errorf("Expected insight to contain frequency")
|
|
}
|
|
if !containsString(insight1, "recurring bug pattern") {
|
|
t.Errorf("Expected bug pattern description")
|
|
}
|
|
|
|
// Pattern with recommendation
|
|
pattern2 := &models.Pattern{
|
|
Type: models.PatternTypeBestPractice,
|
|
Frequency: 10,
|
|
Projects: []string{"proj1", "proj2"},
|
|
Recommendation: sql.NullString{String: "Do this", Valid: true},
|
|
}
|
|
insight2 := formatPatternInsight(pattern2)
|
|
if !containsString(insight2, "10 times") {
|
|
t.Errorf("Expected insight to contain frequency")
|
|
}
|
|
if !containsString(insight2, "2 projects") {
|
|
t.Errorf("Expected insight to contain project count")
|
|
}
|
|
if !containsString(insight2, "Do this") {
|
|
t.Errorf("Expected insight to contain recommendation")
|
|
}
|
|
}
|
|
|
|
func TestFormatPatternInsight_AllTypes(t *testing.T) {
|
|
types := []struct {
|
|
ptype models.PatternType
|
|
contains string
|
|
}{
|
|
{models.PatternTypeBug, "bug pattern"},
|
|
{models.PatternTypeRefactor, "recognized pattern"}, // Falls to default case
|
|
{models.PatternTypeArchitecture, "recognized pattern"}, // Falls to default case
|
|
{models.PatternTypeAntiPattern, "anti-pattern"},
|
|
{models.PatternTypeBestPractice, "best practice"},
|
|
{models.PatternType("unknown"), "recognized pattern"}, // Falls to default case
|
|
}
|
|
|
|
for _, tt := range types {
|
|
t.Run(string(tt.ptype), func(t *testing.T) {
|
|
pattern := &models.Pattern{
|
|
Type: tt.ptype,
|
|
Frequency: 3,
|
|
Projects: []string{"proj1"},
|
|
}
|
|
insight := formatPatternInsight(pattern)
|
|
if !containsString(insight, tt.contains) {
|
|
t.Errorf("Expected insight to contain %q for type %s, got: %s",
|
|
tt.contains, tt.ptype, insight)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFormatPatternInsight_MultiProject(t *testing.T) {
|
|
pattern := &models.Pattern{
|
|
Type: models.PatternTypeBug,
|
|
Frequency: 10,
|
|
Projects: []string{"proj1", "proj2", "proj3"},
|
|
}
|
|
|
|
insight := formatPatternInsight(pattern)
|
|
|
|
if !containsString(insight, "10 times") {
|
|
t.Error("Expected frequency in insight")
|
|
}
|
|
if !containsString(insight, "3 projects") {
|
|
t.Error("Expected project count in insight")
|
|
}
|
|
}
|
|
|
|
func TestFormatPatternInsight_SingleProject(t *testing.T) {
|
|
pattern := &models.Pattern{
|
|
Type: models.PatternTypeBestPractice,
|
|
Frequency: 5,
|
|
Projects: []string{"only-one"},
|
|
}
|
|
|
|
insight := formatPatternInsight(pattern)
|
|
|
|
if !containsString(insight, "5 times") {
|
|
t.Error("Expected frequency in insight")
|
|
}
|
|
// Single project should NOT mention "projects"
|
|
if containsString(insight, "projects") {
|
|
t.Error("Single project should not mention 'projects'")
|
|
}
|
|
}
|
|
|
|
func TestDetector_SetSyncFunc(t *testing.T) {
|
|
store := setupTestStore(t)
|
|
defer store.Close()
|
|
|
|
patternStore := gorm.NewPatternStore(store)
|
|
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
|
|
config := DefaultConfig()
|
|
|
|
detector := NewDetector(patternStore, observationStore, config)
|
|
|
|
// Initially nil
|
|
if detector.syncFunc != nil {
|
|
t.Error("Expected syncFunc to be nil initially")
|
|
}
|
|
|
|
// Set sync func
|
|
var syncCalled bool
|
|
detector.SetSyncFunc(func(p *models.Pattern) {
|
|
syncCalled = true
|
|
})
|
|
|
|
if detector.syncFunc == nil {
|
|
t.Error("Expected syncFunc to be set")
|
|
}
|
|
|
|
// Verify it can be called
|
|
detector.syncFunc(&models.Pattern{})
|
|
if !syncCalled {
|
|
t.Error("Expected sync function to be called")
|
|
}
|
|
}
|
|
|
|
func TestDetector_CandidateCount(t *testing.T) {
|
|
store := setupTestStore(t)
|
|
defer store.Close()
|
|
|
|
patternStore := gorm.NewPatternStore(store)
|
|
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
|
|
config := DefaultConfig()
|
|
|
|
detector := NewDetector(patternStore, observationStore, config)
|
|
|
|
// Initially zero
|
|
if count := detector.CandidateCount(); count != 0 {
|
|
t.Errorf("Expected 0 candidates, got %d", count)
|
|
}
|
|
|
|
// Add some candidates
|
|
detector.candidates["key1"] = &candidatePattern{}
|
|
detector.candidates["key2"] = &candidatePattern{}
|
|
|
|
if count := detector.CandidateCount(); count != 2 {
|
|
t.Errorf("Expected 2 candidates, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestDetector_AnalyzeRecentObservations(t *testing.T) {
|
|
store := setupTestStore(t)
|
|
defer store.Close()
|
|
|
|
patternStore := gorm.NewPatternStore(store)
|
|
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
|
|
config := DefaultConfig()
|
|
|
|
detector := NewDetector(patternStore, observationStore, config)
|
|
ctx := context.Background()
|
|
|
|
// Should not error even with no observations
|
|
err := detector.AnalyzeRecentObservations(ctx)
|
|
if err != nil {
|
|
t.Fatalf("AnalyzeRecentObservations() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGenerateCandidateKey(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
want string
|
|
signature []string
|
|
}{
|
|
{
|
|
name: "single element",
|
|
signature: []string{"error"},
|
|
want: "error|",
|
|
},
|
|
{
|
|
name: "multiple elements",
|
|
signature: []string{"error", "handling", "nil"},
|
|
want: "error|handling|nil|",
|
|
},
|
|
{
|
|
name: "empty signature",
|
|
signature: []string{},
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := generateCandidateKey(tt.signature)
|
|
if got != tt.want {
|
|
t.Errorf("generateCandidateKey() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateCandidateKey_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
want string
|
|
signature []string
|
|
}{
|
|
{
|
|
name: "nil signature",
|
|
signature: nil,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "empty strings in signature",
|
|
signature: []string{"", ""},
|
|
want: "||",
|
|
},
|
|
{
|
|
name: "special characters",
|
|
signature: []string{"error|handling", "nil"},
|
|
want: "error|handling|nil|",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := generateCandidateKey(tt.signature)
|
|
if got != tt.want {
|
|
t.Errorf("generateCandidateKey() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestItoa(t *testing.T) {
|
|
tests := []struct {
|
|
want string
|
|
input int
|
|
}{
|
|
{"0", 0},
|
|
{"1", 1},
|
|
{"10", 10},
|
|
{"123", 123},
|
|
{"-1", -1},
|
|
{"-123", -123},
|
|
{"1000000", 1000000},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.want, func(t *testing.T) {
|
|
got := itoa(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("itoa(%d) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestItoa_EdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
want string
|
|
input int
|
|
}{
|
|
{"0", 0},
|
|
{"0", -0},
|
|
{"1", 1},
|
|
{"-1", -1},
|
|
{"9", 9},
|
|
{"10", 10},
|
|
{"99", 99},
|
|
{"100", 100},
|
|
{"999", 999},
|
|
{"1000", 1000},
|
|
{"-999", -999},
|
|
{"-1000", -1000},
|
|
{"2147483647", 2147483647}, // Max int32
|
|
{"-2147483647", -2147483647}, // Min int32 + 1
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.want, func(t *testing.T) {
|
|
got := itoa(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("itoa(%d) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDetectionResult_ZeroValue(t *testing.T) {
|
|
result := &DetectionResult{}
|
|
|
|
if result.MatchedPattern != nil {
|
|
t.Error("Zero value should have nil MatchedPattern")
|
|
}
|
|
if result.MatchScore != 0 {
|
|
t.Error("Zero value should have 0 MatchScore")
|
|
}
|
|
if result.IsNewPattern {
|
|
t.Error("Zero value should have false IsNewPattern")
|
|
}
|
|
}
|
|
|
|
func TestCandidatePattern_Fields(t *testing.T) {
|
|
candidate := &candidatePattern{
|
|
patternType: models.PatternTypeBug,
|
|
title: "Test Title",
|
|
signature: []string{"sig1", "sig2"},
|
|
observationIDs: []int64{1, 2, 3},
|
|
projects: []string{"proj1", "proj2"},
|
|
lastSeenEpoch: time.Now().UnixMilli(),
|
|
}
|
|
|
|
if candidate.patternType != models.PatternTypeBug {
|
|
t.Error("Wrong pattern type")
|
|
}
|
|
if candidate.title != "Test Title" {
|
|
t.Error("Wrong title")
|
|
}
|
|
if len(candidate.signature) != 2 {
|
|
t.Error("Wrong signature length")
|
|
}
|
|
if len(candidate.observationIDs) != 3 {
|
|
t.Error("Wrong observationIDs length")
|
|
}
|
|
if len(candidate.projects) != 2 {
|
|
t.Error("Wrong projects length")
|
|
}
|
|
}
|
|
|
|
func TestDetector_AnalyzeObservation_EmptySignature(t *testing.T) {
|
|
store := setupTestStore(t)
|
|
defer store.Close()
|
|
|
|
patternStore := gorm.NewPatternStore(store)
|
|
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
|
|
config := DefaultConfig()
|
|
|
|
detector := NewDetector(patternStore, observationStore, config)
|
|
ctx := context.Background()
|
|
|
|
// Create observation with empty concepts/title/narrative
|
|
obs := &models.Observation{
|
|
ID: 1,
|
|
SDKSessionID: "test-session",
|
|
Project: "test-project",
|
|
Scope: models.ScopeProject,
|
|
Type: models.ObsTypeBugfix,
|
|
// All fields that would create signature are empty
|
|
}
|
|
|
|
result, err := detector.AnalyzeObservation(ctx, obs)
|
|
if err != nil {
|
|
t.Fatalf("AnalyzeObservation() error = %v", err)
|
|
}
|
|
|
|
// Should return empty result for empty signature
|
|
if result.MatchedPattern != nil {
|
|
t.Error("Expected nil pattern for empty signature")
|
|
}
|
|
}
|
|
|
|
func TestDetector_AnalyzeObservation_CandidateEviction(t *testing.T) {
|
|
store := setupTestStore(t)
|
|
defer store.Close()
|
|
|
|
patternStore := gorm.NewPatternStore(store)
|
|
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
|
|
config := DefaultConfig()
|
|
config.MaxCandidates = 2 // Very small for testing
|
|
config.MinFrequencyForPattern = 10 // High so nothing gets promoted
|
|
|
|
detector := NewDetector(patternStore, observationStore, config)
|
|
ctx := context.Background()
|
|
|
|
// Add observations with different signatures until we exceed MaxCandidates
|
|
obs1 := createTestObservation(1, "First", []string{"first", "unique"})
|
|
obs2 := createTestObservation(2, "Second", []string{"second", "unique"})
|
|
obs3 := createTestObservation(3, "Third", []string{"third", "unique"})
|
|
|
|
// Analyze all observations
|
|
_, _ = detector.AnalyzeObservation(ctx, obs1)
|
|
time.Sleep(10 * time.Millisecond) // Small delay so timestamps differ
|
|
_, _ = detector.AnalyzeObservation(ctx, obs2)
|
|
time.Sleep(10 * time.Millisecond)
|
|
_, _ = detector.AnalyzeObservation(ctx, obs3)
|
|
|
|
// Should have at most MaxCandidates
|
|
if count := detector.CandidateCount(); count > config.MaxCandidates {
|
|
t.Errorf("Expected at most %d candidates, got %d", config.MaxCandidates, count)
|
|
}
|
|
}
|
|
|
|
func TestDetector_PromoteCandidateWithSyncFunc(t *testing.T) {
|
|
store := setupTestStore(t)
|
|
defer store.Close()
|
|
|
|
patternStore := gorm.NewPatternStore(store)
|
|
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
|
|
config := DefaultConfig()
|
|
config.MinFrequencyForPattern = 2
|
|
|
|
detector := NewDetector(patternStore, observationStore, config)
|
|
ctx := context.Background()
|
|
|
|
// Set up sync function to track calls
|
|
var syncedPattern *models.Pattern
|
|
detector.SetSyncFunc(func(p *models.Pattern) {
|
|
syncedPattern = p
|
|
})
|
|
|
|
// Create two similar observations to trigger pattern promotion
|
|
obs1 := createTestObservation(1, "Sync Test", []string{"sync", "test"})
|
|
obs2 := createTestObservation(2, "Sync Test", []string{"sync", "test"})
|
|
|
|
_, _ = detector.AnalyzeObservation(ctx, obs1)
|
|
result, _ := detector.AnalyzeObservation(ctx, obs2)
|
|
|
|
if result.MatchedPattern == nil {
|
|
t.Fatal("Expected pattern to be created")
|
|
}
|
|
|
|
if syncedPattern == nil {
|
|
t.Error("Expected sync function to be called")
|
|
}
|
|
|
|
if syncedPattern != nil && syncedPattern.Name != result.MatchedPattern.Name {
|
|
t.Errorf("Synced pattern name mismatch: got %s, want %s",
|
|
syncedPattern.Name, result.MatchedPattern.Name)
|
|
}
|
|
}
|
|
|
|
func TestDetector_AnalyzeObservation_UpdateExistingCandidate(t *testing.T) {
|
|
store := setupTestStore(t)
|
|
defer store.Close()
|
|
|
|
patternStore := gorm.NewPatternStore(store)
|
|
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
|
|
config := DefaultConfig()
|
|
config.MinFrequencyForPattern = 5 // High enough that we don't promote
|
|
|
|
detector := NewDetector(patternStore, observationStore, config)
|
|
ctx := context.Background()
|
|
|
|
// Create observations with same signature
|
|
obs1 := createTestObservation(1, "Update Test", []string{"update", "test"})
|
|
obs2 := createTestObservation(2, "Update Test", []string{"update", "test"})
|
|
obs2.Project = "different-project"
|
|
|
|
// Analyze first observation
|
|
_, _ = detector.AnalyzeObservation(ctx, obs1)
|
|
|
|
// Check candidate count
|
|
if count := detector.CandidateCount(); count != 1 {
|
|
t.Errorf("Expected 1 candidate after first obs, got %d", count)
|
|
}
|
|
|
|
// Analyze second observation
|
|
_, _ = detector.AnalyzeObservation(ctx, obs2)
|
|
|
|
// Still 1 candidate (same signature)
|
|
if count := detector.CandidateCount(); count != 1 {
|
|
t.Errorf("Expected 1 candidate after second obs, got %d", count)
|
|
}
|
|
|
|
// Check that candidate has both projects
|
|
key := generateCandidateKey([]string{"update", "test"})
|
|
candidate := detector.candidates[key]
|
|
if candidate == nil {
|
|
t.Fatal("Expected candidate to exist")
|
|
}
|
|
if len(candidate.projects) != 2 {
|
|
t.Errorf("Expected 2 projects, got %d", len(candidate.projects))
|
|
}
|
|
}
|
|
|
|
func TestDetector_GetPatternInsight_NotFound(t *testing.T) {
|
|
store := setupTestStore(t)
|
|
defer store.Close()
|
|
|
|
patternStore := gorm.NewPatternStore(store)
|
|
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
|
|
config := DefaultConfig()
|
|
|
|
detector := NewDetector(patternStore, observationStore, config)
|
|
ctx := context.Background()
|
|
|
|
// Try to get insight for non-existent pattern
|
|
_, err := detector.GetPatternInsight(ctx, 99999)
|
|
if err == nil {
|
|
t.Error("Expected error for non-existent pattern")
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func setupTestStore(t *testing.T) *gorm.Store {
|
|
t.Helper()
|
|
|
|
// Create temp database file
|
|
tmpFile, err := os.CreateTemp("", "pattern_test_*.db")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp file: %v", err)
|
|
}
|
|
tmpFile.Close()
|
|
|
|
t.Cleanup(func() {
|
|
os.Remove(tmpFile.Name())
|
|
})
|
|
|
|
store, err := gorm.NewStore(gorm.Config{
|
|
Path: tmpFile.Name(),
|
|
MaxConns: 1,
|
|
})
|
|
if err != nil {
|
|
// Check if this is an FTS5 related error
|
|
if containsString(err.Error(), "fts5") || containsString(err.Error(), "no such module") {
|
|
t.Skip("FTS5 not available in this SQLite build")
|
|
}
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
|
|
return store
|
|
}
|
|
|
|
func createTestObservation(id int64, title string, concepts []string) *models.Observation {
|
|
return &models.Observation{
|
|
ID: id,
|
|
SDKSessionID: "test-session",
|
|
Project: "test-project",
|
|
Scope: models.ScopeProject,
|
|
Type: models.ObsTypeBugfix,
|
|
Title: sql.NullString{String: title, Valid: true},
|
|
Narrative: sql.NullString{String: "Test narrative", Valid: true},
|
|
Concepts: concepts,
|
|
CreatedAt: time.Now().Format(time.RFC3339),
|
|
CreatedAtEpoch: time.Now().UnixMilli(),
|
|
}
|
|
}
|
|
|
|
func containsString(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasPrefix(s, prefix string) bool {
|
|
if len(s) < len(prefix) {
|
|
return false
|
|
}
|
|
return s[:len(prefix)] == prefix
|
|
}
|