Pulse/pkg/reporting/pdf_ux_test.go
rcourtman 9bcf767b1b fix(reporting): resolve report subjects through the canonical unified view and paginate the metric card grid
Two regressions surfaced by generating a real-mode agent report after a
backend restart:

- Report subject lookups used Monitor.GetUnifiedResources (the raw
  resource store), but the raw store's canonical IDs depend on per-boot
  ingest order for merged-source hosts: after a restart the same host
  resolved to a different agent-<hash> than the one the UI and
  /api/state advertise, so reports lost the resource name, availability,
  and metrics translation entirely. Subject enrichment now reads
  Monitor.UnifiedResourceSnapshot and MetricsTargetForResource resolves
  through GetUnifiedReadStateOrSnapshot first (raw store as fallback) -
  the same re-ingested registry every other read surface uses.
- The performance summary card grid positions cards absolutely and never
  paginated: an agent host reporting 8+ metric families walked off the
  page bottom, fought fpdf's auto page break, and scattered one orphan
  element per page (a 7-day delly report rendered 18 pages, ten of them
  near-blank). The grid now starts a new page before a row that will not
  fit; the same report renders 9 pages with intact cards.

Verified live: real-mode delly report shows name, 288 data points,
availability, charts, and correctly paginated cards. The underlying
canonical-ID instability (raw store vs re-ingested view, and the
resource_changes journal fragmenting across boot eras) is a separate
root issue tracked for its own fix.
2026-06-10 22:02:48 +01:00

512 lines
18 KiB
Go

