mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
3dc06bea71
Free-form strings entering the PDF generator (AI narrative prose, resource names, alert messages, brand display names) were written to fpdf core fonts as raw UTF-8, and the cp1252-decoding fonts rendered em dashes and curly quotes as mojibake. Generate and GenerateMulti now run every string field reachable from ReportData/MultiReportData through fpdf's cp1252 translator once before rendering, so write sites stay encoding-free. The translator is built per call: fpdf's closure reuses an internal buffer and the generator is shared across concurrent requests. Runes outside cp1252 degrade to '.'. Tests render AI-shaped narratives with em dashes and curly quotes for both the single-resource and fleet paths and assert the extracted content streams decode without mojibake.
127 lines
4.4 KiB
Go
127 lines
4.4 KiB
Go
package reporting
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// Free-form report strings arrive as UTF-8 but fpdf core fonts read cp1252
|
||
// bytes. These tests render narratives full of typographic punctuation (the
|
||
// exact characters AI narrators emit) plus a non-ASCII resource name, then
|
||
// assert the content streams carry cp1252 encodings rather than raw UTF-8
|
||
// byte sequences, which a PDF viewer displays as mojibake (an em dash
|
||
// becomes "—", an é becomes "é").
|
||
|
||
func TestGenerate_NarrativeUTF8RendersWithoutMojibake(t *testing.T) {
|
||
now := time.Now()
|
||
data := &ReportData{
|
||
Title: "Monthly Review — “Production”",
|
||
ResourceType: "vm",
|
||
ResourceID: "vm-7f8b2b6cd98c2089",
|
||
Resource: &ResourceInfo{Name: "café-web-01", Status: "running"},
|
||
Start: now.Add(-time.Hour),
|
||
End: now,
|
||
GeneratedAt: now,
|
||
Metrics: map[string][]MetricDataPoint{},
|
||
Summary: MetricSummary{ByMetric: map[string]MetricStats{
|
||
"cpu": {Avg: 10, Max: 20, Count: 60},
|
||
}},
|
||
TotalPoints: 60,
|
||
Narrative: &Narrative{
|
||
Source: NarrativeSourceAI,
|
||
HealthStatus: "HEALTHY",
|
||
HealthMessage: "Quiet period — no incidents recorded",
|
||
ExecutiveSummary: "Utilisation stayed “well within” limits — the host’s capacity is sufficient.",
|
||
Observations: []NarrativeBullet{{Text: "CPU averaged 10% — flat across the window", Severity: NarrativeSeverityOK}},
|
||
Recommendations: []string{"Keep monitoring — no changes required"},
|
||
Disclaimer: "Narrative generated by Pulse Assistant.",
|
||
},
|
||
Alerts: []AlertInfo{{
|
||
Type: "cpu",
|
||
Level: "warning",
|
||
Message: "CPU high — sustained above threshold",
|
||
StartTime: now.Add(-30 * time.Minute),
|
||
}},
|
||
}
|
||
assertNoMojibake(t, renderExecutiveSummaryText(t, data),
|
||
[]string{"—", "“", "”", "’", "café-web-01"})
|
||
}
|
||
|
||
func TestGenerateMulti_FleetNarrativeUTF8RendersWithoutMojibake(t *testing.T) {
|
||
now := time.Now()
|
||
multi := &MultiReportData{
|
||
Title: "Fleet Review — “June”",
|
||
Start: now.Add(-time.Hour),
|
||
End: now,
|
||
GeneratedAt: now,
|
||
Resources: []*ReportData{{
|
||
ResourceID: "vm-a",
|
||
ResourceType: "vm",
|
||
Resource: &ResourceInfo{Name: "café-web-01", Status: "running"},
|
||
Summary: MetricSummary{ByMetric: map[string]MetricStats{"cpu": {Avg: 10, Max: 15, Count: 60}}},
|
||
TotalPoints: 60,
|
||
}},
|
||
FleetNarrative: &FleetNarrative{
|
||
Source: NarrativeSourceAI,
|
||
HealthStatus: "HEALTHY",
|
||
HealthMessage: "Fleet stable — “no outliers” this period",
|
||
Outliers: []FleetOutlier{{
|
||
ResourceID: "vm-a",
|
||
ResourceName: "café-web-01",
|
||
Reason: "Memory averaging 91.2% — sustained pressure",
|
||
Severity: NarrativeSeverityWarning,
|
||
}},
|
||
Disclaimer: "Narrative generated by Pulse Assistant.",
|
||
},
|
||
}
|
||
assertNoMojibake(t, renderFleetSummaryText(t, multi),
|
||
[]string{"—", "“", "”", "café-web-01"})
|
||
}
|
||
|
||
// assertNoMojibake decodes the raw content-stream bytes the way a PDF
|
||
// viewer decodes core-font text (cp1252/WinAnsi) and asserts the
|
||
// typographic characters survived translation. Untranslated UTF-8 decodes
|
||
// to fragments led by "â€" (punctuation range) or "Ã" (latin-1 range).
|
||
func assertNoMojibake(t *testing.T, raw string, want []string) {
|
||
t.Helper()
|
||
rendered := decodeWinAnsi(raw)
|
||
for _, frag := range []string{"â€", "é"} {
|
||
if strings.Contains(rendered, frag) {
|
||
t.Errorf("rendered PDF text contains mojibake %q:\n%s", frag, rendered)
|
||
}
|
||
}
|
||
for _, w := range want {
|
||
if !strings.Contains(rendered, w) {
|
||
t.Errorf("rendered PDF text missing %q after cp1252 translation:\n%s", w, rendered)
|
||
}
|
||
}
|
||
}
|
||
|
||
// winAnsiSpecials covers the 0x80-0x9F block where cp1252 places the
|
||
// typographic punctuation that latin-1 lacks; every other byte maps to the
|
||
// identical Unicode code point.
|
||
var winAnsiSpecials = map[byte]rune{
|
||
0x80: '€', 0x82: '‚', 0x83: 'ƒ', 0x84: '„', 0x85: '…', 0x86: '†', 0x87: '‡',
|
||
0x88: 'ˆ', 0x89: '‰', 0x8A: 'Š', 0x8B: '‹', 0x8C: 'Œ', 0x8E: 'Ž',
|
||
0x91: '‘', 0x92: '’', 0x93: '“', 0x94: '”', 0x95: '•', 0x96: '–', 0x97: '—',
|
||
0x98: '˜', 0x99: '™', 0x9A: 'š', 0x9B: '›', 0x9C: 'œ', 0x9E: 'ž', 0x9F: 'Ÿ',
|
||
}
|
||
|
||
func decodeWinAnsi(s string) string {
|
||
var b strings.Builder
|
||
for i := 0; i < len(s); i++ {
|
||
c := s[i]
|
||
if c < 0x80 {
|
||
b.WriteByte(c)
|
||
continue
|
||
}
|
||
if r, ok := winAnsiSpecials[c]; ok {
|
||
b.WriteRune(r)
|
||
continue
|
||
}
|
||
b.WriteRune(rune(c))
|
||
}
|
||
return b.String()
|
||
}
|