Vectrum
Sign In
340 lines · 10.3 KB · Go
package tui

import (
	"encoding/json"
	"strings"
	"testing"

	"github.com/charmbracelet/lipgloss"
	"github.com/charmbracelet/x/ansi"
)

func TestAppendDeltaCoalesces(t *testing.T) {
	tr := newTranscript(80)
	tr.appendDelta("Hel")
	tr.appendDelta("lo ")
	tr.appendDelta("world")

	if len(tr.entries) != 1 {
		t.Fatalf("streamed text made %d entries, want 1", len(tr.entries))
	}
	if got := tr.entries[0].text; got != "Hello world" {
		t.Errorf("text = %q, want %q", got, "Hello world")
	}
}

// A tool call arriving mid-sentence must close the streaming entry rather than
// interleave with it: this is the bug the line renderer's midLine flag exists
// to prevent, and the transcript has to handle it structurally.
func TestToolCallClosesStreamingText(t *testing.T) {
	tr := newTranscript(80)
	tr.appendDelta("let me look\n")
	tr.startTool("grep", "pattern=foo")
	tr.appendDelta("found it")

	if len(tr.entries) != 3 {
		t.Fatalf("got %d entries, want 3", len(tr.entries))
	}
	if tr.entries[0].open {
		t.Error("first text entry still open after a tool call")
	}
	if tr.entries[0].text != "let me look" {
		t.Errorf("trailing newline not trimmed: %q", tr.entries[0].text)
	}
	if tr.entries[2].kind != entryAssistant {
		t.Error("text after a tool call should start a new assistant entry")
	}
}

func TestFinishToolMatchesMostRecentOpenCall(t *testing.T) {
	tr := newTranscript(80)
	tr.startTool("read_file", "path=a.go")
	tr.finishTool("read_file", "1: package a", false)
	tr.startTool("read_file", "path=b.go")

	tr.finishTool("read_file", "1: package b", false)

	if got := tr.entries[0].result; got != "1: package a" {
		t.Errorf("first call result overwritten: %q", got)
	}
	if got := tr.entries[1].result; got != "1: package b" {
		t.Errorf("second call result = %q", got)
	}
	for i, e := range tr.entries {
		if e.open {
			t.Errorf("entry %d still open", i)
		}
	}
}

// A result with no matching call still has to appear. Dropping it would hide
// evidence of what the agent actually did.
func TestFinishToolWithoutStartStillRecords(t *testing.T) {
	tr := newTranscript(80)
	tr.finishTool("bash", "boom", true)

	if len(tr.entries) != 1 {
		t.Fatalf("got %d entries, want 1", len(tr.entries))
	}
	if !tr.entries[0].isError || tr.entries[0].result != "boom" {
		t.Errorf("orphan result not recorded: %+v", tr.entries[0])
	}
}

func TestRenderCollapsesLongOutputAndVerboseExpands(t *testing.T) {
	long := strings.Repeat("line\n", 40)
	tr := newTranscript(100)
	tr.startTool("bash", "command=go test ./...")
	tr.finishTool("bash", long, false)

	out := tr.render()
	if !strings.Contains(out, "more lines") {
		t.Error("long output was not collapsed")
	}

	tr.setVerbose(true)
	if got := tr.render(); strings.Contains(got, "more lines") {
		t.Error("verbose mode still collapsed the output")
	}
}

func TestRenderCachesUntilDirty(t *testing.T) {
	tr := newTranscript(80)
	tr.addUser("hello")
	first := tr.render()

	if tr.dirty {
		t.Fatal("still dirty after render")
	}
	tr.addUser("again")
	if !tr.dirty {
		t.Fatal("adding an entry did not invalidate the cache")
	}
	if second := tr.render(); second == first {
		t.Error("render returned stale content after a new entry")
	}
}

func TestSetWidthInvalidatesCache(t *testing.T) {
	tr := newTranscript(80)
	tr.addUser("hello")
	tr.render()

	tr.setWidth(80) // unchanged: must not dirty
	if tr.dirty {
		t.Error("setting the same width invalidated the cache")
	}
	tr.setWidth(40)
	if !tr.dirty {
		t.Error("a width change did not invalidate the cache")
	}
}

