package promptinput import ( "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func TestBuildDailyDataPackage(t *testing.T) { pkg, err := Build(validBuildRequest(t)) if err != nil { t.Fatalf("Build() error = %v", err) } if pkg.SchemaVersion != SchemaVersion { t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion) } if pkg.RunID != "20260529T100000Z_daily" { t.Fatalf("RunID = %q, want metadata run id", pkg.RunID) } if pkg.Report.PromptID != "weather.daily_generated_text" { t.Fatalf("PromptID = %q, want weather.daily_generated_text", pkg.Report.PromptID) } if pkg.Report.CurrentLocalDate != "2026-05-29" { t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate) } if pkg.Briefing.Order[0] != "metadata" || pkg.Briefing.Order[1] != "current_conditions" || pkg.Briefing.Order[2] != "derived_daily_summary" { t.Fatalf("Briefing.Order = %#v, want snapshot stanza order", pkg.Briefing.Order) } if got := pkg.Briefing.Values["current_conditions"].(map[string]string)["condition_text"]; got != "Partly cloudy" { t.Fatalf("current_conditions.condition_text = %q, want Partly cloudy", got) } } func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) { req := validBuildRequest(t) req.Metadata.GeneratedAt = time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC) req.Metadata.Timezone = "America/Chicago" pkg, err := Build(req) if err != nil { t.Fatalf("Build() error = %v", err) } if pkg.Report.CurrentLocalDate != "2026-05-29" { t.Fatalf("CurrentLocalDate = %q, want local Chicago date 2026-05-29", pkg.Report.CurrentLocalDate) } } func TestBuildRejectsInvalidReportTimezone(t *testing.T) { req := validBuildRequest(t) req.Metadata.Timezone = "Not/AZone" _, err := Build(req) if err == nil { t.Fatal("Build() error = nil, want invalid timezone error") } if !strings.Contains(err.Error(), "report timezone") { t.Fatalf("error = %q, want report timezone context", err.Error()) } } func TestValidateRequiresFields(t *testing.T) { pkg, err := Build(validBuildRequest(t)) if err != nil { t.Fatalf("Build() error = %v", err) } pkg.RunID = "" err = Validate(pkg) if err == nil { t.Fatal("Validate() error = nil, want required field error") } if !strings.Contains(err.Error(), "runId") { t.Fatalf("error = %q, want runId context", err.Error()) } } func TestValidateRequiresCurrentLocalDate(t *testing.T) { pkg, err := Build(validBuildRequest(t)) if err != nil { t.Fatalf("Build() error = %v", err) } pkg.Report.CurrentLocalDate = "" err = Validate(pkg) if err == nil { t.Fatal("Validate() error = nil, want required field error") } if !strings.Contains(err.Error(), "currentLocalDate") { t.Fatalf("error = %q, want currentLocalDate context", err.Error()) } } func TestBuildUsesNamedSnapshotStanzas(t *testing.T) { req := validBuildRequest(t) req.Metadata.RunID = "20260529T100000Z_today" req.Metadata.ReportID = report.Today req.Metadata.PromptID = "weather.today_generated_text" req.Modules = snapshotWithOutputs(t, module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}}, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}}, module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: map[string]any{"morning_readiness": []string{"routine"}}}, ) pkg, err := Build(req) if err != nil { t.Fatalf("Build() error = %v", err) } if pkg.Report.ID != report.Today { t.Fatalf("Report.ID = %q, want today", pkg.Report.ID) } if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok { t.Fatal("Briefing.Values[derived_daypart_summaries] missing") } if _, ok := pkg.Briefing.Values["today_planning"]; !ok { t.Fatal("Briefing.Values[today_planning] missing") } } func TestBuildUsesPromptValuesFromSnapshot(t *testing.T) { req := validBuildRequest(t) req.Modules = snapshotWithOutputs(t, module.Output{ ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "rich"}, PromptValue: map[string]string{"run_id": "prompt"}, }, module.Output{ ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Rich conditions"}, PromptValue: map[string]string{"condition_text": "Prompt conditions"}, }, module.Output{ ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}, }, ) pkg, err := Build(req) if err != nil { t.Fatalf("Build() error = %v", err) } if got := pkg.Briefing.Values["metadata"].(map[string]string)["run_id"]; got != "prompt" { t.Fatalf("metadata.run_id = %q, want prompt value", got) } if got := pkg.Briefing.Values["current_conditions"].(map[string]string)["condition_text"]; got != "Prompt conditions" { t.Fatalf("current_conditions.condition_text = %q, want prompt value", got) } if got := pkg.Briefing.Values["alert_digest"].(map[string]bool)["checked"]; !got { t.Fatalf("alert_digest.checked = %v, want rich value fallback", got) } } func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) { pkg, err := Build(validBuildRequest(t)) if err != nil { t.Fatalf("Build() error = %v", err) } first, err := MarshalYAML(pkg) if err != nil { t.Fatalf("first marshal: %v", err) } second, err := MarshalYAML(pkg) if err != nil { t.Fatalf("second marshal: %v", err) } if string(first) != string(second) { t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second)) } if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v4") || !strings.Contains(string(first), "briefing:\n") || !strings.Contains(string(first), " applicable_risk_products:\n") || !strings.Contains(string(first), " derived_summaries:\n") || !strings.Contains(string(first), " narrative_products:\n") || !strings.Contains(string(first), " raw_data:\n") || !strings.Contains(string(first), " current_conditions:\n") || !strings.Contains(string(first), " condition_text: Partly cloudy") { t.Fatalf("YAML output missing expected grouped stanzas:\n%s", string(first)) } if strings.Contains(string(first), "recent_changes") { t.Fatalf("YAML output contains removed recent_changes stanza:\n%s", string(first)) } for _, pair := range []struct { before string after string }{ {before: " metadata:\n", after: " applicable_risk_products:\n"}, {before: " applicable_risk_products:\n", after: " derived_summaries:\n"}, {before: " derived_summaries:\n", after: " narrative_products:\n"}, {before: " narrative_products:\n", after: " raw_data:\n"}, } { if strings.Index(string(first), pair.before) < 0 || strings.Index(string(first), pair.after) < 0 || strings.Index(string(first), pair.before) > strings.Index(string(first), pair.after) { t.Fatalf("YAML category order is wrong, want %q before %q:\n%s", pair.before, pair.after, string(first)) } } } func TestMarshalYAMLPlacesSPCConvectiveStanzasInPromptCategories(t *testing.T) { req := validBuildRequest(t) req.Modules = snapshotWithOutputs(t, module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}}, module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: map[string]any{"discussions": []string{"day1"}}}, module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: map[string]any{"outlook_count": 1}}, ) pkg, err := Build(req) if err != nil { t.Fatalf("Build() error = %v", err) } data, err := MarshalYAML(pkg) if err != nil { t.Fatalf("MarshalYAML() error = %v", err) } text := string(data) riskIndex := strings.Index(text, " applicable_risk_products:\n") outlookIndex := strings.Index(text, " spc_convective_outlooks:\n") narrativeIndex := strings.Index(text, " narrative_products:\n") discussionIndex := strings.Index(text, " spc_convective_discussion:\n") if riskIndex < 0 || outlookIndex < 0 || narrativeIndex < 0 || discussionIndex < 0 { t.Fatalf("YAML output missing SPC convective category placement:\n%s", text) } if !(riskIndex < outlookIndex && outlookIndex < narrativeIndex && narrativeIndex < discussionIndex) { t.Fatalf("YAML output placed SPC convective stanzas in wrong order:\n%s", text) } if strings.Contains(text, " raw_data:\n spc_convective") || strings.Contains(text, " derived_summaries:\n spc_convective") { t.Fatalf("YAML output placed SPC convective stanzas in wrong category:\n%s", text) } loaded, err := LoadYAML(data) if err != nil { t.Fatalf("LoadYAML() error = %v", err) } if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveOutlooks)]; !ok { t.Fatal("loaded package missing spc_convective_outlooks stanza") } if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveDiscussion)]; !ok { t.Fatal("loaded package missing spc_convective_discussion stanza") } } func TestLoadYAMLRoundTrip(t *testing.T) { pkg, err := Build(validBuildRequest(t)) if err != nil { t.Fatalf("Build() error = %v", err) } data, err := MarshalYAML(pkg) if err != nil { t.Fatalf("MarshalYAML() error = %v", err) } loaded, err := LoadYAML(data) if err != nil { t.Fatalf("LoadYAML() error = %v", err) } if loaded.SchemaVersion != SchemaVersion || loaded.RunID != pkg.RunID { t.Fatalf("loaded package = %#v, want schema and run id", loaded) } wantOrder := []string{"metadata", "alert_digest", "derived_daily_summary", "narrative_forecast", "current_conditions"} if strings.Join(loaded.Briefing.Order, ",") != strings.Join(wantOrder, ",") { t.Fatalf("loaded package order = %#v, want grouped category order %#v", loaded.Briefing.Order, wantOrder) } if got := loaded.Briefing.Values["current_conditions"].(map[string]any)["condition_text"]; got != "Partly cloudy" { t.Fatalf("loaded current_conditions.condition_text = %#v, want Partly cloudy", got) } } func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) { req := validBuildRequest(t) req.Modules = snapshotWithOutputs(t, module.Output{ID: module.ID("custom"), StanzaName: "custom", Value: map[string]string{"value": "x"}}) _, err := Build(req) if err == nil || !strings.Contains(err.Error(), `briefing stanza "custom" has no prompt-input category`) { t.Fatalf("Build() error = %v, want uncategorized stanza error", err) } } func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) { data := []byte(` schema_version: weatherreporter.data_package.v4 run_id: 20260529T100000Z_daily report: id: daily prompt_id: weather.daily_generated_text generated_at: 2026-05-29T10:00:00Z timezone: America/Chicago current_local_date: "2026-05-29" valid_period: start: 2026-05-29T05:00:00Z end: 2026-05-30T05:00:00Z briefing: metadata: run_id: 20260529T100000Z_daily raw_data: alert_digest: checked: true `) _, err := LoadYAML(data) if err == nil || !strings.Contains(err.Error(), `briefing stanza "alert_digest" belongs under category "applicable_risk_products"`) { t.Fatalf("LoadYAML() error = %v, want misplaced stanza error", err) } } func TestLoadYAMLRejectsOldSchemaVersion(t *testing.T) { pkg, err := Build(validBuildRequest(t)) if err != nil { t.Fatalf("Build() error = %v", err) } data, err := MarshalYAML(pkg) if err != nil { t.Fatalf("MarshalYAML() error = %v", err) } data = []byte(strings.Replace(string(data), "weatherreporter.data_package.v4", "weatherreporter.data_package.v3", 1)) _, err = LoadYAML(data) if err == nil || !strings.Contains(err.Error(), "schemaVersion must be weatherreporter.data_package.v4") { t.Fatalf("LoadYAML() error = %v, want current schema version error", err) } } func validBuildRequest(t *testing.T) BuildRequest { t.Helper() generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC) return BuildRequest{ Metadata: Metadata{ RunID: "20260529T100000Z_daily", ReportID: report.Daily, Variant: "today", PromptID: "weather.daily_generated_text", GeneratedAt: generatedAt, Timezone: "America/Chicago", ValidPeriod: timeutil.Period{ Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC), End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC), }, }, Modules: snapshotWithOutputs(t, module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily"}}, module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}}, module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}}, module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}}, module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: map[string]string{"product": "narrative"}}, ), } } func snapshotWithOutputs(t *testing.T, outputs ...module.Output) module.Snapshot { t.Helper() snapshot, err := module.NewSnapshot(outputs) if err != nil { t.Fatalf("NewSnapshot() error = %v", err) } return snapshot }