4 Commits

Author SHA1 Message Date
8578db99b3 Document current conditions consensus selection
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-29 12:03:57 +00:00
86fd848a6f Preserve current conditions condition code presentation 2026-06-29 12:02:37 +00:00
6f8e01729d Split current conditions condition code querying 2026-06-29 12:00:32 +00:00
3c7ba89e15 Add current conditions condition code consensus helper 2026-06-29 11:56:20 +00:00
15 changed files with 612 additions and 39 deletions

View File

@@ -162,8 +162,10 @@ GET /observations?units=us&precision=1
GET /conditions/current GET /conditions/current
``` ```
Returns current conditions aggregated from recent `observations` rows. The Returns current conditions from recent `observations` rows. Numeric fields are
implemented observation window is 30 minutes. aggregated over the implemented 30-minute observation window. `conditionCode`
is selected from the latest observation per source in that window by
source-balanced WMO family consensus.
Query parameters: `format`, `units`, `precision`. Query parameters: `format`, `units`, `precision`.
@@ -171,6 +173,7 @@ Common `data` fields:
| Field | Type | Notes | | Field | Type | Notes |
| --- | --- | --- | | --- | --- | --- |
| `conditionCode` | integer | WMO weather code selected by source-balanced family consensus |
| `conditionText` | string | optional text derived from WMO code and day/night flag | | `conditionText` | string | optional text derived from WMO code and day/night flag |
| `isDay` | boolean | optional | | `isDay` | boolean | optional |
| `relativeHumidityPercent` | number | optional | | `relativeHumidityPercent` | number | optional |

View File

@@ -57,8 +57,10 @@ Latest parent resources use these ordering rules:
story_order ASC, story_index ASC`. story_order ASC, story_index ASC`.
- convective outlook runs: `as_of DESC, event_emitted_at DESC`. - convective outlook runs: `as_of DESC, event_emitted_at DESC`.
Current conditions aggregate `observations` rows where `observed_at` is inside Current conditions aggregate numeric values from `observations` rows where
the application-provided observation window. `observed_at` is inside the application-provided observation window. They also
use the latest row per `event_source` in that window to select `condition_code`
by source-balanced WMO family consensus.
## Child Ordering ## Child Ordering
@@ -79,15 +81,16 @@ The repository reads only these columns.
### `observations` ### `observations`
`event_id`, `station_id`, `station_name`, `observed_at`, `condition_code`, `event_id`, `event_source`, `station_id`, `station_name`, `observed_at`,
`is_day`, `text_description`, `temperature_c`, `dewpoint_c`, `condition_code`, `is_day`, `text_description`, `temperature_c`, `dewpoint_c`,
`wind_direction_degrees`, `wind_speed_kmh`, `wind_gust_kmh`, `wind_direction_degrees`, `wind_speed_kmh`, `wind_gust_kmh`,
`barometric_pressure_pa`, `visibility_meters`, `relative_humidity_percent`, `barometric_pressure_pa`, `visibility_meters`, `relative_humidity_percent`,
`apparent_temperature_c`, and `event_emitted_at`. `apparent_temperature_c`, and `event_emitted_at`.
Current conditions additionally aggregate recent `observations` values for Current conditions additionally read recent `observations` values for
temperature, apparent temperature, dewpoint, humidity, wind speed, wind temperature, apparent temperature, dewpoint, humidity, wind speed, wind
direction, condition code, and latest `is_day`. direction, latest `is_day`, and latest condition-code candidates per
`event_source`.
### `observation_present_weather` ### `observation_present_weather`

View File

@@ -76,8 +76,9 @@ successful responses with `data: null`.
- `LatestObservation`: latest row from `observations`, then present-weather - `LatestObservation`: latest row from `observations`, then present-weather
rows from `observation_present_weather`. rows from `observation_present_weather`.
- `CurrentConditions`: aggregates recent rows from `observations` using the - `CurrentConditions`: reads an aggregate row from recent `observations` using
application-provided observation window. the application-provided observation window, then reads the latest
condition-code candidate per `event_source` in the same window.
- `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and - `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and
`alert_references`. This is the latest stored alert snapshot. The repository `alert_references`. This is the latest stored alert snapshot. The repository
maps both `ends` and `expires`; active-time filtering is performed by the maps both `ends` and `expires`; active-time filtering is performed by the

View File

