Files
rcourtman ff97e3943c test(reporting): slice PDF streams by declared /Length in text extraction
TestSummarySection_PaginatesCardGridForManyMetrics failed once on CI
(run 31058903048) with metric card "Disk Read" missing and all of page
2 absent from the extracted text. The card-grid pagination is fine; the
bug is in the test helper. Its stream regexp
(?s)stream\r?\n(.*?)\r?\nendstream let the optional \r consume the final
byte of the compressed payload whenever that byte (the last Adler-32
checksum byte) happened to be 0x0D. The truncated stream fails to
inflate and the helper silently skipped the entire page, roughly 1 in
256 streams. fpdf's compressed bytes are a pure function of the report
text, which embeds the report period, so specific date windows fail
deterministically. The CI window (period Jul 30 00:23 to Aug 6 00:23
UTC) reproduces on the first render while neighbouring windows pass,
which is why the test passed locally and on the previous run.

Slice each stream by the /Length declared in its object dictionary the
way a real PDF reader does. fpdf always writes /Length as a direct
integer, and compressed bytes can contain any sequence, so keyword
scanning can never be exact. Verified against the pinned CI window plus
400 shifted windows, and the full package.
2026-08-06 09:51:01 +01:00

540 lines
19 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.
//
// Each stream is sliced by the /Length declared in its object
// dictionary, the way a real PDF reader does — never by scanning ahead
// for the "endstream" keyword, because compressed bytes may contain any
// byte sequence. An earlier version matched `(.*?)\r?\nendstream` and
// the optional \r swallowed the stream's final Adler-32 checksum byte
// whenever it happened to be 0x0D (~1 in 256 streams), making the
// inflate fail and silently dropping that page's text. The compressed
// bytes are a pure function of the report text, which embeds the report
// period, so the drop was deterministic for specific date windows and
// surfaced as a date-dependent CI flake.
var streamStartRe = regexp.MustCompile(`\nstream\r?\n`)
var streamLengthRe = regexp.MustCompile(`/Length (\d+)`)
var literalRe = regexp.MustCompile(`\(([^()\\]*(?:\\.[^()\\]*)*)\)`)
func extractPDFText(t *testing.T, data []byte) string {
t.Helper()
var out bytes.Buffer
for _, loc := range streamStartRe.FindAllIndex(data, -1) {
// The object dictionary immediately precedes the stream keyword;
// its /Length is always a direct integer with fpdf. Take the
// last match in the window so a preceding object's dictionary
// can't shadow this stream's own.
dictStart := loc[0] - 400
if dictStart < 0 {
dictStart = 0
}
lengths := streamLengthRe.FindAllSubmatch(data[dictStart:loc[0]], -1)
if len(lengths) == 0 {
continue
}
n, err := strconv.Atoi(string(lengths[len(lengths)-1][1]))
if err != nil || loc[1]+n > len(data) {
continue
}
decoded, err := inflateStream(data[loc[1] : loc[1]+n])
if err != nil {
// Stream may not be Flate'd (e.g. XMP metadata) — 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)
}
}
}