package app import ( "context" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/state" ) func TestFetchAndSaveBundle(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/observations": _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`)) case "/conditions/current": _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) case "/forecast/hourly": _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00"}]}}`)) case "/forecast/narrative": _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[]}}`)) case "/alerts/active": _, _ = w.Write([]byte(`{"data":{"alerts":[]}}`)) case "/discussion": _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[]}}`)) default: http.NotFound(w, r) } })) defer server.Close() cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" path := filepath.Join(t.TempDir(), "bundle.json") bundle, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: cfg, OutputPath: path}) if err != nil { t.Fatalf("FetchAndSaveBundle() error = %v", err) } if bundle.Hourly == nil { t.Fatal("Hourly = nil, want fetched bundle") } data, err := os.ReadFile(path) if err != nil { t.Fatalf("read saved bundle: %v", err) } if !strings.Contains(string(data), `"product": "hourly"`) { t.Fatalf("saved bundle missing hourly product:\n%s", string(data)) } } func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) { _, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: config.Defaults()}) if err == nil { t.Fatal("FetchAndSaveBundle() error = nil, want output path error") } if !strings.Contains(err.Error(), "output path") { t.Fatalf("error = %q, want output path context", err.Error()) } } func TestGenerateDailyBriefingWritesArtifact(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" resolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), }, mustParse("2026-05-29T05:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) } path := filepath.Join(t.TempDir(), "daily.briefing.json") result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{ Config: cfg, Resolved: resolved, OutputPath: path, }) if err != nil { t.Fatalf("GenerateDailyBriefing() error = %v", err) } if result.OutputPath != path { t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path) } if result.Package.Daily == nil { t.Fatal("Daily = nil") } data, err := os.ReadFile(path) if err != nil { t.Fatalf("read briefing artifact: %v", err) } if !strings.Contains(string(data), `"schemaVersion"`) || !strings.Contains(string(data), `"daily"`) { t.Fatalf("briefing artifact missing expected fields:\n%s", string(data)) } } func TestGenerateDailyBriefingDefaultPath(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" cfg.Workspace.Root = t.TempDir() resolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), }, mustParse("2026-05-29T05:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) } result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{ Config: cfg, Resolved: resolved, }) if err != nil { t.Fatalf("GenerateDailyBriefing() error = %v", err) } if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json")) { t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath) } } func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" cfg.Workspace.Root = t.TempDir() resolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), }, mustParse("2026-05-29T05:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) } renderer := &recordingRenderer{ renderResult: &scriptorium.RenderResult{ Command: []string{"scriptorium", "render"}, Stdout: `{"prepared":true}`, ExitCode: 0, }, runResult: &scriptorium.RunResult{ Command: []string{"scriptorium", "run"}, Stderr: "wrote report", ExitCode: 0, OutputPath: "", }, runBody: "# Daily Report\n\nRain this morning.\n", } outputPath := filepath.Join(t.TempDir(), "daily.md") result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ Config: cfg, Resolved: resolved, OutputPath: outputPath, Renderer: renderer, }) if err != nil { t.Fatalf("GenerateDailyReport() error = %v", err) } if renderer.renderCalls != 1 { t.Fatalf("render calls = %d, want 1", renderer.renderCalls) } if renderer.runCalls != 1 { t.Fatalf("run calls = %d, want 1", renderer.runCalls) } if renderer.renderRequest.PromptID != "weather.daily_report" { t.Fatalf("render PromptID = %q, want weather.daily_report", renderer.renderRequest.PromptID) } if renderer.renderRequest.DataPackagePath != result.DataPackagePath { t.Fatalf("render DataPackagePath = %q, want managed path %q", renderer.renderRequest.DataPackagePath, result.DataPackagePath) } if renderer.runRequest.DataPackagePath != result.DataPackagePath { t.Fatalf("run DataPackagePath = %q, want managed path %q", renderer.runRequest.DataPackagePath, result.DataPackagePath) } if renderer.runRequest.OutputPath != result.ReportPath { t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath) } for _, path := range []string{result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath} { if _, err := os.Stat(path); err != nil { t.Fatalf("expected artifact %q: %v", path, err) } } if _, err := os.Stat(outputPath); err != nil { t.Fatalf("expected requested report output %q: %v", outputPath, err) } data, err := os.ReadFile(result.DataPackagePath) if err != nil { t.Fatalf("read data package: %v", err) } if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) { t.Fatalf("data package missing expected content:\n%s", string(data)) } preflight, err := os.ReadFile(result.PreflightPath) if err != nil { t.Fatalf("read preflight: %v", err) } if !strings.Contains(string(preflight), `prepared`) { t.Fatalf("preflight output missing render stdout:\n%s", string(preflight)) } if result.Metadata.RunID != resolved.Metadata().RunID { t.Fatalf("metadata RunID = %q, want %q", result.Metadata.RunID, resolved.Metadata().RunID) } if result.Metadata.BriefingPath != result.BriefingPath || result.Metadata.DataPackagePath != result.DataPackagePath { t.Fatalf("metadata does not link artifact paths: %#v", result.Metadata) } if result.Metadata.RenderedReportPath != result.ReportPath { t.Fatalf("metadata rendered report path = %q, want %q", result.Metadata.RenderedReportPath, result.ReportPath) } if len(result.RecentChanges) != 0 { t.Fatalf("RecentChanges = %#v, want none without prior snapshot", result.RecentChanges) } report, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("read report output: %v", err) } if !strings.Contains(string(report), "# Daily Report") { t.Fatalf("report output missing markdown:\n%s", string(report)) } } func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" cfg.Workspace.Root = t.TempDir() resolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), }, mustParse("2026-05-29T05:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) } renderer := &recordingRenderer{ renderResult: &scriptorium.RenderResult{ Command: []string{"scriptorium", "render"}, Stderr: "render failed", ExitCode: 1, }, err: errors.New("scriptorium render exited with code 1: render failed"), } _, err = GenerateDailyReport(context.Background(), DailyReportRequest{ Config: cfg, Resolved: resolved, Renderer: renderer, }) if err == nil { t.Fatal("GenerateDailyReport() error = nil, want render error") } store, err := state.NewFilesystemStore(cfg.Workspace) if err != nil { t.Fatalf("NewFilesystemStore() error = %v", err) } paths, err := store.Paths(resolved) if err != nil { t.Fatalf("Paths() error = %v", err) } preflightPath := paths.Preflight preflight, readErr := os.ReadFile(preflightPath) if readErr != nil { t.Fatalf("read failed preflight: %v", readErr) } if !strings.Contains(string(preflight), `"exitCode": 1`) { t.Fatalf("failed preflight was not persisted:\n%s", string(preflight)) } if _, err := os.Stat(paths.Metadata); err != nil { t.Fatalf("expected metadata for failed preflight %q: %v", paths.Metadata, err) } if renderer.runCalls != 0 { t.Fatalf("run calls = %d, want none after failed preflight", renderer.runCalls) } } func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" cfg.Workspace.Root = t.TempDir() resolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), }, mustParse("2026-05-29T05:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) } renderer := &recordingRenderer{ renderResult: &scriptorium.RenderResult{ExitCode: 0}, runResult: &scriptorium.RunResult{ Stderr: "validation failed", ExitCode: 2, }, runErr: errors.New("scriptorium run exited with code 2: validation failed"), runBody: "# Daily Report\n", } _, err = GenerateDailyReport(context.Background(), DailyReportRequest{ Config: cfg, Resolved: resolved, Renderer: renderer, }) if err == nil { t.Fatal("GenerateDailyReport() error = nil, want run error") } if renderer.renderCalls != 1 || renderer.runCalls != 1 { t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls) } store, err := state.NewFilesystemStore(cfg.Workspace) if err != nil { t.Fatalf("NewFilesystemStore() error = %v", err) } paths, err := store.Paths(resolved) if err != nil { t.Fatalf("Paths() error = %v", err) } if _, err := os.Stat(paths.Metadata); err != nil { t.Fatalf("expected metadata for failed run %q: %v", paths.Metadata, err) } if _, err := os.Stat(paths.RenderedReport); err != nil { t.Fatalf("expected report from validation exit %q: %v", paths.RenderedReport, err) } } func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" cfg.Workspace.Root = t.TempDir() store, err := state.NewFilesystemStore(cfg.Workspace) if err != nil { t.Fatalf("NewFilesystemStore() error = %v", err) } priorResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), }, mustParse("2026-05-29T04:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate(prior) error = %v", err) } priorBriefing := priorDailyBriefing(priorResolved) priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing) if err != nil { t.Fatalf("SaveBriefing() error = %v", err) } priorPaths, err := store.Paths(priorResolved) if err != nil { t.Fatalf("Paths() error = %v", err) } _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{ Briefing: priorBriefingPath, DataPackage: priorPaths.DataPackage, Preflight: priorPaths.Preflight, RenderedReport: priorPaths.RenderedReport, })) if err != nil { t.Fatalf("SaveMetadata() error = %v", err) } currentResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), }, mustParse("2026-05-29T05:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate(current) error = %v", err) } renderer := &recordingRenderer{ renderResult: &scriptorium.RenderResult{ExitCode: 0}, runResult: &scriptorium.RunResult{ExitCode: 0}, runBody: "# Daily Report\n", } result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ Config: cfg, Resolved: currentResolved, Renderer: renderer, Store: store, }) if err != nil { t.Fatalf("GenerateDailyReport() error = %v", err) } if len(result.RecentChanges) == 0 { t.Fatal("RecentChanges length = 0, want changes from prior snapshot") } data, err := os.ReadFile(result.DataPackagePath) if err != nil { t.Fatalf("read data package: %v", err) } if !strings.Contains(string(data), "alert_added") || !strings.Contains(string(data), "temperature_shift") { t.Fatalf("data package missing recent changes:\n%s", string(data)) } } func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" cfg.Workspace.Root = t.TempDir() resolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportTomorrow, }, mustParse("2026-05-29T18:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) } renderer := &recordingRenderer{ renderResult: &scriptorium.RenderResult{ExitCode: 0}, runResult: &scriptorium.RunResult{ExitCode: 0}, runBody: "# Tomorrow Planning Brief\n", } result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ Config: cfg, Resolved: resolved, Renderer: renderer, }) if err != nil { t.Fatalf("GenerateDailyReport() error = %v", err) } if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" { t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Briefing.Metadata.ReportID, result.Briefing.Metadata.Variant) } if result.Briefing.Daily.ForecastSummaryDate != "2026-05-30" { t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", result.Briefing.Daily.ForecastSummaryDate) } if result.Briefing.Daily.Planning == nil { t.Fatal("Planning = nil, want tomorrow planning inputs") } if !strings.Contains(filepath.Base(result.ReportPath), "daily_tomorrow") { t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath) } } func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" cfg.Workspace.Root = t.TempDir() store, err := state.NewFilesystemStore(cfg.Workspace) if err != nil { t.Fatalf("NewFilesystemStore() error = %v", err) } priorResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-30T12:00:00-05:00"), }, mustParse("2026-05-29T17:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate(prior) error = %v", err) } priorBriefing := priorDailyBriefing(priorResolved) priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing) if err != nil { t.Fatalf("SaveBriefing() error = %v", err) } priorPaths, err := store.Paths(priorResolved) if err != nil { t.Fatalf("Paths() error = %v", err) } _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{ Briefing: priorBriefingPath, DataPackage: priorPaths.DataPackage, Preflight: priorPaths.Preflight, RenderedReport: priorPaths.RenderedReport, })) if err != nil { t.Fatalf("SaveMetadata() error = %v", err) } currentResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportTomorrow, }, mustParse("2026-05-29T18:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate(current) error = %v", err) } renderer := &recordingRenderer{ renderResult: &scriptorium.RenderResult{ExitCode: 0}, runResult: &scriptorium.RunResult{ExitCode: 0}, runBody: "# Tomorrow Planning Brief\n", } result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ Config: cfg, Resolved: currentResolved, Renderer: renderer, Store: store, }) if err != nil { t.Fatalf("GenerateDailyReport() error = %v", err) } if result.PriorSnapshot == nil { t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot") } if len(result.RecentChanges) == 0 { t.Fatal("RecentChanges length = 0, want changes from compatible prior daily snapshot") } } func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" cfg.Workspace.Root = t.TempDir() store, err := state.NewFilesystemStore(cfg.Workspace) if err != nil { t.Fatalf("NewFilesystemStore() error = %v", err) } priorResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportThreeDay, }, mustParse("2026-05-29T04:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate(prior) error = %v", err) } priorBriefing := priorThreeDayBriefing(priorResolved) priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing) if err != nil { t.Fatalf("SaveBriefing() error = %v", err) } priorPaths, err := store.Paths(priorResolved) if err != nil { t.Fatalf("Paths() error = %v", err) } _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{ Briefing: priorBriefingPath, DataPackage: priorPaths.DataPackage, Preflight: priorPaths.Preflight, RenderedReport: priorPaths.RenderedReport, })) if err != nil { t.Fatalf("SaveMetadata() error = %v", err) } currentResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportThreeDay, }, mustParse("2026-05-29T05:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate(current) error = %v", err) } renderer := &recordingRenderer{ renderResult: &scriptorium.RenderResult{ExitCode: 0}, runResult: &scriptorium.RunResult{ExitCode: 0}, runBody: "# 3-Day Outlook\n", } result, err := GenerateReport(context.Background(), ReportRequest{ Config: cfg, Resolved: currentResolved, Renderer: renderer, Store: store, }) if err != nil { t.Fatalf("GenerateReport() error = %v", err) } if result.Briefing.ThreeDay == nil { t.Fatal("ThreeDay = nil") } if len(result.Briefing.ThreeDay.Days) != 3 { t.Fatalf("ThreeDay days = %d, want 3", len(result.Briefing.ThreeDay.Days)) } if renderer.renderRequest.PromptID != "weather.three_day_outlook" { t.Fatalf("render PromptID = %q, want weather.three_day_outlook", renderer.renderRequest.PromptID) } if result.PriorSnapshot == nil { t.Fatal("PriorSnapshot = nil, want prior 3-day snapshot") } if len(result.RecentChanges) == 0 { t.Fatal("RecentChanges length = 0, want changes from prior 3-day snapshot") } } func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" cfg.Workspace.Root = t.TempDir() store, err := state.NewFilesystemStore(cfg.Workspace) if err != nil { t.Fatalf("NewFilesystemStore() error = %v", err) } priorResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportWeekend, }, mustParse("2026-05-29T04:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate(prior) error = %v", err) } priorBriefing := priorWeekendBriefing(priorResolved) priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing) if err != nil { t.Fatalf("SaveBriefing() error = %v", err) } priorPaths, err := store.Paths(priorResolved) if err != nil { t.Fatalf("Paths() error = %v", err) } _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{ Briefing: priorBriefingPath, DataPackage: priorPaths.DataPackage, Preflight: priorPaths.Preflight, RenderedReport: priorPaths.RenderedReport, })) if err != nil { t.Fatalf("SaveMetadata() error = %v", err) } currentResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportWeekend, }, mustParse("2026-05-29T05:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate(current) error = %v", err) } renderer := &recordingRenderer{ renderResult: &scriptorium.RenderResult{ExitCode: 0}, runResult: &scriptorium.RunResult{ExitCode: 0}, runBody: "# Weekend Outlook\n", } result, err := GenerateReport(context.Background(), ReportRequest{ Config: cfg, Resolved: currentResolved, Renderer: renderer, Store: store, }) if err != nil { t.Fatalf("GenerateReport() error = %v", err) } if result.Briefing.Weekend == nil { t.Fatal("Weekend = nil") } if len(result.Briefing.Weekend.Days) != 3 { t.Fatalf("Weekend days = %d, want 3", len(result.Briefing.Weekend.Days)) } if renderer.renderRequest.PromptID != "weather.weekend_outlook" { t.Fatalf("render PromptID = %q, want weather.weekend_outlook", renderer.renderRequest.PromptID) } if result.PriorSnapshot == nil { t.Fatal("PriorSnapshot = nil, want prior weekend snapshot") } if len(result.RecentChanges) == 0 { t.Fatal("RecentChanges length = 0, want changes from prior weekend snapshot") } } func TestGenerateStormReportWritesReport(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" cfg.Workspace.Root = t.TempDir() resolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportStorm, StormStart: mustParse("2026-05-29T06:00:00-05:00"), StormEnd: mustParse("2026-05-29T10:00:00-05:00"), }, mustParse("2026-05-29T05:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) } renderer := &recordingRenderer{ renderResult: &scriptorium.RenderResult{ExitCode: 0}, runResult: &scriptorium.RunResult{ExitCode: 0}, runBody: "# Storm Report\n", } outputPath := filepath.Join(t.TempDir(), "storm.md") result, err := GenerateReport(context.Background(), ReportRequest{ Config: cfg, Resolved: resolved, OutputPath: outputPath, Renderer: renderer, }) if err != nil { t.Fatalf("GenerateReport() error = %v", err) } if result.Briefing.Storm == nil { t.Fatal("Storm = nil") } if renderer.renderRequest.PromptID != "weather.storm_report" { t.Fatalf("render PromptID = %q, want weather.storm_report", renderer.renderRequest.PromptID) } if len(result.Briefing.Storm.HourlyPeriods) == 0 || len(result.Briefing.Storm.NarrativePeriods) == 0 { t.Fatalf("selected source periods hourly/narrative = %d/%d, want relevant periods", len(result.Briefing.Storm.HourlyPeriods), len(result.Briefing.Storm.NarrativePeriods)) } if _, err := os.Stat(outputPath); err != nil { t.Fatalf("expected requested report output %q: %v", outputPath, err) } data, err := os.ReadFile(result.DataPackagePath) if err != nil { t.Fatalf("read data package: %v", err) } if !strings.Contains(string(data), `"storm"`) || !strings.Contains(string(data), `"weather.storm_report"`) { t.Fatalf("data package missing storm content:\n%s", string(data)) } } func TestInspectGeneratedReportArtifacts(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" cfg.Workspace.Root = t.TempDir() resolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), }, mustParse("2026-05-29T05:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) } renderer := &recordingRenderer{ renderResult: &scriptorium.RenderResult{ExitCode: 0}, runResult: &scriptorium.RunResult{ExitCode: 0}, runBody: "# Daily Report\n", } result, err := GenerateReport(context.Background(), ReportRequest{ Config: cfg, Resolved: resolved, Renderer: renderer, }) if err != nil { t.Fatalf("GenerateReport() error = %v", err) } records, err := InspectReports(context.Background(), InspectReportsRequest{Config: cfg, Limit: 1}) if err != nil { t.Fatalf("InspectReports() error = %v", err) } if len(records) != 1 || records[0].RunID != result.Metadata.RunID { t.Fatalf("records = %#v, want generated run", records) } metadata, err := InspectMetadata(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID}) if err != nil { t.Fatalf("InspectMetadata() error = %v", err) } if metadata.BriefingPath != result.BriefingPath || metadata.DataPackagePath != result.DataPackagePath { t.Fatalf("metadata paths = %#v, want generated artifact paths", metadata) } briefingPackage, err := InspectBriefing(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID}) if err != nil { t.Fatalf("InspectBriefing() error = %v", err) } if briefingPackage.Metadata.RunID != result.Metadata.RunID { t.Fatalf("briefing RunID = %q, want %q", briefingPackage.Metadata.RunID, result.Metadata.RunID) } dataPackage, err := InspectDataPackage(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID}) if err != nil { t.Fatalf("InspectDataPackage() error = %v", err) } if dataPackage.RunID != result.Metadata.RunID { t.Fatalf("data package RunID = %q, want %q", dataPackage.RunID, result.Metadata.RunID) } sources, err := InspectSources(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID}) if err != nil { t.Fatalf("InspectSources() error = %v", err) } if len(sources.Sources) == 0 || len(sources.Warnings) == 0 { t.Fatalf("sources = %#v, want provenance and warnings", sources) } } func TestInspectPriorSnapshot(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" cfg.Workspace.Root = t.TempDir() store, err := state.NewFilesystemStore(cfg.Workspace) if err != nil { t.Fatalf("NewFilesystemStore() error = %v", err) } priorResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), }, mustParse("2026-05-29T04:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate(prior) error = %v", err) } currentResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), }, mustParse("2026-05-29T05:00:00-05:00")) if err != nil { t.Fatalf("ResolveGenerate(current) error = %v", err) } renderer := &recordingRenderer{ renderResult: &scriptorium.RenderResult{ExitCode: 0}, runResult: &scriptorium.RunResult{ExitCode: 0}, runBody: "# Daily Report\n", } if _, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Resolved: priorResolved, Renderer: renderer, Store: store}); err != nil { t.Fatalf("GenerateReport(prior) error = %v", err) } current, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Resolved: currentResolved, Renderer: renderer, Store: store}) if err != nil { t.Fatalf("GenerateReport(current) error = %v", err) } prior, err := InspectPriorSnapshot(context.Background(), InspectRunRequest{Config: cfg, RunID: current.Metadata.RunID}) if err != nil { t.Fatalf("InspectPriorSnapshot() error = %v", err) } if prior == nil || prior.Metadata.RunID != priorResolved.Metadata().RunID { t.Fatalf("prior = %#v, want previous generated run", prior) } } func TestInspectMissingMetadata(t *testing.T) { cfg := config.Defaults() cfg.Workspace.Root = t.TempDir() _, err := InspectMetadata(context.Background(), InspectRunRequest{Config: cfg, RunID: "missing"}) if err == nil { t.Fatal("InspectMetadata() error = nil, want missing metadata error") } if !strings.Contains(err.Error(), "metadata for run id") { t.Fatalf("error = %q, want missing run id context", err.Error()) } } func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) { cfg := config.Defaults() cfg.WeatherAPI.Timezone = "America/Chicago" now := mustParse("2026-05-29T18:00:00-05:00") resolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportTomorrow, }, now) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) } if resolved.Definition.ID != report.DailyTomorrow { t.Fatalf("ID = %q, want daily_tomorrow", resolved.Definition.ID) } if resolved.Definition.PromptID != "weather.daily_report" { t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID) } if got := resolved.ValidPeriod.Start.Format("2006-01-02"); got != "2026-05-30" { t.Fatalf("valid start date = %s, want 2026-05-30", got) } } func dailyBundleServer(t *testing.T) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/observations": _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`)) case "/conditions/current": _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) case "/forecast/hourly": _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) case "/forecast/narrative": _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T18:00:00-05:00","textDescription":"Tomorrow starts stormy."}]}}`)) case "/alerts/active": _, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`)) case "/discussion": _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`)) default: http.NotFound(w, r) } })) t.Cleanup(server.Close) return server } func TestResolveGenerateStorm(t *testing.T) { cfg := config.Defaults() cfg.WeatherAPI.Timezone = "America/Chicago" now := mustParse("2026-05-29T12:00:00-05:00") start := mustParse("2026-05-29T18:00:00-05:00") end := mustParse("2026-05-30T06:00:00-05:00") resolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportStorm, StormStart: start, StormEnd: end, }, now) if err != nil { t.Fatalf("ResolveGenerate() error = %v", err) } if resolved.Definition.ID != report.Storm { t.Fatalf("ID = %q, want storm", resolved.Definition.ID) } if !resolved.ValidPeriod.Start.Equal(start) || !resolved.ValidPeriod.End.Equal(end) { t.Fatalf("period = %#v, want storm window", resolved.ValidPeriod) } } func TestResolveBatchMorningSkipsWeekendOnSunday(t *testing.T) { cfg := config.Defaults() cfg.WeatherAPI.Timezone = "America/Chicago" now := mustParse("2026-05-31T06:00:00-05:00") resolved, err := ResolveBatch(BatchRequest{Config: cfg, Batch: BatchMorning}, now) if err != nil { t.Fatalf("ResolveBatch() error = %v", err) } if len(resolved) != 2 { t.Fatalf("resolved length = %d, want 2", len(resolved)) } for _, item := range resolved { if item.Definition.ID == report.Weekend { t.Fatal("morning batch included weekend on Sunday") } } } func TestRunBatchContinuesAfterReportFailure(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" cfg.Workspace.Root = t.TempDir() renderer := &selectiveRenderer{ failRenderPrompt: "weather.three_day_outlook", runBody: "# Batch Report\n", } result, err := RunBatchDetailed(context.Background(), BatchRequest{ Config: cfg, Batch: BatchMorning, Now: mustParse("2026-05-29T05:00:00-05:00"), Renderer: renderer, }) if err != nil { t.Fatalf("RunBatchDetailed() error = %v", err) } if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 { t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed) } if renderer.runCalls != 2 { t.Fatalf("run calls = %d, want successful reports to continue", renderer.runCalls) } var failedThreeDay bool for _, item := range result.Reports { if item.ReportID == report.ThreeDay && item.Status == "failed" && strings.Contains(item.Error, "render failed") { failedThreeDay = true } if item.ReportID != report.ThreeDay && item.Status != "succeeded" { t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status) } } if !failedThreeDay { t.Fatalf("reports = %#v, want failed 3-day item", result.Reports) } } func TestRunBatchUsesOutputDirectory(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.Timezone = "America/Chicago" cfg.Workspace.Root = t.TempDir() outputDir := filepath.Join(t.TempDir(), "reports") result, err := RunBatchDetailed(context.Background(), BatchRequest{ Config: cfg, Batch: BatchEvening, Now: mustParse("2026-05-29T18:00:00-05:00"), OutputDir: outputDir, Renderer: &selectiveRenderer{runBody: "# Tomorrow\n"}, }) if err != nil { t.Fatalf("RunBatchDetailed() error = %v", err) } if result.Failed != 0 || len(result.Reports) != 1 { t.Fatalf("summary = %#v, want one successful report", result) } want := filepath.Join(outputDir, "tomorrow.md") if result.Reports[0].OutputPath != want { t.Fatalf("OutputPath = %q, want %q", result.Reports[0].OutputPath, want) } if _, err := os.Stat(want); err != nil { t.Fatalf("expected output copy %q: %v", want, err) } } func mustParse(value string) time.Time { parsed, err := time.Parse(time.RFC3339, value) if err != nil { panic(err) } return parsed } func priorDailyBriefing(resolved report.Resolved) briefing.Package { low := 50.0 high := 58.0 precip := 10.0 return briefing.Package{ Metadata: briefing.Metadata{ SchemaVersion: briefing.SchemaVersion, RunID: resolved.Metadata().RunID, ReportID: resolved.Definition.ID, Variant: "today", PromptID: resolved.Definition.PromptID, GeneratedAt: resolved.GeneratedAt, Units: "us", Timezone: resolved.Timezone, ValidPeriod: resolved.ValidPeriod, }, Daily: &briefing.Daily{ BottomLine: briefing.BottomLine{ Temperature: forecast.Range{Min: &low, Max: &high}, MaxPrecipProbability: &forecast.TimedValue{ Value: precip, Time: resolved.ValidPeriod.Start.Add(6 * time.Hour), }, }, Dayparts: []forecast.DaypartSummary{ {Name: "morning"}, }, }, } } func priorThreeDayBriefing(resolved report.Resolved) briefing.Package { high := 58.0 precip := 10.0 return briefing.Package{ Metadata: briefing.Metadata{ SchemaVersion: briefing.SchemaVersion, RunID: resolved.Metadata().RunID, ReportID: resolved.Definition.ID, PromptID: resolved.Definition.PromptID, GeneratedAt: resolved.GeneratedAt, Units: "us", Timezone: resolved.Timezone, ValidPeriod: resolved.ValidPeriod, }, ThreeDay: &briefing.ThreeDay{ Days: []briefing.OutlookDay{ { Date: "2026-05-29", Temperature: forecast.Range{Max: &high}, MaxPrecipitationProbability: &forecast.TimedValue{ Value: precip, Time: resolved.ValidPeriod.Start.Add(6 * time.Hour), }, }, }, }, } } func priorWeekendBriefing(resolved report.Resolved) briefing.Package { high := 58.0 precip := 10.0 return briefing.Package{ Metadata: briefing.Metadata{ SchemaVersion: briefing.SchemaVersion, RunID: resolved.Metadata().RunID, ReportID: resolved.Definition.ID, PromptID: resolved.Definition.PromptID, GeneratedAt: resolved.GeneratedAt, Units: "us", Timezone: resolved.Timezone, ValidPeriod: resolved.ValidPeriod, }, Weekend: &briefing.Weekend{ Days: []briefing.OutlookDay{ { Date: "2026-05-30", Temperature: forecast.Range{Max: &high}, MaxPrecipitationProbability: &forecast.TimedValue{ Value: precip, Time: resolved.ValidPeriod.Start.Add(12 * time.Hour), }, }, }, }, } } type recordingRenderer struct { renderCalls int runCalls int renderRequest scriptorium.RenderRequest runRequest scriptorium.RunRequest renderResult *scriptorium.RenderResult runResult *scriptorium.RunResult err error runErr error runBody string } type selectiveRenderer struct { renderCalls int runCalls int failRenderPrompt string runBody string } func (r *selectiveRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) { r.renderCalls++ if req.PromptID == r.failRenderPrompt { return &scriptorium.RenderResult{ExitCode: 1, Stderr: "render failed"}, errors.New("render failed") } return &scriptorium.RenderResult{ExitCode: 0}, nil } func (r *selectiveRenderer) Run(_ context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error) { r.runCalls++ if r.runBody != "" { if err := os.WriteFile(req.OutputPath, []byte(r.runBody), 0o600); err != nil { return nil, err } } return &scriptorium.RunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil } func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) { r.renderCalls++ r.renderRequest = req return r.renderResult, r.err } func (r *recordingRenderer) Run(_ context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error) { r.runCalls++ r.runRequest = req if r.runBody != "" { if err := os.WriteFile(req.OutputPath, []byte(r.runBody), 0o600); err != nil { return nil, err } } if r.runResult != nil { r.runResult.OutputPath = req.OutputPath } return r.runResult, r.runErr }