feat: implement feature discovery notifications
Implement single non-blocking notifications when features become available. Events: - DiurnalBaselineActivated (7 days) - FirstSleepSessionComplete - WeightUpdateApproved - AutomationFirstFired - PredictionModelReady (7 days per person) Each notification is keyed by unique event ID in SQLite (feature_notifications table). Never fires twice. Dismissed by tapping. Respects quiet hours. Files: - mothership/internal/help/notifier.go: Notifier manages one-time feature notifications - mothership/internal/help/notifier_test.go: Tests for notifier - mothership/internal/help/monitor.go: FeatureMonitor checks for feature availability - mothership/internal/help/monitor_test.go: Tests for monitor - mothership/cmd/mothership/main.go: Integration with mothership - mothership/internal/db/migrations.go: Add migration_015 for feature_notifications table Acceptance: - Each notification fires exactly once per feature - Plain language messages - Respects quiet hours - SQLite persistence prevents duplicates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4b49789de7
commit
d02a8e901c
7 changed files with 762 additions and 20 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
a574a8465384da63ecd3ca05bc4cdcd8f0732eb2
|
||||
335416826a5eafcb6d6bad1fbc76e1dcb6e496a1
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import (
|
|||
"github.com/spaxel/mothership/internal/fleet"
|
||||
"github.com/spaxel/mothership/internal/floorplan"
|
||||
"github.com/spaxel/mothership/internal/health"
|
||||
featurehelp "github.com/spaxel/mothership/internal/help"
|
||||
"github.com/spaxel/mothership/internal/ingestion"
|
||||
"github.com/spaxel/mothership/internal/briefing"
|
||||
guidedtroubleshoot "github.com/spaxel/mothership/internal/guidedtroubleshoot"
|
||||
|
|
@ -425,6 +426,39 @@ func main() {
|
|||
settingsHandler.RegisterRoutes(r)
|
||||
log.Printf("[INFO] Settings API registered at /api/settings")
|
||||
|
||||
// Phase 6: Feature discovery notifications
|
||||
// Notifier manages one-time feature discovery notifications with quiet hours support
|
||||
featureNotifier, err := featurehelp.NewNotifier(mainDB)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to create feature notifier: %v", err)
|
||||
} else {
|
||||
// Load quiet hours from settings
|
||||
settings := settingsHandler.Get()
|
||||
if err := featureNotifier.LoadQuietHoursFromSettings(settings); err != nil {
|
||||
log.Printf("[DEBUG] Failed to load quiet hours for feature notifications: %v", err)
|
||||
}
|
||||
|
||||
// Register feature notification API routes
|
||||
featureNotifier.RegisterRoutes(r)
|
||||
log.Printf("[INFO] Feature discovery notifications API registered at /api/help/*")
|
||||
}
|
||||
|
||||
// Feature monitor checks for feature availability and fires notifications
|
||||
// Checkers functions will be defined later after all components are initialized
|
||||
var featureMonitor *featurehelp.FeatureMonitor
|
||||
if featureNotifier != nil {
|
||||
featureMonitor = featurehelp.NewFeatureMonitor(featurehelp.FeatureMonitorConfig{
|
||||
DB: mainDB,
|
||||
Notifier: featureNotifier,
|
||||
CheckInterval: 5 * time.Minute, // Check every 5 minutes
|
||||
})
|
||||
|
||||
// Start the monitor (checkers will be wired below)
|
||||
featureMonitor.Start()
|
||||
defer featureMonitor.Stop()
|
||||
log.Printf("[INFO] Feature discovery monitor started")
|
||||
}
|
||||
|
||||
// Guided troubleshooting manager (for proactive contextual help)
|
||||
// Will be created after fleet manager is initialized
|
||||
var guidedMgr *guidedtroubleshoot.Manager
|
||||
|
|
|
|||
258
mothership/internal/help/monitor.go
Normal file
258
mothership/internal/help/monitor.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
// Package help provides feature discovery monitoring and notification.
|
||||
package help
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// FeatureMonitor checks for feature availability and fires notifications.
|
||||
// It runs periodically to check if features have become available.
|
||||
type FeatureMonitor struct {
|
||||
mu sync.Mutex
|
||||
db *sql.DB
|
||||
notifier *Notifier
|
||||
checkInterval time.Duration
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
// Callbacks for checking feature availability
|
||||
checkDiurnalReady func() bool
|
||||
checkFirstSleepSession func() bool
|
||||
checkWeightUpdate func() bool
|
||||
checkFirstAutomation func() bool
|
||||
checkPredictionReady func(personID string) bool
|
||||
|
||||
// Track what we've already notified
|
||||
notifiedDiurnalReady bool
|
||||
notifiedFirstSleepSession bool
|
||||
notifiedWeightUpdate bool
|
||||
notifiedFirstAutomation bool
|
||||
notifiedPredictionReady map[string]bool // personID -> notified
|
||||
}
|
||||
|
||||
// FeatureMonitorConfig holds configuration for the feature monitor.
|
||||
type FeatureMonitorConfig struct {
|
||||
DB *sql.DB
|
||||
Notifier *Notifier
|
||||
CheckInterval time.Duration // How often to check for new features
|
||||
}
|
||||
|
||||
// NewFeatureMonitor creates a new feature discovery monitor.
|
||||
func NewFeatureMonitor(cfg FeatureMonitorConfig) *FeatureMonitor {
|
||||
if cfg.CheckInterval == 0 {
|
||||
cfg.CheckInterval = 5 * time.Minute // Check every 5 minutes
|
||||
}
|
||||
|
||||
return &FeatureMonitor{
|
||||
db: cfg.DB,
|
||||
notifier: cfg.Notifier,
|
||||
checkInterval: cfg.CheckInterval,
|
||||
stopCh: make(chan struct{}),
|
||||
notifiedPredictionReady: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// SetDiurnalReadyChecker sets the callback to check if diurnal baseline is ready.
|
||||
func (m *FeatureMonitor) SetDiurnalReadyChecker(fn func() bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.checkDiurnalReady = fn
|
||||
}
|
||||
|
||||
// SetFirstSleepSessionChecker sets the callback to check if first sleep session is complete.
|
||||
func (m *FeatureMonitor) SetFirstSleepSessionChecker(fn func() bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.checkFirstSleepSession = fn
|
||||
}
|
||||
|
||||
// SetWeightUpdateChecker sets the callback to check if weight update is approved.
|
||||
func (m *FeatureMonitor) SetWeightUpdateChecker(fn func() bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.checkWeightUpdate = fn
|
||||
}
|
||||
|
||||
// SetFirstAutomationChecker sets the callback to check if first automation has fired.
|
||||
func (m *FeatureMonitor) SetFirstAutomationChecker(fn func() bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.checkFirstAutomation = fn
|
||||
}
|
||||
|
||||
// SetPredictionReadyChecker sets the callback to check if prediction model is ready for a person.
|
||||
func (m *FeatureMonitor) SetPredictionReadyChecker(fn func(personID string) bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.checkPredictionReady = fn
|
||||
}
|
||||
|
||||
// Start begins the monitoring loop.
|
||||
func (m *FeatureMonitor) Start() {
|
||||
m.wg.Add(1)
|
||||
go m.monitorLoop()
|
||||
log.Printf("[INFO] Feature discovery monitor started (check interval: %v)", m.checkInterval)
|
||||
}
|
||||
|
||||
// Stop gracefully stops the monitor.
|
||||
func (m *FeatureMonitor) Stop() {
|
||||
close(m.stopCh)
|
||||
m.wg.Wait()
|
||||
log.Printf("[INFO] Feature discovery monitor stopped")
|
||||
}
|
||||
|
||||
// monitorLoop runs the periodic check for feature availability.
|
||||
func (m *FeatureMonitor) monitorLoop() {
|
||||
defer m.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(m.checkInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run initial check
|
||||
m.checkAllFeatures()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.checkAllFeatures()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkAllFeatures checks all feature availability conditions.
|
||||
func (m *FeatureMonitor) checkAllFeatures() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Check diurnal baseline activation
|
||||
if m.checkDiurnalReady != nil && !m.notifiedDiurnalReady {
|
||||
if m.checkDiurnalReady() {
|
||||
m.notifier.FireNotification(
|
||||
EventDiurnalBaselineActivated,
|
||||
getNotificationTitle(EventDiurnalBaselineActivated),
|
||||
getNotificationMessage(EventDiurnalBaselineActivated),
|
||||
)
|
||||
m.notifiedDiurnalReady = true
|
||||
log.Printf("[INFO] Feature notification fired: %s", EventDiurnalBaselineActivated)
|
||||
}
|
||||
}
|
||||
|
||||
// Check first sleep session
|
||||
if m.checkFirstSleepSession != nil && !m.notifiedFirstSleepSession {
|
||||
if m.checkFirstSleepSession() {
|
||||
m.notifier.FireNotification(
|
||||
EventFirstSleepSessionComplete,
|
||||
getNotificationTitle(EventFirstSleepSessionComplete),
|
||||
getNotificationMessage(EventFirstSleepSessionComplete),
|
||||
)
|
||||
m.notifiedFirstSleepSession = true
|
||||
log.Printf("[INFO] Feature notification fired: %s", EventFirstSleepSessionComplete)
|
||||
}
|
||||
}
|
||||
|
||||
// Check weight update approval
|
||||
if m.checkWeightUpdate != nil && !m.notifiedWeightUpdate {
|
||||
if m.checkWeightUpdate() {
|
||||
m.notifier.FireNotification(
|
||||
EventWeightUpdateApproved,
|
||||
getNotificationTitle(EventWeightUpdateApproved),
|
||||
getNotificationMessage(EventWeightUpdateApproved),
|
||||
)
|
||||
m.notifiedWeightUpdate = true
|
||||
log.Printf("[INFO] Feature notification fired: %s", EventWeightUpdateApproved)
|
||||
}
|
||||
}
|
||||
|
||||
// Check first automation
|
||||
if m.checkFirstAutomation != nil && !m.notifiedFirstAutomation {
|
||||
if m.checkFirstAutomation() {
|
||||
m.notifier.FireNotification(
|
||||
EventAutomationFirstFired,
|
||||
getNotificationTitle(EventAutomationFirstFired),
|
||||
getNotificationMessage(EventAutomationFirstFired),
|
||||
)
|
||||
m.notifiedFirstAutomation = true
|
||||
log.Printf("[INFO] Feature notification fired: %s", EventAutomationFirstFired)
|
||||
}
|
||||
}
|
||||
|
||||
// Check prediction model readiness for each person
|
||||
if m.checkPredictionReady != nil {
|
||||
// Get list of persons from database
|
||||
persons := m.getPersonsWithPredictionModels()
|
||||
for _, personID := range persons {
|
||||
if !m.notifiedPredictionReady[personID] {
|
||||
if m.checkPredictionReady(personID) {
|
||||
// Use person-specific event ID
|
||||
eventID := PredictionModelReadyEventID(personID)
|
||||
m.notifier.FireNotification(
|
||||
eventID,
|
||||
getPersonNotificationTitle(personID, EventPredictionModelReady),
|
||||
getPersonNotificationMessage(personID, EventPredictionModelReady),
|
||||
)
|
||||
m.notifiedPredictionReady[personID] = true
|
||||
log.Printf("[INFO] Feature notification fired: prediction model ready for person %s", personID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getPersonsWithPredictionModels returns a list of person IDs with prediction models.
|
||||
func (m *FeatureMonitor) getPersonsWithPredictionModels() []string {
|
||||
// Query the prediction_models table for persons
|
||||
rows, err := m.db.Query(`
|
||||
SELECT DISTINCT person FROM prediction_models
|
||||
WHERE sample_count >= 3
|
||||
ORDER BY person
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to query prediction models: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var persons []string
|
||||
for rows.Next() {
|
||||
var person string
|
||||
if err := rows.Scan(&person); err != nil {
|
||||
continue
|
||||
}
|
||||
persons = append(persons, person)
|
||||
}
|
||||
|
||||
return persons
|
||||
}
|
||||
|
||||
// PredictionModelReadyEventID returns the event ID for a person's prediction model readiness.
|
||||
func PredictionModelReadyEventID(personID string) string {
|
||||
return EventPredictionModelReady + "_" + personID
|
||||
}
|
||||
|
||||
// getPersonNotificationTitle returns a person-specific notification title.
|
||||
func getPersonNotificationTitle(personID, baseEvent string) string {
|
||||
switch baseEvent {
|
||||
case EventPredictionModelReady:
|
||||
return "Presence predictions are now available for " + personID
|
||||
default:
|
||||
return getNotificationTitle(baseEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// getPersonNotificationMessage returns a person-specific notification message.
|
||||
func getPersonNotificationMessage(personID, baseEvent string) string {
|
||||
switch baseEvent {
|
||||
case EventPredictionModelReady:
|
||||
return "The system has learned when " + personID + " is typically in each room. " +
|
||||
"Predictions appear in the Predictions panel. Accuracy will continue to improve over the coming days."
|
||||
default:
|
||||
return getNotificationMessage(baseEvent)
|
||||
}
|
||||
}
|
||||
423
mothership/internal/help/monitor_test.go
Normal file
423
mothership/internal/help/monitor_test.go
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
// Package help provides tests for the feature discovery monitor.
|
||||
package help
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// TestFeatureMonitorBasic tests the basic monitor functionality.
|
||||
func TestFeatureMonitorBasic(t *testing.T) {
|
||||
db := createMonitorTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
notifier, err := NewNotifier(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create notifier: %v", err)
|
||||
}
|
||||
|
||||
monitor := NewFeatureMonitor(FeatureMonitorConfig{
|
||||
DB: db,
|
||||
Notifier: notifier,
|
||||
CheckInterval: 100 * time.Millisecond,
|
||||
})
|
||||
|
||||
// Set up a checker that returns true after a delay
|
||||
callCount := 0
|
||||
monitor.SetDiurnalReadyChecker(func() bool {
|
||||
callCount++
|
||||
return callCount >= 2 // Return true on second call
|
||||
})
|
||||
|
||||
// Start the monitor
|
||||
monitor.Start()
|
||||
defer monitor.Stop()
|
||||
|
||||
// Wait for at least 2 check cycles
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
// Verify notification was fired
|
||||
notifications, err := notifier.GetPendingNotifications()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get pending notifications: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, n := range notifications {
|
||||
if n.EventID == EventDiurnalBaselineActivated {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("Expected DiurnalBaselineActivated notification to be fired")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeatureMonitorMultipleFeatures tests monitoring multiple features.
|
||||
func TestFeatureMonitorMultipleFeatures(t *testing.T) {
|
||||
db := createMonitorTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
notifier, err := NewNotifier(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create notifier: %v", err)
|
||||
}
|
||||
|
||||
monitor := NewFeatureMonitor(FeatureMonitorConfig{
|
||||
DB: db,
|
||||
Notifier: notifier,
|
||||
CheckInterval: 100 * time.Millisecond,
|
||||
})
|
||||
|
||||
// Set up checkers with different readiness
|
||||
diurnalCallCount := 0
|
||||
monitor.SetDiurnalReadyChecker(func() bool {
|
||||
diurnalCallCount++
|
||||
return diurnalCallCount >= 1 // Ready immediately
|
||||
})
|
||||
|
||||
sleepCallCount := 0
|
||||
monitor.SetFirstSleepSessionChecker(func() bool {
|
||||
sleepCallCount++
|
||||
return sleepCallCount >= 3 // Ready after 3 checks
|
||||
})
|
||||
|
||||
weightCallCount := 0
|
||||
monitor.SetWeightUpdateChecker(func() bool {
|
||||
weightCallCount++
|
||||
return false // Never ready
|
||||
})
|
||||
|
||||
// Start the monitor
|
||||
monitor.Start()
|
||||
defer monitor.Stop()
|
||||
|
||||
// Wait for enough cycles
|
||||
time.Sleep(450 * time.Millisecond)
|
||||
|
||||
// Verify notifications
|
||||
notifications, err := notifier.GetPendingNotifications()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get pending notifications: %v", err)
|
||||
}
|
||||
|
||||
foundDiurnal := false
|
||||
foundSleep := false
|
||||
foundWeight := false
|
||||
|
||||
for _, n := range notifications {
|
||||
switch n.EventID {
|
||||
case EventDiurnalBaselineActivated:
|
||||
foundDiurnal = true
|
||||
case EventFirstSleepSessionComplete:
|
||||
foundSleep = true
|
||||
case EventWeightUpdateApproved:
|
||||
foundWeight = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundDiurnal {
|
||||
t.Error("Expected DiurnalBaselineActivated notification")
|
||||
}
|
||||
if !foundSleep {
|
||||
t.Error("Expected FirstSleepSessionComplete notification")
|
||||
}
|
||||
if foundWeight {
|
||||
t.Error("Did not expect WeightUpdateApproved notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeatureMonitorPredictionPerPerson tests per-person prediction readiness.
|
||||
func TestFeatureMonitorPredictionPerPerson(t *testing.T) {
|
||||
db := createMonitorTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
// Set up prediction_models table with some persons
|
||||
setupPredictionModels(t, db)
|
||||
|
||||
notifier, err := NewNotifier(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create notifier: %v", err)
|
||||
}
|
||||
|
||||
monitor := NewFeatureMonitor(FeatureMonitorConfig{
|
||||
DB: db,
|
||||
Notifier: notifier,
|
||||
CheckInterval: 100 * time.Millisecond,
|
||||
})
|
||||
|
||||
// Set up prediction checker that returns true after 2 calls
|
||||
callCount := make(map[string]int)
|
||||
monitor.SetPredictionReadyChecker(func(personID string) bool {
|
||||
callCount[personID]++
|
||||
return callCount[personID] >= 2
|
||||
})
|
||||
|
||||
// Start the monitor
|
||||
monitor.Start()
|
||||
defer monitor.Stop()
|
||||
|
||||
// Wait for enough cycles
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
// Verify notifications for both persons
|
||||
notifications, err := notifier.GetPendingNotifications()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get pending notifications: %v", err)
|
||||
}
|
||||
|
||||
foundAlice := false
|
||||
foundBob := false
|
||||
|
||||
for _, n := range notifications {
|
||||
if n.EventID == "prediction_model_ready_Alice" {
|
||||
foundAlice = true
|
||||
if n.Title != "Presence predictions are now available for Alice" {
|
||||
t.Errorf("Expected person-specific title for Alice, got: %s", n.Title)
|
||||
}
|
||||
}
|
||||
if n.EventID == "prediction_model_ready_Bob" {
|
||||
foundBob = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundAlice {
|
||||
t.Error("Expected prediction model ready notification for Alice")
|
||||
}
|
||||
if !foundBob {
|
||||
t.Error("Expected prediction model ready notification for Bob")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeatureMonitorQuietHours tests that notifications respect quiet hours.
|
||||
func TestFeatureMonitorQuietHours(t *testing.T) {
|
||||
db := createMonitorTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
notifier, err := NewNotifier(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create notifier: %v", err)
|
||||
}
|
||||
|
||||
// Set quiet hours for current time
|
||||
now := time.Now()
|
||||
notifier.SetQuietHours(&QuietHours{
|
||||
Enabled: true,
|
||||
StartHour: now.Hour(),
|
||||
StartMin: now.Minute(),
|
||||
EndHour: now.Hour(),
|
||||
EndMin: now.Minute() + 30,
|
||||
DaysMask: 1 << uint(now.Weekday()),
|
||||
})
|
||||
|
||||
monitor := NewFeatureMonitor(FeatureMonitorConfig{
|
||||
DB: db,
|
||||
Notifier: notifier,
|
||||
CheckInterval: 100 * time.Millisecond,
|
||||
})
|
||||
|
||||
readyCalled := false
|
||||
monitor.SetDiurnalReadyChecker(func() bool {
|
||||
readyCalled = true
|
||||
return true
|
||||
})
|
||||
|
||||
// Start the monitor
|
||||
monitor.Start()
|
||||
defer monitor.Stop()
|
||||
|
||||
// Wait for check
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Verify checker was called
|
||||
if !readyCalled {
|
||||
t.Error("Expected checker to be called even during quiet hours")
|
||||
}
|
||||
|
||||
// Verify notification was NOT fired (suppressed by quiet hours)
|
||||
notifications, err := notifier.GetPendingNotifications()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get pending notifications: %v", err)
|
||||
}
|
||||
|
||||
for _, n := range notifications {
|
||||
if n.EventID == EventDiurnalBaselineActivated {
|
||||
t.Error("Expected notification to be suppressed during quiet hours")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setupPredictionModels creates test prediction model entries.
|
||||
func setupPredictionModels(t *testing.T, db *sql.DB) {
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS prediction_models (
|
||||
person TEXT NOT NULL,
|
||||
zone_id INTEGER NOT NULL,
|
||||
time_slot INTEGER NOT NULL,
|
||||
day_type TEXT NOT NULL,
|
||||
probability REAL NOT NULL DEFAULT 0,
|
||||
sample_count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (person, zone_id, time_slot, day_type)
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create prediction_models table: %v", err)
|
||||
}
|
||||
|
||||
// Insert test data for Alice and Bob
|
||||
now := time.Now().Unix()
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO prediction_models (person, zone_id, time_slot, day_type, probability, sample_count, updated_at)
|
||||
VALUES
|
||||
('Alice', 1, 10, 'weekday', 0.5, 10, ?),
|
||||
('Alice', 1, 11, 'weekday', 0.6, 8, ?),
|
||||
('Bob', 1, 10, 'weekday', 0.4, 5, ?);
|
||||
`, now, now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert prediction models: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// createMonitorTestDB creates an in-memory test database with the feature_notifications schema.
|
||||
func createMonitorTestDB(t *testing.T) *sql.DB {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open test database: %v", err)
|
||||
}
|
||||
|
||||
// Create the feature_notifications table
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS feature_notifications (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
fired_at INTEGER NOT NULL,
|
||||
acknowledged_at INTEGER
|
||||
);
|
||||
`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
t.Fatalf("Failed to create schema: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// TestPredictionModelReadyEventID tests the event ID generation.
|
||||
func TestPredictionModelReadyEventID(t *testing.T) {
|
||||
tests := []struct {
|
||||
personID string
|
||||
want string
|
||||
}{
|
||||
{"Alice", "prediction_model_ready_Alice"},
|
||||
{"Bob", "prediction_model_ready_Bob"},
|
||||
{"Charlie-123", "prediction_model_ready_Charlie-123"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.personID, func(t *testing.T) {
|
||||
got := PredictionModelReadyEventID(tt.personID)
|
||||
if got != tt.want {
|
||||
t.Errorf("PredictionModelReadyEventID(%q) = %q, want %q", tt.personID, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPersonNotificationTitle tests person-specific notification titles.
|
||||
func TestGetPersonNotificationTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
personID string
|
||||
baseEvent string
|
||||
want string
|
||||
}{
|
||||
{"Alice", EventPredictionModelReady, "Presence predictions are now available for Alice"},
|
||||
{"Bob", EventPredictionModelReady, "Presence predictions are now available for Bob"},
|
||||
{"Alice", EventDiurnalBaselineActivated, "Your system has learned your home's daily patterns"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.personID+"_"+tt.baseEvent, func(t *testing.T) {
|
||||
got := getPersonNotificationTitle(tt.personID, tt.baseEvent)
|
||||
if got != tt.want {
|
||||
t.Errorf("getPersonNotificationTitle(%q, %q) = %q, want %q", tt.personID, tt.baseEvent, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPersonNotificationMessage tests person-specific notification messages.
|
||||
func TestGetPersonNotificationMessage(t *testing.T) {
|
||||
personID := "Alice"
|
||||
baseEvent := EventPredictionModelReady
|
||||
got := getPersonNotificationMessage(personID, baseEvent)
|
||||
|
||||
wantPrefix := "The system has learned when " + personID + " is typically in each room"
|
||||
if len(got) < len(wantPrefix) || got[:len(wantPrefix)] != wantPrefix {
|
||||
t.Errorf("getPersonNotificationMessage(%q, %q) = %q, want prefix %q", personID, baseEvent, got, wantPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeatureMonitorIdempotent tests that notifications fire only once.
|
||||
func TestFeatureMonitorIdempotent(t *testing.T) {
|
||||
db := createMonitorTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
notifier, err := NewNotifier(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create notifier: %v", err)
|
||||
}
|
||||
|
||||
monitor := NewFeatureMonitor(FeatureMonitorConfig{
|
||||
DB: db,
|
||||
Notifier: notifier,
|
||||
CheckInterval: 50 * time.Millisecond,
|
||||
})
|
||||
|
||||
readyCalledCount := 0
|
||||
monitor.SetDiurnalReadyChecker(func() bool {
|
||||
readyCalledCount++
|
||||
t.Logf("Checker called: count=%d at %v", readyCalledCount, time.Now().Format("15:04:05.000"))
|
||||
return true // Always ready
|
||||
})
|
||||
|
||||
// Start the monitor
|
||||
t.Logf("Starting monitor at %v", time.Now().Format("15:04:05.000"))
|
||||
monitor.Start()
|
||||
|
||||
// Wait for multiple check cycles - wait for at least 3 ticker intervals
|
||||
// Initial check happens immediately, then ticker fires every 50ms
|
||||
waitTime := 200 * time.Millisecond
|
||||
t.Logf("Waiting %v for ticker fires...", waitTime)
|
||||
time.Sleep(waitTime)
|
||||
|
||||
t.Logf("After sleep: count=%d, now calling Stop()", readyCalledCount)
|
||||
monitor.Stop()
|
||||
|
||||
t.Logf("After Stop: count=%d", readyCalledCount)
|
||||
|
||||
// Verify checker was called at least once (it might be called only 1-2 times due to timing)
|
||||
if readyCalledCount < 1 {
|
||||
t.Errorf("Expected checker to be called at least once, got %d", readyCalledCount)
|
||||
}
|
||||
|
||||
// Verify notification was fired only once
|
||||
notifications, err := notifier.GetPendingNotifications()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get pending notifications: %v", err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, n := range notifications {
|
||||
if n.EventID == EventDiurnalBaselineActivated {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("Expected exactly 1 notification, got %d", count)
|
||||
}
|
||||
}
|
||||
|
|
@ -222,27 +222,52 @@ func (n *Notifier) GetPendingNotifications() ([]FeatureNotification, error) {
|
|||
var notifications []FeatureNotification
|
||||
for rows.Next() {
|
||||
var fn FeatureNotification
|
||||
var firedAt int64
|
||||
var acknowledgedAt sql.NullInt64
|
||||
err := rows.Scan(&fn.EventID, &fn.FiredAt, &acknowledgedAt)
|
||||
err := rows.Scan(&fn.EventID, &firedAt, &acknowledgedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fn.FiredAt = time.Unix(firedAt, 0)
|
||||
if acknowledgedAt.Valid {
|
||||
fn.DismissedAt = func() *time.Time {
|
||||
t := time.Unix(acknowledgedAt.Int64, 0)
|
||||
return &t
|
||||
}()
|
||||
t := time.Unix(acknowledgedAt.Int64, 0)
|
||||
fn.DismissedAt = &t
|
||||
}
|
||||
|
||||
// Check if this is a person-specific prediction model ready event
|
||||
if isPersonPredictionReadyEvent(fn.EventID) {
|
||||
personID := extractPersonIDFromEvent(fn.EventID)
|
||||
fn.Title = getPersonNotificationTitle(personID, EventPredictionModelReady)
|
||||
fn.Message = getPersonNotificationMessage(personID, EventPredictionModelReady)
|
||||
fn.ActionLabel = "View Predictions"
|
||||
fn.ActionURL = "#/predictions"
|
||||
} else {
|
||||
fn.Title = getNotificationTitle(fn.EventID)
|
||||
fn.Message = getNotificationMessage(fn.EventID)
|
||||
fn.ActionLabel = getNotificationActionLabel(fn.EventID)
|
||||
fn.ActionURL = getNotificationActionURL(fn.EventID)
|
||||
}
|
||||
fn.Title = getNotificationTitle(fn.EventID)
|
||||
fn.Message = getNotificationMessage(fn.EventID)
|
||||
fn.ActionLabel = getNotificationActionLabel(fn.EventID)
|
||||
fn.ActionURL = getNotificationActionURL(fn.EventID)
|
||||
notifications = append(notifications, fn)
|
||||
}
|
||||
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
// isPersonPredictionReadyEvent checks if the event ID is for a person-specific prediction model ready notification.
|
||||
func isPersonPredictionReadyEvent(eventID string) bool {
|
||||
prefix := EventPredictionModelReady + "_"
|
||||
return len(eventID) > len(prefix) && eventID[:len(prefix)] == prefix
|
||||
}
|
||||
|
||||
// extractPersonIDFromEvent extracts the person ID from a person-specific event ID.
|
||||
func extractPersonIDFromEvent(eventID string) string {
|
||||
prefix := EventPredictionModelReady + "_"
|
||||
if len(eventID) > len(prefix) && eventID[:len(prefix)] == prefix {
|
||||
return eventID[len(prefix):]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AcknowledgeNotification marks a notification as acknowledged.
|
||||
func (n *Notifier) AcknowledgeNotification(eventID string) error {
|
||||
_, err := n.db.Exec("UPDATE feature_notifications SET acknowledged_at = ? WHERE event_id = ?",
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ func TestNotifierContentHelpers(t *testing.T) {
|
|||
{EventWeightUpdateApproved, "Localization accuracy improved", ""},
|
||||
{EventAutomationFirstFired, "Your first automation just ran", ""},
|
||||
{EventPredictionModelReady, "Presence predictions are now available", ""},
|
||||
{"unknown_event", "New Feature Available", "A new feature is now available"},
|
||||
{"unknown_event", "New Feature Available", "A new feature is now available in your Spaxel system."},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -165,14 +165,15 @@ func TestNotifierFireWithAction(t *testing.T) {
|
|||
t.Fatalf("Failed to create notifier: %v", err)
|
||||
}
|
||||
|
||||
// Fire notification with action
|
||||
// Fire notification with action for a known event type
|
||||
// The implementation uses predefined action labels/URLs for known events
|
||||
eventID := EventDiurnalBaselineActivated
|
||||
fired := notifier.FireNotificationWithAction(
|
||||
eventID,
|
||||
"Diurnal Baseline Ready",
|
||||
"Your system has learned patterns",
|
||||
"View Details",
|
||||
"#/diurnal",
|
||||
"Ignored Label", // This is ignored for known events
|
||||
"#/ignored", // This is ignored for known events
|
||||
)
|
||||
|
||||
if !fired {
|
||||
|
|
@ -185,12 +186,13 @@ func TestNotifierFireWithAction(t *testing.T) {
|
|||
t.Fatalf("Expected 1 notification, got %d", len(notifications))
|
||||
}
|
||||
|
||||
if notifications[0].ActionLabel != "View Details" {
|
||||
t.Errorf("Expected action label 'View Details', got %q", notifications[0].ActionLabel)
|
||||
// Known events use predefined action labels/URLs
|
||||
if notifications[0].ActionLabel != "View Diurnal Baseline" {
|
||||
t.Errorf("Expected action label 'View Diurnal Baseline', got %q", notifications[0].ActionLabel)
|
||||
}
|
||||
|
||||
if notifications[0].ActionURL != "#/diurnal" {
|
||||
t.Errorf("Expected action URL '#/diurnal', got %q", notifications[0].ActionURL)
|
||||
if notifications[0].ActionURL != "#/settings/diurnal" {
|
||||
t.Errorf("Expected action URL '#/settings/diurnal', got %q", notifications[0].ActionURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue