// Package models contains domain models for claude-mnemonic. package models import ( "database/sql" "testing" ) func TestDetectFileOverlapRelation(t *testing.T) { tests := []struct { newer *Observation older *Observation name string wantRelType RelationType wantMinConfid float64 wantRelation bool }{ { name: "no file overlap", newer: &Observation{ ID: 1, FilesModified: []string{"file1.go", "file2.go"}, }, older: &Observation{ ID: 2, FilesModified: []string{"file3.go", "file4.go"}, }, wantRelation: false, }, { name: "shared modified files", newer: &Observation{ ID: 1, Type: ObsTypeRefactor, FilesModified: []string{"shared.go", "file2.go"}, }, older: &Observation{ ID: 2, Type: ObsTypeRefactor, FilesModified: []string{"shared.go", "file4.go"}, }, wantRelation: true, wantRelType: RelationSupersedes, wantMinConfid: 0.5, }, { name: "bugfix on feature file", newer: &Observation{ ID: 1, Type: ObsTypeBugfix, FilesModified: []string{"feature.go"}, }, older: &Observation{ ID: 2, Type: ObsTypeFeature, FilesModified: []string{"feature.go"}, }, wantRelation: true, wantRelType: RelationFixes, wantMinConfid: 0.6, }, { name: "newer reads older modified", newer: &Observation{ ID: 1, Type: ObsTypeChange, FilesRead: []string{"dep.go"}, FilesModified: []string{"caller.go"}, }, older: &Observation{ ID: 2, Type: ObsTypeDecision, FilesModified: []string{"dep.go"}, }, wantRelation: true, wantMinConfid: 0.5, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := DetectFileOverlapRelation(tt.newer, tt.older) if tt.wantRelation { if result == nil { t.Fatal("expected relation, got nil") } if tt.wantRelType != "" && result.RelationType != tt.wantRelType { t.Errorf("relation type = %v, want %v", result.RelationType, tt.wantRelType) } if result.Confidence < tt.wantMinConfid { t.Errorf("confidence = %v, want at least %v", result.Confidence, tt.wantMinConfid) } if result.DetectionSource != DetectionSourceFileOverlap { t.Errorf("source = %v, want %v", result.DetectionSource, DetectionSourceFileOverlap) } } else { if result != nil { t.Errorf("expected no relation, got %+v", result) } } }) } } func TestDetectConceptOverlapRelation(t *testing.T) { tests := []struct { newer *Observation older *Observation name string wantMinConfid float64 wantRelation bool }{ { name: "no concept overlap", newer: &Observation{ ID: 1, Concepts: []string{"auth", "api"}, }, older: &Observation{ ID: 2, Concepts: []string{"database", "caching"}, }, wantRelation: false, }, { name: "shared concepts", newer: &Observation{ ID: 1, Concepts: []string{"security", "auth"}, }, older: &Observation{ ID: 2, Concepts: []string{"security", "validation"}, }, wantRelation: true, wantMinConfid: 0.4, // security is a high-value concept }, { name: "multiple shared concepts", newer: &Observation{ ID: 1, Concepts: []string{"auth", "api", "validation"}, }, older: &Observation{ ID: 2, Concepts: []string{"auth", "api", "database"}, }, wantRelation: true, wantMinConfid: 0.5, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := DetectConceptOverlapRelation(tt.newer, tt.older) if tt.wantRelation { if result == nil { t.Fatal("expected relation, got nil") } if result.Confidence < tt.wantMinConfid { t.Errorf("confidence = %v, want at least %v", result.Confidence, tt.wantMinConfid) } if result.DetectionSource != DetectionSourceConceptOverlap { t.Errorf("source = %v, want %v", result.DetectionSource, DetectionSourceConceptOverlap) } } else { if result != nil { t.Errorf("expected no relation, got %+v", result) } } }) } } func TestDetectTypeProgressionRelation(t *testing.T) { tests := []struct { name string newerType ObservationType olderType ObservationType wantRelType RelationType wantRelation bool }{ { name: "bugfix fixes discovery", newerType: ObsTypeBugfix, olderType: ObsTypeDiscovery, wantRelation: true, wantRelType: RelationFixes, }, { name: "bugfix fixes feature", newerType: ObsTypeBugfix, olderType: ObsTypeFeature, wantRelation: true, wantRelType: RelationFixes, }, { name: "feature depends on decision", newerType: ObsTypeFeature, olderType: ObsTypeDecision, wantRelation: true, wantRelType: RelationDependsOn, }, { name: "refactor evolves from discovery", newerType: ObsTypeRefactor, olderType: ObsTypeDiscovery, wantRelation: true, wantRelType: RelationEvolvesFrom, }, { name: "no progression discovery to bugfix", newerType: ObsTypeDiscovery, olderType: ObsTypeBugfix, wantRelation: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { newer := &Observation{ID: 1, Type: tt.newerType} older := &Observation{ID: 2, Type: tt.olderType} result := DetectTypeProgressionRelation(newer, older) if tt.wantRelation { if result == nil { t.Fatal("expected relation, got nil") } if result.RelationType != tt.wantRelType { t.Errorf("relation type = %v, want %v", result.RelationType, tt.wantRelType) } if result.DetectionSource != DetectionSourceTypeProgression { t.Errorf("source = %v, want %v", result.DetectionSource, DetectionSourceTypeProgression) } } else { if result != nil { t.Errorf("expected no relation, got %+v", result) } } }) } } func TestDetectTemporalProximityRelation(t *testing.T) { baseTime := int64(1700000000000) // some base epoch ms tests := []struct { name string newerSession string olderSession string newerTime int64 olderTime int64 wantRelation bool }{ { name: "same session close time", newerSession: "session-1", olderSession: "session-1", newerTime: baseTime + 60000, // 1 minute later olderTime: baseTime, wantRelation: true, }, { name: "same session far apart", newerSession: "session-1", olderSession: "session-1", newerTime: baseTime + 600000, // 10 minutes later olderTime: baseTime, wantRelation: false, // > 5 minutes }, { name: "different sessions close time", newerSession: "session-1", olderSession: "session-2", newerTime: baseTime + 30000, olderTime: baseTime, wantRelation: false, // different sessions }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { newer := &Observation{ ID: 1, SDKSessionID: tt.newerSession, CreatedAtEpoch: tt.newerTime, } older := &Observation{ ID: 2, SDKSessionID: tt.olderSession, CreatedAtEpoch: tt.olderTime, } result := DetectTemporalProximityRelation(newer, older) if tt.wantRelation { if result == nil { t.Fatal("expected relation, got nil") } if result.DetectionSource != DetectionSourceTemporalProximity { t.Errorf("source = %v, want %v", result.DetectionSource, DetectionSourceTemporalProximity) } } else { if result != nil { t.Errorf("expected no relation, got %+v", result) } } }) } } func TestDetectNarrativeMentionRelation(t *testing.T) { tests := []struct { name string narrative string wantRelType RelationType wantRelation bool }{ { name: "fixes language", narrative: "This change fixes the issue with authentication", wantRelation: true, wantRelType: RelationFixes, }, { name: "causes language", narrative: "This decision caused unexpected side effects", wantRelation: true, wantRelType: RelationCauses, }, { name: "supersedes language", narrative: "This approach supersedes the previous workaround", wantRelation: true, wantRelType: RelationSupersedes, }, { name: "depends on language", narrative: "This feature depends on the authentication module", wantRelation: true, wantRelType: RelationDependsOn, }, { name: "no relationship language", narrative: "Added new feature for user management", wantRelation: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { newer := &Observation{ ID: 1, Narrative: sql.NullString{String: tt.narrative, Valid: true}, } older := &Observation{ID: 2} result := DetectNarrativeMentionRelation(newer, older) if tt.wantRelation { if result == nil { t.Fatal("expected relation, got nil") } if result.RelationType != tt.wantRelType { t.Errorf("relation type = %v, want %v", result.RelationType, tt.wantRelType) } if result.DetectionSource != DetectionSourceNarrativeMention { t.Errorf("source = %v, want %v", result.DetectionSource, DetectionSourceNarrativeMention) } } else { if result != nil { t.Errorf("expected no relation, got %+v", result) } } }) } } func TestDetectRelationsWithExisting(t *testing.T) { newer := &Observation{ ID: 1, SDKSessionID: "session-1", Project: "test-project", Type: ObsTypeBugfix, FilesModified: []string{"auth.go"}, Concepts: []string{"security", "auth"}, Narrative: sql.NullString{String: "Fixed security issue in auth module", Valid: true}, } existing := []*Observation{ { ID: 2, SDKSessionID: "session-1", Project: "test-project", Type: ObsTypeDiscovery, FilesModified: []string{"auth.go"}, Concepts: []string{"security"}, }, { ID: 3, SDKSessionID: "session-2", Project: "test-project", Type: ObsTypeFeature, FilesModified: []string{"other.go"}, Concepts: []string{"api"}, }, { ID: 4, SDKSessionID: "session-1", Project: "other-project", // different project Type: ObsTypeDiscovery, }, } results := DetectRelationsWithExisting(newer, existing, 0.4) // Should find relation with observation 2 (file overlap + concept overlap + type progression) // Should not find relation with observation 3 (no overlap) // Should not find relation with observation 4 (different project) if len(results) == 0 { t.Fatal("expected at least one relation") } // Check that we found relation with observation 2 foundObs2 := false for _, r := range results { if r.TargetID == 2 { foundObs2 = true // Should be high confidence due to multiple signals if r.Confidence < 0.5 { t.Errorf("expected higher confidence for obs 2, got %v", r.Confidence) } } // Should not find relation with obs 4 if r.TargetID == 4 { t.Error("should not find relation with different project") } } if !foundObs2 { t.Error("expected to find relation with observation 2") } } func TestNewObservationRelation(t *testing.T) { rel := NewObservationRelation(1, 2, RelationFixes, 0.8, DetectionSourceFileOverlap, "test reason") if rel.SourceID != 1 { t.Errorf("SourceID = %v, want 1", rel.SourceID) } if rel.TargetID != 2 { t.Errorf("TargetID = %v, want 2", rel.TargetID) } if rel.RelationType != RelationFixes { t.Errorf("RelationType = %v, want %v", rel.RelationType, RelationFixes) } if rel.Confidence != 0.8 { t.Errorf("Confidence = %v, want 0.8", rel.Confidence) } if rel.DetectionSource != DetectionSourceFileOverlap { t.Errorf("DetectionSource = %v, want %v", rel.DetectionSource, DetectionSourceFileOverlap) } if rel.Reason != "test reason" { t.Errorf("Reason = %v, want 'test reason'", rel.Reason) } if rel.CreatedAt == "" { t.Error("CreatedAt should be set") } if rel.CreatedAtEpoch == 0 { t.Error("CreatedAtEpoch should be set") } }