spaxel/mothership/internal/analytics/anomaly_test.go
jedarden f7df7740bf feat: implement 7-day pattern learning algorithm for anomaly detection
Welford's online algorithm for per-zone, per-hour, per-day-of-week
occupancy modeling with cold start suppression, outlier protection,
security mode override, and SQLite persistence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:27:10 -04:00

795 lines
23 KiB
Go

// Package analytics provides anomaly detection based on learned normal behaviour patterns.
package analytics
import (
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/spaxel/mothership/internal/events"
)
// testZoneProvider implements ZoneProvider for testing.
type testZoneProvider struct {
zones map[string]string // zoneID -> zoneName
}
func (p *testZoneProvider) GetZoneName(zoneID string) string {
if name, ok := p.zones[zoneID]; ok {
return name
}
return zoneID
}
func (p *testZoneProvider) GetZoneOccupancy(zoneID string) (int, []int) {
return 0, nil
}
// testDeviceProvider implements DeviceProvider for testing.
type testDeviceProvider struct {
registered map[string]bool
seenBefore map[string]bool
names map[string]string
}
func (p *testDeviceProvider) IsDeviceRegistered(mac string) bool {
return p.registered[mac]
}
func (p *testDeviceProvider) IsDeviceSeenBefore(mac string) bool {
return p.seenBefore[mac]
}
func (p *testDeviceProvider) GetDeviceName(mac string) string {
if name, ok := p.names[mac]; ok {
return name
}
return mac
}
// testPositionProvider implements PositionProvider for testing.
type testPositionProvider struct {
positions map[int]struct{ x, y, z float64 }
}
func (p *testPositionProvider) GetBlobPosition(blobID int) (x, y, z float64, ok bool) {
if pos, exists := p.positions[blobID]; exists {
return pos.x, pos.y, pos.z, true
}
return 0, 0, 0, false
}
// testAlertHandler implements AlertHandler for testing.
type testAlertHandler struct {
mu sync.RWMutex
alerts []events.AnomalyEvent
webhooks []events.AnomalyEvent
escalations []events.AnomalyEvent
}
func (h *testAlertHandler) SendAlert(event events.AnomalyEvent, immediate bool) error {
h.mu.Lock()
defer h.mu.Unlock()
h.alerts = append(h.alerts, event)
return nil
}
func (h *testAlertHandler) SendWebhook(event events.AnomalyEvent, immediate bool) error {
h.mu.Lock()
defer h.mu.Unlock()
h.webhooks = append(h.webhooks, event)
return nil
}
func (h *testAlertHandler) SendEscalation(event events.AnomalyEvent) error {
h.mu.Lock()
defer h.mu.Unlock()
h.escalations = append(h.escalations, event)
return nil
}
// alertCount returns the number of alerts after waiting for goroutines.
func (h *testAlertHandler) alertCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.alerts)
}
func (h *testAlertHandler) webhookCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.webhooks)
}
func (h *testAlertHandler) escalationCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.escalations)
}
func setupTestDetector(t *testing.T) (*Detector, *testAlertHandler) {
tmpDir, err := os.MkdirTemp("", "anomaly_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tmpDir) })
config := DefaultAnomalyScoreConfig()
detector, err := NewDetector(filepath.Join(tmpDir, "anomaly.db"), config)
if err != nil {
t.Fatalf("Failed to create detector: %v", err)
}
t.Cleanup(func() { detector.Close() })
// Set up providers
detector.SetZoneProvider(&testZoneProvider{
zones: map[string]string{
"zone_kitchen": "Kitchen",
"zone_living": "Living Room",
"zone_bedroom": "Bedroom",
},
})
detector.SetDeviceProvider(&testDeviceProvider{
registered: map[string]bool{
"aa:bb:cc:dd:ee:ff": true,
},
seenBefore: map[string]bool{},
names: map[string]string{},
})
detector.SetPositionProvider(&testPositionProvider{
positions: map[int]struct{ x, y, z float64 }{
1: {1.5, 0, 2.0},
},
})
alertHandler := &testAlertHandler{}
detector.SetAlertHandler(alertHandler)
// Set registered devices
detector.SetRegisteredDevices([]string{"aa:bb:cc:dd:ee:ff"})
return detector, alertHandler
}
// TestAnomaly_UnusualHourPresence tests unusual hour anomaly detection.
func TestAnomaly_UnusualHourPresence(t *testing.T) {
detector, alertHandler := setupTestDetector(t)
// Simulate model being ready
detector.mu.Lock()
detector.modelReady = true
// Create a behaviour slot with low expected occupancy for the current hour
hourOfWeek := getHourOfWeek(time.Now())
key := hourOfWeekZoneKey(hourOfWeek, "zone_kitchen")
detector.behaviourSlots[key] = &NormalBehaviourSlot{
HourOfWeek: hourOfWeek,
ZoneID: "zone_kitchen",
ExpectedOccupancy: 0.05, // Very low - this zone is usually empty at this hour
SampleCount: 50, // Enough samples
}
detector.mu.Unlock()
// Process occupancy - should trigger anomaly
event := detector.ProcessOccupancy("zone_kitchen", 1, nil, false)
if event == nil {
t.Error("Expected anomaly for unusual hour presence")
return
}
if event.Type != events.AnomalyUnusualHour {
t.Errorf("Expected unusual_hour anomaly, got %s", event.Type)
}
if event.Score < detector.config.AlertThresholdNormal {
t.Errorf("Expected score >= %.2f, got %.2f", detector.config.AlertThresholdNormal, event.Score)
}
// Check alert was sent
waitForGoroutines()
if alertHandler.alertCount() == 0 {
t.Error("Expected alert to be sent")
}
}
// TestAnomaly_UnknownBLEDevice tests unknown BLE device anomaly detection.
func TestAnomaly_UnknownBLEDevice(t *testing.T) {
detector, alertHandler := setupTestDetector(t)
// Unknown device with strong signal (close range)
// Use security mode so the score (0.8) exceeds the security threshold (0.4)
event := detector.ProcessBLEDevice("11:22:33:44:55:66", -55, true)
if event == nil {
t.Error("Expected anomaly for unknown BLE device")
return
}
if event.Type != events.AnomalyUnknownBLE {
t.Errorf("Expected unknown_ble anomaly, got %s", event.Type)
}
// Check alert was sent
waitForGoroutines()
if alertHandler.alertCount() == 0 {
t.Error("Expected alert to be sent")
}
}
// TestAnomaly_UnknownBLEDevice_WeakSignal tests that weak signals don't trigger anomalies.
func TestAnomaly_UnknownBLEDevice_WeakSignal(t *testing.T) {
detector, _ := setupTestDetector(t)
// Unknown device with weak signal (far away)
event := detector.ProcessBLEDevice("11:22:33:44:55:66", -80, false)
if event != nil {
t.Error("Expected no anomaly for weak signal BLE device")
}
}
// TestAnomaly_MotionDuringAway tests motion during away mode.
func TestAnomaly_MotionDuringAway(t *testing.T) {
detector, alertHandler := setupTestDetector(t)
// Process motion during away mode (isSecurityMode = true)
event := detector.ProcessMotionDuringAway("zone_kitchen", 1, true)
if event == nil {
t.Error("Expected anomaly for motion during away")
return
}
if event.Type != events.AnomalyMotionDuringAway {
t.Errorf("Expected motion_during_away anomaly, got %s", event.Type)
}
// Motion during away should have high score
if event.Score < 0.9 {
t.Errorf("Expected score >= 0.9 for motion during away, got %.2f", event.Score)
}
// Check alert was sent
waitForGoroutines()
if alertHandler.alertCount() == 0 {
t.Error("Expected alert to be sent")
}
}
// TestAnomaly_MotionDuringAway_AlwaysFires tests that motion during away fires regardless of model status.
func TestAnomaly_MotionDuringAway_AlwaysFires(t *testing.T) {
detector, _ := setupTestDetector(t)
// Model is NOT ready
detector.mu.Lock()
detector.modelReady = false
detector.mu.Unlock()
// Motion during away should still fire
event := detector.ProcessMotionDuringAway("zone_kitchen", 1, true)
if event == nil {
t.Error("Expected anomaly for motion during away even when model not ready")
}
}
// TestAnomaly_UnusualDwell tests unusual dwell duration detection.
func TestAnomaly_UnusualDwell(t *testing.T) {
detector, alertHandler := setupTestDetector(t)
// Set model as ready and add dwell slot
detector.mu.Lock()
detector.modelReady = true
hourOfWeek := getHourOfWeek(time.Now())
key := hourOfWeekZonePersonKey(hourOfWeek, "zone_bedroom", "person1")
detector.dwellSlots[key] = &DwellBehaviourSlot{
HourOfWeek: hourOfWeek,
ZoneID: "zone_bedroom",
PersonID: "person1",
MeanDwellDuration: 5 * time.Minute, // Usually dwells for 5 minutes
SampleCount: 20,
}
detector.mu.Unlock()
// Person dwelling for > 5x mean (25+ minutes), use security mode so score exceeds threshold
event := detector.ProcessDwellDuration("zone_bedroom", "person1", 30*time.Minute, true, false)
if event == nil {
t.Error("Expected anomaly for unusual dwell duration")
return
}
if event.Type != events.AnomalyUnusualDwell {
t.Errorf("Expected unusual_dwell anomaly, got %s", event.Type)
}
// Wait for async alert goroutine to complete
time.Sleep(10 * time.Millisecond)
// Check alert was sent
if alertHandler.alertCount() == 0 {
t.Error("Expected alert to be sent")
}
}
// TestAnomaly_UnusualDwell_FallDetected tests that dwell anomaly is suppressed if fall is detected.
func TestAnomaly_UnusualDwell_FallDetected(t *testing.T) {
detector, _ := setupTestDetector(t)
// Set model as ready and add dwell slot
detector.mu.Lock()
detector.modelReady = true
hourOfWeek := getHourOfWeek(time.Now())
key := hourOfWeekZonePersonKey(hourOfWeek, "zone_bedroom", "person1")
detector.dwellSlots[key] = &DwellBehaviourSlot{
HourOfWeek: hourOfWeek,
ZoneID: "zone_bedroom",
PersonID: "person1",
MeanDwellDuration: 5 * time.Minute,
SampleCount: 20,
}
detector.mu.Unlock()
// Person dwelling for > 5x mean but fall is detected - should NOT trigger dwell anomaly
event := detector.ProcessDwellDuration("zone_bedroom", "person1", 30*time.Minute, false, true)
if event != nil {
t.Error("Expected no dwell anomaly when fall is detected")
}
}
// TestAnomaly_Cooldown tests that anomalies are deduplicated via cooldown.
func TestAnomaly_Cooldown(t *testing.T) {
detector, _ := setupTestDetector(t)
// First anomaly
event1 := detector.ProcessMotionDuringAway("zone_kitchen", 1, true)
if event1 == nil {
t.Fatal("Expected first anomaly")
}
// Immediate second anomaly should be suppressed by cooldown
event2 := detector.ProcessMotionDuringAway("zone_kitchen", 1, true)
if event2 != nil {
t.Error("Expected second anomaly to be suppressed by cooldown")
}
}
// TestAnomaly_AcknowledgeCancelsTimers tests that acknowledgement cancels alert timers.
func TestAnomaly_AcknowledgeCancelsTimers(t *testing.T) {
detector, _ := setupTestDetector(t)
// Create an anomaly
event := detector.ProcessMotionDuringAway("zone_kitchen", 1, false) // Normal mode
if event == nil {
t.Fatal("Expected anomaly")
}
// Acknowledge it
err := detector.AcknowledgeAnomaly(event.ID, "expected", "test_user")
if err != nil {
t.Fatalf("Failed to acknowledge anomaly: %v", err)
}
// Verify it's marked as acknowledged
anomaly, exists := detector.activeAnomalies[event.ID]
if !exists {
t.Fatal("Anomaly should still exist after acknowledgement")
}
if !anomaly.Acknowledged {
t.Error("Anomaly should be marked as acknowledged")
}
}
// TestAnomaly_LearningProgress tests learning progress calculation.
func TestAnomaly_LearningProgress(t *testing.T) {
detector, _ := setupTestDetector(t)
// New detector - progress should be near 0
progress := detector.GetLearningProgress()
if progress > 0.1 {
t.Errorf("Expected progress near 0 for new detector, got %.2f", progress)
}
// Set model as ready
detector.mu.Lock()
detector.modelReady = true
detector.mu.Unlock()
progress = detector.GetLearningProgress()
if progress != 1.0 {
t.Errorf("Expected progress 1.0 when model ready, got %.2f", progress)
}
}
// TestAnomaly_SecurityModeThreshold tests lower thresholds in security mode.
func TestAnomaly_SecurityModeThreshold(t *testing.T) {
detector, _ := setupTestDetector(t)
// Create a behaviour slot with marginal expected occupancy
detector.mu.Lock()
detector.modelReady = true
hourOfWeek := getHourOfWeek(time.Now())
key := hourOfWeekZoneKey(hourOfWeek, "zone_living")
detector.behaviourSlots[key] = &NormalBehaviourSlot{
HourOfWeek: hourOfWeek,
ZoneID: "zone_living",
ExpectedOccupancy: 0.08, // Low but not extremely low
SampleCount: 50,
}
detector.mu.Unlock()
// In security mode, should trigger with lower threshold
securityEvent := detector.ProcessOccupancy("zone_living", 1, nil, true)
if securityEvent == nil {
t.Error("Expected anomaly in security mode")
}
}
// TestAnomaly_LateNightMultiplier tests that late night anomalies have higher scores.
func TestAnomaly_LateNightMultiplier(t *testing.T) {
detector, _ := setupTestDetector(t)
// This test is time-dependent, so we just verify the config exists
if detector.config.LateNightMultiplier < 1.0 {
t.Error("Late night multiplier should be >= 1.0")
}
if detector.config.LateNightMultiplier != 1.5 {
t.Logf("Note: Late night multiplier is %.2f (expected 1.5)", detector.config.LateNightMultiplier)
}
}
// TestAnomaly_WeeklySummary tests weekly summary generation.
func TestAnomaly_WeeklySummary(t *testing.T) {
detector, _ := setupTestDetector(t)
// Create some anomalies
detector.ProcessMotionDuringAway("zone_kitchen", 1, true)
detector.ProcessMotionDuringAway("zone_living", 2, true)
// Get summary
summary := detector.GetWeeklySummary()
if summary.TotalAnomalies < 2 {
t.Errorf("Expected at least 2 anomalies in summary, got %d", summary.TotalAnomalies)
}
if summary.ByType[events.AnomalyMotionDuringAway] < 2 {
t.Errorf("Expected 2 motion_during_away anomalies, got %d", summary.ByType[events.AnomalyMotionDuringAway])
}
}
// TestAnomaly_GetActiveAnomalies tests retrieval of active anomalies.
func TestAnomaly_GetActiveAnomalies(t *testing.T) {
detector, _ := setupTestDetector(t)
// Initially no active anomalies
active := detector.GetActiveAnomalies()
if len(active) != 0 {
t.Errorf("Expected 0 active anomalies initially, got %d", len(active))
}
// Create an anomaly
detector.ProcessMotionDuringAway("zone_kitchen", 1, true)
// Should have 1 active anomaly
active = detector.GetActiveAnomalies()
if len(active) != 1 {
t.Errorf("Expected 1 active anomaly, got %d", len(active))
}
}
// TestAnomaly_UpdateBehaviourModel tests behaviour model updates.
func TestAnomaly_UpdateBehaviourModel(t *testing.T) {
detector, _ := setupTestDetector(t)
// Record some occupancy samples
detector.ProcessOccupancy("zone_kitchen", 1, []string{"aa:bb:cc:dd:ee:ff"}, false)
detector.ProcessOccupancy("zone_kitchen", 2, []string{"aa:bb:cc:dd:ee:ff"}, false)
detector.ProcessOccupancy("zone_living", 1, nil, false)
// Update the model
err := detector.UpdateBehaviourModel()
if err != nil {
t.Fatalf("Failed to update behaviour model: %v", err)
}
// Check that slots were created
detector.mu.RLock()
slotCount := len(detector.behaviourSlots)
detector.mu.RUnlock()
if slotCount == 0 {
t.Error("Expected behaviour slots to be created after update")
}
}
// TestAnomaly_SecurityModeState tests security mode state management.
func TestAnomaly_SecurityModeState(t *testing.T) {
detector, _ := setupTestDetector(t)
// Initially disarmed
if detector.GetSecurityMode() != SecurityModeDisarmed {
t.Error("Expected initial security mode to be disarmed")
}
// Arm the system
detector.SetSecurityMode(SecurityModeArmed, "manual")
if detector.GetSecurityMode() != SecurityModeArmed {
t.Error("Expected security mode to be armed")
}
// Check if active
if !detector.IsSecurityModeActive() {
t.Error("Expected security mode to be active when armed")
}
// Disarm
detector.SetSecurityMode(SecurityModeDisarmed, "manual")
if detector.GetSecurityMode() != SecurityModeDisarmed {
t.Error("Expected security mode to be disarmed")
}
}
// TestAnomaly_ManualOverride tests manual override functionality.
func TestAnomaly_ManualOverride(t *testing.T) {
detector, _ := setupTestDetector(t)
// Set manual override
detector.SetManualOverride(30 * time.Minute)
if !detector.IsManualOverrideActive() {
t.Error("Expected manual override to be active")
}
// Clear it
detector.ClearManualOverride()
if detector.IsManualOverrideActive() {
t.Error("Expected manual override to be inactive after clear")
}
}
// TestAnomaly_RegisteredDevices tests that registered devices don't trigger anomalies.
func TestAnomaly_RegisteredDevices(t *testing.T) {
detector, _ := setupTestDetector(t)
// Registered device with strong signal
event := detector.ProcessBLEDevice("aa:bb:cc:dd:ee:ff", -55, false)
if event != nil {
t.Error("Expected no anomaly for registered BLE device")
}
}
// TestAnomaly_BLEDeviceFirstSeen tests that unknown devices are tracked for first-seen time.
func TestAnomaly_BLEDeviceFirstSeen(t *testing.T) {
detector, _ := setupTestDetector(t)
// Process an unknown device
mac := "11:22:33:44:55:66"
detector.ProcessBLEDevice(mac, -55, false)
// Check that first-seen time was recorded
detector.mu.RLock()
firstSeen, exists := detector.deviceFirstSeen[mac]
detector.mu.RUnlock()
if !exists {
t.Error("Expected first-seen time to be recorded for unknown device")
}
if firstSeen.IsZero() {
t.Error("Expected non-zero first-seen time")
}
}
// TestAnomaly_AlertChainNormalMode tests alert chain timing in normal mode.
func TestAnomaly_AlertChainNormalMode(t *testing.T) {
detector, alertHandler := setupTestDetector(t)
// Create an anomaly in normal mode (not security mode)
event := detector.ProcessMotionDuringAway("zone_kitchen", 1, false)
if event == nil {
t.Fatal("Expected anomaly to be created")
}
// In normal mode:
// - Dashboard alert should be sent immediately (via callback)
// - Notification should be sent at T+30s
// - Webhook should be sent at T+2min
// - Escalation should be sent at T+5min
// Alert should be sent immediately (wait for goroutine)
waitForGoroutines()
if len(alertHandler.alerts) == 0 {
t.Error("Expected immediate alert in normal mode")
}
// Webhook should NOT be sent immediately
if len(alertHandler.webhooks) > 0 {
t.Error("Webhook should not be sent immediately in normal mode")
}
// Escalation should NOT be sent immediately
if len(alertHandler.escalations) > 0 {
t.Error("Escalation should not be sent immediately in normal mode")
}
// Verify pending alerts exist
detector.mu.RLock()
pendingCount := len(detector.pendingAlerts)
detector.mu.RUnlock()
if pendingCount == 0 {
t.Error("Expected pending alert timers to be set")
}
// Acknowledge to clean up timers
detector.AcknowledgeAnomaly(event.ID, "expected", "test_user")
}
// TestAnomaly_AlertChainSecurityMode tests that all alerts fire immediately in security mode.
func TestAnomaly_AlertChainSecurityMode(t *testing.T) {
detector, alertHandler := setupTestDetector(t)
// Create an anomaly in security mode
event := detector.ProcessMotionDuringAway("zone_kitchen", 1, true)
if event == nil {
t.Fatal("Expected anomaly to be created")
}
// In security mode, ALL alerts should fire immediately (wait for goroutines)
waitForGoroutines()
if len(alertHandler.alerts) == 0 {
t.Error("Expected immediate alert in security mode")
}
if len(alertHandler.webhooks) == 0 {
t.Error("Expected immediate webhook in security mode")
}
if len(alertHandler.escalations) == 0 {
t.Error("Expected immediate escalation in security mode")
}
// Verify the anomaly is marked as all alerts sent
if !event.AlertSent {
t.Error("Expected AlertSent to be true")
}
if !event.WebhookSent {
t.Error("Expected WebhookSent to be true")
}
if !event.EscalationSent {
t.Error("Expected EscalationSent to be true")
}
}
// TestAnomaly_AcknowledgementCancelsTimers tests that acknowledgement cancels pending timers.
func TestAnomaly_AcknowledgementCancelsTimers(t *testing.T) {
detector, alertHandler := setupTestDetector(t)
// Create an anomaly in normal mode (delayed escalation)
event := detector.ProcessMotionDuringAway("zone_kitchen", 1, false)
if event == nil {
t.Fatal("Expected anomaly")
}
// Immediately acknowledge
err := detector.AcknowledgeAnomaly(event.ID, "false_alarm", "test_user")
if err != nil {
t.Fatalf("Failed to acknowledge: %v", err)
}
// Verify pending timers were cancelled
detector.mu.RLock()
pendingCount := len(detector.pendingAlerts)
detector.mu.RUnlock()
if pendingCount != 0 {
t.Error("Expected all pending alert timers to be cancelled after acknowledgement")
}
// Only the immediate alert should have been sent
if len(alertHandler.alerts) == 0 {
t.Error("Expected immediate alert to have been sent")
}
if len(alertHandler.webhooks) > 0 {
t.Error("Webhook should not be sent after acknowledgement")
}
if len(alertHandler.escalations) > 0 {
t.Error("Escalation should not be sent after acknowledgement")
}
}
// TestAnomaly_CooldownDeduplication tests that anomalies are deduplicated within cooldown period.
func TestAnomaly_CooldownDeduplication(t *testing.T) {
detector, _ := setupTestDetector(t)
// First anomaly should fire
event1 := detector.ProcessMotionDuringAway("zone_kitchen", 1, true)
if event1 == nil {
t.Fatal("Expected first anomaly to fire")
}
// Immediate second anomaly should be suppressed by cooldown
event2 := detector.ProcessMotionDuringAway("zone_kitchen", 1, true)
if event2 != nil {
t.Error("Expected second anomaly to be suppressed by cooldown")
}
// Different zone should still fire
event3 := detector.ProcessMotionDuringAway("zone_living", 2, true)
if event3 == nil {
t.Error("Expected anomaly in different zone to fire")
}
}
// TestAnomaly_SecurityModeStatePersistence tests security mode state management.
func TestAnomaly_SecurityModeStatePersistence(t *testing.T) {
detector, _ := setupTestDetector(t)
// Initially disarmed
if detector.GetSecurityMode() != SecurityModeDisarmed {
t.Error("Expected initial security mode to be disarmed")
}
// Arm the system
detector.SetSecurityMode(SecurityModeArmed, "manual")
if detector.GetSecurityMode() != SecurityModeArmed {
t.Error("Expected security mode to be armed")
}
// Check if active
if !detector.IsSecurityModeActive() {
t.Error("Expected security mode to be active when armed")
}
// Disarm
detector.SetSecurityMode(SecurityModeDisarmed, "manual")
if detector.GetSecurityMode() != SecurityModeDisarmed {
t.Error("Expected security mode to be disarmed")
}
}
// TestAnomaly_GetActiveAnomaliesAfterCreate tests active anomaly retrieval.
func TestAnomaly_GetActiveAnomaliesAfterCreate(t *testing.T) {
detector, _ := setupTestDetector(t)
// Initially no active anomalies
active := detector.GetActiveAnomalies()
if len(active) != 0 {
t.Errorf("Expected 0 active anomalies initially, got %d", len(active))
}
// Create an anomaly
event := detector.ProcessMotionDuringAway("zone_kitchen", 1, true)
if event == nil {
t.Fatal("Expected anomaly")
}
// Should have 1 active anomaly
active = detector.GetActiveAnomalies()
if len(active) != 1 {
t.Errorf("Expected 1 active anomaly, got %d", len(active))
}
// Acknowledge it
detector.AcknowledgeAnomaly(event.ID, "expected", "test_user")
// Should have 0 unacknowledged anomalies (acknowledged ones are filtered)
active = detector.GetActiveAnomalies()
if len(active) != 0 {
t.Errorf("Expected 0 active unacknowledged anomalies, got %d", len(active))
}
}
// Helper functions for generating keys
func hourOfWeekZoneKey(hourOfWeek int, zoneID string) string {
return fmt.Sprintf("%d-%s", hourOfWeek, zoneID)
}
func hourOfWeekZonePersonKey(hourOfWeek int, zoneID, personID string) string {
return fmt.Sprintf("%d-%s-%s", hourOfWeek, zoneID, personID)
}
// waitForGoroutines gives goroutines a moment to complete.
func waitForGoroutines() {
time.Sleep(50 * time.Millisecond)
}