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() }