mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 07:54:10 +00:00
500 lines
15 KiB
Go
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
|
|
}
|