func TestDiffIsRenderedAsDiff(t *testing.T) {
	diff := "--- a/x.go\n+++ b/x.go\n@@ -1 +1 @@\n-old\n+new\n"
	if !looksLikeDiff(diff) {
		t.Fatal("looksLikeDiff rejected a unified diff")
	}
	if looksLikeDiff("just a message") {
		t.Error("looksLikeDiff accepted plain text")
	}

	tr := newTranscript(80)
	tr.startTool("edit_file", "path=x.go")
	tr.finishTool("edit_file", diff, false)
	out := tr.render()

	for _, want := range []string{"-old", "+new", "@@ -1 +1 @@"} {
		if !strings.Contains(out, want) {
			t.Errorf("diff line %q missing from render", want)
		}
	}
	// The ⎿ prefix belongs to summarised output, not to a diff.
	if strings.Contains(out, "⎿") {
		t.Error("diff was rendered through the summary path")
	}
}

func TestSummarizeInputIsSorted(t *testing.T) {
	in := json.RawMessage(`{"path":"x.go","limit":10,"offset":2}`)
	if got, want := summarizeInput(in), "limit=10, offset=2, path=x.go"; got != want {
		t.Errorf("summarizeInput = %q, want %q", got, want)
	}
}

func TestSummarizeInputFlattensNewlines(t *testing.T) {
	in := json.RawMessage(`{"command":"go build\ngo test"}`)
	if got := summarizeInput(in); strings.Contains(got, "\n") {
		t.Errorf("newline survived into the header line: %q", got)
	}
}

func TestSummarizeInputHandlesGarbage(t *testing.T) {
	if got := summarizeInput(json.RawMessage(`not json`)); got != "not json" {
		t.Errorf("garbage input = %q, want it passed through", got)
	}
	if got := summarizeInput(nil); got != "" {
		t.Errorf("nil input = %q, want empty", got)
	}
}

func TestTruncateCountsRunesNotBytes(t *testing.T) {
	// Slicing this by bytes would produce invalid UTF-8.
	if got := truncate("日本語テキスト", 4); len([]rune(got)) != 4 {
		t.Errorf("truncate returned %d runes, want 4 (%q)", len([]rune(got)), got)
	}
	if got := truncate("short", 50); got != "short" {
		t.Errorf("truncate shortened a short string: %q", got)
	}
	if got := truncate("abc", 0); got != "" {
		t.Errorf("truncate(_, 0) = %q, want empty", got)
	}
}

// An approval gates a call before it runs, so it must render with the call
// rather than after the result that came back later.
func TestAnnotateToolAttachesToTheGatedCall(t *testing.T) {
	tr := newTranscript(80)
	tr.startTool("bash", "command=go build ./...")
	tr.annotateTool("bash", "approved once")
	tr.finishTool("bash", "ok", false)

	if len(tr.entries) != 1 {
		t.Fatalf("got %d entries, want the note folded into the call", len(tr.entries))
	}
	if tr.entries[0].note != "approved once" {
		t.Errorf("note = %q", tr.entries[0].note)
	}

	out := tr.render()
	note, result := strings.Index(out, "approved once"), strings.Index(out, "ok")
	if note < 0 || result < 0 {
		t.Fatalf("note or result missing from render:\n%s", out)
	}
	if note > result {
		t.Error("the approval rendered after the result it gated")
	}
}

func TestAnnotateToolWithoutOpenCallFallsBackToANotice(t *testing.T) {
	tr := newTranscript(80)
	tr.annotateTool("bash", "denied")

	if len(tr.entries) != 1 || tr.entries[0].kind != entryNotice {
		t.Fatalf("orphan note not recorded: %+v", tr.entries)
	}
}

