mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-12 15:28:12 +00:00
371a04ad43
Found by actually generating two PDFs against the dev server and holding them in hand — neither was visible by reading code alone. 1. HEALTHY on empty data was misleading. A report against a resource with zero data points and no alerts showed a green HEALTHY card with "All systems operating normally," contradicting the "Data Points: 0" line on the cover. A user reading the report would believe their resource was operating cleanly when really Pulse had no metrics to evaluate. writeExecutiveSummary now detects TotalPoints == 0 and len(Summary.ByMetric) == 0 and renders a muted grey "NO DATA / No metrics reported during the selected window" card instead. 2. AI discoverability gap. With AI unconfigured (or failing), the PDF is functionally identical to what it was before the AI narrative work landed — no AI prose, no period comparison, no provenance footer. A user has zero signal that AI-narrated reports are a separate Pulse Assistant capability. Adds a one-line muted tip at the end of the executive summary when Narrative.Source == NarrativeSourceHeuristic pointing at Settings. Fleet path gets the same nudge scoped to fleet synthesis. Mutually exclusive with the AI provenance disclaimer so we never show both. Tests in pdf_ux_test.go inflate FlateDecode'd content streams to substring-check the actual rendered text, covering empty-data -> NO DATA, quiet-with-data -> HEALTHY (regression guard), heuristic narrative -> tip, AI narrative -> disclaimer + no tip, and the fleet-heuristic tip.
230 lines
7.7 KiB
Go
230 lines
7.7 KiB
Go
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
|