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

import (
	"context"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"testing"

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

	"github.com/johannSo/codey/internal/approval"
	"github.com/johannSo/codey/internal/llm"
	"github.com/johannSo/codey/internal/statedir"
)

// newHistoryModel builds a model whose history has a real directory to live
// in, so a test can look at what was written as well as at what was recalled.
// What it returns is that directory: the chats directory, not the checkout.
func newHistoryModel(t *testing.T) (*model, string) {
	t.Helper()
	root := t.TempDir()
	dir := filepath.Join(t.TempDir(), "chats")
	m := newModel(context.Background(), Options{
		Client:     llm.New(llm.WithAPIKey("x")),
		Root:       root,
		HomeRoot:   root,
		SessionDir: dir,
		Policy:     approval.New(nil),
	})
	m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
	return m, dir
}

// The whole point of the feature: what was sent comes back.
func TestUpRecallsThePreviousPrompt(t *testing.T) {
	m, _ := newHistoryModel(t)
	m.hist.record("fix the failing test")

	m.Update(key("up"))

	if v := m.input.Value(); v != "fix the failing test" {
		t.Errorf("prompt = %q, want the previous one back", v)
	}
}

func TestUpWalksBackThroughSeveralPrompts(t *testing.T) {
	m, _ := newHistoryModel(t)
	for _, line := range []string{"first", "second", "third"} {
		m.hist.record(line)
	}

	want := []string{"third", "second", "first", "first"}
	for i, w := range want {
		m.Update(key("up"))
		if v := m.input.Value(); v != w {
			t.Errorf("after %d ups prompt = %q, want %q", i+1, v, w)
		}
	}
}

// Walking forward again has to give back what was half-written, or the feature
// costs you the prompt you were in the middle of.
func TestDownReturnsTheDraftTheWalkStartedFrom(t *testing.T) {
	m, _ := newHistoryModel(t)
	m.hist.record("an old prompt")
	typeIn(m, "half a thought")

	m.Update(key("up"))
	if v := m.input.Value(); v != "an old prompt" {
		t.Fatalf("prompt = %q, want the recalled entry", v)
	}
	m.Update(key("down"))
	if v := m.input.Value(); v != "half a thought" {
		t.Errorf("prompt = %q, want the draft back", v)
	}
}

func TestEscAbandonsTheWalk(t *testing.T) {
	m, _ := newHistoryModel(t)
	m.hist.record("an old prompt")
	typeIn(m, "draft")

	m.Update(key("up"))
	m.Update(key("esc"))

	if v := m.input.Value(); v != "draft" {
		t.Errorf("prompt = %q, want the draft back after esc", v)
	}
}

// Once a recalled line has been edited it is yours, and the draft it displaced
// is gone. What must not happen is down quietly wiping the edit.
func TestEditingARecalledLineEndsTheWalk(t *testing.T) {
	m, _ := newHistoryModel(t)
	m.hist.record("go test ./...")

	m.Update(key("up"))
	typeIn(m, " -run X")
	m.Update(key("down"))

	if v := m.input.Value(); v != "go test ./... -run X" {
		t.Errorf("prompt = %q, want the edited line left alone", v)
	}
}

// The arrows are only history at the edge of the text. In the middle of a
// paragraph they are what they look like: cursor movement.
func TestUpInsideAMultiLinePromptMovesTheCursor(t *testing.T) {
	m, _ := newHistoryModel(t)
	m.hist.record("an old prompt")
	typeIn(m, "first line")
	m.Update(key("ctrl+j")) // the textarea's newline
	typeIn(m, "second line")

	m.Update(key("up"))

	if v := m.input.Value(); v != "first line\nsecond line" {
		t.Errorf("prompt = %q, want the typed paragraph untouched", v)
	}
	if m.input.Line() != 0 {
		t.Errorf("cursor line = %d, want the key to have moved it to the first line", m.input.Line())
	}
	// From the first line the same key now means history, and the paragraph
	// is the draft it can come back to.
	m.Update(key("up"))
	if v := m.input.Value(); v != "an old prompt" {
		t.Errorf("prompt = %q, want the recall from the first line", v)
	}
}

// A shell command and a prompt are different kinds of thing, and offering one
// where the other belongs would send `go test` to the model as prose.
func TestModesKeepSeparateHistories(t *testing.T) {
	m, _ := newHistoryModel(t)
	m.hist.record("a prompt for the model")
	m.hist.record("!go test ./...")
	m.hist.record("$print(1)")

	m.Update(key("up"))
	if v := m.input.Value(); v != "a prompt for the model" {
		t.Errorf("prompt = %q, want the model's own history and nothing else", v)
	}

	m.Update(key("esc"))
	m.Update(key("!"))
	m.Update(key("up"))
	if v := m.input.Value(); v != "go test ./..." {
		t.Errorf("shell prompt = %q, want the shell line without its marker", v)
	}
}

func TestSubmittingAPromptRecordsIt(t *testing.T) {
	m, _ := newHistoryModel(t)
	typeIn(m, "say something")
	m.Update(key("enter"))

	if got := m.hist.entries; len(got) != 1 || got[0] != "say something" {
		t.Errorf("entries = %v, want the submitted line", got)
	}
	if m.hist.walking {
		t.Error("still walking after a submit")
	}
}

// Entries survive a restart, which is the case the file exists for at all.
func TestHistoryIsReadBackFromDisk(t *testing.T) {
	m, root := newHistoryModel(t)
	m.hist.record("remembered")

	again := newHistory(root)
	if got := again.entries; len(got) != 1 || got[0] != "remembered" {
		t.Errorf("entries after reload = %v, want the recorded line", got)
	}
}

