Add embedded hourly report template assets
This commit is contained in:
60
internal/reporttemplate/reporttemplate.go
Normal file
60
internal/reporttemplate/reporttemplate.go
Normal 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
|
||||
}
|
||||
157
internal/reporttemplate/reporttemplate_test.go
Normal file
157
internal/reporttemplate/reporttemplate_test.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
71
internal/reporttemplate/templates/hourly.md.tmpl
Normal file
71
internal/reporttemplate/templates/hourly.md.tmpl
Normal 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 }}
|
||||
Reference in New Issue
Block a user