// Styled text measured by rune count treats every escape byte as a visible
// cell, which silently eats the end of a status line.
func TestTruncateStyledMeasuresDisplayWidth(t *testing.T) {
	styled := styDim.Render("ready") + " · " + styCoral.Render("$0.0241")
	plain := "ready · $0.0241"

	// Compare display width against display width: len() would count the
	// two-byte · as two cells and make this assertion wrong rather than strict.
	if got, want := lipgloss.Width(truncateStyled(styled, 40)), lipgloss.Width(plain); got != want {
		t.Errorf("width %d, want %d — styled text was cut though it fits", got, want)
	}
	if got := truncateStyled(styled, 40); !strings.Contains(got, "$0.0241") {
		t.Errorf("cost was truncated away though it fits: %q", got)
	}
	if got := truncateStyled(styled, 8); lipgloss.Width(got) > 8 {
		t.Errorf("width %d exceeds the limit of 8", lipgloss.Width(got))
	}
	if got := truncateStyled(styled, 0); got != "" {
		t.Errorf("zero width = %q, want empty", got)
	}
}

func TestRenderNarrowWidthDoesNotPanic(t *testing.T) {
	tr := newTranscript(1)
	tr.addUser("a reasonably long line that must wrap somewhere")
	tr.startTool("bash", "command=echo hi")
	tr.finishTool("bash", "hi", false)
	tr.addError("boom")
	_ = tr.render()
}

// Assistant text is Markdown and the transcript renders it as such. The dot
// gutter survives it: a heading is still one event hanging off one dot, with
// everything under it indented to match.
func TestAssistantTextRendersAsMarkdown(t *testing.T) {
	tr := newTranscript(60)
	tr.appendDelta("# Findings\n\nThe **refresh** path drops the token.\n")
	tr.closeOpen()

	out := ansi.Strip(tr.render())
	for _, markup := range []string{"#", "**"} {
		if strings.Contains(out, markup) {
			t.Errorf("markup %q printed rather than rendered:\n%s", markup, out)
		}
	}
	lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
	if !strings.HasPrefix(lines[0], dot+" ") {
		t.Errorf("first line lost its dot: %q", lines[0])
	}
	for _, line := range lines[1:] {
		if strings.TrimSpace(line) != "" && !strings.HasPrefix(line, gutter) {
			t.Errorf("continuation line not indented under the dot: %q", line)
		}
	}
}

// A rendered entry is cached, so the three things that change what it should
// look like all have to throw that cache away. Each of these is a way the
// scrollback could show something that was true one frame ago.
func TestRenderedMarkdownIsInvalidated(t *testing.T) {
	t.Run("by more text arriving", func(t *testing.T) {
		tr := newTranscript(60)
		tr.appendDelta("The **refresh** path")
		_ = tr.render()
		tr.appendDelta(" drops the token.")

		if got := ansi.Strip(tr.render()); !strings.Contains(got, "drops the token") {
			t.Errorf("streamed text missing after a re-render:\n%s", got)
		}
	})

	t.Run("by a resize", func(t *testing.T) {
		tr := newTranscript(100)
		tr.appendDelta(strings.Repeat("word ", 40))
		tr.closeOpen()
		wide := len(strings.Split(tr.render(), "\n"))

		tr.setWidth(40)
		if narrow := len(strings.Split(tr.render(), "\n")); narrow <= wide {
			t.Errorf("narrowing the terminal did not rewrap: %d lines at 100, %d at 40", wide, narrow)
		}
	})

	t.Run("by a theme change", func(t *testing.T) {
		forceColour(t)
		t.Cleanup(func() { applyTheme(defaultTheme) })

		tr := newTranscript(60)
		tr.appendDelta("# Findings")
		tr.closeOpen()
		applyTheme("ember")
		ember := tr.render()
		applyTheme("iris")

		if iris := tr.render(); iris == ember {
			t.Error("the transcript kept the old theme's colours after /setup changed it")
		}
	})
}

// Markdown puts blank lines between blocks, and the gutter must not turn them
// into lines of trailing whitespace.
func TestBlankLinesCarryNoGutter(t *testing.T) {
	tr := newTranscript(60)
	tr.appendDelta("# Findings\n\nThe refresh path drops the token.")
	tr.closeOpen()

	for _, line := range strings.Split(ansi.Strip(tr.render()), "\n") {
		if strings.TrimSpace(line) == "" && line != "" {
			t.Errorf("blank line padded to %q", line)
		}
	}
}