Add embedded hourly report template assets

This commit is contained in:
2026-06-14 04:38:48 +00:00
parent b39ec1e4d3
commit bed2b84100
4 changed files with 314 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
// Package reporttemplate provides embedded Markdown templates and schemas.
package reporttemplate
import (
"bytes"
"embed"
"fmt"
"text/template"
)
//go:embed templates/*.md.tmpl schemas/*.schema.json
var assets embed.FS
var templates = map[string]string{
"hourly": "templates/hourly.md.tmpl",
}
var schemas = map[string]string{
"hourly": "schemas/hourly.generated_text.schema.json",
}
func Template(id string) (string, error) {
path, ok := templates[id]
if !ok {
return "", fmt.Errorf("unknown report template %q", id)
}
data, err := assets.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read report template %q: %w", id, err)
}
return string(data), nil
}
func Schema(id string) ([]byte, error) {
path, ok := schemas[id]
if !ok {
return nil, fmt.Errorf("unknown generated text schema %q", id)
}
data, err := assets.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read generated text schema %q: %w", id, err)
}
return append([]byte(nil), data...), nil
}
func Render(id string, data any) ([]byte, error) {
source, err := Template(id)
if err != nil {
return nil, err
}
tmpl, err := template.New(id).Option("missingkey=error").Parse(source)
if err != nil {
return nil, fmt.Errorf("parse report template %q: %w", id, err)
}
var out bytes.Buffer
if err := tmpl.Execute(&out, data); err != nil {
return nil, fmt.Errorf("render report template %q: %w", id, err)
}
return out.Bytes(), nil
}

View File

@@ -0,0 +1,157 @@
package reporttemplate
import (
"encoding/json"
"strings"
"testing"
)
func TestTemplateLookup(t *testing.T) {
source, err := Template("hourly")
if err != nil {
t.Fatalf("Template() error = %v", err)
}
for _, want := range []string{"# {{ .ReportTitle }}", "## Summary", "## Hourly Forecast", "## Weather Story"} {
if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source)
}
}
}
func TestSchemaLookup(t *testing.T) {
data, err := Schema("hourly")
if err != nil {
t.Fatalf("Schema() error = %v", err)
}
var schema struct {
Type string `json:"type"`
AdditionalProperties bool `json:"additionalProperties"`
Required []string `json:"required"`
Properties map[string]any `json:"properties"`
}
if err := json.Unmarshal(data, &schema); err != nil {
t.Fatalf("schema is invalid JSON: %v", err)
}
if schema.Type != "object" {
t.Fatalf("schema type = %q, want object", schema.Type)
}
if schema.AdditionalProperties {
t.Fatal("additionalProperties = true, want false")
}
if strings.Join(schema.Required, ",") != "summary,timing,impacts" {
t.Fatalf("required = %#v, want summary/timing/impacts", schema.Required)
}
for _, field := range []string{"summary", "timing", "impacts", "confidence"} {
property, ok := schema.Properties[field].(map[string]any)
if !ok {
t.Fatalf("schema property %q missing or invalid", field)
}
if property["type"] != "string" {
t.Fatalf("schema property %q type = %v, want string", field, property["type"])
}
}
}
func TestRenderHourly(t *testing.T) {
rendered, err := Render("hourly", testRenderContext{
ReportTitle: "Hourly Report",
LocationName: "Brentwood",
ValidPeriod: "May 29, 8:30 AM to 2:30 PM",
GeneratedAt: "May 29, 8:30 AM",
CurrentConditions: "74 F, light south wind.",
PrecipitationTiming: "Showers are most likely late morning.",
WeatherStory: "Morning storms remain the main story.",
GeneratedText: testGeneratedText{
Summary: "Storm chances increase through late morning.",
Timing: "The main window is 10 AM to noon.",
Impacts: "Brief downpours may slow travel.",
Confidence: "Medium confidence in timing.",
},
HourlyForecast: []testHourlyRow{
{Time: "9 AM", Summary: "Cloudy", Temperature: "74 F", Precipitation: "30% showers", Wind: "S 8 mph"},
{Time: "10 AM", Summary: "Showers", Temperature: "75 F", Precipitation: "70% showers", Wind: "S 10 mph"},
},
Alerts: []string{"Flood Watch until 2:30 PM"},
SPCOutlooks: []string{"Slight Risk through afternoon"},
SPCDiscussions: []string{"Strong storms may develop late morning."},
ForecastDiscussion: testForecastDiscussion{
KeyMessages: []string{"Storms are most likely late morning."},
ShortTerm: "Short-term discussion favors increasing rain coverage.",
},
})
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"# Hourly Report",
"Valid: May 29, 8:30 AM to 2:30 PM",
"Storm chances increase through late morning.",
"## Confidence",
"- 10 AM: Showers; 75 F; 70% showers; S 10 mph",
"- Flood Watch until 2:30 PM",
"Short-term discussion favors increasing rain coverage.",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
}
func TestUnknownAssetsReturnActionableErrors(t *testing.T) {
if _, err := Template("daily"); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) {
t.Fatalf("Template() error = %v, want unknown template", err)
}
if _, err := Schema("daily"); err == nil || !strings.Contains(err.Error(), `unknown generated text schema "daily"`) {
t.Fatalf("Schema() error = %v, want unknown schema", err)
}
if _, err := Render("daily", testRenderContext{}); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) {
t.Fatalf("Render() error = %v, want unknown template", err)
}
}
func TestRenderFailsForMissingContextFields(t *testing.T) {
_, err := Render("hourly", map[string]any{"ReportTitle": "Hourly Report"})
if err == nil {
t.Fatal("Render() error = nil, want missing field error")
}
if !strings.Contains(err.Error(), `render report template "hourly"`) {
t.Fatalf("Render() error = %v, want render context", err)
}
}
type testRenderContext struct {
ReportTitle string
LocationName string
ValidPeriod string
GeneratedAt string
CurrentConditions string
PrecipitationTiming string
WeatherStory string
GeneratedText testGeneratedText
HourlyForecast []testHourlyRow
Alerts []string
SPCOutlooks []string
SPCDiscussions []string
ForecastDiscussion testForecastDiscussion
}
type testGeneratedText struct {
Summary string
Timing string
Impacts string
Confidence string
}
type testHourlyRow struct {
Time string
Summary string
Temperature string
Precipitation string
Wind string
}
type testForecastDiscussion struct {
KeyMessages []string
ShortTerm string
}

