Vectrum
Sign In
311 lines · 10.0 KB · Go
package tui

import (
	"context"
	"strings"
	"testing"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"

	"codey/internal/approval"
	"codey/internal/llm"
)

func TestPrettyModel(t *testing.T) {
	cases := map[string]string{
		"claude-opus-5":             "Opus 5",
		"claude-sonnet-5":           "Sonnet 5",
		"claude-haiku-4-5-20251001": "Haiku 4.5",
		"claude-opus-5[1m]":         "Opus 5[1m]",
		"claude-sonnet-4-5-latest":  "Sonnet 4.5",
		"gpt-4o":                    "gpt-4o", // not ours: left alone rather than mangled
		"some/gateway/model":        "some/gateway/model",
		"":                          "",
	}
	for in, want := range cases {
		if got := prettyModel(in); got != want {
			t.Errorf("prettyModel(%q) = %q, want %q", in, got, want)
		}
	}
}

// The welcome block is laid out in columns, so a terminal too narrow to hold
// both must drop the mascot rather than slice it into noise.
func TestSplashDropsMascotWhenNarrow(t *testing.T) {
	o := Options{Client: llm.New(llm.WithAPIKey("x")), Root: "/tmp/repo", Version: "v0.6.0"}

	wide := splash(o, 100)
	if !strings.Contains(wide, mascot[2]) {
		t.Error("mascot missing at a comfortable width")
	}
	if !strings.Contains(wide, "/tmp/repo") {
		t.Error("root missing from the welcome block")
	}
	for _, line := range strings.Split(strings.TrimRight(wide, "\n"), "\n") {
		if w := lipgloss.Width(line); w > 100 {
			t.Errorf("welcome line is %d cells wide, over the 100 available: %q", w, line)
		}
	}

	// A deeply nested working directory is a long line, not a narrow
	// terminal: it gets truncated, and the mascot stays.
	deep := Options{Client: o.Client, Version: o.Version,
		Root: "/tmp/claude-1000/-home-joni-Work-GO-coding-harness/b520442d-29dd-4870/scratchpad/demo"}
	if !strings.Contains(splash(deep, 100), mascot[2]) {
		t.Error("a long root path dropped the mascot on a wide terminal")
	}

	narrow := splash(o, 24)
	if strings.Contains(narrow, mascot[2]) {
		t.Error("mascot survived into a terminal too narrow for it")
	}
	if !strings.Contains(narrow, "codey") {
		t.Error("narrow welcome block lost its name")
	}
}

func TestStatusShowsContextGauge(t *testing.T) {
	m := newTestModel(t)
	m.opts.ContextWindow = 200_000
	m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
	m.Update(evUsage{usage: llm.Usage{InputTokens: 2_000, CacheReadInputTokens: 18_000, OutputTokens: 500}})

	status := m.status()
	if !strings.Contains(status, "20.0k") {
		t.Errorf("context size missing from the status bar: %q", status)
	}
	if !strings.Contains(status, "(10%)") {
		t.Errorf("context percentage wrong or missing: %q", status)
	}

	// A second turn replaces the gauge rather than adding to it: the context
	// is how much the model read this time, not the sum of every turn.
	m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000, CacheReadInputTokens: 3_000}})
	if got := m.status(); !strings.Contains(got, "(2%)") {
		t.Errorf("gauge accumulated across turns instead of replacing: %q", got)
	}
}

func TestShiftTabCyclesApprovalMode(t *testing.T) {
	m := newTestModel(t)
	if strings.Contains(m.modeLine(), "accept-all") {
		t.Fatal("started in accept-all without being asked to")
	}

	m.Update(key("shift+tab"))
	if !m.opts.Policy.YOLO {
		t.Error("shift+tab did not turn accept-all on")
	}
	if !strings.Contains(m.modeLine(), "accept-all on") {
		t.Errorf("mode line does not report accept-all: %q", m.modeLine())
	}
	if !strings.Contains(m.tr.render(), "accept-all on") {
		t.Error("a change to what the agent may do was not recorded in the transcript")
	}

	m.Update(key("shift+tab"))
	if m.opts.Policy.YOLO {
		t.Error("shift+tab did not turn accept-all back off")
	}
}

