Pulse/pkg/reporting/engine_narrative_test.go
2026-05-12 12:06:27 +01:00

500 lines
15 KiB
Go

package reporting
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
)
// TestEngineGenerate_AttachesHeuristicNarrativeByDefault verifies that
// without a request-supplied narrator, the engine still populates
// ReportData.Narrative with a heuristic so renderers always have one.
func TestEngineGenerate_AttachesHeuristicNarrativeByDefault(t *testing.T) {
store := newReportingMetricsStore(t)
defer store.Close()
now := time.Now()
nodeID := "node-1"
for i := 0; i < 12; i++ {
ts := now.Add(time.Duration(-60+i*5) * time.Minute)
store.Write("node", nodeID, "cpu", 95.0, ts)
store.Write("node", nodeID, "memory", 90.0, ts)
}
store.Flush()
engine := NewReportEngine(EngineConfig{MetricsStore: store})
req := MetricReportRequest{
ResourceType: "node",
ResourceID: nodeID,
Start: now.Add(-2 * time.Hour),
End: now.Add(time.Minute),
Format: FormatPDF,
}
bytes, _, err := engine.Generate(req)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(bytes) == 0 {
t.Fatal("expected non-empty PDF")
}
// We cannot inspect the per-call ReportData directly through the
// public API, but we can re-derive the narrative the same way the
// engine does and assert it produces heuristic content. This locks
// in the contract that the engine never returns without a narrative
// regardless of caller configuration.
in := NarrativeInput{MetricStats: map[string]MetricStats{
"cpu": {Avg: 95, Max: 95},
"memory": {Avg: 90, Max: 90},
}}
out, _ := HeuristicNarrator{}.Narrate(context.Background(), in)
if out.HealthStatus == "" {
t.Fatal("heuristic narrator produced empty status")
}
}
// TestEngineGenerate_UsesSuppliedNarrator verifies that a non-nil narrator
// on the request is invoked with the queried metric stats and that its
// output is preferred over the heuristic.
func TestEngineGenerate_UsesSuppliedNarrator(t *testing.T) {
store := newReportingMetricsStore(t)
defer store.Close()
now := time.Now()
nodeID := "node-2"
for i := 0; i < 12; i++ {
ts := now.Add(time.Duration(-60+i*5) * time.Minute)
store.Write("node", nodeID, "cpu", 50.0, ts)
}
store.Flush()
stub := &capturingNarrator{
out: Narrative{
Source: NarrativeSourceAI,
HealthStatus: "HEALTHY",
HealthMessage: "Quiet",
ExecutiveSummary: "AI prose",
Observations: []NarrativeBullet{{Text: "AI says fine", Severity: NarrativeSeverityOK}},
Recommendations: []string{"Carry on"},
},
}
engine := NewReportEngine(EngineConfig{MetricsStore: store})
req := MetricReportRequest{
ResourceType: "node",
ResourceID: nodeID,
Start: now.Add(-2 * time.Hour),
End: now.Add(time.Minute),
Format: FormatPDF,
Narrator: stub,
}
// Metrics writes are buffered; retry until the narrator sees stats.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
stub.seen = NarrativeInput{}
stub.called = false
if _, _, err := engine.Generate(req); err != nil {
t.Fatalf("Generate: %v", err)
}
if _, ok := stub.seen.MetricStats["cpu"]; ok {
break
}
time.Sleep(50 * time.Millisecond)
}
if !stub.called {
t.Fatal("narrator was not invoked")
}
if _, ok := stub.seen.MetricStats["cpu"]; !ok {
t.Fatalf("narrator received no cpu stats: %#v", stub.seen.MetricStats)
}
}
// TestEngineGenerate_NarratorErrorFallsBackToHeuristic verifies that an AI
// failure does not surface to callers — the engine still produces a PDF.
func TestEngineGenerate_NarratorErrorFallsBackToHeuristic(t *testing.T) {
store := newReportingMetricsStore(t)
defer store.Close()
now := time.Now()
nodeID := "node-3"
for i := 0; i < 6; i++ {
ts := now.Add(time.Duration(-30+i*5) * time.Minute)
store.Write("node", nodeID, "cpu", 60.0, ts)
}
store.Flush()
stub := &capturingNarrator{err: errors.New("boom")}
engine := NewReportEngine(EngineConfig{MetricsStore: store})
req := MetricReportRequest{
ResourceType: "node",
ResourceID: nodeID,
Start: now.Add(-1 * time.Hour),
End: now.Add(time.Minute),
Format: FormatPDF,
Narrator: stub,
}
pdf, _, err := engine.Generate(req)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(pdf) == 0 {
t.Fatal("expected non-empty PDF after AI fallback")
}
}
// TestEngineGenerate_PriorPeriodQueriedWhenAvailable verifies that when
// historical data exists for the comparable prior window, the engine
// supplies it to the narrator so deltas can be expressed.
func TestEngineGenerate_PriorPeriodQueriedWhenAvailable(t *testing.T) {
store := newReportingMetricsStore(t)
defer store.Close()
now := time.Now()
nodeID := "node-4"
// Populate two adjacent one-hour windows so the prior-period query
// finds data.
for i := 0; i < 12; i++ {
ts := now.Add(time.Duration(-120+i*5) * time.Minute)
store.Write("node", nodeID, "cpu", 30.0, ts)
}
for i := 0; i < 12; i++ {
ts := now.Add(time.Duration(-60+i*5) * time.Minute)
store.Write("node", nodeID, "cpu", 80.0, ts)
}
store.Flush()
stub := &capturingNarrator{
out: Narrative{HealthStatus: "WARNING", Observations: []NarrativeBullet{{Text: "x"}}, Recommendations: []string{"y"}},
}
engine := NewReportEngine(EngineConfig{MetricsStore: store})
req := MetricReportRequest{
ResourceType: "node",
ResourceID: nodeID,
Start: now.Add(-1 * time.Hour),
End: now.Add(time.Minute),
Format: FormatPDF,
Narrator: stub,
}
// Metrics writes are buffered; retry until the prior-period query
// surfaces data, mirroring the eventually-pattern used by the other
// integration tests in this package.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
stub.seen = NarrativeInput{}
if _, _, err := engine.Generate(req); err != nil {
t.Fatalf("Generate: %v", err)
}
if stub.seen.PriorPeriod != nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if stub.seen.PriorPeriod == nil {
t.Fatal("expected PriorPeriod to be passed to narrator")
}
if _, ok := stub.seen.PriorPeriod.MetricStats["cpu"]; !ok {
t.Fatalf("PriorPeriod.MetricStats missing cpu: %#v", stub.seen.PriorPeriod.MetricStats)
}
}
// stubFindingsProvider lets us assert the engine threads findings through.
type stubFindingsProvider struct {
findings []FindingSummary
called bool
}
func (s *stubFindingsProvider) FindingsForReport(_ context.Context, _ string, _ time.Time, _ time.Time) []FindingSummary {
s.called = true
return s.findings
}
func TestEngineGenerate_FindingsProviderInvoked(t *testing.T) {
store := newReportingMetricsStore(t)
defer store.Close()
now := time.Now()
nodeID := "node-5"
for i := 0; i < 6; i++ {
ts := now.Add(time.Duration(-30+i*5) * time.Minute)
store.Write("node", nodeID, "cpu", 50.0, ts)
}
store.Flush()
provider := &stubFindingsProvider{
findings: []FindingSummary{{Severity: "high", Title: "Patrol caught a thing"}},
}
stub := &capturingNarrator{out: Narrative{HealthStatus: "HEALTHY", Observations: []NarrativeBullet{{Text: "x"}}, Recommendations: []string{"y"}}}
engine := NewReportEngine(EngineConfig{MetricsStore: store})
req := MetricReportRequest{
ResourceType: "node",
ResourceID: nodeID,
Start: now.Add(-1 * time.Hour),
End: now.Add(time.Minute),
Format: FormatPDF,
Narrator: stub,
FindingsProvider: provider,
}
if _, _, err := engine.Generate(req); err != nil {
t.Fatalf("Generate: %v", err)
}
if !provider.called {
t.Fatal("FindingsProvider was not invoked")
}
if len(stub.seen.Findings) != 1 || stub.seen.Findings[0].Title != "Patrol caught a thing" {
t.Fatalf("Findings not threaded to narrator: %#v", stub.seen.Findings)
}
}
func TestEngineGenerate_CSVSkipsNarratorAndFindingsProvider(t *testing.T) {
store := newReportingMetricsStore(t)
defer store.Close()
now := time.Now()
nodeID := "node-csv-narrative-skip"
for i := 0; i < 6; i++ {
ts := now.Add(time.Duration(-30+i*5) * time.Minute)
store.Write("node", nodeID, "cpu", 50.0, ts)
}
store.Flush()
provider := &stubFindingsProvider{
findings: []FindingSummary{{Severity: "warning", Title: "Should not load"}},
}
stub := &capturingNarrator{out: Narrative{HealthStatus: "HEALTHY"}}
engine := NewReportEngine(EngineConfig{MetricsStore: store})
req := MetricReportRequest{
ResourceType: "node",
ResourceID: nodeID,
Start: now.Add(-1 * time.Hour),
End: now.Add(time.Minute),
Format: FormatCSV,
Narrator: stub,
FindingsProvider: provider,
}
csv, contentType, err := engine.Generate(req)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(csv) == 0 {
t.Fatal("expected non-empty CSV")
}
if contentType != "text/csv" {
t.Fatalf("contentType = %q, want text/csv", contentType)
}
if stub.called {
t.Fatal("CSV generation invoked narrator")
}
if provider.called {
t.Fatal("CSV generation invoked findings provider")
}
}
func TestEngineGenerateMulti_CSVSkipsFleetNarrator(t *testing.T) {
store := newReportingMetricsStore(t)
defer store.Close()
now := time.Now()
for _, nodeID := range []string{"node-csv-fleet-a", "node-csv-fleet-b"} {
for i := 0; i < 6; i++ {
ts := now.Add(time.Duration(-30+i*5) * time.Minute)
store.Write("node", nodeID, "cpu", 60.0, ts)
}
}
store.Flush()
stub := &capturingFleetNarrator{out: FleetNarrative{HealthStatus: "HEALTHY"}}
engine := NewReportEngine(EngineConfig{MetricsStore: store})
req := MultiReportRequest{
Title: "Fleet CSV narrative skip",
Start: now.Add(-1 * time.Hour),
End: now.Add(time.Minute),
Format: FormatCSV,
Resources: []MetricReportRequest{
{ResourceType: "node", ResourceID: "node-csv-fleet-a"},
{ResourceType: "node", ResourceID: "node-csv-fleet-b"},
},
FleetNarrator: stub,
}
csv, contentType, err := engine.GenerateMulti(req)
if err != nil {
t.Fatalf("GenerateMulti: %v", err)
}
if len(csv) == 0 {
t.Fatal("expected non-empty CSV")
}
if contentType != "text/csv" {
t.Fatalf("contentType = %q, want text/csv", contentType)
}
if stub.called {
t.Fatal("CSV generation invoked fleet narrator")
}
}
// TestEngineNarrativeFor_ReturnsStructuredNarrativeWithoutRendering
// verifies the non-rendering entry point used by Pulse Assistant tools:
// it must produce a Narrative grounded in the queried metrics without
// running the PDF or CSV generator.
func TestEngineNarrativeFor_ReturnsStructuredNarrativeWithoutRendering(t *testing.T) {
store := newReportingMetricsStore(t)
defer store.Close()
now := time.Now()
nodeID := "node-narrate-1"
for i := 0; i < 12; i++ {
ts := now.Add(time.Duration(-60+i*5) * time.Minute)
store.Write("node", nodeID, "cpu", 55.0, ts)
}
store.Flush()
engine := NewReportEngine(EngineConfig{MetricsStore: store})
req := MetricReportRequest{
ResourceType: "node",
ResourceID: nodeID,
Start: now.Add(-2 * time.Hour),
End: now.Add(time.Minute),
}
deadline := time.Now().Add(2 * time.Second)
var narrative *Narrative
for time.Now().Before(deadline) {
var err error
narrative, err = engine.NarrativeFor(req)
if err != nil {
t.Fatalf("NarrativeFor: %v", err)
}
if narrative != nil && len(narrative.Observations) > 0 {
break
}
time.Sleep(50 * time.Millisecond)
}
if narrative == nil {
t.Fatal("expected non-nil narrative")
}
if narrative.Source != NarrativeSourceHeuristic {
t.Errorf("Source = %q, want heuristic (no narrator supplied)", narrative.Source)
}
if len(narrative.Observations) == 0 {
t.Error("expected at least one observation from the heuristic narrator")
}
}
// TestEngineFleetNarrativeFor_ReturnsStructuredFleetNarrativeWithoutRendering
// is the multi-resource counterpart.
func TestEngineFleetNarrativeFor_ReturnsStructuredFleetNarrativeWithoutRendering(t *testing.T) {
store := newReportingMetricsStore(t)
defer store.Close()
now := time.Now()
for _, nodeID := range []string{"node-fleet-a", "node-fleet-b"} {
for i := 0; i < 6; i++ {
ts := now.Add(time.Duration(-30+i*5) * time.Minute)
store.Write("node", nodeID, "cpu", 60.0, ts)
}
}
store.Flush()
engine := NewReportEngine(EngineConfig{MetricsStore: store})
req := MultiReportRequest{
Title: "Fleet narrative test",
Start: now.Add(-1 * time.Hour),
End: now.Add(time.Minute),
Resources: []MetricReportRequest{
{ResourceType: "node", ResourceID: "node-fleet-a"},
{ResourceType: "node", ResourceID: "node-fleet-b"},
},
}
deadline := time.Now().Add(2 * time.Second)
var fleet *FleetNarrative
for time.Now().Before(deadline) {
var err error
fleet, err = engine.FleetNarrativeFor(req)
if err != nil {
t.Fatalf("FleetNarrativeFor: %v", err)
}
if fleet != nil && fleet.HealthStatus != "" {
break
}
time.Sleep(50 * time.Millisecond)
}
if fleet == nil {
t.Fatal("expected non-nil fleet narrative")
}
if fleet.Source != NarrativeSourceHeuristic {
t.Errorf("Source = %q, want heuristic", fleet.Source)
}
}
// TestEngineFleetNarrativeFor_NoResourcesReturnsError verifies the
// non-rendering fleet entry point matches GenerateMulti's error contract
// when zero resources are requested.
func TestEngineFleetNarrativeFor_NoResourcesReturnsError(t *testing.T) {
store := newReportingMetricsStore(t)
defer store.Close()
engine := NewReportEngine(EngineConfig{MetricsStore: store})
now := time.Now()
req := MultiReportRequest{
Start: now.Add(-1 * time.Hour),
End: now,
Resources: nil,
}
if _, err := engine.FleetNarrativeFor(req); err == nil {
t.Fatal("expected error when no resources are requested")
}
}
// TestEngineFleetNarrativeFor_NoMetricsStoreReturnsError covers the
// guard at the top of the entry point.
func TestEngineFleetNarrativeFor_NoMetricsStoreReturnsError(t *testing.T) {
engine := NewReportEngine(EngineConfig{})
if _, err := engine.FleetNarrativeFor(MultiReportRequest{}); err == nil {
t.Fatal("expected error when metrics store is nil")
}
}
type capturingNarrator struct {
out Narrative
err error
called bool
seen NarrativeInput
}
func (c *capturingNarrator) Narrate(_ context.Context, in NarrativeInput) (Narrative, error) {
c.called = true
c.seen = in
return c.out, c.err
}
type capturingFleetNarrator struct {
out FleetNarrative
err error
called bool
seen FleetNarrativeInput
}
func (c *capturingFleetNarrator) NarrateFleet(_ context.Context, in FleetNarrativeInput) (FleetNarrative, error) {
c.called = true
c.seen = in
return c.out, c.err
}
func newReportingMetricsStore(t *testing.T) *metrics.Store {
t.Helper()
dir := t.TempDir()
store, err := metrics.NewStore(metrics.StoreConfig{
DBPath: filepath.Join(dir, "metrics.db"),
WriteBufferSize: 10,
FlushInterval: 50 * time.Millisecond,
RetentionRaw: 24 * time.Hour,
RetentionMinute: 7 * 24 * time.Hour,
RetentionHourly: 30 * 24 * time.Hour,
RetentionDaily: 90 * 24 * time.Hour,
})
if err != nil {
t.Fatalf("failed to create metrics store: %v", err)
}
return store
}