Pulse/internal/ai/learning/store_additional_test.go
2026-01-25 21:08:44 +00:00

373 lines
9.7 KiB
Go

package learning
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func TestDefaultLearningStoreConfig(t *testing.T) {
cfg := DefaultLearningStoreConfig()
if cfg.MaxRecords != 10000 {
t.Fatalf("expected MaxRecords 10000, got %d", cfg.MaxRecords)
}
if cfg.RetentionDays != 90 {
t.Fatalf("expected RetentionDays 90, got %d", cfg.RetentionDays)
}
}
func TestRecordFeedback_GeneratesIDTimestampAndSignal(t *testing.T) {
store := NewLearningStore(LearningStoreConfig{})
store.RecordFeedback(FeedbackRecord{
FindingID: "f-1",
ResourceID: "vm-1",
Category: "performance",
Severity: "warning",
Action: ActionThumbsDown,
})
store.mu.RLock()
defer store.mu.RUnlock()
if len(store.feedbackRecords) != 1 {
t.Fatalf("expected 1 feedback record, got %d", len(store.feedbackRecords))
}
for _, record := range store.feedbackRecords {
if record.ID == "" {
t.Fatalf("expected record ID to be generated")
}
if record.Timestamp.IsZero() {
t.Fatalf("expected timestamp to be set")
}
if !record.Signal.IsFalsePositive {
t.Fatalf("expected thumbs down to mark false positive")
}
}
}
func TestComputeFeedbackSignal_Default(t *testing.T) {
signal := computeFeedbackSignal(UserAction("unknown"))
if signal.Confidence != 0.5 {
t.Fatalf("expected default confidence 0.5, got %.2f", signal.Confidence)
}
}
func TestResourcePreferences_NotesTrim(t *testing.T) {
store := NewLearningStore(LearningStoreConfig{})
for i := 0; i < 12; i++ {
store.RecordFeedback(FeedbackRecord{
FindingID: "f" + intToStr(i),
ResourceID: "vm-1",
Category: "performance",
Severity: "warning",
Action: ActionDismissExpected,
UserNote: "note-" + intToStr(i),
})
}
pref := store.GetResourcePreference("vm-1")
if pref == nil {
t.Fatalf("expected resource preference to exist")
}
if len(pref.Notes) != 10 {
t.Fatalf("expected 10 notes after trimming, got %d", len(pref.Notes))
}
if pref.Notes[0] != "note-2" {
t.Fatalf("expected oldest notes to be trimmed, got %s", pref.Notes[0])
}
if pref.Notes[len(pref.Notes)-1] != "note-11" {
t.Fatalf("expected last note to be retained")
}
}
func TestShouldSuppress_SeverityThreshold(t *testing.T) {
store := NewLearningStore(LearningStoreConfig{})
store.RecordFeedback(FeedbackRecord{
FindingID: "f1",
ResourceID: "vm-1",
Category: "performance",
Severity: "warning",
Action: ActionDismissNotAnIssue,
})
if !store.ShouldSuppress("vm-1", "performance", "info") {
t.Fatalf("expected info severity to be suppressed for thresholded category")
}
if store.ShouldSuppress("vm-1", "performance", "critical") {
t.Fatalf("expected critical severity not to be suppressed")
}
}
func TestCategoryPreferences_RollingAverage(t *testing.T) {
store := NewLearningStore(LearningStoreConfig{})
store.RecordFeedback(FeedbackRecord{
FindingID: "f1",
ResourceID: "vm-1",
Category: "capacity",
Severity: "warning",
Action: ActionQuickFix,
TimeToAction: 10 * time.Minute,
})
store.RecordFeedback(FeedbackRecord{
FindingID: "f2",
ResourceID: "vm-2",
Category: "capacity",
Severity: "warning",
Action: ActionQuickFix,
TimeToAction: 20 * time.Minute,
})
pref := store.GetCategoryPreference("capacity")
if pref == nil {
t.Fatalf("expected category preference to exist")
}
expected := 15 * time.Minute
if pref.AverageTimeToAction != expected {
t.Fatalf("expected rolling average %s, got %s", expected, pref.AverageTimeToAction)
}
}
func TestFormatForContext_Details(t *testing.T) {
store := NewLearningStore(LearningStoreConfig{})
for i := 0; i < 6; i++ {
store.RecordFeedback(FeedbackRecord{
FindingID: "f" + intToStr(i),
ResourceID: "vm-1",
Category: "performance",
Severity: "warning",
Action: ActionDismissNotAnIssue,
UserNote: "note-" + intToStr(i),
})
}
for i := 0; i < 12; i++ {
action := ActionQuickFix
if i%5 == 0 {
action = ActionDismissNotAnIssue
}
store.RecordFeedback(FeedbackRecord{
FindingID: "c" + intToStr(i),
ResourceID: "vm-" + intToStr(i),
Category: "capacity",
Severity: "warning",
Action: action,
})
}
context := store.FormatForContext()
if context == "" {
t.Fatalf("expected context to be populated")
}
if !containsStr(context, "vm-1") {
t.Fatalf("expected resource preference details in context")
}
if !containsStr(context, "Category value") {
t.Fatalf("expected category section in context")
}
}
func TestCleanup_TrimMaxRecords(t *testing.T) {
store := NewLearningStore(LearningStoreConfig{MaxRecords: 2})
store.RecordFeedback(FeedbackRecord{
FindingID: "f1",
ResourceID: "vm-1",
Category: "performance",
Severity: "warning",
Action: ActionQuickFix,
})
store.RecordFeedback(FeedbackRecord{
FindingID: "f2",
ResourceID: "vm-2",
Category: "performance",
Severity: "warning",
Action: ActionQuickFix,
})
store.RecordFeedback(FeedbackRecord{
FindingID: "f3",
ResourceID: "vm-3",
Category: "performance",
Severity: "warning",
Action: ActionQuickFix,
})
removed := store.Cleanup()
if removed == 0 {
t.Fatalf("expected Cleanup to trim records when over max")
}
stats := store.GetStatistics()
if stats.TotalFeedbackRecords > 2 {
t.Fatalf("expected records trimmed to max, got %d", stats.TotalFeedbackRecords)
}
}
func TestSaveAndLoadLearningStore(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ai_learning.json")
payload := struct {
FeedbackRecords map[string]*FeedbackRecord `json:"feedback_records"`
ResourcePreferences map[string]*ResourcePreference `json:"resource_preferences"`
CategoryPreferences map[string]*CategoryPreference `json:"category_preferences"`
}{
FeedbackRecords: map[string]*FeedbackRecord{
"fb-1": {
ID: "fb-1",
FindingID: "finding-1",
Category: "performance",
Action: ActionQuickFix,
Timestamp: time.Now(),
},
},
ResourcePreferences: map[string]*ResourcePreference{
"vm-1": {
ResourceID: "vm-1",
TotalFindings: 3,
ActionedCount: 2,
DismissedCount: 1,
},
},
CategoryPreferences: map[string]*CategoryPreference{
"performance": {
Category: "performance",
TotalFindings: 5,
ActionedCount: 3,
},
},
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal payload: %v", err)
}
if err := os.WriteFile(path, raw, 0600); err != nil {
t.Fatalf("failed to write payload: %v", err)
}
loaded := NewLearningStore(LearningStoreConfig{DataDir: dir})
stats := loaded.GetStatistics()
if stats.TotalFeedbackRecords != 1 {
t.Fatalf("expected 1 feedback record, got %d", stats.TotalFeedbackRecords)
}
if stats.ResourcePreferences != 1 || stats.CategoryPreferences != 1 {
t.Fatalf("expected resource and category prefs to load")
}
}
func TestForceSave_PersistsData(t *testing.T) {
dir := t.TempDir()
store := NewLearningStore(LearningStoreConfig{DataDir: dir})
store.mu.Lock()
store.feedbackRecords["fb-1"] = &FeedbackRecord{
ID: "fb-1",
FindingID: "finding-1",
Category: "capacity",
Action: ActionQuickFix,
Timestamp: time.Now(),
}
store.mu.Unlock()
if err := store.ForceSave(); err != nil {
t.Fatalf("force save failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "ai_learning.json"))
if err != nil {
t.Fatalf("expected saved file to exist: %v", err)
}
if !containsStr(string(data), "fb-1") {
t.Fatalf("expected saved data to contain record id")
}
}
func TestSaveIfDirty_WritesFile(t *testing.T) {
dir := t.TempDir()
store := NewLearningStore(LearningStoreConfig{DataDir: dir})
store.mu.Lock()
store.feedbackRecords["fb-1"] = &FeedbackRecord{
ID: "fb-1",
FindingID: "finding-1",
Category: "capacity",
Action: ActionQuickFix,
Timestamp: time.Now(),
}
store.dirty = true
store.mu.Unlock()
store.saveIfDirty()
if _, err := os.Stat(filepath.Join(dir, "ai_learning.json")); err != nil {
t.Fatalf("expected learning file to exist: %v", err)
}
}
func TestSaveIfDirty_NotDirty(t *testing.T) {
store := NewLearningStore(LearningStoreConfig{})
store.saveIfDirty()
}
func TestSaveToDisk_NoDir(t *testing.T) {
store := NewLearningStore(LearningStoreConfig{})
if err := store.saveToDisk(); err != nil {
t.Fatalf("expected saveToDisk to no-op without DataDir, got %v", err)
}
}
func TestSaveIfDirty_SaveError(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "not-a-dir")
if err := os.WriteFile(filePath, []byte("x"), 0600); err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
store := NewLearningStore(LearningStoreConfig{DataDir: filePath})
store.mu.Lock()
store.feedbackRecords["fb-1"] = &FeedbackRecord{
ID: "fb-1",
FindingID: "finding-1",
Category: "capacity",
Action: ActionQuickFix,
Timestamp: time.Now(),
}
store.dirty = true
store.mu.Unlock()
store.saveIfDirty()
store.mu.RLock()
dirty := store.dirty
store.mu.RUnlock()
if !dirty {
t.Fatalf("expected dirty to remain true on save error")
}
}
func TestComputeFeedbackSignal_AdditionalActions(t *testing.T) {
signal := computeFeedbackSignal(ActionDismissWillFixLater)
if !signal.WasActionable || signal.Confidence <= 0 {
t.Fatalf("expected actionable signal for dismiss will fix later")
}
signal = computeFeedbackSignal(ActionAcknowledge)
if !signal.WasActionable || signal.Confidence <= 0 {
t.Fatalf("expected actionable signal for acknowledge")
}
}
func TestGetPreferences_NotFound(t *testing.T) {
store := NewLearningStore(LearningStoreConfig{})
if store.GetResourcePreference("missing") != nil {
t.Fatalf("expected nil for missing resource preference")
}
if store.GetCategoryPreference("missing") != nil {
t.Fatalf("expected nil for missing category preference")
}
}