// A prompt with newlines in it is one entry, not three.
func TestAMultiLinePromptIsOneEntry(t *testing.T) {
	_, root := newHistoryModel(t)
	h := newHistory(root)
	h.record("do this:\n  and this\n  and that")

	again := newHistory(root)
	if got := again.entries; len(got) != 1 || got[0] != "do this:\n  and this\n  and that" {
		t.Errorf("entries = %q, want one entry holding every line", got)
	}
}

func TestRepeatsAndBlanksAreNotRecorded(t *testing.T) {
	_, root := newHistoryModel(t)
	h := newHistory(root)
	h.record("go test")
	h.record("go test")
	h.record("   ")
	h.record("")
	h.record("go build")
	h.record("go test")

	want := []string{"go test", "go build", "go test"}
	if got := h.entries; !equalStrings(got, want) {
		t.Errorf("entries = %v, want %v — only an immediate repeat is dropped", got, want)
	}
}

func TestHistoryIsCappedAndCompacted(t *testing.T) {
	_, root := newHistoryModel(t)
	path := historyPath(root)
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}

	var b strings.Builder
	total := historyLimit*compactAtMultiple + 10
	for i := range total {
		b.WriteString(strconv.Quote("line "+strconv.Itoa(i)) + "\n")
	}
	if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
		t.Fatal(err)
	}

	h := newHistory(root)
	if len(h.entries) != historyLimit {
		t.Errorf("entries = %d, want the last %d", len(h.entries), historyLimit)
	}
	if h.entries[len(h.entries)-1] != "line "+strconv.Itoa(total-1) {
		t.Errorf("newest entry = %q, want the last line of the file", h.entries[len(h.entries)-1])
	}
	raw, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	if n := strings.Count(string(raw), "\n"); n != historyLimit {
		t.Errorf("file has %d lines, want it compacted to %d", n, historyLimit)
	}
}

// Nothing here may be a reason the prompt box does not work. A history with
// nowhere to live still walks; it just forgets at the end of the session.
func TestAHistoryWithNoDirectoryStillWorks(t *testing.T) {
	h := newHistory("")
	if h.path != "" {
		t.Errorf("path = %q, want no file without a chats directory", h.path)
	}
	h.record("kept in memory")
	if text, ok := h.prev("", ""); !ok || text != "kept in memory" {
		t.Errorf("prev = %q, %v; want the in-memory entry", text, ok)
	}
}

func TestGarbageLinesAreSkippedRatherThanFatal(t *testing.T) {
	_, root := newHistoryModel(t)
	path := historyPath(root)
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}
	body := strconv.Quote("good one") + "\nnot quoted at all\n\"unterminated\n" + strconv.Quote("another") + "\n"
	if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
		t.Fatal(err)
	}

	h := newHistory(root)
	if want := []string{"good one", "another"}; !equalStrings(h.entries, want) {
		t.Errorf("entries = %v, want %v", h.entries, want)
	}
}

// The file belongs beside the sessions, out of the checkout.
func TestTheFileLivesBesideTheSessions(t *testing.T) {
	dir := t.TempDir()
	want := filepath.Join(dir, statedir.HistoryFile)
	if got := historyPath(dir); got != want {
		t.Errorf("historyPath = %q, want %q", got, want)
	}
}

func equalStrings(a, b []string) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

// The prompt's hint is advertising for keys nothing else on screen mentions,
// so it loses whole clauses on a narrow terminal rather than the ends of
// words.
func TestThePlaceholderFitsTheBox(t *testing.T) {
	for _, width := range []int{100, 60, 48, 34, 20, 1} {
		p := placeholderFor(width)
		if w := lipgloss.Width(p); w > width && p != placeholders[len(placeholders)-1] {
			t.Errorf("placeholder for width %d is %d cells: %q", width, w, p)
		}
		if !strings.Contains(p, "ask") {
			t.Errorf("placeholder for width %d does not say what the box is for: %q", width, p)
		}
	}
	// The widest one names the key this history feature added, because there
	// is nowhere else on the idle screen that it is written down except the
	// welcome block, which scrolls away.
	if !strings.Contains(placeholderFor(100), "↑") {
		t.Error("the full placeholder does not mention the up arrow")
	}
}

func TestResizingNarrowsThePlaceholder(t *testing.T) {
	m, _ := newHistoryModel(t)
	wide := m.input.Placeholder
	m.Update(tea.WindowSizeMsg{Width: 40, Height: 24})
	if m.input.Placeholder == wide {
		t.Errorf("the placeholder did not shorten for a 40-column terminal: %q", m.input.Placeholder)
	}
	m.Update(tea.WindowSizeMsg{Width: 100, Height: 24})
	if m.input.Placeholder != defaultPlaceholder {
		t.Errorf("the placeholder did not come back on a wide terminal: %q", m.input.Placeholder)
	}
}

// A local mode has a placeholder of its own, and resizing must not replace it
// with the model's.
func TestResizingLeavesALocalModePlaceholderAlone(t *testing.T) {
	m, _ := newHistoryModel(t)
	m.Update(key("!"))
	want := m.input.Placeholder
	m.Update(tea.WindowSizeMsg{Width: 40, Height: 24})
	if m.input.Placeholder != want {
		t.Errorf("shell placeholder = %q, want %q", m.input.Placeholder, want)
	}
}