Vectrum
Sign In
336 lines · 12.5 KB · Go
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)
	}
}