mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 19:41:17 +00:00
373 lines
9.7 KiB
Go
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")
|
|
}
|
|
}
|