From 6e0b71f6f48e7f973d97ccd52a4ee69bfb011157 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 11 Jun 2026 15:27:03 +0000 Subject: [PATCH] Add convective outlook application filtering --- internal/app/service.go | 98 ++++++++++++++ internal/app/service_test.go | 254 +++++++++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+) diff --git a/internal/app/service.go b/internal/app/service.go index 4df60be..416e850 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -4,6 +4,8 @@ package app import ( "context" + "strings" + "time" "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) @@ -17,9 +19,18 @@ type Repository interface { LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error) LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) + LatestConvectiveOutlookRun(ctx context.Context) (*model.WeatherOutlookRun, error) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, error) } +// OutlookFilter selects outlook entries from the latest convective outlook run. +type OutlookFilter struct { + Day *int + OutlookType string + ContainsLocation *bool + ActiveAt *time.Time +} + // Service provides weather read use-cases. type Service struct { repo Repository @@ -57,6 +68,93 @@ func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, e return s.repo.LatestAlertRun(ctx) } +func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFilter) (*model.WeatherOutlookRun, error) { + run, err := s.repo.LatestConvectiveOutlookRun(ctx) + if err != nil { + return nil, err + } + if run == nil { + return nil, nil + } + + out := cloneOutlookRun(run) + outlooks := out.Outlooks[:0] + for _, outlook := range out.Outlooks { + if matchesOutlookFilter(outlook, filter) { + outlooks = append(outlooks, outlook) + } + } + out.Outlooks = outlooks + return out, nil +} + func (s *Service) CurrentConditions(ctx context.Context) (*CurrentConditions, error) { return s.repo.CurrentConditions(ctx, ObservationWindowMinutesDefault) } + +func matchesOutlookFilter(outlook model.WeatherOutlook, filter OutlookFilter) bool { + if filter.Day != nil && outlook.Day != *filter.Day { + return false + } + if filter.OutlookType != "" && outlook.OutlookType != normalizeOutlookType(filter.OutlookType) { + return false + } + if filter.ContainsLocation != nil && outlook.ContainsLocation != *filter.ContainsLocation { + return false + } + if filter.ActiveAt != nil && (filter.ActiveAt.Before(outlook.ValidFrom) || !filter.ActiveAt.Before(outlook.ValidTo)) { + return false + } + return true +} + +func normalizeOutlookType(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun { + out := *run + out.Latitude = copyFloat64(run.Latitude) + out.Longitude = copyFloat64(run.Longitude) + out.IssuedAt = copyTime(run.IssuedAt) + if run.Outlooks != nil { + out.Outlooks = make([]model.WeatherOutlook, len(run.Outlooks)) + for i := range run.Outlooks { + out.Outlooks[i] = cloneOutlook(run.Outlooks[i]) + } + } + return &out +} + +func cloneOutlook(outlook model.WeatherOutlook) model.WeatherOutlook { + out := outlook + out.SeverityRank = copyInt(outlook.SeverityRank) + if outlook.Geometry != nil { + out.Geometry = append([]byte(nil), outlook.Geometry...) + } + return out +} + +func copyFloat64(value *float64) *float64 { + if value == nil { + return nil + } + out := *value + return &out +} + +func copyInt(value *int) *int { + if value == nil { + return nil + } + out := *value + return &out +} + +func copyTime(value *time.Time) *time.Time { + if value == nil { + return nil + } + out := *value + return &out +} diff --git a/internal/app/service_test.go b/internal/app/service_test.go index c53bfba..e65cf09 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "testing" + "time" "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) @@ -18,10 +19,12 @@ type fakeRepository struct { storyRun *model.WeatherStoryRun story *model.WeatherStory alerts *model.WeatherAlertRun + outlookRun *model.WeatherOutlookRun conditions *CurrentConditions err error currentConditionsWindow int + outlookRunCalls int } func (r *fakeRepository) LatestObservation(context.Context) (*model.WeatherObservation, error) { @@ -52,6 +55,11 @@ func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun return r.alerts, r.err } +func (r *fakeRepository) LatestConvectiveOutlookRun(context.Context) (*model.WeatherOutlookRun, error) { + r.outlookRunCalls++ + return r.outlookRun, r.err +} + func (r *fakeRepository) CurrentConditions(_ context.Context, observationWindowMinutes int) (*CurrentConditions, error) { r.currentConditionsWindow = observationWindowMinutes return r.conditions, r.err @@ -109,6 +117,35 @@ func TestServiceDelegatesAlerts(t *testing.T) { } } +func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) { + repo := &fakeRepository{outlookRun: testOutlookRun()} + svc := NewService(repo) + + run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if repo.outlookRunCalls != 1 { + t.Fatalf("expected one repository call, got %d", repo.outlookRunCalls) + } + if run == nil || run.LocationID != "stl" { + t.Fatalf("unexpected outlook run: %+v", run) + } +} + +func TestServiceLatestConvectiveOutlookNoData(t *testing.T) { + repo := &fakeRepository{} + svc := NewService(repo) + + run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if run != nil { + t.Fatalf("expected nil outlook run, got %+v", run) + } +} + func TestServiceDelegatesForecastDiscussion(t *testing.T) { repo := &fakeRepository{discussion: &model.WeatherForecastDiscussion{OfficeID: "LSX"}} svc := NewService(repo) @@ -161,6 +198,160 @@ func TestServiceUsesDefaultCurrentConditionsWindow(t *testing.T) { } } +func TestServiceLatestConvectiveOutlookFiltersByDay(t *testing.T) { + day := 2 + repo := &fakeRepository{outlookRun: testOutlookRun()} + svc := NewService(repo) + + run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{Day: &day}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertOutlookIDs(t, run, []string{"day-2"}) +} + +func TestServiceLatestConvectiveOutlookFiltersByOutlookType(t *testing.T) { + repo := &fakeRepository{outlookRun: testOutlookRun()} + svc := NewService(repo) + + run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{OutlookType: " Tornado "}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertOutlookIDs(t, run, []string{"tor-1"}) +} + +func TestServiceLatestConvectiveOutlookFiltersByContainsLocation(t *testing.T) { + containsLocation := true + repo := &fakeRepository{outlookRun: testOutlookRun()} + svc := NewService(repo) + + run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ContainsLocation: &containsLocation}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertOutlookIDs(t, run, []string{"cat-1", "tor-1"}) +} + +func TestServiceLatestConvectiveOutlookFiltersByActiveAt(t *testing.T) { + activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC) + repo := &fakeRepository{outlookRun: testOutlookRun()} + svc := NewService(repo) + + run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &activeAt}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertOutlookIDs(t, run, []string{"cat-1", "tor-1"}) +} + +func TestServiceLatestConvectiveOutlookCombinesFilters(t *testing.T) { + day := 1 + containsLocation := true + activeAt := time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC) + repo := &fakeRepository{outlookRun: testOutlookRun()} + svc := NewService(repo) + + run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ + Day: &day, + OutlookType: "categorical", + ContainsLocation: &containsLocation, + ActiveAt: &activeAt, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertOutlookIDs(t, run, []string{"cat-1"}) +} + +func TestServiceLatestConvectiveOutlookActiveAtBoundary(t *testing.T) { + run := testOutlookRun() + run.Outlooks = run.Outlooks[:1] + validFrom := run.Outlooks[0].ValidFrom + validTo := run.Outlooks[0].ValidTo + repo := &fakeRepository{outlookRun: run} + svc := NewService(repo) + + fromRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validFrom}) + if err != nil { + t.Fatalf("unexpected validFrom error: %v", err) + } + assertOutlookIDs(t, fromRun, []string{"cat-1"}) + + toRun, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{ActiveAt: &validTo}) + if err != nil { + t.Fatalf("unexpected validTo error: %v", err) + } + assertOutlookIDs(t, toRun, nil) +} + +func TestServiceLatestConvectiveOutlookKeepsRunMetadataWithEmptyOutlooks(t *testing.T) { + day := 3 + repo := &fakeRepository{outlookRun: testOutlookRun()} + svc := NewService(repo) + + run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{Day: &day}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if run == nil { + t.Fatal("expected outlook run") + } + if run.LocationID != "stl" || run.LocationName != "St. Louis" || !run.AsOf.Equal(testTime(12)) { + t.Fatalf("unexpected run metadata: %+v", run) + } + if run.Outlooks == nil { + t.Fatal("expected empty outlook slice, got nil") + } + if len(run.Outlooks) != 0 { + t.Fatalf("expected no outlooks, got %+v", run.Outlooks) + } +} + +func TestServiceLatestConvectiveOutlookDoesNotMutateRepositoryRun(t *testing.T) { + original := testOutlookRun() + repo := &fakeRepository{outlookRun: original} + svc := NewService(repo) + + run, err := svc.LatestConvectiveOutlook(context.Background(), OutlookFilter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(run.Outlooks) == 0 { + t.Fatal("expected outlooks") + } + + *run.Latitude = 99 + *run.Longitude = -99 + *run.IssuedAt = testTime(99) + *run.Outlooks[0].SeverityRank = 99 + run.Outlooks[0].Geometry[0] = '{' + run.Outlooks[0].ID = "changed" + run.Outlooks = run.Outlooks[:1] + + if *original.Latitude != 38.62 { + t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude) + } + if *original.Longitude != -90.2 { + t.Fatalf("expected original longitude unchanged, got %v", *original.Longitude) + } + if !original.IssuedAt.Equal(testTime(11)) { + t.Fatalf("expected original issuedAt unchanged, got %v", original.IssuedAt) + } + if *original.Outlooks[0].SeverityRank != 5 { + t.Fatalf("expected original severity rank unchanged, got %v", *original.Outlooks[0].SeverityRank) + } + if string(original.Outlooks[0].Geometry) != `["cat"]` { + t.Fatalf("expected original geometry unchanged, got %s", original.Outlooks[0].Geometry) + } + if original.Outlooks[0].ID != "cat-1" { + t.Fatalf("expected original outlook ID unchanged, got %q", original.Outlooks[0].ID) + } + if len(original.Outlooks) != 3 { + t.Fatalf("expected original outlook slice unchanged, got %d entries", len(original.Outlooks)) + } +} + func TestServicePropagatesErrors(t *testing.T) { want := errors.New("boom") repo := &fakeRepository{err: want} @@ -170,3 +361,66 @@ func TestServicePropagatesErrors(t *testing.T) { t.Fatalf("expected error %v, got %v", want, err) } } + +func testOutlookRun() *model.WeatherOutlookRun { + latitude := 38.62 + longitude := -90.2 + issuedAt := testTime(11) + return &model.WeatherOutlookRun{ + LocationID: "stl", + LocationName: "St. Louis", + Latitude: &latitude, + Longitude: &longitude, + AsOf: testTime(12), + IssuedAt: &issuedAt, + Outlooks: []model.WeatherOutlook{ + testOutlook("cat-1", 1, "categorical", true, testTime(12), testTime(18), 5, `["cat"]`), + testOutlook("tor-1", 1, "tornado", true, testTime(13), testTime(19), 7, `["tor"]`), + testOutlook("day-2", 2, "wind", false, testTime(18), testTime(24), 2, `["wind"]`), + }, + } +} + +func testOutlook(id string, day int, outlookType string, containsLocation bool, validFrom time.Time, validTo time.Time, severityRank int, geometry string) model.WeatherOutlook { + return model.WeatherOutlook{ + ID: id, + Provider: "spc", + Product: "convective", + Day: day, + OutlookType: outlookType, + Label: "SLGT", + LabelText: "Slight Risk", + SeverityRank: &severityRank, + ValidFrom: validFrom, + ValidTo: validTo, + IssuedAt: validFrom.Add(-time.Hour), + ExpiresAt: validTo, + Forecaster: "DIAL", + Headline: id + " headline", + Summary: id + " summary", + Discussion: id + " discussion", + SourceURL: "https://example.test/" + id, + ImageURL: "https://example.test/" + id + ".png", + ContainsLocation: containsLocation, + Geometry: []byte(geometry), + } +} + +func testTime(hour int) time.Time { + return time.Date(2026, 6, 11, hour, 0, 0, 0, time.UTC) +} + +func assertOutlookIDs(t *testing.T, run *model.WeatherOutlookRun, want []string) { + t.Helper() + if run == nil { + t.Fatal("expected outlook run") + } + if len(run.Outlooks) != len(want) { + t.Fatalf("expected outlook IDs %v, got %+v", want, run.Outlooks) + } + for i := range want { + if run.Outlooks[i].ID != want[i] { + t.Fatalf("expected outlook IDs %v, got %+v", want, run.Outlooks) + } + } +}