package tui
import (
"regexp"
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/muesli/termenv"
)
// plain is what the reader sees with the colour taken off, which is what most
// of these assertions are about: the shape of the text, not its palette.
func plain(s string) string { return ansi.Strip(s) }
// A heading is markup, not content: the hashes are an instruction to make the
// line a heading, and printing them back means the instruction was ignored.
func TestMarkdownHeadingLosesItsHashes(t *testing.T) {
got := plain(renderMarkdown("# Overview", 60))
if !strings.Contains(got, "Overview") {
t.Errorf("heading text missing from %q", got)
}
if strings.Contains(got, "#") {
t.Errorf("heading still carries its hashes: %q", got)
}
}
// fgSeq is the SGR parameters lipgloss emits for a foreground colour — the
// "38;2;r;g;b" alone, without the brackets, because glamour packs bold into
// the same escape and an assertion on the whole sequence would be an
// assertion about that packing rather than about the colour.
func fgSeq(c lipgloss.AdaptiveColor) string {
open, _, _ := strings.Cut(lipgloss.NewStyle().Foreground(c).Render("x"), "x")
return strings.TrimSuffix(strings.TrimPrefix(open, "\x1b["), "m")
}
// Markdown is styled from the theme in force, so switching theme repaints it
// with everything else rather than leaving glamour's own colours on screen.
func TestMarkdownFollowsTheTheme(t *testing.T) {
forceColour(t)
t.Cleanup(func() { applyTheme(defaultTheme) })
for _, name := range []string{"ember", "mint", "iris"} {
applyTheme(name)
got := renderMarkdown("# Overview", 60)
if want := fgSeq(lookupTheme(name).accent); !strings.Contains(got, want) {
t.Errorf("%s: heading is not in the theme's accent\n got %q\nwant %q inside", name, got, want)
}
}
}
// sampleMarkdown is the shape of an ordinary assistant answer: a heading, a
// sentence with weight in it, a list, a table and a fenced block.
const sampleMarkdown = "# Findings\n\n" +
"The **refresh** path drops the token when `rotate` is set, which is why\n" +
"the login dies on the second day.\n\n" +
"## What to change\n\n" +
"- Write the replacement back to disk\n" +
"- Keep the old one until the write lands\n" +
" - and fsync before returning\n\n" +
"| File | Change |\n| --- | --- |\n| refresh.go | write-back |\n| config.go | none |\n\n" +
"```go\nfunc refresh(tok string) error {\n\treturn nil // TODO\n}\n```\n\n" +
"> Worth a second pair of eyes.\n"
// Trailing spaces are invisible until you select the text, and then they are
// a ragged right edge on every line. glamour pads to the wrap width; the
// transcript should not carry that padding into the scrollback.
func TestMarkdownLeavesNoTrailingSpaces(t *testing.T) {
forceColour(t)
for _, line := range strings.Split(renderMarkdown(sampleMarkdown, 60), "\n") {
if bare := plain(line); bare != strings.TrimRight(bare, " \t") {
t.Errorf("line padded with trailing whitespace: %q", bare)
}
}
}
// The transcript hands Markdown the columns it has. Anything wider wraps into
// the viewport's own wrapping, which turns one over-long table row into two
// rows of rubble.
func TestMarkdownFitsTheWidth(t *testing.T) {
forceColour(t)
for _, width := range []int{20, 34, 60, 100} {
for _, line := range strings.Split(renderMarkdown(sampleMarkdown, width), "\n") {
if w := lipgloss.Width(line); w > width {
t.Errorf("width %d: line is %d cells: %q", width, w, plain(line))
}
}
}
}
// A URL is one word as far as word wrapping is concerned, so it runs off the
// end of a narrow terminal and the viewport wraps it again at a column the
// layout knows nothing about. Assistant answers are full of URLs and paths.
func TestMarkdownBreaksAnUnbreakableWord(t *testing.T) {
forceColour(t)
src := "See https://example.com/a/very/long/url/that/cannot/be/broken/anywhere for more.\n"
for _, width := range []int{20, 30, 40} {
for _, line := range strings.Split(renderMarkdown(src, width), "\n") {
if w := lipgloss.Width(line); w > width {
t.Errorf("width %d: line is %d cells: %q", width, w, plain(line))
}
}
}
}
// A fenced block with a language on it is highlighted, in this theme's
// colours rather than chroma's own, so code in the transcript looks like it
// belongs to the same program as the rest of the screen.
func TestMarkdownHighlightsFencedCode(t *testing.T) {
forceColour(t)
got := renderMarkdown("```go\nfunc main() { return }\n```\n", 60)
if !strings.Contains(got, fgSeq(currentTheme.accent)) {
t.Errorf("keywords are not in the theme's accent:\n%q", got)
}
if !strings.Contains(plain(got), "func main()") {
t.Errorf("code text missing:\n%q", plain(got))
}
}
// An unlabelled fence has no language to highlight, and guessing one is how a
// block of log output ends up half-coloured. It stays plain.
func TestMarkdownLeavesUnlabelledFencePlain(t *testing.T) {
forceColour(t)
got := renderMarkdown("```\nplain block\n```\n", 60)
if !strings.Contains(plain(got), "plain block") {
t.Errorf("code text missing:\n%q", plain(got))
}
if strings.Contains(got, fgSeq(currentTheme.accent)) {
t.Errorf("an unlabelled block was highlighted anyway:\n%q", got)
}
}
// Code blocks are painted by chroma, which keeps one global registry of
// palettes keyed by name. Sharing a name across themes is how a /setup theme
// change repaints everything on screen except the code.
func TestMarkdownRepaintsCodeOnAThemeChange(t *testing.T) {
forceColour(t)
t.Cleanup(func() { applyTheme(defaultTheme) })
const src = "```go\nfunc main() { return }\n```\n"
applyTheme("ember")
ember := renderMarkdown(src, 60)
applyTheme("iris")
iris := renderMarkdown(src, 60)
if ember == iris {
t.Error("code block kept the old theme's syntax colours")
}
if !strings.Contains(iris, fgSeq(lookupTheme("iris").accent)) {
t.Errorf("keywords are not in iris's accent:\n%q", iris)
}
}
// A terminal that cannot show colour must not be sent colour. glamour guards
// its own stylesheet against this; a chroma theme named directly is not
// guarded, and would paint code blocks under NO_COLOR regardless.
//
// Weight and italics do survive, which is what lipgloss does for every other
// style in this UI at the same profile: NO_COLOR is about colour.
func TestMarkdownEmitsNoColourOnAColourlessTerminal(t *testing.T) {
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.Ascii)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
got := renderMarkdown(sampleMarkdown, 60)
for _, seq := range []string{"38;", "48;"} {
if strings.Contains(got, seq) {
t.Errorf("colour %q emitted on a colourless terminal:\n%q", seq, got)
}
}
if !strings.Contains(got, "func refresh") {
t.Errorf("code block content missing:\n%s", plain(got))
}
}
// Half a fence is what every code block looks like while it streams. goldmark
// reads the unterminated one as a block running to the end, which is the right
// guess, and nothing here may drop the text on the floor.
func TestMarkdownHandlesPartialInput(t *testing.T) {
forceColour(t)
cases := []struct {
name, src string
want []string
}{
{"unterminated fence", "Here:\n\n```go\nfunc main() {", []string{"Here:", "func main() {"}},
{"half an emphasis", "The **refresh", []string{"The", "refresh"}},
{"a table mid-row", "| File | Change |\n| --- | --- |\n| refresh.go |", []string{"File", "Change", "refresh.go"}},
// A lone hash is an empty heading, and a lone fence an empty block:
// both render to nothing until their content arrives a token later.
// What matters is that neither takes the renderer down.
{"a bare hash", "#", nil},
{"a fence with no body yet", "```", nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := plain(renderMarkdown(c.src, 60))
for _, want := range c.want {
if !strings.Contains(got, want) {
t.Errorf("lost %q from the stream:\n%s", want, got)
}
}
})
}
}
// Below a certain width there is no layout worth attempting, and a table or an
// indented code block becomes noise. Plain wrapped text is the useful answer.
func TestMarkdownFallsBackOnANarrowTerminal(t *testing.T) {
forceColour(t)
got := plain(renderMarkdown("# Findings\n\nThe refresh path drops the token.", 8))
if !strings.Contains(got, "refresh") {
t.Errorf("text missing at a narrow width:\n%s", got)
}
for _, line := range strings.Split(got, "\n") {
if w := lipgloss.Width(line); w > 8 {
t.Errorf("line is %d cells on an 8-column terminal: %q", w, line)
}
}
}
// Empty text is what an assistant entry holds for the instant between the turn
// starting and the first token landing.
func TestMarkdownRendersNothingForEmptyText(t *testing.T) {
for _, src := range []string{"", "\n\n", " "} {
if got := renderMarkdown(src, 60); got != "" {
t.Errorf("renderMarkdown(%q) = %q, want empty", src, got)
}
}
}
// Every theme has to produce a complete stylesheet. A missing role would
// render as an unstyled patch rather than as an obviously wrong colour, which
// is the kind of bug that ships.
func TestEveryThemeStylesMarkdown(t *testing.T) {
forceColour(t)
t.Cleanup(func() { applyTheme(defaultTheme) })
for _, name := range themeNames() {
applyTheme(name)
got := renderMarkdown(sampleMarkdown, 60)
if strings.TrimSpace(plain(got)) == "" {
t.Errorf("%s: rendered nothing", name)
}
if got == plain(got) {
t.Errorf("%s: rendered without any colour at all", name)
}
}
}
// Trimming the padding off a line means cutting a string that ends in an
// escape. A line left with its colour still open bleeds that colour across
// everything the viewport draws after it.
func TestMarkdownClosesEveryStyledLine(t *testing.T) {
forceColour(t)
src := sampleMarkdown + "\nA line with a very long unbreakable https://example.com/x/y/z/aaaaaaaaaaaaaaaaaaaaaaaaaaaa tail.\n"
for _, line := range strings.Split(renderMarkdown(src, 40), "\n") {
if strings.Contains(line, "\x1b[") && !strings.HasSuffix(line, "\x1b[0m") {
t.Errorf("styled line left open: %q", line)
}
}
}
// glamour emits one escape sequence per cell of the padding it lays down, so
// trimming that padding by width alone leaves a tail of dozens of escapes
// painting nothing. They cost the scrollback more bytes than the text does.
func TestMarkdownLeavesNoDanglingEscapes(t *testing.T) {
forceColour(t)
dangling := regexp.MustCompile(`\x1b\[[0-9;]*m\x1b\[[0-9;]*m$`)
rendered := renderMarkdown(sampleMarkdown, 60)
for _, line := range strings.Split(rendered, "\n") {
if dangling.MatchString(line) {
t.Errorf("line ends in escapes that paint nothing: %q", line)
}
}
if visible := len(plain(rendered)); len(rendered) > 8*visible {
t.Errorf("%d bytes of escapes for %d bytes of text", len(rendered)-visible, visible)
}
}
// A rule divides the page, so it is as wide as the page. A fixed-length stub
// reads as a stray dash rather than as a division.
func TestMarkdownRuleSpansTheWidth(t *testing.T) {
forceColour(t)
for _, width := range []int{30, 72} {
got := plain(renderMarkdown("above\n\n---\n\nbelow", width))
rule := ""
for _, line := range strings.Split(got, "\n") {
if strings.Contains(line, "─") {
rule = line
}
}
if lipgloss.Width(rule) != width {
t.Errorf("width %d: rule is %d cells: %q", width, lipgloss.Width(rule), rule)
}
}
}
// Most of what the model says is ordinary prose with no markup in it at all,
// and that must come out as the words it went in as — wrapped, and nothing
// else. This is the case the feature is least allowed to get wrong.
func TestMarkdownLeavesPlainProseAlone(t *testing.T) {
forceColour(t)
const prose = "The test fails because the window is zero, so compaction never runs " +
"and the conversation grows until the API refuses it. Two lines, one paragraph."
got := plain(renderMarkdown(prose, 50))
if strings.Join(strings.Fields(got), " ") != prose {
t.Errorf("prose came back changed:\n got %q\nwant %q", got, prose)
}
if len(strings.Split(got, "\n")) < 2 {
t.Errorf("prose was not wrapped to 50 columns:\n%s", got)
}
}
// Nesting is the structure of a list, so a sub-item has to sit inside its
// parent rather than beside it.
func TestMarkdownIndentsNestedLists(t *testing.T) {
forceColour(t)
got := plain(renderMarkdown("- outer\n - inner\n- second\n", 60))
var outer, inner int
for _, line := range strings.Split(got, "\n") {
switch {
case strings.Contains(line, "outer"):
outer = len(line) - len(strings.TrimLeft(line, " "))
case strings.Contains(line, "inner"):
inner = len(line) - len(strings.TrimLeft(line, " "))
}
}
if inner <= outer {
t.Errorf("nested item indented %d, parent %d:\n%s", inner, outer, got)
}
}