da72ee5b80
renderMarkdown wraps charmbracelet/glamour (dark style, per-width memoized) and is hooked into the router chat-turn render path; falls back to plain content on any glamour error so the TUI never crashes or loses text. Other roles/UI untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
95 lines
2.7 KiB
Go
95 lines
2.7 KiB
Go
package app
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// stripANSI removes CSI escape sequences so we can assert on the visible text
|
|
// glamour produced without depending on exact styling codes.
|
|
func stripANSI(s string) string {
|
|
var b strings.Builder
|
|
for i := 0; i < len(s); {
|
|
if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
|
|
j := i + 2
|
|
for j < len(s) && (s[j] < 0x40 || s[j] > 0x7e) {
|
|
j++
|
|
}
|
|
if j < len(s) {
|
|
j++ // consume the final byte
|
|
}
|
|
i = j
|
|
continue
|
|
}
|
|
b.WriteByte(s[i])
|
|
i++
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// Rich markdown should be transformed (output differs from raw input) while the
|
|
// literal words survive the round-trip.
|
|
func TestRenderMarkdownTransformsAndPreservesText(t *testing.T) {
|
|
in := "# Heading\n\nSome **bold** and *italic* text.\n\n- first item\n- second item\n\n`inline code` and:\n\n```go\nfmt.Println(\"hi\")\n```\n"
|
|
out := renderMarkdown(in, 80)
|
|
|
|
if out == in {
|
|
t.Fatalf("markdown input should be rendered, got identical output")
|
|
}
|
|
|
|
plain := stripANSI(out)
|
|
for _, want := range []string{"Heading", "bold", "italic", "first item", "second item", "inline code", "fmt.Println"} {
|
|
if !strings.Contains(plain, want) {
|
|
t.Errorf("rendered output missing %q\n--- visible ---\n%s", want, plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A plain, short string must round-trip to contain its text and not panic,
|
|
// regardless of how much (or how little) styling glamour adds.
|
|
func TestRenderMarkdownPlainStringRoundTrips(t *testing.T) {
|
|
in := "just a short plain sentence"
|
|
out := renderMarkdown(in, 80)
|
|
if !strings.Contains(stripANSI(out), in) {
|
|
t.Fatalf("plain string did not survive rendering: %q", stripANSI(out))
|
|
}
|
|
}
|
|
|
|
// Pathological / empty inputs must never panic and must return something
|
|
// containing the input text (trivially true for empty input).
|
|
func TestRenderMarkdownPathologicalDoesNotPanic(t *testing.T) {
|
|
cases := []string{
|
|
"",
|
|
" ",
|
|
"\n\n\n",
|
|
"```unterminated code fence\nstill going",
|
|
strings.Repeat("a", 10_000),
|
|
"[](()]][[**__",
|
|
}
|
|
for _, in := range cases {
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("renderMarkdown panicked on %q: %v", in, r)
|
|
}
|
|
}()
|
|
out := renderMarkdown(in, 40)
|
|
// Empty/whitespace-only inputs fall back to the original content.
|
|
if strings.TrimSpace(in) == "" {
|
|
if out != in {
|
|
t.Errorf("whitespace input %q should fall back to itself, got %q", in, out)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// A non-positive width must not crash and should fall back to the default
|
|
// wrap width rather than feeding glamour an invalid value.
|
|
func TestRenderMarkdownZeroWidthFallsBack(t *testing.T) {
|
|
out := renderMarkdown("**hello** world", 0)
|
|
if !strings.Contains(stripANSI(out), "hello") {
|
|
t.Fatalf("zero-width render dropped text: %q", stripANSI(out))
|
|
}
|
|
}
|