func TestInputBoxGrowsWithContentAndStops(t *testing.T) {
	m := newTestModel(t)
	if got := m.input.Height(); got != 1 {
		t.Errorf("empty prompt is %d lines tall, want 1", got)
	}

	m.input.SetValue("one\ntwo\nthree")
	m.growInput()
	if got := m.input.Height(); got != 3 {
		t.Errorf("three lines of input gave a box %d tall", got)
	}

	m.input.SetValue(strings.Repeat("x\n", 40))
	m.growInput()
	if got := m.input.Height(); got != inputMaxLines {
		t.Errorf("box grew to %d, want it capped at %d", got, inputMaxLines)
	}
}

// The numbered options are the primary way to answer, so the number a person
// reads must reach the decision it is printed next to.
func TestApprovalNumbersMatchTheirLabels(t *testing.T) {
	cases := []struct {
		name         string
		rememberable bool
		want         map[string]approval.Decision
	}{
		{"rememberable", true, map[string]approval.Decision{
			"1": approval.Once,
			"2": approval.AlwaysThisCommand,
			"3": approval.AlwaysThisTool,
			"4": approval.Deny,
		}},
		{"opaque", false, map[string]approval.Decision{
			"1": approval.Once,
			"2": approval.AlwaysThisTool,
			"3": approval.Deny,
		}},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			for k, want := range tc.want {
				m := newTestModel(t)
				req := approval.Request{Tool: "bash", Summary: "run it", Detail: "ls", Rememberable: tc.rememberable}
				reply := pend(m, req)

				view := m.approvalView()
				opts := m.choices(req)
				idx := int(k[0] - '1')
				if !strings.Contains(view, opts[idx].label) {
					t.Fatalf("option %s (%q) is not in the box:\n%s", k, opts[idx].label, view)
				}

				m.Update(key(k))
				select {
				case got := <-reply:
					if got != want {
						t.Errorf("key %s sent %v, want %v", k, got, want)
					}
				default:
					t.Fatalf("key %s answered nothing", k)
				}
			}
		})
	}
}

// The chrome grows and shrinks — the prompt box with what is typed, the
// approval box when it appears — so the view has to stay exactly as tall as
// the terminal rather than pushing its own top off the screen.
func TestViewFillsExactlyTheTerminalHeight(t *testing.T) {
	for _, size := range [][2]int{{80, 24}, {120, 40}, {60, 30}} {
		m := newTestModel(t)
		m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]})
		for i := 0; i < 40; i++ {
			m.tr.addUser("a line of scrollback")
		}
		m.sync()

		if got := lipgloss.Height(m.View()); got != size[1] {
			t.Errorf("%dx%d: view is %d rows, want %d", size[0], size[1], got, size[1])
		}

		m.input.SetValue("one\ntwo\nthree\nfour")
		m.growInput()
		if got := lipgloss.Height(m.View()); got != size[1] {
			t.Errorf("%dx%d with a grown prompt: view is %d rows, want %d", size[0], size[1], got, size[1])
		}

		pend(m, approval.Request{Tool: "bash", Summary: "run it", Detail: "rm -rf /", Rememberable: true})
		if got := lipgloss.Height(m.View()); got != size[1] {
			t.Errorf("%dx%d while approving: view is %d rows, want %d", size[0], size[1], got, size[1])
		}
	}
}

