package reporting import ( "bytes" "compress/zlib" "io" "regexp" "strings" "testing" "time" "github.com/go-pdf/fpdf" ) // TestExecutiveSummary_EmptyDataShowsNoData asserts that when a report // runs against a window that produced zero data points and no alerts, // the executive summary renders a muted "NO DATA" card rather than the // green HEALTHY card it shipped with originally. This was a real UX // bug found by generating an actual PDF: a user looking at an // empty-window report would see "All systems operating normally" and // believe their resource was fine when really Pulse had no metrics to // evaluate. func TestExecutiveSummary_EmptyDataShowsNoData(t *testing.T) { data := &ReportData{ Title: "Empty", ResourceType: "node", ResourceID: "empty", Start: time.Now().Add(-time.Hour), End: time.Now(), GeneratedAt: time.Now(), Metrics: map[string][]MetricDataPoint{}, Summary: MetricSummary{ByMetric: map[string]MetricStats{}}, TotalPoints: 0, } text := renderExecutiveSummaryText(t, data) if !strings.Contains(text, "NO DATA") { t.Errorf("expected 'NO DATA' card on empty data, got:\n%s", text) } if strings.Contains(text, "All systems operating normally") { t.Errorf("HEALTHY message should not appear on empty data, got:\n%s", text) } } // TestExecutiveSummary_HealthyWhenDataPresent_NoAlerts confirms the // HEALTHY path still works when there IS data and no alerts. This is // the regression guard: the empty-data fix above must not change the // behavior for actually-quiet resources. func TestExecutiveSummary_HealthyWhenDataPresent_NoAlerts(t *testing.T) { data := &ReportData{ Title: "Quiet", ResourceType: "node", ResourceID: "quiet", Start: time.Now().Add(-time.Hour), End: time.Now(), GeneratedAt: time.Now(), Summary: MetricSummary{ByMetric: map[string]MetricStats{ "cpu": {Avg: 5, Max: 12, Count: 60}, "memory": {Avg: 30, Max: 35, Count: 60}, }}, TotalPoints: 120, } text := renderExecutiveSummaryText(t, data) if !strings.Contains(text, "HEALTHY") { t.Errorf("expected HEALTHY card on quiet resource, got:\n%s", text) } } // TestExecutiveSummary_HeuristicSourceShowsDiscoverabilityTip asserts // that when the narrative came from the heuristic narrator (no AI // configured or AI failed), the executive summary surfaces a one-line // tip pointing operators at Pulse Assistant. Without this nudge a // user has no signal that AI-narrated reports are a separate // capability they could enable. func TestExecutiveSummary_HeuristicSourceShowsDiscoverabilityTip(t *testing.T) { data := &ReportData{ Title: "Quiet", ResourceType: "node", ResourceID: "quiet", Start: time.Now().Add(-time.Hour), End: time.Now(), GeneratedAt: time.Now(), Summary: MetricSummary{ByMetric: map[string]MetricStats{ "cpu": {Avg: 10, Max: 20, Count: 60}, }}, TotalPoints: 60, Narrative: &Narrative{ Source: NarrativeSourceHeuristic, HealthStatus: "HEALTHY", HealthMessage: "OK", Observations: []NarrativeBullet{{Text: "Looks fine", Severity: NarrativeSeverityOK}}, }, } text := renderExecutiveSummaryText(t, data) if !strings.Contains(text, "Configure Pulse Assistant") { t.Errorf("expected discoverability tip on heuristic-source narrative, got:\n%s", text) } } // TestExecutiveSummary_AISourceDoesNotShowDiscoverabilityTip is the // converse: when AI actually fired, the disclaimer footer (which the // AI narrator populates) replaces the tip. Showing both would be // noisy and contradict the AI provenance line. func TestExecutiveSummary_AISourceDoesNotShowDiscoverabilityTip(t *testing.T) { data := &ReportData{ Title: "Quiet", ResourceType: "node", ResourceID: "quiet", Start: time.Now().Add(-time.Hour), End: time.Now(), GeneratedAt: time.Now(), Summary: MetricSummary{ByMetric: map[string]MetricStats{ "cpu": {Avg: 10, Max: 20, Count: 60}, }}, TotalPoints: 60, Narrative: &Narrative{ Source: NarrativeSourceAI, HealthStatus: "HEALTHY", HealthMessage: "OK", Observations: []NarrativeBullet{{Text: "AI prose here", Severity: NarrativeSeverityOK}}, Disclaimer: "Narrative generated by Pulse Assistant.", }, } text := renderExecutiveSummaryText(t, data) if strings.Contains(text, "Configure Pulse Assistant") { t.Errorf("discoverability tip should not appear when AI fired, got:\n%s", text) } if !strings.Contains(text, "Narrative generated by Pulse Assistant") { t.Errorf("expected AI provenance disclaimer when source is ai, got:\n%s", text) } } // TestFleetSummary_HeuristicSourceShowsDiscoverabilityTip mirrors the // single-resource test for the fleet path. The fleet narrative has a // distinct nudge (mentions outliers / patterns) so the copy doesn't // over-promise single-resource synthesis. func TestFleetSummary_HeuristicSourceShowsDiscoverabilityTip(t *testing.T) { now := time.Now() multi := &MultiReportData{ Title: "Fleet", Start: now.Add(-time.Hour), End: now, GeneratedAt: now, Resources: []*ReportData{ { ResourceID: "a", ResourceType: "node", Resource: &ResourceInfo{Name: "alpha", Status: "online"}, Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Max: 15, Count: 60}}}, TotalPoints: 60, }, }, FleetNarrative: &FleetNarrative{ Source: NarrativeSourceHeuristic, HealthStatus: "HEALTHY", HealthMessage: "Fleet quiet", }, } text := renderFleetSummaryText(t, multi) if !strings.Contains(text, "Configure Pulse Assistant") { t.Errorf("expected fleet discoverability tip on heuristic source, got:\n%s", text) } if !strings.Contains(text, "outliers") && !strings.Contains(text, "patterns") { t.Errorf("fleet tip should mention outliers/patterns, got:\n%s", text) } } // renderExecutiveSummaryText runs writeExecutiveSummary against a // fresh fpdf, extracts text by parsing the resulting PDF, and returns // the rendered text content. Used by the UX assertions above. func renderExecutiveSummaryText(t *testing.T, data *ReportData) string { t.Helper() gen := NewPDFGenerator() bytes, err := gen.Generate(data) if err != nil { t.Fatalf("Generate: %v", err) } return extractPDFText(t, bytes) } func renderFleetSummaryText(t *testing.T, data *MultiReportData) string { t.Helper() gen := NewPDFGenerator() bytes, err := gen.GenerateMulti(data) if err != nil { t.Fatalf("GenerateMulti: %v", err) } return extractPDFText(t, bytes) } // extractPDFText pulls plain text out of a PDF blob by finding every // FlateDecode'd content stream, inflating it, and harvesting the // parenthesised string literals used by Tj operators. fpdf always // compresses its content streams, so a substring scan over the raw // bytes misses everything visible to a reader. var streamRe = regexp.MustCompile(`(?s)stream\r?\n(.*?)\r?\nendstream`) var literalRe = regexp.MustCompile(`\(([^()\\]*(?:\\.[^()\\]*)*)\)`) func extractPDFText(t *testing.T, data []byte) string { t.Helper() var out bytes.Buffer for _, m := range streamRe.FindAllSubmatch(data, -1) { raw := m[1] decoded, err := inflateStream(raw) if err != nil { // Stream may not be Flate'd (e.g. xref tables) — skip. continue } for _, lit := range literalRe.FindAllSubmatch(decoded, -1) { out.Write(lit[1]) out.WriteByte(' ') } } return out.String() } func inflateStream(b []byte) ([]byte, error) { r, err := zlib.NewReader(bytes.NewReader(b)) if err != nil { return nil, err } defer r.Close() return io.ReadAll(r) } // Compile-time assertion the fpdf import isn't dropped by goimports // when this test file is processed in isolation; the import lives in // pdf.go but tests referencing fpdf.New constants confirm we still // resolve the package. var _ = fpdf.New