package reporting
import (
"bytes"
"compress/zlib"
"fmt"
"io"
"regexp"
"strconv"
"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)
}
}
// TestFleetSummary_EmptyDataShowsNoData mirrors the single-resource
// empty-window guard for fleet reports. A fleet PDF whose every resource
// returned zero data points must not render the green HEALTHY card —
// "All systems operating normally" over no evidence is false assurance,
// which is the worst failure mode for a report whose job is to prove
// stability to a client.
func TestFleetSummary_EmptyDataShowsNoData(t *testing.T) {
now := time.Now()
multi := &MultiReportData{
Title: "Fleet",
Start: now.Add(-time.Hour),
End: now,
GeneratedAt: now,
Resources: []*ReportData{
{
ResourceID: "vm-aaaa",
ResourceType: "vm",
Summary: MetricSummary{ByMetric: map[string]MetricStats{}},
TotalPoints: 0,
},
{
ResourceID: "vm-bbbb",
ResourceType: "vm",
Summary: MetricSummary{ByMetric: map[string]MetricStats{}},
TotalPoints: 0,
},
},
}
text := renderFleetSummaryText(t, multi)
if !strings.Contains(text, "NO DATA") {
t.Errorf("expected 'NO DATA' card on empty fleet data, got:\n%s", text)
}
if strings.Contains(text, "All systems operating normally") {
t.Errorf("HEALTHY message should not appear on empty fleet data, got:\n%s", text)
}
}
// TestCoverPage_PrefersResourceName asserts the cover page and the page
// header lead with the human-readable resource name when enrichment
// resolved one. Canonical v6 resource IDs are opaque hashes
// (vm-7f8b2b6cd98c2089); a client reading a monthly report cannot map
// those to their machines.
func TestCoverPage_PrefersResourceName(t *testing.T) {
data := &ReportData{
Title: "Named",
ResourceType: "vm",
ResourceID: "vm-7f8b2b6cd98c2089",
Resource: &ResourceInfo{Name: "checkout-web-01", Status: "running"},
Start: time.Now().Add(-time.Hour),
End: time.Now(),
GeneratedAt: time.Now(),
Metrics: map[string][]MetricDataPoint{},
Summary: MetricSummary{ByMetric: map[string]MetricStats{}},
}
text := renderExecutiveSummaryText(t, data)
if !strings.Contains(text, "checkout-web-01") {
t.Errorf("expected resource name on cover, 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
// TestExecutiveSummary_AvailabilitySectionRendersUptime asserts the
// availability block renders the headline uptime number, outage detail,
// and the partial-observation disclosure. This is the number an MSP's
// client reads the report for.
func TestExecutiveSummary_AvailabilitySectionRendersUptime(t *testing.T) {
data := &ReportData{
Title: "Avail",
ResourceType: "vm",
ResourceID: "vm-1",
Start: time.Now().Add(-30 * 24 * time.Hour),
End: time.Now(),
GeneratedAt: time.Now(),
Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Count: 10}}},
TotalPoints: 10,
Availability: &AvailabilityInfo{
UptimePercent: 99.42,
ObservedPercent: 87.5,
TotalDowntime: 4 * time.Hour,
LongestOutage: 3 * time.Hour,
DownIncidents: 2,
},
}
text := renderExecutiveSummaryText(t, data)
if !strings.Contains(text, "Availability") {
t.Errorf("expected Availability section, got:\n%s", text)
}
if !strings.Contains(text, "99.42%") {
t.Errorf("expected uptime percentage, got:\n%s", text)
}
if !strings.Contains(text, "2 outages") || !strings.Contains(text, "4 hours total downtime") {
t.Errorf("expected outage detail, got:\n%s", text)
}
if !strings.Contains(text, "87.5% of this period") {
t.Errorf("expected partial-observation disclosure, got:\n%s", text)
}
}
// TestExecutiveSummary_AvailabilityOmittedWhenUnavailable asserts reports
// without a resource timeline render no availability section at all (no
// fabricated 100%).
func TestExecutiveSummary_AvailabilityOmittedWhenUnavailable(t *testing.T) {
data := &ReportData{
Title: "NoAvail",
ResourceType: "vm",
ResourceID: "vm-1",
Start: time.Now().Add(-time.Hour),
End: time.Now(),
GeneratedAt: time.Now(),
Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Count: 10}}},
TotalPoints: 10,
}
text := renderExecutiveSummaryText(t, data)
if strings.Contains(text, "Availability") {
t.Errorf("expected no availability section without data, got:\n%s", text)
}
}
// TestFleetSummary_UptimeColumn asserts the fleet table carries the
// per-resource uptime column, with a dash for unobserved resources.
func TestFleetSummary_UptimeColumn(t *testing.T) {
now := time.Now()
multi := &MultiReportData{
Title: "Fleet",
Start: now.Add(-30 * 24 * time.Hour),
End: now,
GeneratedAt: now,
Resources: []*ReportData{
{
ResourceID: "vm-a",
ResourceType: "vm",
Resource: &ResourceInfo{Name: "alpha", Status: "online"},
Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Count: 60}}},
TotalPoints: 60,
Availability: &AvailabilityInfo{UptimePercent: 99.95, ObservedPercent: 100, TotalDowntime: 20 * time.Minute, DownIncidents: 1},
},
{
ResourceID: "vm-b",
ResourceType: "vm",
Resource: &ResourceInfo{Name: "beta", Status: "online"},
Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Count: 60}}},
TotalPoints: 60,
},
},
}
text := renderFleetSummaryText(t, multi)
if !strings.Contains(text, "Uptime") {
t.Errorf("expected Uptime column header, got:\n%s", text)
}
if !strings.Contains(text, "99.95%") {
t.Errorf("expected uptime value for observed resource, got:\n%s", text)
}
}
// TestAvailabilityUptimeLabel_NeverOverstates pins the rounding clamp: a
// window with any downtime must not round up to a clean 100%.
func TestAvailabilityUptimeLabel_NeverOverstates(t *testing.T) {
if got := availabilityUptimeLabel(99.999); got != "99.99%" {
t.Fatalf("availabilityUptimeLabel(99.999) = %q, want 99.99%%", got)
}
if got := availabilityUptimeLabel(100); got != "100%" {
t.Fatalf("availabilityUptimeLabel(100) = %q, want 100%%", got)
}
if got := availabilityUptimeLabel(99.4249); got != "99.42%" {
t.Fatalf("availabilityUptimeLabel(99.4249) = %q, want 99.42%%", got)
}
}
// TestMetricFormatting_RateMetricsAreHumanReadable pins the display
// vocabulary for the rate metrics that previously rendered as raw keys
// with unformatted values ("diskread 880000.00") in client-facing
// reports.
func TestMetricFormatting_RateMetricsAreHumanReadable(t *testing.T) {
if got := GetMetricTypeDisplayName("diskread"); got != "Disk Read" {
t.Fatalf("GetMetricTypeDisplayName(diskread) = %q", got)
}
if got := GetMetricTypeDisplayName("netout"); got != "Network Out" {
t.Fatalf("GetMetricTypeDisplayName(netout) = %q", got)
}
if got := GetMetricUnit("diskwrite"); got != "bytes/s" {
t.Fatalf("GetMetricUnit(diskwrite) = %q", got)
}
if got := formatMetricValue(880000, "bytes/s"); got != "859.38 KiB/s" {
t.Fatalf("formatMetricValue(880000, bytes/s) = %q", got)
}
if got := formatMetricValue(22.1, "%"); got != "22.10%" {
t.Fatalf("formatMetricValue(22.1, %%) = %q", got)
}
// Byte units are self-describing; the old +unit concatenation
// produced "12.00 GiBbytes".
if got := formatMetricValue(12884901888, "bytes"); got != "12.00 GiB" {
t.Fatalf("formatMetricValue(12GiB, bytes) = %q", got)
}
}
// TestGenerateMulti_FlowsResourceBlocksOntoSharedPages asserts the fleet
// report no longer spends one near-empty A4 page per resource: six sparse
// resources must fit on a handful of pages, not eight.
func TestGenerateMulti_FlowsResourceBlocksOntoSharedPages(t *testing.T) {
now := time.Now()
multi := &MultiReportData{
Title: "Fleet",
Start: now.Add(-24 * time.Hour),
End: now,
GeneratedAt: now,
}
for i := 0; i < 6; i++ {
multi.Resources = append(multi.Resources, &ReportData{
ResourceID: fmt.Sprintf("vm-%d", i),
ResourceType: "vm",
Resource: &ResourceInfo{Name: fmt.Sprintf("guest-%02d", i), Status: "online"},
Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Max: 20, Count: 1}}},
TotalPoints: 1,
Availability: &AvailabilityInfo{UptimePercent: 100, ObservedPercent: 100},
})
}
gen := NewPDFGenerator()
out, err := gen.GenerateMulti(multi)
if err != nil {
t.Fatalf("GenerateMulti: %v", err)
}
text := extractPDFText(t, out)
pageTotal := regexp.MustCompile(`Page \d+ of (\d+)`).FindStringSubmatch(text)
if pageTotal == nil {
t.Fatalf("no page footer found in:\n%s", text)
}
if pages, _ := strconv.Atoi(pageTotal[1]); pages > 4 {
t.Fatalf("6 sparse resources rendered %d pages; blocks must flow onto shared pages", pages)
}
for i := 0; i < 6; i++ {
name := fmt.Sprintf("guest-%02d", i)
if !strings.Contains(text, name) {
t.Fatalf("resource %s missing from flowed detail pages:\n%s", name, text)
}
}
}
// TestSummarySection_PaginatesCardGridForManyMetrics pins the card grid's
// self-pagination. Agent hosts report 8+ metric families; the absolutely
// positioned grid previously walked off the page bottom and fought fpdf's
// auto page break, scattering one orphan element per page for the rest of
// the section (observed live as ten near-blank pages).
func TestSummarySection_PaginatesCardGridForManyMetrics(t *testing.T) {
byMetric := map[string]MetricStats{}
for _, m := range []string{"cpu", "memory", "disk", "diskread", "diskwrite", "netin", "netout", "temperature", "iops", "usage"} {
byMetric[m] = MetricStats{Min: 1, Max: 9, Avg: 5, Current: 5, Count: 100}
}
data := &ReportData{
Title: "Many metrics",
ResourceType: "agent",
ResourceID: "agent-1",
Resource: &ResourceInfo{Name: "host-01", Status: "online"},
Start: time.Now().Add(-7 * 24 * time.Hour),
End: time.Now(),
GeneratedAt: time.Now(),
Summary: MetricSummary{ByMetric: byMetric},
TotalPoints: 1000,
}
gen := NewPDFGenerator()
out, err := gen.Generate(data)
if err != nil {
t.Fatalf("Generate: %v", err)
}
text := extractPDFText(t, out)
pageTotal := regexp.MustCompile(`Page \d+ of (\d+)`).FindStringSubmatch(text)
if pageTotal == nil {
t.Fatalf("no page footer found in:\n%s", text)
}
if pages, _ := strconv.Atoi(pageTotal[1]); pages > 6 {
t.Fatalf("10-metric report rendered %d pages; the card grid must paginate compactly", pages)
}
for _, label := range []string{"Disk Read", "Network Out", "Temperature"} {
if !strings.Contains(text, label) {
t.Fatalf("metric card %q missing after pagination:\n%s", label, text)
}
}
}