Files
weatherreporter/internal/generatedtext/render_context_test.go

604 lines
22 KiB
Go

package generatedtext
import (
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestBuildHourlyRenderContext(t *testing.T) {
metadata := testMetadata()
snapshot := testSnapshot(t)
generated := Hourly{
Summary: "Storm chances increase through late morning.",
ForecastDiscussion: "A front will keep the region unsettled.",
PrecipitationTiming: "A cold front is moving into the region.",
Confidence: "Medium confidence in timing.",
}
collected := testCollected()
derived := testDerived()
ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated, collected, derived)
if err != nil {
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
}
if ctx.Report.Title != "Hourly Report" {
t.Fatalf("Report.Title = %q, want Hourly Report", ctx.Report.Title)
}
if ctx.Report.LocationName != "Brentwood, MO" {
t.Fatalf("Report.LocationName = %q, want Brentwood, MO", ctx.Report.LocationName)
}
if ctx.Report.ValidPeriodLabel != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" {
t.Fatalf("Report.ValidPeriodLabel = %q, want friendly period", ctx.Report.ValidPeriodLabel)
}
if ctx.Report.GeneratedAtLabel != "Friday, May 29, 2026 at 8:30 AM" {
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
}
if ctx.Modules.CurrentConditions == nil || ctx.Modules.CurrentConditions.ConditionText != "Partly cloudy" || ctx.Modules.CurrentConditions.TemperatureF == nil || *ctx.Modules.CurrentConditions.TemperatureF != 74 {
t.Fatalf("Modules.CurrentConditions = %#v, want structured current conditions", ctx.Modules.CurrentConditions)
}
if ctx.Modules.HourlyForecast == nil || len(ctx.Modules.HourlyForecast.Periods) != 2 {
t.Fatalf("Modules.HourlyForecast = %#v, want 2 periods", ctx.Modules.HourlyForecast)
}
if period := ctx.Modules.HourlyForecast.Periods[1]; period.PeriodBegins != "2026-05-29 at 10:00 AM" || period.TextDescription != "Showers" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("Modules.HourlyForecast.Periods[1] = %#v, want 10 AM showers row", period)
}
if ctx.Modules.PrecipTiming == nil || ctx.Modules.PrecipTiming.MaxPopPercent == nil || *ctx.Modules.PrecipTiming.MaxPopPercent != 70 {
t.Fatalf("Modules.PrecipTiming = %#v, want max pop", ctx.Modules.PrecipTiming)
}
if ctx.Modules.AlertDigest == nil || len(ctx.Modules.AlertDigest.Relevant) != 1 || ctx.Modules.AlertDigest.Relevant[0].Event != "Flood Watch" {
t.Fatalf("Modules.AlertDigest = %#v, want alert", ctx.Modules.AlertDigest)
}
if ctx.Modules.SPCConvectiveOutlooks == nil || len(ctx.Modules.SPCConvectiveOutlooks.Outlooks) != 1 || ctx.Modules.SPCConvectiveOutlooks.Outlooks[0].LabelText != "Slight Risk" {
t.Fatalf("Modules.SPCConvectiveOutlooks = %#v, want outlook", ctx.Modules.SPCConvectiveOutlooks)
}
if ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.AreaForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." {
t.Fatalf("Modules.AreaForecastDiscussion = %#v, want discussion text", ctx.Modules.AreaForecastDiscussion)
}
if ctx.Modules.SPCConvectiveDiscussion == nil || len(ctx.Modules.SPCConvectiveDiscussion.Discussions) != 1 || ctx.Modules.SPCConvectiveDiscussion.Discussions[0].Headline != "Mesoscale discussion" {
t.Fatalf("Modules.SPCConvectiveDiscussion = %#v, want discussion", ctx.Modules.SPCConvectiveDiscussion)
}
if ctx.Modules.WeatherStory == nil || ctx.Modules.WeatherStory.Title != "Morning storms" {
t.Fatalf("Modules.WeatherStory = %#v, want story", ctx.Modules.WeatherStory)
}
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
}
if !ctx.Derived.PrecipTiming.ThunderMentioned {
t.Fatalf("Derived.PrecipTiming.ThunderMentioned = false, want true")
}
rendered, err := reporttemplate.Render("hourly", ctx)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"# Hourly Report",
"**Updated:** Friday, May 29, 2026 at 8:30 AM",
"Storm chances increase through late morning.",
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
"- **Flood Watch**: Flood Watch until early afternoon",
"A cold front is moving into the region.",
"A front will keep the region unsettled.",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
}
func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{}},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep the region unsettled.",
}, testCollected(), facts.DerivedFacts{})
if err != nil {
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
}
if ctx.Modules.CurrentConditions == nil {
t.Fatal("Modules.CurrentConditions = nil, want populated module")
}
if ctx.Modules.HourlyForecast != nil || ctx.Modules.PrecipTiming != nil || ctx.Modules.AlertDigest != nil {
t.Fatalf("Modules = %#v, want omitted optional modules to remain nil", ctx.Modules)
}
}
func TestBuildRenderContextReportsModuleExtractionError(t *testing.T) {
snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: "not a current conditions stanza"},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
_, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep the region unsettled.",
}, testCollected(), facts.DerivedFacts{})
if err == nil {
t.Fatal("BuildHourlyRenderContext() error = nil, want extraction error")
}
if !strings.Contains(err.Error(), string(module.CurrentConditions)) {
t.Fatalf("BuildHourlyRenderContext() error = %v, want module ID", err)
}
}
func TestBuildTomorrowRenderContext(t *testing.T) {
metadata := testTomorrowMetadata()
snapshot := testTomorrowSnapshot(t)
generated := Tomorrow{
Summary: "Tomorrow starts quiet, then showers become more likely later in the day.",
ForecastDiscussion: []string{
"Morning conditions should stay mostly dry.",
"Rain chances increase during the afternoon as deeper moisture arrives.",
},
PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.",
}
collected := testCollected()
derived := testTomorrowDerived()
ctx, err := BuildTomorrowRenderContext(metadata, snapshot, generated, collected, derived)
if err != nil {
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
}
if ctx.Report.Title != "Monday's Weather" {
t.Fatalf("Report.Title = %q, want Monday's Weather", ctx.Report.Title)
}
if ctx.Report.ForecastDateLabel != "Monday, June 15, 2026" || ctx.Report.ForecastDayName != "Monday" {
t.Fatalf("forecast date labels = %q/%q, want Monday labels", ctx.Report.ForecastDateLabel, ctx.Report.ForecastDayName)
}
if ctx.Report.GeneratedAtLabel != "Sunday, June 14, 2026 at 9:14 AM" {
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
}
if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Tomorrow {
t.Fatalf("Modules.Metadata = %#v, want tomorrow metadata", ctx.Modules.Metadata)
}
if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil {
t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast)
}
if ctx.Modules.DerivedDailySummary == nil || ctx.Modules.DerivedDailySummary.HighTempF == nil || *ctx.Modules.DerivedDailySummary.HighTempF != 74 {
t.Fatalf("Modules.DerivedDailySummary = %#v, want daily summary", ctx.Modules.DerivedDailySummary)
}
if ctx.Modules.DerivedDaypartSummaries == nil || len(*ctx.Modules.DerivedDaypartSummaries) != 2 {
t.Fatalf("Modules.DerivedDaypartSummaries = %#v, want daypart map", ctx.Modules.DerivedDaypartSummaries)
}
if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" {
t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts)
}
if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 {
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
}
if ctx.Modules.AlertDigest == nil || ctx.Modules.SPCConvectiveOutlooks == nil || ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.SPCConvectiveDiscussion == nil || ctx.Modules.WeatherStory == nil || ctx.Modules.TomorrowPlanning == nil {
t.Fatalf("optional modules missing from render context: %#v", ctx.Modules)
}
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
}
if len(ctx.Derived.DaypartSummaries) != 2 {
t.Fatalf("Derived.DaypartSummaries = %#v, want passthrough facts", ctx.Derived.DaypartSummaries)
}
rendered, err := reporttemplate.Render("tomorrow", ctx)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"# Monday's Weather",
"**Forecast date:** Monday, June 15, 2026",
"Tomorrow starts quiet, then showers become more likely later in the day.",
"- **Morning:** Partly cloudy, with temperatures in the low 60s.",
"- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.",
"## Precipitation Timing",
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.",
"The most likely rain window is from midafternoon into early evening.",
"Morning conditions should stay mostly dry.",
"Rain chances increase during the afternoon as deeper moisture arrives.",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
assertOrderedText(t, text, []string{
"# Monday's Weather",
"## Daypart Forecast",
"- **Morning:**",
"- **Afternoon:**",
"## Precipitation Timing",
"## Forecast Discussion",
})
}
func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) {
snapshot, err := module.NewSnapshot(nil)
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), snapshot, Tomorrow{
Summary: "Dry weather is expected tomorrow.",
ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
}, testCollected(), facts.DerivedFacts{})
if err != nil {
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
}
if ctx.Modules.Metadata != nil || ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 {
t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules)
}
rendered, err := reporttemplate.Render("tomorrow", ctx)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
if strings.Contains(text, "## Precipitation Timing") {
t.Fatalf("rendered template included precipitation section without windows:\n%s", text)
}
if !strings.Contains(text, "- No daypart forecast details are available.") {
t.Fatalf("rendered template missing daypart fallback:\n%s", text)
}
}
func testMetadata() briefing.Metadata {
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
return briefing.Metadata{
RunID: "run-1",
ReportID: report.Hourly,
PromptID: "weather.hourly_generated_text",
GeneratedAt: generatedAt,
Units: "imperial",
Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{
Start: generatedAt,
End: generatedAt.Add(6 * time.Hour),
},
Location: &briefing.LocationContext{
Name: "Brentwood",
Region: "MO",
},
}
}
func testTomorrowMetadata() briefing.Metadata {
generatedAt := time.Date(2026, 6, 14, 14, 14, 0, 0, time.UTC)
return briefing.Metadata{
RunID: "run-tomorrow",
ReportID: report.Tomorrow,
PromptID: "weather.tomorrow_generated_text",
GeneratedAt: generatedAt,
Units: "imperial",
Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{
Start: time.Date(2026, 6, 15, 5, 0, 0, 0, time.UTC),
End: time.Date(2026, 6, 16, 5, 0, 0, 0, time.UTC),
},
Location: &briefing.LocationContext{
Name: "Brentwood",
Region: "MO",
},
}
}
func testCollected() facts.CollectedFacts {
return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)}
}
func testDerived() facts.DerivedFacts {
return facts.DerivedFacts{PrecipTiming: forecast.PrecipTiming{ThunderMentioned: true}}
}
func testTomorrowDerived() facts.DerivedFacts {
morningStart := time.Date(2026, 6, 15, 11, 0, 0, 0, time.UTC)
afternoonStart := time.Date(2026, 6, 15, 17, 0, 0, 0, time.UTC)
return facts.DerivedFacts{
DaypartSummaries: []forecast.DaypartSummary{
{
Name: "morning",
Period: timeutil.Period{
Start: morningStart,
End: afternoonStart,
},
},
{
Name: "afternoon",
Period: timeutil.Period{
Start: afternoonStart,
End: time.Date(2026, 6, 15, 23, 0, 0, 0, time.UTC),
},
},
},
}
}
func testSnapshot(t *testing.T) module.Snapshot {
t.Helper()
snapshot, err := module.NewSnapshot([]module.Output{
{
ID: module.CurrentConditions,
StanzaName: string(module.CurrentConditions),
Value: briefing.CurrentConditionsModule{
ConditionText: "Partly cloudy",
ConditionTextLower: "partly cloudy",
TemperatureF: intPtr(74),
ApparentTemperatureF: intPtr(76),
RelativeHumidityPercent: intPtr(71),
WindSpeedMph: intPtr(8),
WindDirection: "S",
WindDirectionText: "south",
},
},
{
ID: module.HourlyForecast,
StanzaName: string(module.HourlyForecast),
Value: briefing.HourlyForecastModule{
Periods: []briefing.HourlyForecastPeriod{
{
HourLabel: "9:00 AM",
PeriodBegins: "2026-05-29 at 9:00 AM",
TextDescription: "Cloudy",
TextDescriptionLower: "cloudy",
TemperatureF: floatPtr(74),
ProbabilityOfPrecipitationPercent: floatPtr(30),
MentionPrecipitation: true,
WindSpeedMph: floatPtr(8),
WindDirection: "S",
},
{
HourLabel: "10:00 AM",
PeriodBegins: "2026-05-29 at 10:00 AM",
TextDescription: "Showers",
TextDescriptionLower: "showers",
TemperatureF: floatPtr(75),
ProbabilityOfPrecipitationPercent: floatPtr(70),
MentionPrecipitation: true,
WindSpeedMph: floatPtr(10),
WindGustMph: floatPtr(18),
WindDirection: "S",
},
},
},
},
{
ID: module.PrecipTiming,
StanzaName: string(module.PrecipTiming),
Value: briefing.PrecipTimingModule{
MaxPopPercent: intPtr(70),
MaxPopTime: "10 AM",
ProbabilityThreshold: 50,
PrecipitationWindows: []briefing.PrecipitationWindowModule{
{
PeriodBegins: "2026-05-29 at 10:00 AM",
PeriodBeginsHourLabel: "10:00 AM",
PeriodEnds: "2026-05-29 at 12:00 PM",
PeriodEndsHourLabel: "12:00 PM",
MaxPopPercent: intPtr(70),
MaxPopTime: "10 AM",
MaxPopHourLabel: "10:00 AM",
},
},
ThunderMentioned: true,
},
},
{
ID: module.AlertDigest,
StanzaName: string(module.AlertDigest),
Value: briefing.AlertDigestModule{
Checked: true,
ActiveCount: 1,
RelevantCount: 1,
Relevant: []briefing.AlertSummary{
{Event: "Flood Watch", Headline: "Flood Watch until early afternoon", Severity: "Moderate"},
},
},
},
{
ID: module.SPCConvectiveOutlooks,
StanzaName: string(module.SPCConvectiveOutlooks),
Value: briefing.SPCConvectiveOutlooksModule{
Checked: true,
OutlookCount: 1,
Outlooks: []briefing.SPCConvectiveOutlookRecord{
{
Label: "SLGT",
LabelText: "Slight Risk",
PeriodBegins: "2026-05-29 at 7:00 AM",
PeriodEnds: "2026-05-29 at 3:00 PM",
},
},
},
},
{
ID: module.AreaForecastDiscussion,
StanzaName: string(module.AreaForecastDiscussion),
Value: briefing.AreaForecastDiscussionModule{
KeyMessages: []string{"Storms are most likely late morning."},
ShortTerm: "Short-term discussion favors increasing rain coverage.",
},
},
{
ID: module.SPCConvectiveDiscussion,
StanzaName: string(module.SPCConvectiveDiscussion),
Value: briefing.SPCConvectiveDiscussionModule{
IncludedBecause: "categorical severity_rank >= 3",
Discussions: []briefing.SPCConvectiveDiscussionRecord{
{Headline: "Mesoscale discussion", Summary: "Strong storms may develop late morning."},
},
},
},
{
ID: module.WeatherStory,
StanzaName: string(module.WeatherStory),
Value: briefing.WeatherStoryModule{
Available: true,
Title: "Morning storms",
Description: "Morning storms remain the main story.",
},
},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
func testTomorrowSnapshot(t *testing.T) module.Snapshot {
t.Helper()
snapshot, err := module.NewSnapshot([]module.Output{
{
ID: module.Metadata,
StanzaName: string(module.Metadata),
Value: briefing.MetadataModule{
RunID: "run-tomorrow",
ReportID: report.Tomorrow,
PromptID: "weather.tomorrow_generated_text",
GeneratedAt: testTomorrowMetadata().GeneratedAt,
Units: "imperial",
Timezone: "America/Chicago",
ValidPeriod: testTomorrowMetadata().ValidPeriod,
},
},
{
ID: module.CurrentConditions,
StanzaName: string(module.CurrentConditions),
Value: briefing.CurrentConditionsModule{
ConditionTextLower: "clear",
TemperatureF: intPtr(58),
},
},
{
ID: module.HourlyForecast,
StanzaName: string(module.HourlyForecast),
Value: briefing.HourlyForecastModule{
Periods: []briefing.HourlyForecastPeriod{
{HourLabel: "7:00 AM", TextDescriptionLower: "partly cloudy", TemperatureF: floatPtr(63)},
},
},
},
{
ID: module.DerivedDailySummary,
StanzaName: string(module.DerivedDailySummary),
Value: briefing.DerivedDailySummaryModule{
Date: "Monday, June 15, 2026",
HighTempF: intPtr(74),
LowTempF: intPtr(61),
DailyPrecipitationProbability: intPtr(70),
},
},
{
ID: module.DerivedDaypartSummaries,
StanzaName: string(module.DerivedDaypartSummaries),
Value: map[string]briefing.DerivedDaypartSummaryModule{
"afternoon": {
DisplayName: "Afternoon",
TemperatureTrend: "steady",
TemperatureSteadyPhraseF: "mid 70s",
DominantConditionDisplay: "Showers",
DominantConditionLower: "showers",
MaxPopPercent: intPtr(70),
MaxPopTimeLabel: "3:00 PM",
MentionPrecipitation: true,
},
"morning": {
DisplayName: "Morning",
TemperatureTrend: "steady",
TemperatureSteadyPhraseF: "low 60s",
DominantConditionDisplay: "Partly cloudy",
DominantConditionLower: "partly cloudy",
},
},
},
{
ID: module.PrecipTiming,
StanzaName: string(module.PrecipTiming),
Value: briefing.PrecipTimingModule{
MaxPopPercent: intPtr(70),
MaxPopTime: "3 PM",
PrecipitationWindows: []briefing.PrecipitationWindowModule{
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"},
},
},
},
{
ID: module.AlertDigest,
StanzaName: string(module.AlertDigest),
Value: briefing.AlertDigestModule{
Checked: true,
},
},
{
ID: module.SPCConvectiveOutlooks,
StanzaName: string(module.SPCConvectiveOutlooks),
Value: briefing.SPCConvectiveOutlooksModule{
Checked: true,
},
},
{
ID: module.AreaForecastDiscussion,
StanzaName: string(module.AreaForecastDiscussion),
Value: briefing.AreaForecastDiscussionModule{
KeyMessages: []string{"Rain chances increase late."},
},
},
{
ID: module.SPCConvectiveDiscussion,
StanzaName: string(module.SPCConvectiveDiscussion),
Value: briefing.SPCConvectiveDiscussionModule{
IncludedBecause: "convective outlook",
},
},
{
ID: module.WeatherStory,
StanzaName: string(module.WeatherStory),
Value: briefing.WeatherStoryModule{
Available: true,
Title: "Rain returns",
},
},
{
ID: module.TomorrowPlanning,
StanzaName: string(module.TomorrowPlanning),
Value: briefing.TomorrowPlanningModule{
MorningReadiness: []string{"Take sunglasses early."},
},
},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
func floatPtr(value float64) *float64 {
return &value
}
func intPtr(value int) *int {
return &value
}
func assertOrderedText(t *testing.T, text string, ordered []string) {
t.Helper()
previousIndex := -1
for _, want := range ordered {
index := strings.Index(text, want)
if index < 0 {
t.Fatalf("text missing %q:\n%s", want, text)
}
if index <= previousIndex {
t.Fatalf("%q appears out of order in:\n%s", want, text)
}
previousIndex = index
}
}