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