337 lines
12 KiB
Go
337 lines
12 KiB
Go
package promptinput
|
|
|
|
import (
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
)
|
|
|
|
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 TestMarshalYAMLProjectsSourceWarningsWithoutTransportDetails(t *testing.T) {
|
|
req := validBuildRequest(t)
|
|
req.Metadata.SourceWarnings = []weatherdata.SourceWarning{{
|
|
Source: "hourly",
|
|
Code: "missing_source",
|
|
Severity: "warning",
|
|
Message: "hourly source is unavailable",
|
|
Endpoint: "/transport/path/only-for-this-test",
|
|
CompletenessImpact: "source omitted",
|
|
}}
|
|
|
|
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)
|
|
|
|
for _, safeDetail := range []string{"source_warnings:", "source: hourly", "code: missing_source", "severity: warning", "message: hourly source is unavailable", "completeness_impact: source omitted"} {
|
|
if !strings.Contains(text, safeDetail) {
|
|
t.Fatalf("YAML output missing safe warning detail %q:\n%s", safeDetail, text)
|
|
}
|
|
}
|
|
if strings.Contains(text, "/transport/path/only-for-this-test") || strings.Contains(text, "endpoint:") {
|
|
t.Fatalf("YAML output contains raw warning transport details:\n%s", text)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
}
|
|
|
|
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 TestMarshalYAMLAttributesStanzaSerializationFailures(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.CurrentConditions, StanzaName: "current_conditions", Value: map[string]float64{"temperature": math.Inf(1)}},
|
|
)
|
|
pkg, err := Build(req)
|
|
if err != nil {
|
|
t.Fatalf("Build() error = %v", err)
|
|
}
|
|
|
|
_, err = MarshalYAML(pkg)
|
|
if err == nil || !strings.Contains(err.Error(), `marshal briefing stanza "current_conditions"`) {
|
|
t.Fatalf("MarshalYAML() error = %v, want current_conditions stanza context", 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
|
|
}
|