@@ -1361,6 +1361,9 @@ func TestCurrentConditionsMetricDefaultJSON(t *testing.T) {
if payload.Data["conditionText"] != "Rain" { if payload.Data["conditionText"] != "Rain" {
t.Fatalf("expected conditionText Rain, got %#v", payload.Data["conditionText"]) t.Fatalf("expected conditionText Rain, got %#v", payload.Data["conditionText"])
} }
if payload.Data["conditionCode"] != float64(63) {
t.Fatalf("expected conditionCode 63, got %#v", payload.Data["conditionCode"])
}
} }
func TestCurrentConditionsUSJSON(t *testing.T) { func TestCurrentConditionsUSJSON(t *testing.T) {
@@ -1426,6 +1429,9 @@ func TestCurrentConditionsXMLAndTextFormats(t *testing.T) {
if !strings.Contains(w.Body.String(), "temperatureF") { if !strings.Contains(w.Body.String(), "temperatureF") {
t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String()) t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String())
} }
if !strings.Contains(w.Body.String(), "<conditionCode>2</conditionCode>") {
t.Fatalf("expected conditionCode in XML payload: %s", w.Body.String())
}
w = httptest.NewRecorder() w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/conditions/current?format=TEXT", nil) req = httptest.NewRequest(http.MethodGet, "/conditions/current?format=TEXT", nil)
@@ -1437,7 +1443,7 @@ func TestCurrentConditionsXMLAndTextFormats(t *testing.T) {
if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
} }
if !strings.Contains(w.Body.String(), "Conditions text") { if !strings.Contains(w.Body.String(), "Condition Code: 2") {
t.Fatalf("expected rendered text template body, got %q", w.Body.String()) t.Fatalf("expected rendered text template body, got %q", w.Body.String())
} }
} }
@@ -2458,7 +2464,7 @@ func testRenderers(t *testing.T) *render.Registry {
"weatherstories.txt.tmpl": "Weather Stories", "weatherstories.txt.tmpl": "Weather Stories",
"weatherstories_latest.txt.tmpl": "Latest Weather Story", "weatherstories_latest.txt.tmpl": "Latest Weather Story",
"alerts_active.txt.tmpl": "{{if .Data}}Active Alerts\nAlerts: {{len .Data.Alerts}}{{range .Data.Alerts}}\n{{.ID}}{{if .Headline}}\nHeadline: {{.Headline}}{{end}}{{if .Ends}}\nEnds: {{.Ends}}{{end}}{{end}}{{else}}No active alerts data available.{{end}}", "alerts_active.txt.tmpl": "{{if .Data}}Active Alerts\nAlerts: {{len .Data.Alerts}}{{range .Data.Alerts}}\n{{.ID}}{{if .Headline}}\nHeadline: {{.Headline}}{{end}}{{if .Ends}}\nEnds: {{.Ends}}{{end}}{{end}}{{else}}No active alerts data available.{{end}}",
"conditions_current.txt.tmpl": "Conditions text", "conditions_current.txt.tmpl": "Condition Code: {{.Data.ConditionCode}}",
} { } {
tmpl, err := template.New(name).Parse(body) tmpl, err := template.New(name).Parse(body)
if err != nil { if err != nil {

View File

@@ -4,25 +4,27 @@ package presenter
import ( import (
"gitea.maximumdirect.net/ejr/weatherapi/internal/app" "gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
"gitea.maximumdirect.net/ejr/weatherfeeder/standards" "gitea.maximumdirect.net/ejr/weatherfeeder/standards"
) )
// CurrentConditionsResponse is the response shape for /conditions/current. // CurrentConditionsResponse is the response shape for /conditions/current.
// Unit-bearing fields are populated according to the requested unit mode. // Unit-bearing fields are populated according to the requested unit mode.
type CurrentConditionsResponse struct { type CurrentConditionsResponse struct {
TemperatureC *float64 `json:"temperatureC,omitempty" xml:"temperatureC,omitempty"` TemperatureC *float64 `json:"temperatureC,omitempty" xml:"temperatureC,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty" xml:"apparentTemperatureC,omitempty"` ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty" xml:"apparentTemperatureC,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty" xml:"dewpointC,omitempty"` DewpointC *float64 `json:"dewpointC,omitempty" xml:"dewpointC,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty" xml:"windSpeedKmh,omitempty"` WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty" xml:"windSpeedKmh,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"` TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"` ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"` DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"` WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"` RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"` WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"` ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"`
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"` ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"`
IsDayText string `json:"-" xml:"-"` IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
IsDayText string `json:"-" xml:"-"`
} }
func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, precision int) any { func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, precision int) any {
@@ -33,6 +35,7 @@ func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, pr
out := CurrentConditionsResponse{ out := CurrentConditionsResponse{
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision), RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision),
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision), WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision),
ConditionCode: conditions.ConditionCode,
ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay), ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay),
IsDay: copyBoolPtr(conditions.IsDay), IsDay: copyBoolPtr(conditions.IsDay),
IsDayText: boolText(conditions.IsDay), IsDayText: boolText(conditions.IsDay),

View File

@@ -378,6 +378,9 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
if metric.ConditionText != "Sunny" { if metric.ConditionText != "Sunny" {
t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText) t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
} }
if metric.ConditionCode != 0 {
t.Fatalf("expected condition code 0, got %d", metric.ConditionCode)
}
usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2) usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2)
us, ok := usPayload.(CurrentConditionsResponse) us, ok := usPayload.(CurrentConditionsResponse)
@@ -389,6 +392,9 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
if us.TemperatureC != nil || us.WindSpeedKmh != nil { if us.TemperatureC != nil || us.WindSpeedKmh != nil {
t.Fatalf("expected metric fields omitted for US payload") t.Fatalf("expected metric fields omitted for US payload")
} }
if us.ConditionCode != 0 {
t.Fatalf("expected condition code 0, got %d", us.ConditionCode)
}
} }
func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) { func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {

View File

@@ -0,0 +1,107 @@
// conditions_codes.go selects current-conditions WMO codes from source candidates.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
type currentConditionsConditionCodeCandidate struct {
EventSource string
ConditionCode model.WMOCode
}
type conditionCodeFamily int
const (
conditionCodeFamilyUnknown conditionCodeFamily = iota
conditionCodeFamilyClearOrCloud
conditionCodeFamilyFog
conditionCodeFamilyDrizzle
conditionCodeFamilyRain
conditionCodeFamilySnow
conditionCodeFamilyThunderstorm
)
var currentConditionsConditionCodeRankings = map[conditionCodeFamily][]model.WMOCode{
conditionCodeFamilyClearOrCloud: {0, 1, 2, 3},
conditionCodeFamilyFog: {45, 48},
conditionCodeFamilyDrizzle: {51, 53, 55, 56, 57},
conditionCodeFamilyRain: {61, 63, 65, 80, 81, 82, 66, 67},
conditionCodeFamilySnow: {71, 73, 75, 85, 86, 77},
conditionCodeFamilyThunderstorm: {95, 96, 99},
}
var currentConditionsConditionCodeFamilies = buildCurrentConditionsConditionCodeFamilies()
func buildCurrentConditionsConditionCodeFamilies() map[model.WMOCode]conditionCodeFamily {
families := make(map[model.WMOCode]conditionCodeFamily)
for family, ranking := range currentConditionsConditionCodeRankings {
for _, code := range ranking {
families[code] = family
}
}
return families
}
func selectCurrentConditionsConditionCode(candidates []currentConditionsConditionCodeCandidate) model.WMOCode {
seenSources := make(map[string]struct{})
familyCounts := make(map[conditionCodeFamily]int)
codeCounts := make(map[model.WMOCode]int)
for _, candidate := range candidates {
if _, seen := seenSources[candidate.EventSource]; seen {
continue
}
seenSources[candidate.EventSource] = struct{}{}
family, ok := currentConditionsConditionCodeFamilies[candidate.ConditionCode]
if !ok {
continue
}
familyCounts[family]++
codeCounts[candidate.ConditionCode]++
}
winningFamily, ok := currentConditionsWinningConditionCodeFamily(familyCounts)
if !ok {
return model.WMOUnknown
}
return currentConditionsWinningConditionCode(winningFamily, codeCounts)
}
func currentConditionsWinningConditionCodeFamily(counts map[conditionCodeFamily]int) (conditionCodeFamily, bool) {
winningFamily := conditionCodeFamilyUnknown
winningCount := 0
tied := false
for family, count := range counts {
if count > winningCount {
winningFamily = family
winningCount = count
tied = false
continue
}
if count == winningCount {
tied = true
}
}
if winningCount == 0 || tied {
return conditionCodeFamilyUnknown, false
}
return winningFamily, true
}
func currentConditionsWinningConditionCode(family conditionCodeFamily, counts map[model.WMOCode]int) model.WMOCode {
winningCode := model.WMOUnknown
winningCount := 0
for _, code := range currentConditionsConditionCodeRankings[family] {
if counts[code] > winningCount {
winningCode = code
winningCount = counts[code]
}
}
return winningCode
}

View File

@@ -0,0 +1,116 @@
// conditions_codes_test.go tests current-conditions WMO code selection.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
import (
"testing"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func TestSelectCurrentConditionsConditionCode(t *testing.T) {
tests := []struct {
name string
candidates []currentConditionsConditionCodeCandidate
want model.WMOCode
}{
{
name: "clear cloud ranking breaks exact tie",
candidates: conditionCodeCandidates(
0,
1,
2,
),
want: 0,
},
{
name: "clear cloud family wins over thunderstorm",
candidates: conditionCodeCandidates(
1,
2,
95,
),
want: 1,
},
{
name: "tied families return unknown",
candidates: conditionCodeCandidates(
0,
95,
),
want: model.WMOUnknown,
},
{
name: "rain ranking breaks exact tie",
candidates: conditionCodeCandidates(
61,
63,
80,
),
want: 61,
},
{
name: "three tied families return unknown",
candidates: conditionCodeCandidates(
61,
95,
0,
),
want: model.WMOUnknown,
},
{
name: "single thunderstorm code wins",
candidates: conditionCodeCandidates(
95,
),
want: 95,
},
{
name: "unrecognized only returns unknown",
candidates: conditionCodeCandidates(
4,
100,
),
want: model.WMOUnknown,
},
{
name: "duplicate source only votes once",
candidates: []currentConditionsConditionCodeCandidate{
{EventSource: "source-1", ConditionCode: 95},
{EventSource: "source-1", ConditionCode: 95},
{EventSource: "source-2", ConditionCode: 0},
{EventSource: "source-3", ConditionCode: 0},
},
want: 0,
},
{
name: "exact code frequency wins before ranking",
candidates: []currentConditionsConditionCodeCandidate{
{EventSource: "source-1", ConditionCode: 61},
{EventSource: "source-2", ConditionCode: 63},
{EventSource: "source-3", ConditionCode: 63},
},
want: 63,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := selectCurrentConditionsConditionCode(tt.candidates)
if got != tt.want {
t.Fatalf("expected condition code %d, got %d", tt.want, got)
}
})
}
}
func conditionCodeCandidates(codes ...model.WMOCode) []currentConditionsConditionCodeCandidate {
candidates := make([]currentConditionsConditionCodeCandidate, 0, len(codes))
for i, code := range codes {
candidates = append(candidates, currentConditionsConditionCodeCandidate{
EventSource: string(rune('a' + i)),
ConditionCode: code,
})
}
return candidates
}

View File

@@ -7,16 +7,11 @@ import (
"gitea.maximumdirect.net/ejr/weatherfeeder/model" "gitea.maximumdirect.net/ejr/weatherfeeder/model"
) )
func mapCurrentConditionsRow(row currentConditionsRow) *app.CurrentConditions { func mapCurrentConditionsRow(row currentConditionsRow, conditionCode model.WMOCode) *app.CurrentConditions {
if row.SampleCount == 0 { if row.SampleCount == 0 {
return nil return nil
} }
conditionCode := model.WMOUnknown
if row.ConditionCode.Valid {
conditionCode = model.WMOCode(row.ConditionCode.Int64)
}
return &app.CurrentConditions{ return &app.CurrentConditions{
TemperatureC: float64Ptr(row.TemperatureC), TemperatureC: float64Ptr(row.TemperatureC),
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC), ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),

View File

@@ -12,7 +12,6 @@ WITH windowed AS (
relative_humidity_percent, relative_humidity_percent,
wind_speed_kmh, wind_speed_kmh,
wind_direction_degrees, wind_direction_degrees,
condition_code,
is_day, is_day,
observed_at observed_at
FROM observations FROM observations
@@ -39,7 +38,6 @@ SELECT
AVG(cosd(wind_direction_degrees)) AVG(cosd(wind_direction_degrees))
) )
END AS wind_direction_degrees, END AS wind_direction_degrees,
MAX(condition_code) AS condition_code,
( (
SELECT is_day SELECT is_day
FROM windowed FROM windowed
@@ -47,4 +45,23 @@ SELECT
LIMIT 1 LIMIT 1
) AS is_day ) AS is_day
FROM windowed` FROM windowed`
queryCurrentConditionsConditionCodeCandidates = `
WITH ranked AS (
SELECT
event_source,
condition_code,
ROW_NUMBER() OVER (
PARTITION BY event_source
ORDER BY observed_at DESC, event_emitted_at DESC
) AS source_rank
FROM observations
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
)
SELECT
event_source,
condition_code
FROM ranked
WHERE source_rank = 1
ORDER BY event_source`
) )

View File

@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app" "gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
) )
func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*app.CurrentConditions, error) { func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*app.CurrentConditions, error) {
@@ -25,7 +26,6 @@ func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMin
&row.RelativeHumidityPercent, &row.RelativeHumidityPercent,
&row.WindSpeedKmh, &row.WindSpeedKmh,
&row.WindDirectionDegrees, &row.WindDirectionDegrees,
&row.ConditionCode,
&row.IsDay, &row.IsDay,
) )
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -35,5 +35,42 @@ func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMin
return nil, fmt.Errorf("query current conditions: %w", err) return nil, fmt.Errorf("query current conditions: %w", err)
} }
return mapCurrentConditionsRow(row), nil if row.SampleCount == 0 {
return nil, nil
}
candidates, err := r.currentConditionsConditionCodeCandidates(ctx, observationWindowMinutes)
if err != nil {
return nil, err
}
return mapCurrentConditionsRow(row, selectCurrentConditionsConditionCode(candidates)), nil
}
func (r *Repository) currentConditionsConditionCodeCandidates(ctx context.Context, observationWindowMinutes int) ([]currentConditionsConditionCodeCandidate, error) {
rows, err := r.db.QueryContext(ctx, queryCurrentConditionsConditionCodeCandidates, observationWindowMinutes)
if err != nil {
return nil, fmt.Errorf("query current conditions condition code candidates: %w", err)
}
defer rows.Close()
var candidates []currentConditionsConditionCodeCandidate
for rows.Next() {
var (
eventSource string
conditionCode int64
)
if err := rows.Scan(&eventSource, &conditionCode); err != nil {
return nil, fmt.Errorf("scan current conditions condition code candidate: %w", err)
}
candidates = append(candidates, currentConditionsConditionCodeCandidate{
EventSource: eventSource,
ConditionCode: model.WMOCode(conditionCode),
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate current conditions condition code candidates: %w", err)
}
return candidates, nil
} }

View File

@@ -0,0 +1,280 @@
// conditions_read_test.go validates current-conditions repository read flow.
// Layer: adapters/outbound/postgres conditions read tests.
package postgres
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"io"
"strings"
"sync"
"testing"
)
const currentConditionsTestDriverName = "weatherapi_current_conditions_read_test"
func init() {
sql.Register(currentConditionsTestDriverName, currentConditionsTestDriver{})
}
func TestCurrentConditionsUsesConsensusConditionCode(t *testing.T) {
repo, closeDB := openCurrentConditionsTestRepository(t,
currentConditionsAggregateQuery(currentConditionsAggregateRow(3), nil),
currentConditionsConditionCodeCandidatesQuery([][]driver.Value{
{"source-a", int64(1)},
{"source-b", int64(2)},
{"source-c", int64(95)},
}, nil),
)
defer closeDB()
conditions, err := repo.CurrentConditions(context.Background(), 15)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if conditions == nil {
t.Fatal("expected current conditions")
}
if conditions.ConditionCode != 1 {
t.Fatalf("expected consensus condition code 1, got %d", conditions.ConditionCode)
}
if conditions.TemperatureC == nil || *conditions.TemperatureC != 15.5 {
t.Fatalf("expected temperature 15.5, got %v", conditions.TemperatureC)
}
if conditions.WindDirectionDegrees == nil || *conditions.WindDirectionDegrees != 182.5 {
t.Fatalf("expected wind direction 182.5, got %v", conditions.WindDirectionDegrees)
}
if conditions.IsDay == nil || !*conditions.IsDay {
t.Fatalf("expected isDay true, got %v", conditions.IsDay)
}
assertCurrentConditionsTestQueriesConsumed(t)
}
func TestCurrentConditionsNoSamplesSkipsConditionCodeCandidates(t *testing.T) {
repo, closeDB := openCurrentConditionsTestRepository(t,
currentConditionsAggregateQuery([]driver.Value{int64(0), nil, nil, nil, nil, nil, nil, nil}, nil),
)
defer closeDB()
conditions, err := repo.CurrentConditions(context.Background(), 15)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if conditions != nil {
t.Fatalf("expected nil current conditions, got %+v", conditions)
}
assertCurrentConditionsTestQueriesConsumed(t)
}
func TestCurrentConditionsConditionCodeCandidateQueryUsesLatestPerSourceOrdering(t *testing.T) {
query := compactSQL(queryCurrentConditionsConditionCodeCandidates)
want := "PARTITION BY event_source ORDER BY observed_at DESC, event_emitted_at DESC"
if !strings.Contains(query, want) {
t.Fatalf("expected condition code candidate query to contain %q, got %q", want, query)
}
if !strings.Contains(query, "SELECT event_source, condition_code") {
t.Fatalf("expected condition code candidate query to select event_source and condition_code, got %q", query)
}
}
func TestCurrentConditionsAggregateQueryDoesNotSelectConditionCode(t *testing.T) {
query := compactSQL(queryCurrentConditions)
if strings.Contains(query, "condition_code") {
t.Fatalf("expected aggregate query not to select condition_code, got %q", query)
}
}
func TestCurrentConditionsConditionCodeCandidateQueryWrapsErrors(t *testing.T) {
repo, closeDB := openCurrentConditionsTestRepository(t,
currentConditionsAggregateQuery(currentConditionsAggregateRow(1), nil),
scriptedCurrentConditionsQuery{
name: "condition code candidates",
query: queryCurrentConditionsConditionCodeCandidates,
args: []driver.Value{int64(15)},
err: errors.New("candidate query unavailable"),
},
)
defer closeDB()
_, err := repo.CurrentConditions(context.Background(), 15)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "query current conditions condition code candidates") {
t.Fatalf("expected candidate query context, got %v", err)
}
}
func openCurrentConditionsTestRepository(t *testing.T, queries ...scriptedCurrentConditionsQuery) (*Repository, func()) {
t.Helper()
currentConditionsTestScript.set(queries)
db, err := sql.Open(currentConditionsTestDriverName, "")
if err != nil {
t.Fatalf("open test db: %v", err)
}
db.SetMaxOpenConns(1)
return NewRepository(db), func() {
_ = db.Close()
currentConditionsTestScript.set(nil)
}
}
func assertCurrentConditionsTestQueriesConsumed(t *testing.T) {
t.Helper()
if remaining := currentConditionsTestScript.remaining(); remaining != 0 {
t.Fatalf("expected all scripted queries consumed, got %d remaining", remaining)
}
}
func currentConditionsAggregateQuery(row []driver.Value, nextErr error) scriptedCurrentConditionsQuery {
return scriptedCurrentConditionsQuery{
name: "aggregate",
query: queryCurrentConditions,
args: []driver.Value{int64(15)},
columns: []string{"sample_count", "temperature_c", "apparent_temperature_c", "dewpoint_c", "relative_humidity_percent", "wind_speed_kmh", "wind_direction_degrees", "is_day"},
rows: [][]driver.Value{row},
nextErr: nextErr,
}
}
func currentConditionsConditionCodeCandidatesQuery(rows [][]driver.Value, nextErr error) scriptedCurrentConditionsQuery {
return scriptedCurrentConditionsQuery{
name: "condition code candidates",
query: queryCurrentConditionsConditionCodeCandidates,
args: []driver.Value{int64(15)},
columns: []string{"event_source", "condition_code"},
rows: rows,
nextErr: nextErr,
}
}
func currentConditionsAggregateRow(sampleCount int64) []driver.Value {
return []driver.Value{
sampleCount,
float64(15.5),
float64(14.2),
float64(10.1),
float64(72),
float64(24.8),
float64(182.5),
true,
}
}
type currentConditionsTestDriver struct{}
func (currentConditionsTestDriver) Open(string) (driver.Conn, error) {
return currentConditionsTestConn{}, nil
}
type currentConditionsTestConn struct{}
func (currentConditionsTestConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("prepare is not supported")
}
func (currentConditionsTestConn) Close() error {
return nil
}
func (currentConditionsTestConn) Begin() (driver.Tx, error) {
return nil, errors.New("transactions are not supported")
}
func (currentConditionsTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
return currentConditionsTestScript.next(query, args)
}
type scriptedCurrentConditionsQuery struct {
name string
query string
args []driver.Value
columns []string
rows [][]driver.Value
err error
nextErr error
}
type currentConditionsTestScriptState struct {
mu sync.Mutex
queries []scriptedCurrentConditionsQuery
}
var currentConditionsTestScript currentConditionsTestScriptState
func (s *currentConditionsTestScriptState) set(queries []scriptedCurrentConditionsQuery) {
s.mu.Lock()
defer s.mu.Unlock()
s.queries = append([]scriptedCurrentConditionsQuery(nil), queries...)
}
func (s *currentConditionsTestScriptState) remaining() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.queries)
}
func (s *currentConditionsTestScriptState) next(query string, args []driver.NamedValue) (driver.Rows, error) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.queries) == 0 {
return nil, fmt.Errorf("unexpected query: %s", compactSQL(query))
}
next := s.queries[0]
s.queries = s.queries[1:]
if compactSQL(query) != compactSQL(next.query) {
return nil, fmt.Errorf("expected %s query %q, got %q", next.name, compactSQL(next.query), compactSQL(query))
}
if len(args) != len(next.args) {
return nil, fmt.Errorf("expected %s args %v, got %v", next.name, next.args, namedValues(args))
}
for i, arg := range args {
if arg.Value != next.args[i] {
return nil, fmt.Errorf("expected %s arg %d to be %v, got %v", next.name, i, next.args[i], arg.Value)
}
}
if next.err != nil {
return nil, next.err
}
return &currentConditionsTestRows{
columns: append([]string(nil), next.columns...),
rows: append([][]driver.Value(nil), next.rows...),
nextErr: next.nextErr,
}, nil
}
type currentConditionsTestRows struct {
columns []string
rows [][]driver.Value
index int
nextErr error
}
func (r *currentConditionsTestRows) Columns() []string {
return r.columns
}
func (r *currentConditionsTestRows) Close() error {
return nil
}
func (r *currentConditionsTestRows) Next(dest []driver.Value) error {
if r.index >= len(r.rows) {
if r.nextErr != nil {
err := r.nextErr
r.nextErr = nil
return err
}
return io.EOF
}
copy(dest, r.rows[r.index])
r.index++
return nil
}

View File

@@ -12,6 +12,5 @@ type currentConditionsRow struct {
RelativeHumidityPercent sql.NullFloat64 RelativeHumidityPercent sql.NullFloat64
WindSpeedKmh sql.NullFloat64 WindSpeedKmh sql.NullFloat64
WindDirectionDegrees sql.NullFloat64 WindDirectionDegrees sql.NullFloat64
ConditionCode sql.NullInt64
IsDay sql.NullBool IsDay sql.NullBool
} }

View File

@@ -279,7 +279,7 @@ func TestMapAlertRowNullableEnds(t *testing.T) {
func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) { func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) {
got := mapCurrentConditionsRow(currentConditionsRow{ got := mapCurrentConditionsRow(currentConditionsRow{
SampleCount: 0, SampleCount: 0,
}) }, model.WMOUnknown)
if got != nil { if got != nil {
t.Fatalf("expected nil for empty sample window, got %+v", got) t.Fatalf("expected nil for empty sample window, got %+v", got)
} }
@@ -295,9 +295,8 @@ func TestMapCurrentConditionsRowMapsFields(t *testing.T) {
RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true}, RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true},
WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true}, WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true},
WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true}, WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true},
ConditionCode: sql.NullInt64{Int64: 65, Valid: true},
IsDay: sql.NullBool{Bool: isDay, Valid: true}, IsDay: sql.NullBool{Bool: isDay, Valid: true},
}) }, 65)
if got == nil { if got == nil {
t.Fatalf("expected mapped current conditions") t.Fatalf("expected mapped current conditions")
} }

View File

@@ -1,5 +1,6 @@
{{- if .Data -}} {{- if .Data -}}
Current Conditions Current Conditions
Condition Code: {{.Data.ConditionCode}}
{{- if .Data.ConditionText}} {{- if .Data.ConditionText}}
Condition: {{.Data.ConditionText}} Condition: {{.Data.ConditionText}}
{{- end}} {{- end}}