// Every line of chrome has to fit the terminal it is drawn in; a status bar
// one cell too wide wraps and silently eats a row of the transcript.
func TestChromeNeverExceedsTerminalWidth(t *testing.T) {
	for _, w := range []int{40, 60, 80, 120} {
		m := newTestModel(t)
		m.opts.Effort = "high"
		m.Update(tea.WindowSizeMsg{Width: w, Height: 24})
		m.Update(evUsage{usage: llm.Usage{InputTokens: 24_000, CacheReadInputTokens: 180_000, OutputTokens: 4_200}})
		m.phase = phaseRunning

		for name, line := range map[string]string{
			"activity": m.activity(),
			"status":   m.status(),
			"mode":     m.modeLine(),
			"input":    m.inputBox(),
		} {
			for _, row := range strings.Split(line, "\n") {
				if got := lipgloss.Width(row); got > w {
					t.Errorf("width %d: %s line is %d cells: %q", w, name, got, row)
				}
			}
		}
	}
}

// A transcript entry the renderer does not know about must not vanish; the
// welcome block is passed through verbatim.
func TestRawEntryIsPassedThrough(t *testing.T) {
	tr := newTranscript(80)
	tr.addRaw("▐██████▌ verbatim\n")
	if got := tr.render(); !strings.Contains(got, "▐██████▌ verbatim") {
		t.Errorf("raw entry was rewritten: %q", got)
	}
}

func TestCamelNamesTools(t *testing.T) {
	for in, want := range map[string]string{
		"read_file": "ReadFile",
		"bash":      "Bash",
		"edit_file": "EditFile",
		"":          "",
	} {
		if got := camel(in); got != want {
			t.Errorf("camel(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestNewModelOpensWithTheWelcomeBlock(t *testing.T) {
	m := newModel(context.Background(), Options{
		Client: llm.New(llm.WithAPIKey("x")),
		Root:   "/tmp/repo",
		Policy: approval.New(nil),
		Banner: []string{"resumed session abc"},
	})
	out := m.tr.render()
	if !strings.Contains(out, "codey") {
		t.Error("welcome block missing")
	}
	if i, j := strings.Index(out, "codey"), strings.Index(out, "resumed session abc"); i < 0 || j < 0 || i > j {
		t.Error("the banner should follow the welcome block, not precede it")
	}
}

func TestDiffStatCountsEditsNotHeaders(t *testing.T) {
	diff := "--- a/x.go\n+++ b/x.go\n@@ -1,2 +1,4 @@\n ctx\n-gone\n+one\n+two\n+three\n"
	if got, want := diffStat(strings.Split(diff, "\n")), "3 additions, 1 removal"; got != want {
		t.Errorf("diffStat = %q, want %q", got, want)
	}
	// The +++/--- file headers look like edits and are not.
	if got := diffStat([]string{"--- a/x.go", "+++ b/x.go"}); got != "0 additions, 0 removals" {
		t.Errorf("file headers counted as edits: %q", got)
	}

	tr := newTranscript(80)
	tr.startTool("edit_file", "path=x.go")
	tr.finishTool("edit_file", diff, false)
	if out := tr.render(); !strings.Contains(out, "3 additions, 1 removal") {
		t.Errorf("diff rendered without its stat line:\n%s", out)
	}
}

// Cache reads are the number Codey exists to move, so they belong on
// the status bar rather than only in the metrics file.
func TestStatusReportsCacheReadsSeparately(t *testing.T) {
	m := newTestModel(t)
	m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
	m.Update(evUsage{usage: llm.Usage{InputTokens: 4_200, CacheReadInputTokens: 58_000, OutputTokens: 1_340}})

	status := m.status()
	for _, want := range []string{"4.2k", "58.0k", "1.3k"} {
		if !strings.Contains(status, want) {
			t.Errorf("%s missing from the status bar: %q", want, status)
		}
	}

	// With nothing cached there is no cache field to read past.
	m2 := newTestModel(t)
	m2.Update(evUsage{usage: llm.Usage{InputTokens: 900, OutputTokens: 100}})
	if strings.Contains(m2.status(), "⟳") {
		t.Errorf("empty cache field still drawn: %q", m2.status())
	}
}