diff --git a/alerts/config.go b/alerts/config.go index 9437c6a5ff..f26387c1b8 100644 --- a/alerts/config.go +++ b/alerts/config.go @@ -6,10 +6,15 @@ import ( "bytes" "context" "encoding/json" + "slices" "time" "github.com/tidepool-org/platform/data" - "github.com/tidepool-org/platform/data/blood/glucose" + nontypesglucose "github.com/tidepool-org/platform/data/blood/glucose" + "github.com/tidepool-org/platform/data/types/blood/glucose" + "github.com/tidepool-org/platform/data/types/dosingdecision" + "github.com/tidepool-org/platform/errors" + "github.com/tidepool-org/platform/log" "github.com/tidepool-org/platform/structure" "github.com/tidepool-org/platform/structure/validator" "github.com/tidepool-org/platform/user" @@ -50,6 +55,45 @@ func (c Config) Validate(validator structure.Validator) { c.Alerts.Validate(validator) } +// Evaluate alerts in the context of the provided data. +// +// While this method, or the methods it calls, can fail, there's no point in returning an +// error. Instead errors are logged before continuing. This is to ensure that any possible alert +// that should be triggered, will be triggered. +func (c Config) Evaluate(ctx context.Context, gd []*glucose.Glucose, dd []*dosingdecision.DosingDecision) *Note { + n := c.Alerts.Evaluate(ctx, gd, dd) + if n != nil { + n.FollowedUserID = c.FollowedUserID + n.RecipientUserID = c.UserID + } + if lgr := log.LoggerFromContext(ctx); lgr != nil { + lgr.WithField("note", n).Info("evaluated alert") + } + + return n +} + +// LongestDelay of the delays set on enabled alerts. +func (a Alerts) LongestDelay() time.Duration { + delays := []time.Duration{} + if a.Low != nil && a.Low.Enabled { + delays = append(delays, a.Low.Delay.Duration()) + } + if a.High != nil && a.High.Enabled { + delays = append(delays, a.High.Delay.Duration()) + } + if a.NotLooping != nil && a.NotLooping.Enabled { + delays = append(delays, a.NotLooping.Delay.Duration()) + } + if a.NoCommunication != nil && a.NoCommunication.Enabled { + delays = append(delays, a.NoCommunication.Delay.Duration()) + } + if len(delays) == 0 { + return 0 + } + return slices.Max(delays) +} + func (a Alerts) Validate(validator structure.Validator) { if a.UrgentLow != nil { a.UrgentLow.Validate(validator) @@ -68,6 +112,41 @@ func (a Alerts) Validate(validator structure.Validator) { } } +// Evaluate a user's data to determine if notifications are indicated. +// +// Evaluations are performed according to priority. The process is +// "short-circuited" at the first indicated notification. +func (a Alerts) Evaluate(ctx context.Context, + gd []*glucose.Glucose, dd []*dosingdecision.DosingDecision) *Note { + + if a.NoCommunication != nil && a.NoCommunication.Enabled { + if n := a.NoCommunication.Evaluate(ctx, gd); n != nil { + return n + } + } + if a.UrgentLow != nil && a.UrgentLow.Enabled { + if n := a.UrgentLow.Evaluate(ctx, gd); n != nil { + return n + } + } + if a.Low != nil && a.Low.Enabled { + if n := a.Low.Evaluate(ctx, gd); n != nil { + return n + } + } + if a.High != nil && a.High.Enabled { + if n := a.High.Evaluate(ctx, gd); n != nil { + return n + } + } + if a.NotLooping != nil && a.NotLooping.Enabled { + if n := a.NotLooping.Evaluate(ctx, dd); n != nil { + return n + } + } + return nil +} + // Base describes the minimum specifics of a desired alert. type Base struct { // Enabled controls whether notifications should be sent for this alert. @@ -81,6 +160,13 @@ func (b Base) Validate(validator structure.Validator) { validator.Bool("enabled", &b.Enabled) } +func (b Base) Evaluate(ctx context.Context, data []*glucose.Glucose) *Note { + if lgr := log.LoggerFromContext(ctx); lgr != nil { + lgr.Warn("alerts.Base.Evaluate called, this shouldn't happen!") + } + return nil +} + type Activity struct { // Triggered records the last time this alert was triggered. Triggered time.Time `json:"triggered" bson:"triggered"` @@ -132,6 +218,46 @@ func (a UrgentLowAlert) Validate(validator structure.Validator) { a.Threshold.Validate(validator) } +// Evaluate urgent low condition. +// +// Assumes data is pre-sorted in descending order by Time. +func (a *UrgentLowAlert) Evaluate(ctx context.Context, data []*glucose.Glucose) (note *Note) { + lgr := log.LoggerFromContext(ctx) + if len(data) == 0 { + lgr.Debug("no data to evaluate for urgent low") + return nil + } + datum := data[0] + okDatum, okThreshold, err := validateGlucoseAlertDatum(datum, a.Threshold) + if err != nil { + lgr.WithError(err).Warn("Unable to evaluate urgent low") + return nil + } + defer func() { logGlucoseAlertEvaluation(lgr, "urgent low", note, okDatum, okThreshold) }() + active := okDatum < okThreshold + if !active { + if a.IsActive() { + a.Resolved = time.Now() + } + return nil + } + if !a.IsActive() { + a.Triggered = time.Now() + } + return &Note{Message: genGlucoseThresholdMessage("below urgent low")} +} + +func validateGlucoseAlertDatum(datum *glucose.Glucose, t Threshold) (float64, float64, error) { + if datum.Blood.Units == nil || datum.Blood.Value == nil || datum.Blood.Time == nil { + return 0, 0, errors.Newf("Unable to evaluate datum: Units, Value, or Time is nil") + } + threshold := nontypesglucose.NormalizeValueForUnits(&t.Value, datum.Blood.Units) + if threshold == nil { + return 0, 0, errors.Newf("Unable to normalize threshold units: normalized to nil") + } + return *datum.Blood.Value, *threshold, nil +} + // NotLoopingAlert extends Base with a delay. type NotLoopingAlert struct { Base `bson:",inline"` @@ -144,6 +270,16 @@ func (a NotLoopingAlert) Validate(validator structure.Validator) { validator.Duration("delay", &dur).InRange(0, 2*time.Hour) } +// Evaluate if the device is looping. +func (a NotLoopingAlert) Evaluate(ctx context.Context, decisions []*dosingdecision.DosingDecision) (note *Note) { + // TODO will be implemented in the near future. + return nil +} + +// DosingDecisionReasonLoop is specified in a [dosingdecision.DosingDecision] to indicate that +// the decision is part of a loop adjustment (as opposed to bolus or something else). +const DosingDecisionReasonLoop string = "loop" + // NoCommunicationAlert extends Base with a delay. type NoCommunicationAlert struct { Base `bson:",inline"` @@ -156,6 +292,26 @@ func (a NoCommunicationAlert) Validate(validator structure.Validator) { validator.Duration("delay", &dur).InRange(0, 6*time.Hour) } +// Evaluate if CGM data is being received by Tidepool. +// +// Assumes data is pre-sorted by Time in descending order. +func (a NoCommunicationAlert) Evaluate(ctx context.Context, data []*glucose.Glucose) *Note { + var newest time.Time + for _, d := range data { + if d != nil && d.Time != nil && !(*d.Time).IsZero() { + newest = *d.Time + break + } + } + if time.Since(newest) > a.Delay.Duration() { + return &Note{Message: NoCommunicationMessage} + } + + return nil +} + +const NoCommunicationMessage = "Tidepool is unable to communicate with a user's device" + // LowAlert extends Base with threshold and a delay. type LowAlert struct { Base `bson:",inline"` @@ -178,6 +334,51 @@ func (a LowAlert) Validate(validator structure.Validator) { validator.Duration("repeat", &repeatDur).Using(validateRepeat) } +// Evaluate the given data to determine if an alert should be sent. +// +// Assumes data is pre-sorted in descending order by Time. +func (a *LowAlert) Evaluate(ctx context.Context, data []*glucose.Glucose) (note *Note) { + lgr := log.LoggerFromContext(ctx) + if len(data) == 0 { + lgr.Debug("no data to evaluate for low") + return nil + } + var eventBegan time.Time + var okDatum, okThreshold float64 + var err error + defer func() { logGlucoseAlertEvaluation(lgr, "low", note, okDatum, okThreshold) }() + for _, datum := range data { + okDatum, okThreshold, err = validateGlucoseAlertDatum(datum, a.Threshold) + if err != nil { + lgr.WithError(err).Debug("Skipping low alert datum evaluation") + continue + } + active := okDatum < okThreshold + if !active { + break + } + if (*datum.Time).Before(eventBegan) || eventBegan.IsZero() { + eventBegan = *datum.Time + } + } + if eventBegan.IsZero() { + if a.IsActive() { + a.Resolved = time.Now() + } + return nil + } + if !a.IsActive() { + if time.Since(eventBegan) > a.Delay.Duration() { + a.Triggered = time.Now() + } + } + return &Note{Message: genGlucoseThresholdMessage("below low")} +} + +func genGlucoseThresholdMessage(alertType string) string { + return "Glucose reading " + alertType + " threshold" +} + // HighAlert extends Base with a threshold and a delay. type HighAlert struct { Base `bson:",inline"` @@ -200,6 +401,57 @@ func (a HighAlert) Validate(validator structure.Validator) { validator.Duration("repeat", &repeatDur).Using(validateRepeat) } +// Evaluate the given data to determine if an alert should be sent. +// +// Assumes data is pre-sorted in descending order by Time. +func (a *HighAlert) Evaluate(ctx context.Context, data []*glucose.Glucose) (note *Note) { + lgr := log.LoggerFromContext(ctx) + if len(data) == 0 { + lgr.Debug("no data to evaluate for high") + return nil + } + var eventBegan time.Time + var okDatum, okThreshold float64 + var err error + defer func() { logGlucoseAlertEvaluation(lgr, "high", note, okDatum, okThreshold) }() + for _, datum := range data { + okDatum, okThreshold, err = validateGlucoseAlertDatum(datum, a.Threshold) + if err != nil { + lgr.WithError(err).Debug("Skipping high alert datum evaluation") + continue + } + active := okDatum > okThreshold + if !active { + break + } + if (*datum.Time).Before(eventBegan) || eventBegan.IsZero() { + eventBegan = *datum.Time + } + } + if eventBegan.IsZero() { + if a.IsActive() { + a.Resolved = time.Now() + } + return nil + } + if !a.IsActive() { + if time.Since(eventBegan) > a.Delay.Duration() { + a.Triggered = time.Now() + } + } + return &Note{Message: genGlucoseThresholdMessage("above high")} +} + +// logGlucoseAlertEvaluation is called during each glucose-based evaluation for record-keeping. +func logGlucoseAlertEvaluation(lgr log.Logger, alertType string, note *Note, value, threshold float64) { + fields := log.Fields{ + "isAlerting?": note != nil, + "threshold": threshold, + "value": value, + } + lgr.WithFields(fields).Info(alertType) +} + // DurationMinutes reads a JSON integer and converts it to a time.Duration. // // Values are specified in minutes. @@ -227,7 +479,7 @@ func (m DurationMinutes) Duration() time.Duration { return time.Duration(m) } -// ValueWithUnits binds a value to its units. +// ValueWithUnits binds a value with its units. // // Other types can extend it to parse and validate the Units. type ValueWithUnits struct { @@ -240,20 +492,20 @@ type Threshold ValueWithUnits // Validate implements structure.Validatable func (t Threshold) Validate(v structure.Validator) { - v.String("units", &t.Units).OneOf(glucose.MgdL, glucose.MmolL) + v.String("units", &t.Units).OneOf(nontypesglucose.MgdL, nontypesglucose.MmolL) // This is a sanity check. Client software will likely further constrain these values. The // broadness of these values allows clients to change their own min and max values // independently, and it sidesteps rounding and conversion conflicts between the backend and // clients. var max, min float64 switch t.Units { - case glucose.MgdL, glucose.Mgdl: - max = glucose.MgdLMaximum - min = glucose.MgdLMinimum + case nontypesglucose.MgdL, nontypesglucose.Mgdl: + max = nontypesglucose.MgdLMaximum + min = nontypesglucose.MgdLMinimum v.Float64("value", &t.Value).InRange(min, max) - case glucose.MmolL, glucose.Mmoll: - max = glucose.MmolLMaximum - min = glucose.MmolLMinimum + case nontypesglucose.MmolL, nontypesglucose.Mmoll: + max = nontypesglucose.MmolLMaximum + min = nontypesglucose.MmolLMinimum v.Float64("value", &t.Value).InRange(min, max) default: v.WithReference("value").ReportError(validator.ErrorValueNotValid()) diff --git a/alerts/config_test.go b/alerts/config_test.go index 1d17b5a852..74d7ca6110 100644 --- a/alerts/config_test.go +++ b/alerts/config_test.go @@ -2,6 +2,7 @@ package alerts import ( "bytes" + "context" "fmt" "strings" "testing" @@ -10,7 +11,13 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/tidepool-org/platform/data/blood/glucose" + nontypesglucose "github.com/tidepool-org/platform/data/blood/glucose" + "github.com/tidepool-org/platform/data/types" + "github.com/tidepool-org/platform/data/types/blood" + "github.com/tidepool-org/platform/data/types/blood/glucose" + "github.com/tidepool-org/platform/log" + logtest "github.com/tidepool-org/platform/log/test" + "github.com/tidepool-org/platform/pointer" "github.com/tidepool-org/platform/request" "github.com/tidepool-org/platform/structure/validator" "github.com/tidepool-org/platform/test" @@ -76,15 +83,15 @@ var _ = Describe("Config", func() { Expect(conf.High.Repeat).To(Equal(DurationMinutes(30 * time.Minute))) Expect(conf.High.Delay).To(Equal(DurationMinutes(5 * time.Minute))) Expect(conf.High.Threshold.Value).To(Equal(10.0)) - Expect(conf.High.Threshold.Units).To(Equal(glucose.MmolL)) + Expect(conf.High.Threshold.Units).To(Equal(nontypesglucose.MmolL)) Expect(conf.Low.Enabled).To(Equal(true)) Expect(conf.Low.Repeat).To(Equal(DurationMinutes(30 * time.Minute))) Expect(conf.Low.Delay).To(Equal(DurationMinutes(10 * time.Minute))) Expect(conf.Low.Threshold.Value).To(Equal(80.0)) - Expect(conf.Low.Threshold.Units).To(Equal(glucose.MgdL)) + Expect(conf.Low.Threshold.Units).To(Equal(nontypesglucose.MgdL)) Expect(conf.UrgentLow.Enabled).To(Equal(false)) Expect(conf.UrgentLow.Threshold.Value).To(Equal(47.5)) - Expect(conf.UrgentLow.Threshold.Units).To(Equal(glucose.MgdL)) + Expect(conf.UrgentLow.Threshold.Units).To(Equal(nontypesglucose.MgdL)) Expect(conf.NotLooping.Enabled).To(Equal(true)) Expect(conf.NotLooping.Delay).To(Equal(DurationMinutes(4 * time.Minute))) Expect(conf.NoCommunication.Enabled).To(Equal(true)) @@ -125,6 +132,44 @@ var _ = Describe("Config", func() { }) }) + Describe("Evaluate", func() { + Context("when a note is returned", func() { + It("injects the userIDs", func() { + ctx := contextWithTestLogger() + mockGlucoseData := []*glucose.Glucose{ + { + Blood: blood.Blood{ + Base: types.Base{ + Time: pointer.FromAny(time.Now()), + }, + Units: pointer.FromAny(nontypesglucose.MmolL), + Value: pointer.FromAny(0.0), + }, + }, + } + conf := Config{ + UserID: mockUserID1, + FollowedUserID: mockUserID2, + Alerts: Alerts{ + UrgentLow: &UrgentLowAlert{ + Base: Base{Enabled: true}, + Threshold: Threshold{ + Value: 10, + Units: nontypesglucose.MmolL, + }, + }, + }, + } + + note := conf.Evaluate(ctx, mockGlucoseData, nil) + + Expect(note).ToNot(BeNil()) + Expect(note.RecipientUserID).To(Equal(mockUserID1)) + Expect(note.FollowedUserID).To(Equal(mockUserID2)) + }) + }) + }) + Context("Base", func() { Context("Activity", func() { Context("IsActive()", func() { @@ -173,6 +218,18 @@ var _ = Describe("Config", func() { }) }) + var testGlucoseDatum = func(v float64) *glucose.Glucose { + return &glucose.Glucose{ + Blood: blood.Blood{ + Base: types.Base{ + Time: pointer.FromAny(time.Now()), + }, + Units: pointer.FromAny(nontypesglucose.MmolL), + Value: pointer.FromAny(v), + }, + } + } + Context("UrgentLowAlert", func() { Context("Threshold", func() { It("accepts values between 0 and 1000 mg/dL", func() { @@ -197,6 +254,138 @@ var _ = Describe("Config", func() { Expect(val.Error()).To(MatchError("value -1 is not between 0 and 1000")) }) }) + + Context("Evaluate", func() { + testUrgentLow := func() *UrgentLowAlert { + return &UrgentLowAlert{ + Threshold: Threshold{ + Value: 4.0, + Units: nontypesglucose.MmolL, + }, + } + } + + It("handles being passed empty data", func() { + ctx := contextWithTestLogger() + var note *Note + + alert := testUrgentLow() + + Expect(func() { + note = alert.Evaluate(ctx, []*glucose.Glucose{}) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + Expect(func() { + note = alert.Evaluate(ctx, nil) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + }) + + It("logs evaluation results", func() { + ctx := contextWithTestLogger() + data := []*glucose.Glucose{testGlucoseDatum(1.1)} + + alert := testUrgentLow() + + Expect(func() { + alert.Evaluate(ctx, data) + }).ToNot(Panic()) + Expect(func() { + lgr := log.LoggerFromContext(ctx).(*logtest.Logger) + lgr.AssertLog(log.InfoLevel, "urgent low", log.Fields{ + "threshold": 4.0, + "value": 1.1, + "isAlerting?": true, + }) + }).ToNot(Panic()) + }) + + Context("when currently active", func() { + It("marks itself resolved", func() { + ctx := contextWithTestLogger() + + alert := testUrgentLow() + + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(1.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).To(BeZero()) + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(5.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).ToNot(BeZero()) + }) + }) + + Context("when currently INactive", func() { + It("doesn't re-mark itself resolved", func() { + ctx := contextWithTestLogger() + + alert := testUrgentLow() + + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(1.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).To(BeZero()) + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(5.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).ToNot(BeZero()) + was := alert.Resolved + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(5.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).To(Equal(was)) + }) + }) + + It("marks itself triggered", func() { + ctx := contextWithTestLogger() + + alert := testUrgentLow() + + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(5.0)}) + }).ToNot(Panic()) + Expect(alert.Triggered).To(BeZero()) + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(1.0)}) + }).ToNot(Panic()) + Expect(alert.Triggered).ToNot(BeZero()) + }) + + It("validates glucose data", func() { + ctx := contextWithTestLogger() + var note *Note + + Expect(func() { + note = testUrgentLow().Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(1)}) + }).ToNot(Panic()) + Expect(note).ToNot(BeNil()) + + badUnits := testGlucoseDatum(1) + badUnits.Units = nil + Expect(func() { + note = testUrgentLow().Evaluate(ctx, []*glucose.Glucose{badUnits}) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + + badValue := testGlucoseDatum(1) + badValue.Value = nil + Expect(func() { + note = testUrgentLow().Evaluate(ctx, []*glucose.Glucose{badValue}) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + + badTime := testGlucoseDatum(1) + badTime.Time = nil + Expect(func() { + note = testUrgentLow().Evaluate(ctx, []*glucose.Glucose{badTime}) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + + }) + }) }) Context("LowAlert", func() { @@ -256,6 +445,137 @@ var _ = Describe("Config", func() { Expect(val.Error()).To(MatchError("value 6h1m0s is not between 0s and 6h0m0s")) }) }) + + Context("Evaluate", func() { + testLow := func() *LowAlert { + return &LowAlert{ + Threshold: Threshold{ + Value: 4.0, + Units: nontypesglucose.MmolL, + }, + } + } + + It("handles being passed empty data", func() { + ctx := contextWithTestLogger() + var note *Note + + alert := testLow() + + Expect(func() { + note = alert.Evaluate(ctx, []*glucose.Glucose{}) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + Expect(func() { + note = alert.Evaluate(ctx, nil) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + }) + + It("logs evaluation results", func() { + ctx := contextWithTestLogger() + data := []*glucose.Glucose{testGlucoseDatum(1.1)} + + alert := testLow() + + Expect(func() { + alert.Evaluate(ctx, data) + }).ToNot(Panic()) + Expect(func() { + lgr := log.LoggerFromContext(ctx).(*logtest.Logger) + lgr.AssertLog(log.InfoLevel, "low", log.Fields{ + "threshold": 4.0, + "value": 1.1, + "isAlerting?": true, + }) + }).ToNot(Panic()) + }) + + Context("when currently active", func() { + It("marks itself resolved", func() { + ctx := contextWithTestLogger() + + alert := testLow() + + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(1.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).To(BeZero()) + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(5.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).ToNot(BeZero()) + }) + }) + + Context("when currently INactive", func() { + It("doesn't re-mark itself resolved", func() { + ctx := contextWithTestLogger() + + alert := testLow() + + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(1.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).To(BeZero()) + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(5.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).ToNot(BeZero()) + was := alert.Resolved + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(5.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).To(Equal(was)) + }) + }) + + It("marks itself triggered", func() { + ctx := contextWithTestLogger() + + alert := testLow() + + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(5.0)}) + }).ToNot(Panic()) + Expect(alert.Triggered).To(BeZero()) + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(1.0)}) + }).ToNot(Panic()) + Expect(alert.Triggered).ToNot(BeZero()) + }) + + It("validates glucose data", func() { + ctx := contextWithTestLogger() + var note *Note + + Expect(func() { + note = testLow().Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(1)}) + }).ToNot(Panic()) + Expect(note).ToNot(BeNil()) + + badUnits := testGlucoseDatum(1) + badUnits.Units = nil + Expect(func() { + note = testLow().Evaluate(ctx, []*glucose.Glucose{badUnits}) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + + badValue := testGlucoseDatum(1) + badValue.Value = nil + Expect(func() { + note = testLow().Evaluate(ctx, []*glucose.Glucose{badValue}) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + + badTime := testGlucoseDatum(1) + badTime.Time = nil + Expect(func() { + note = testLow().Evaluate(ctx, []*glucose.Glucose{badTime}) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + }) + }) }) Context("HighAlert", func() { @@ -308,6 +628,137 @@ var _ = Describe("Config", func() { Expect(val.Error()).To(MatchError("value 6h1m0s is not between 0s and 6h0m0s")) }) }) + + Context("Evaluate", func() { + testHigh := func() *HighAlert { + return &HighAlert{ + Threshold: Threshold{ + Value: 20.0, + Units: nontypesglucose.MmolL, + }, + } + } + + It("handles being passed empty data", func() { + ctx := contextWithTestLogger() + var note *Note + + alert := testHigh() + + Expect(func() { + note = alert.Evaluate(ctx, []*glucose.Glucose{}) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + Expect(func() { + note = alert.Evaluate(ctx, nil) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + }) + + It("logs evaluation results", func() { + ctx := contextWithTestLogger() + data := []*glucose.Glucose{testGlucoseDatum(21.1)} + + alert := testHigh() + + Expect(func() { + alert.Evaluate(ctx, data) + }).ToNot(Panic()) + Expect(func() { + lgr := log.LoggerFromContext(ctx).(*logtest.Logger) + lgr.AssertLog(log.InfoLevel, "high", log.Fields{ + "threshold": 20.0, + "value": 21.1, + "isAlerting?": true, + }) + }).ToNot(Panic()) + }) + + Context("when currently active", func() { + It("marks itself resolved", func() { + ctx := contextWithTestLogger() + + alert := testHigh() + + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(21.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).To(BeZero()) + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(5.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).ToNot(BeZero()) + }) + }) + + Context("when currently INactive", func() { + It("doesn't re-mark itself resolved", func() { + ctx := contextWithTestLogger() + + alert := testHigh() + + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(21.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).To(BeZero()) + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(5.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).ToNot(BeZero()) + was := alert.Resolved + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(5.0)}) + }).ToNot(Panic()) + Expect(alert.Resolved).To(Equal(was)) + }) + }) + + It("marks itself triggered", func() { + ctx := contextWithTestLogger() + + alert := testHigh() + + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(5.0)}) + }).ToNot(Panic()) + Expect(alert.Triggered).To(BeZero()) + Expect(func() { + alert.Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(21.0)}) + }).ToNot(Panic()) + Expect(alert.Triggered).ToNot(BeZero()) + }) + + It("validates glucose data", func() { + ctx := contextWithTestLogger() + var note *Note + + Expect(func() { + note = testHigh().Evaluate(ctx, []*glucose.Glucose{testGlucoseDatum(21)}) + }).ToNot(Panic()) + Expect(note).ToNot(BeNil()) + + badUnits := testGlucoseDatum(1) + badUnits.Units = nil + Expect(func() { + note = testHigh().Evaluate(ctx, []*glucose.Glucose{badUnits}) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + + badValue := testGlucoseDatum(1) + badValue.Value = nil + Expect(func() { + note = testHigh().Evaluate(ctx, []*glucose.Glucose{badValue}) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + + badTime := testGlucoseDatum(1) + badTime.Time = nil + Expect(func() { + note = testHigh().Evaluate(ctx, []*glucose.Glucose{badTime}) + }).ToNot(Panic()) + Expect(note).To(BeNil()) + }) + }) }) Context("NoCommunicationAlert", func() { @@ -365,7 +816,7 @@ var _ = Describe("Config", func() { Context("repeat", func() { var defaultAlert = LowAlert{ - Threshold: Threshold{Value: 11, Units: glucose.MmolL}, + Threshold: Threshold{Value: 11, Units: nontypesglucose.MmolL}, } It("accepts values of 0 (indicating disabled)", func() { @@ -446,7 +897,7 @@ var _ = Describe("Config", func() { "value": 47.5 } } -}`, mockUserID1, mockUserID2, mockUploadID, glucose.MgdL) +}`, mockUserID1, mockUserID2, mockUploadID, nontypesglucose.MgdL) cfg := &Config{} err := request.DecodeObject(nil, buf, cfg) Expect(err).To(MatchError("value -11m0s is not greater than or equal to 15m0s")) @@ -464,13 +915,217 @@ var _ = Describe("Config", func() { "value": 1 } } -}`, mockUserID1, mockUserID2, mockUploadID, glucose.MgdL) +}`, mockUserID1, mockUserID2, mockUploadID, nontypesglucose.MgdL) cfg := &Config{} err := request.DecodeObject(nil, buf, cfg) Expect(err).To(MatchError("json is malformed")) }) }) +var ( + testNoCommunicationAlert = func() *NoCommunicationAlert { + return &NoCommunicationAlert{ + Base: Base{Enabled: true}, + } + } + testLowAlert = func() *LowAlert { + return &LowAlert{ + Base: Base{Enabled: true}, + Threshold: Threshold{ + Value: 4, + Units: nontypesglucose.MmolL, + }, + } + } + testHighAlert = func() *HighAlert { + return &HighAlert{ + Base: Base{Enabled: true}, + Threshold: Threshold{ + Value: 10, + Units: nontypesglucose.MmolL, + }, + } + } + testUrgentLowAlert = func() *UrgentLowAlert { + return &UrgentLowAlert{ + Base: Base{Enabled: true}, + Threshold: Threshold{ + Value: 3, + Units: nontypesglucose.MmolL, + }, + } + } + testNotLoopingAlert = func() *NotLoopingAlert { + return &NotLoopingAlert{ + Base: Base{Enabled: true}, + } + } + testNoCommunicationDatum = &glucose.Glucose{ + Blood: blood.Blood{ + Base: types.Base{ + Time: pointer.FromAny(time.Now()), + }, + Units: pointer.FromAny(nontypesglucose.MmolL), + Value: pointer.FromAny(11.0), + }, + } + testHighDatum = &glucose.Glucose{ + Blood: blood.Blood{ + Base: types.Base{ + Time: pointer.FromAny(time.Now()), + }, + Units: pointer.FromAny(nontypesglucose.MmolL), + Value: pointer.FromAny(11.0), + }, + } + testLowDatum = &glucose.Glucose{ + Blood: blood.Blood{ + Base: types.Base{ + Time: pointer.FromAny(time.Now()), + }, + Units: pointer.FromAny(nontypesglucose.MmolL), + Value: pointer.FromAny(3.9), + }, + } + testUrgentLowDatum = &glucose.Glucose{ + Blood: blood.Blood{ + Base: types.Base{ + Time: pointer.FromAny(time.Now()), + }, + Units: pointer.FromAny(nontypesglucose.MmolL), + Value: pointer.FromAny(2.9), + }, + } +) + +var _ = Describe("Alerts", func() { + Describe("LongestDelay", func() { + It("does what it says", func() { + noComm := testNoCommunicationAlert() + noComm.Delay = DurationMinutes(10 * time.Minute) + low := testLowAlert() + low.Delay = DurationMinutes(5 * time.Minute) + high := testHighAlert() + high.Delay = DurationMinutes(5 * time.Minute) + notLooping := testNotLoopingAlert() + notLooping.Delay = DurationMinutes(5 * time.Minute) + + a := Alerts{ + NoCommunication: noComm, + Low: low, + High: high, + NotLooping: notLooping, + } + + delay := a.LongestDelay() + + Expect(delay).To(Equal(10 * time.Minute)) + }) + + It("ignores disabled alerts", func() { + noComm := testNoCommunicationAlert() + noComm.Delay = DurationMinutes(10 * time.Minute) + noComm.Enabled = false + low := testLowAlert() + low.Delay = DurationMinutes(7 * time.Minute) + high := testHighAlert() + high.Delay = DurationMinutes(5 * time.Minute) + notLooping := testNotLoopingAlert() + notLooping.Delay = DurationMinutes(5 * time.Minute) + + a := Alerts{ + NoCommunication: noComm, + Low: low, + High: high, + NotLooping: notLooping, + } + + delay := a.LongestDelay() + + Expect(delay).To(Equal(7 * time.Minute)) + }) + + It("returns a Zero Duration when no alerts are set", func() { + a := Alerts{ + NoCommunication: nil, + Low: nil, + High: nil, + NotLooping: nil, + } + + delay := a.LongestDelay() + + Expect(delay).To(Equal(time.Duration(0))) + }) + }) + + Describe("Evaluate", func() { + Context("when not communicating", func() { + It("returns only NoCommunication alerts", func() { + ctx := contextWithTestLogger() + data := []*glucose.Glucose{testNoCommunicationDatum} + data[0].Value = pointer.FromAny(0.0) + a := Alerts{ + NoCommunication: testNoCommunicationAlert(), + UrgentLow: testUrgentLowAlert(), + Low: testLowAlert(), + High: testHighAlert(), + } + + note := a.Evaluate(ctx, data, nil) + + Expect(note).To(HaveField("Message", ContainSubstring(NoCommunicationMessage))) + }) + }) + + It("logs decisions", func() { + Skip("TODO logAlertEvaluation") + }) + + It("detects low data", func() { + ctx := contextWithTestLogger() + data := []*glucose.Glucose{testLowDatum} + a := Alerts{ + Low: testLowAlert(), + } + + note := a.Evaluate(ctx, data, nil) + + Expect(note).ToNot(BeNil()) + Expect(note.Message).To(ContainSubstring("below low threshold")) + }) + + It("detects high data", func() { + ctx := contextWithTestLogger() + data := []*glucose.Glucose{testHighDatum} + a := Alerts{ + High: testHighAlert(), + } + + note := a.Evaluate(ctx, data, nil) + + Expect(note).ToNot(BeNil()) + Expect(note.Message).To(ContainSubstring("above high threshold")) + }) + + Context("with both low and urgent low alerts detected", func() { + It("prefers urgent low", func() { + ctx := contextWithTestLogger() + data := []*glucose.Glucose{testUrgentLowDatum} + a := Alerts{ + Low: testLowAlert(), + UrgentLow: testUrgentLowAlert(), + } + + note := a.Evaluate(ctx, data, nil) + + Expect(note).ToNot(BeNil()) + Expect(note.Message).To(ContainSubstring("below urgent low threshold")) + }) + }) + }) +}) + var _ = Describe("DurationMinutes", func() { It("parses 42", func() { d := DurationMinutes(0) @@ -506,20 +1161,20 @@ var _ = Describe("DurationMinutes", func() { var _ = Describe("Threshold", func() { It("accepts mg/dL", func() { - buf := buff(`{"units":"%s","value":42}`, glucose.MgdL) + buf := buff(`{"units":"%s","value":42}`, nontypesglucose.MgdL) threshold := &Threshold{} err := request.DecodeObject(nil, buf, threshold) Expect(err).To(BeNil()) Expect(threshold.Value).To(Equal(42.0)) - Expect(threshold.Units).To(Equal(glucose.MgdL)) + Expect(threshold.Units).To(Equal(nontypesglucose.MgdL)) }) It("accepts mmol/L", func() { - buf := buff(`{"units":"%s","value":42}`, glucose.MmolL) + buf := buff(`{"units":"%s","value":42}`, nontypesglucose.MmolL) threshold := &Threshold{} err := request.DecodeObject(nil, buf, threshold) Expect(err).To(BeNil()) Expect(threshold.Value).To(Equal(42.0)) - Expect(threshold.Units).To(Equal(glucose.MmolL)) + Expect(threshold.Units).To(Equal(nontypesglucose.MmolL)) }) It("rejects lb/gal", func() { buf := buff(`{"units":"%s","value":42}`, "lb/gal") @@ -532,7 +1187,7 @@ var _ = Describe("Threshold", func() { Expect(err).Should(HaveOccurred()) }) It("is case-sensitive with respect to Units", func() { - badUnits := strings.ToUpper(glucose.MmolL) + badUnits := strings.ToUpper(nontypesglucose.MmolL) buf := buff(`{"units":"%s","value":42}`, badUnits) err := request.DecodeObject(nil, buf, &Threshold{}) Expect(err).Should(HaveOccurred()) @@ -544,3 +1199,8 @@ var _ = Describe("Threshold", func() { func buff(format string, args ...interface{}) *bytes.Buffer { return bytes.NewBufferString(fmt.Sprintf(format, args...)) } + +func contextWithTestLogger() context.Context { + lgr := logtest.NewLogger() + return log.NewContextWithLogger(context.Background(), lgr) +}