View File

@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "weatherreporter.hourly.generated_text.schema.json",
"title": "Hourly GeneratedText",
"type": "object",
"additionalProperties": false,
"required": [
"summary",
"timing",
"impacts"
],
"properties": {
"summary": {
"type": "string"
},
"timing": {
"type": "string"
},
"impacts": {
"type": "string"
},
"confidence": {
"type": "string"
}
}
}

View File

@@ -0,0 +1,71 @@
# {{ .ReportTitle }}
{{ .LocationName }}
Valid: {{ .ValidPeriod }}
Generated: {{ .GeneratedAt }}
## Summary
{{ .GeneratedText.Summary }}
## Timing
{{ .GeneratedText.Timing }}
## Impacts
{{ .GeneratedText.Impacts }}
{{ with .GeneratedText.Confidence }}
## Confidence
{{ . }}
{{ end }}
## Current Conditions
{{ .CurrentConditions }}
## Hourly Forecast
{{ range .HourlyForecast }}
- {{ .Time }}: {{ .Summary }}{{ with .Temperature }}; {{ . }}{{ end }}{{ with .Precipitation }}; {{ . }}{{ end }}{{ with .Wind }}; {{ . }}{{ end }}
{{ else }}
- No hourly forecast rows available.
{{ end }}
## Precipitation Timing
{{ .PrecipitationTiming }}
## Alerts
{{ range .Alerts }}
- {{ . }}
{{ else }}
- No active alert overlaps for this report period.
{{ end }}
## SPC Outlooks
{{ range .SPCOutlooks }}
- {{ . }}
{{ else }}
- No overlapping SPC outlooks.
{{ end }}
## Forecast Discussion
{{ range .ForecastDiscussion.KeyMessages }}
- {{ . }}
{{ end }}{{ with .ForecastDiscussion.ShortTerm }}
{{ . }}
{{ end }}
## SPC Discussion
{{ range .SPCDiscussions }}
- {{ . }}
{{ else }}
- No overlapping SPC discussion.
{{ end }}
## Weather Story
{{ .WeatherStory }}