Add convective outlook application filtering
This commit is contained in:
@@ -4,6 +4,8 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
)
|
)
|
||||||
@@ -17,9 +19,18 @@ type Repository interface {
|
|||||||
LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error)
|
LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error)
|
||||||
LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error)
|
LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error)
|
||||||
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
|
LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error)
|
||||||
|
LatestConvectiveOutlookRun(ctx context.Context) (*model.WeatherOutlookRun, error)
|
||||||
CurrentConditions(ctx context.Context, observationWindowMinutes int) (*CurrentConditions, 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.
|
// Service provides weather read use-cases.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo Repository
|
repo Repository
|
||||||
@@ -57,6 +68,93 @@ func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, e
|
|||||||
return s.repo.LatestAlertRun(ctx)
|
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) {
|
func (s *Service) CurrentConditions(ctx context.Context) (*CurrentConditions, error) {
|
||||||
return s.repo.CurrentConditions(ctx, ObservationWindowMinutesDefault)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
)
|
)
|
||||||
@@ -18,10 +19,12 @@ type fakeRepository struct {
|
|||||||
storyRun *model.WeatherStoryRun
|
storyRun *model.WeatherStoryRun
|
||||||
story *model.WeatherStory
|
story *model.WeatherStory
|
||||||
alerts *model.WeatherAlertRun
|
alerts *model.WeatherAlertRun
|
||||||
|
outlookRun *model.WeatherOutlookRun
|
||||||
conditions *CurrentConditions
|
conditions *CurrentConditions
|
||||||
err error
|
err error
|
||||||
|
|
||||||
currentConditionsWindow int
|
currentConditionsWindow int
|
||||||
|
outlookRunCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeRepository) LatestObservation(context.Context) (*model.WeatherObservation, error) {
|
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
|
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) {
|
func (r *fakeRepository) CurrentConditions(_ context.Context, observationWindowMinutes int) (*CurrentConditions, error) {
|
||||||
r.currentConditionsWindow = observationWindowMinutes
|
r.currentConditionsWindow = observationWindowMinutes
|
||||||
return r.conditions, r.err
|
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) {
|
func TestServiceDelegatesForecastDiscussion(t *testing.T) {
|
||||||
repo := &fakeRepository{discussion: &model.WeatherForecastDiscussion{OfficeID: "LSX"}}
|
repo := &fakeRepository{discussion: &model.WeatherForecastDiscussion{OfficeID: "LSX"}}
|
||||||
svc := NewService(repo)
|
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) {
|
func TestServicePropagatesErrors(t *testing.T) {
|
||||||
want := errors.New("boom")
|
want := errors.New("boom")
|
||||||
repo := &fakeRepository{err: want}
|
repo := &fakeRepository{err: want}
|
||||||
@@ -170,3 +361,66 @@ func TestServicePropagatesErrors(t *testing.T) {
|
|||||||
t.Fatalf("expected error %v, got %v", want, err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user