Correct daypart identity and display handling
This commit is contained in:
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
@@ -80,6 +79,9 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
||||
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
|
||||
for _, daypart := range ctx.Derived.DaypartSummaries {
|
||||
key := daypartKey(daypart, prefixDates)
|
||||
if existing, exists := value[key]; exists {
|
||||
return nil, fmt.Errorf("daypart summary key %q collides with display name %q", key, existing.DisplayName)
|
||||
}
|
||||
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
|
||||
}
|
||||
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
||||
@@ -135,7 +137,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
||||
temperature := daypartTemperatureDisplay(daypart)
|
||||
value := DerivedDaypartSummaryModule{
|
||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||
DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
|
||||
DisplayName: capitalizeFirst(strings.TrimSpace(daypart.Name)),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||
TempRangeF: rangeLabel(daypart.Temperature),
|
||||
@@ -148,7 +150,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||
DominantCondition: daypart.DominantCondition,
|
||||
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
|
||||
DominantConditionDisplay: sentenceCase(daypart.DominantCondition),
|
||||
DominantConditionDisplay: capitalizeFirst(strings.TrimSpace(daypart.DominantCondition)),
|
||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||
Snow: daypart.Indicators.Snow,
|
||||
Ice: daypart.Indicators.Ice,
|
||||
@@ -381,16 +383,6 @@ func temperatureBandQualifier(remainder int) string {
|
||||
}
|
||||
}
|
||||
|
||||
func sentenceCase(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(trimmed)
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||
seen := map[string]struct{}{}
|
||||
for _, summary := range summaries {
|
||||
|
||||
@@ -355,6 +355,52 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartSummariesRejectCanonicalKeyCollisions(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
first := ctx.Derived.DaypartSummaries[0]
|
||||
first.Name = "Morning"
|
||||
second := first
|
||||
second.Name = "morning!"
|
||||
ctx.Derived.DaypartSummaries = []forecast.DaypartSummary{first, second}
|
||||
ctx.Derived.DailySummaries = []forecast.DailySummary{{Date: first.Period.Start.Format(timeutil.DateLayout)}}
|
||||
|
||||
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||
if err == nil || !strings.Contains(err.Error(), "collides") {
|
||||
t.Fatalf("BuildModule() error = %v, want canonical daypart-key collision", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartSummaryDisplayCapitalizesUnicodeNames(t *testing.T) {
|
||||
value := derivedDaypartSummaryValue(forecast.DaypartSummary{
|
||||
Name: "mañana",
|
||||
DominantCondition: "llovizna",
|
||||
}, "UTC")
|
||||
if value.DisplayName != "Mañana" || value.DominantConditionDisplay != "Llovizna" {
|
||||
t.Fatalf("daypart display = %#v, want rune-safe capitalization", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartSummariesKeepDistinctUnicodeKeys(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
first := ctx.Derived.DaypartSummaries[0]
|
||||
first.Name = "mañana"
|
||||
second := first
|
||||
second.Name = "manana"
|
||||
ctx.Derived.DaypartSummaries = []forecast.DaypartSummary{first, second}
|
||||
ctx.Derived.DailySummaries = []forecast.DailySummary{{Date: first.Period.Start.Format(timeutil.DateLayout)}}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[map[string]DerivedDaypartSummaryModule](t, output)
|
||||
if len(value) != 2 || value["mañana"].DisplayName != "Mañana" || value["manana"].DisplayName != "Manana" {
|
||||
t.Fatalf("daypart summaries = %#v, want distinct Unicode canonical keys", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
@@ -670,6 +716,65 @@ func TestOutdoorWindowsScoreSnowIceAndFog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanningUsesCanonicalDaypartIdentities(t *testing.T) {
|
||||
timedValue := func(value float64) *forecast.TimedValue {
|
||||
return &forecast.TimedValue{Value: value}
|
||||
}
|
||||
containsText := func(values []string, text string) bool {
|
||||
return strings.Contains(strings.Join(values, "\n"), text)
|
||||
}
|
||||
summary := &forecast.DailySummary{Dayparts: []forecast.DaypartSummary{
|
||||
{Name: "Overnight!", MaxPrecipitationProbability: timedValue(60)},
|
||||
{Name: "MORNING", MaxPrecipitationProbability: timedValue(60)},
|
||||
{Name: "Afternoon!!!", MaxPrecipitationProbability: timedValue(60)},
|
||||
{Name: "EVENING!", MaxPrecipitationProbability: timedValue(60)},
|
||||
}}
|
||||
|
||||
today := buildTodayPlanning(summary)
|
||||
if !containsText(today.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
|
||||
t.Fatalf("today morning readiness = %#v, want canonical morning window", today.MorningReadiness)
|
||||
}
|
||||
if !containsText(today.CommuteSchoolWorkdayConcerns, "Afternoon!!! precipitation chance reaches 60%.") ||
|
||||
containsText(today.CommuteSchoolWorkdayConcerns, "Overnight!") ||
|
||||
containsText(today.CommuteSchoolWorkdayConcerns, "EVENING!") {
|
||||
t.Fatalf("today workday concerns = %#v, want only canonical workday windows", today.CommuteSchoolWorkdayConcerns)
|
||||
}
|
||||
if !containsText(today.LateDayChangeWatch, "Afternoon!!! precipitation timing may shift") ||
|
||||
!containsText(today.LateDayChangeWatch, "EVENING! precipitation timing may shift") {
|
||||
t.Fatalf("today late-day watch = %#v, want canonical afternoon and evening windows", today.LateDayChangeWatch)
|
||||
}
|
||||
|
||||
base := buildMorningCommuteOvernightPlanning(summary)
|
||||
if !containsText(base.MorningReadiness, "Morning precipitation chance peaks near 60%.") ||
|
||||
!containsText(base.OvernightChangeWatch, "Overnight precipitation timing may shift") ||
|
||||
containsText(base.CommuteSchoolWorkdayConcerns, "Overnight!") ||
|
||||
containsText(base.CommuteSchoolWorkdayConcerns, "EVENING!") {
|
||||
t.Fatalf("daily/tomorrow planning = %#v, want canonical daypart treatment", base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapitalizeFirst(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "empty", input: "", want: ""},
|
||||
{name: "ASCII", input: "morning", want: "Morning"},
|
||||
{name: "multibyte", input: "mañana", want: "Mañana"},
|
||||
{name: "already uppercase", input: "Morning", want: "Morning"},
|
||||
{name: "leading space", input: " morning", want: " morning"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := capitalizeFirst(tt.input); got != tt.want {
|
||||
t.Fatalf("capitalizeFirst(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Tomorrow)
|
||||
|
||||
@@ -100,7 +100,7 @@ func precipitationWindowExpectationPhrase(maxPopPercent int, precipitationType s
|
||||
case maxPopPercent >= precipTimingExpectLowerBound:
|
||||
return fmt.Sprintf("Expect %s.", precipitationType)
|
||||
case maxPopPercent >= precipTimingLikelyLowerBound:
|
||||
return fmt.Sprintf("%s likely.", sentenceCase(precipitationType))
|
||||
return fmt.Sprintf("%s likely.", capitalizeFirst(strings.TrimSpace(precipitationType)))
|
||||
case maxPopPercent >= precipTimingChanceLowerBound:
|
||||
return fmt.Sprintf("Chance of %s.", precipitationType)
|
||||
default:
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
@@ -43,6 +44,13 @@ type TodayPlanning struct {
|
||||
|
||||
const outdoorIndicatorRiskScore = 25
|
||||
|
||||
const (
|
||||
morningDaypartIdentity = "morning"
|
||||
afternoonDaypartIdentity = "afternoon"
|
||||
eveningDaypartIdentity = "evening"
|
||||
overnightDaypartIdentity = "overnight"
|
||||
)
|
||||
|
||||
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
||||
var best *OutdoorWindow
|
||||
var worst *OutdoorWindow
|
||||
@@ -74,7 +82,7 @@ func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
|
||||
}
|
||||
|
||||
for _, daypart := range summary.Dayparts {
|
||||
if daypart.Name == "overnight" || daypart.Name == "evening" {
|
||||
if isOutsideWorkday(daypart) {
|
||||
continue
|
||||
}
|
||||
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
||||
@@ -93,7 +101,7 @@ func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
|
||||
planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.")
|
||||
}
|
||||
|
||||
for _, name := range []string{"afternoon", "evening"} {
|
||||
for _, name := range []string{afternoonDaypartIdentity, eveningDaypartIdentity} {
|
||||
daypart := daypartNamed(summary.Dayparts, name)
|
||||
if daypart != nil {
|
||||
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
|
||||
@@ -126,7 +134,7 @@ func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morni
|
||||
}
|
||||
|
||||
for _, daypart := range summary.Dayparts {
|
||||
if daypart.Name == "overnight" || daypart.Name == "evening" {
|
||||
if isOutsideWorkday(daypart) {
|
||||
continue
|
||||
}
|
||||
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
||||
@@ -155,10 +163,10 @@ func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string {
|
||||
windows := buildOutdoorWindows(dayparts)
|
||||
var notes []string
|
||||
if windows.Best != nil {
|
||||
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", titleWord(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
|
||||
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", capitalizeFirst(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
|
||||
}
|
||||
if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) {
|
||||
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", titleWord(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
|
||||
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", capitalizeFirst(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
|
||||
}
|
||||
return appendUnique(nil, notes...)
|
||||
}
|
||||
@@ -185,7 +193,7 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
|
||||
|
||||
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
prefix := titleWord(daypart.Name)
|
||||
prefix := capitalizeFirst(daypart.Name)
|
||||
if prefix == "" {
|
||||
prefix = "Late-day"
|
||||
}
|
||||
@@ -206,7 +214,7 @@ func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||
|
||||
func concernNotes(daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
prefix := titleWord(daypart.Name)
|
||||
prefix := capitalizeFirst(daypart.Name)
|
||||
if prefix == "" {
|
||||
prefix = "Daytime"
|
||||
}
|
||||
@@ -249,8 +257,9 @@ func overnightWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||
}
|
||||
|
||||
func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.DaypartSummary {
|
||||
identity := forecast.CanonicalDaypartKey(name)
|
||||
for i := range dayparts {
|
||||
if strings.EqualFold(dayparts[i].Name, name) {
|
||||
if forecast.CanonicalDaypartKey(dayparts[i].Name) == identity {
|
||||
return &dayparts[i]
|
||||
}
|
||||
}
|
||||
@@ -397,9 +406,20 @@ func appendUnique(values []string, candidates ...string) []string {
|
||||
return values
|
||||
}
|
||||
|
||||
func titleWord(value string) string {
|
||||
func isOutsideWorkday(daypart forecast.DaypartSummary) bool {
|
||||
switch forecast.CanonicalDaypartKey(daypart.Name) {
|
||||
case overnightDaypartIdentity, eveningDaypartIdentity:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func capitalizeFirst(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(value[:1]) + value[1:]
|
||||
runes := []rune(value)
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user