Vectrum
Sign In
364 lines · 10.8 KB · Go
package tui

import (
	"context"
	"strings"
	"testing"

	tea "github.com/charmbracelet/bubbletea"

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

// newTestTabs builds a strip whose NewSession hands back empty sessions: no
// agent and no supervisor, which is all a test of the strip itself needs. The
// counter says how many were asked for.
func newTestTabs(t *testing.T) (*tabs, *int, *int) {
	t.Helper()
	opened, closed := 0, 0
	tb := newTabs(context.Background(), Options{
		Client: llm.New(llm.WithAPIKey("x")),
		Root:   "/tmp/repo",
		Policy: approval.New(nil),
		NewSession: func(root string) (SessionSpec, error) {
			opened++
			return SessionSpec{
				Policy:    approval.New(nil),
				SessionID: "sess",
				Close:     func() { closed++ },
			}, nil
		},
	})
	tb.attach(&recorder{})
	tb.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
	return tb, &opened, &closed
}

func altKey(r rune) tea.KeyMsg {
	return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}, Alt: true}
}

func TestTabsStartAsOneUndecoratedSession(t *testing.T) {
	tb, _, _ := newTestTabs(t)

	if got := len(tb.list); got != 1 {
		t.Fatalf("tabs at startup = %d, want 1", got)
	}
	// One session is not a strip: nothing is drawn for it, and the
	// conversation gets the whole terminal, exactly as it did before tabs.
	if rows := tb.barRows(); rows != 0 {
		t.Errorf("barRows with one tab = %d, want 0", rows)
	}
	if h := tb.cur().m.height; h != 24 {
		t.Errorf("height with one tab = %d, want the full 24", h)
	}
}

func TestCtrlTOpensASessionInANewTab(t *testing.T) {
	tb, opened, _ := newTestTabs(t)

	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})

	if *opened != 1 {
		t.Fatalf("NewSession called %d times, want 1", *opened)
	}
	if got := len(tb.list); got != 2 {
		t.Fatalf("tabs after ctrl+t = %d, want 2", got)
	}
	if tb.active != 1 {
		t.Errorf("active tab = %d, want the new one (1)", tb.active)
	}
	// Ids are what messages are addressed by, so two tabs must never share one.
	if tb.list[0].m.id == tb.list[1].m.id {
		t.Errorf("both tabs have id %d", tb.list[0].m.id)
	}
	// The new conversation is wired to the program, or nothing it says would
	// ever arrive.
	if tb.list[1].m.bridge.p == nil || tb.list[1].m.bridge.tab != tb.list[1].m.id {
		t.Error("the new tab's bridge is not wired to its own id")
	}
}

func TestSecondTabCostsTheStripARow(t *testing.T) {
	tb, _, _ := newTestTabs(t)
	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})

	if rows := tb.barRows(); rows != 1 {
		t.Fatalf("barRows with two tabs = %d, want 1", rows)
	}
	for i, tab := range tb.list {
		if tab.m.height != 23 {
			t.Errorf("tab %d height = %d, want 23 — the strip takes a row from every tab, not just the one on screen", i, tab.m.height)
		}
	}
	if got := strings.Count(tb.View(), "\n"); got == 0 {
		t.Fatal("the view has no rows at all")
	}
	if !strings.Contains(tb.View(), "new session") {
		t.Error("the strip does not name the tabs")
	}
}

func TestTabsWithoutAFactorySaySoAndStayPut(t *testing.T) {
	tb := newTabs(context.Background(), Options{
		Client: llm.New(llm.WithAPIKey("x")),
		Root:   "/tmp/repo",
		Policy: approval.New(nil),
	})
	tb.attach(&recorder{})
	tb.Update(tea.WindowSizeMsg{Width: 80, Height: 24})

	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})

	if got := len(tb.list); got != 1 {
		t.Fatalf("tabs = %d, want the one we started with", got)
	}
	if !strings.Contains(tb.View(), "cannot open a second session") {
		t.Error("nothing was said about why no tab opened")
	}
}

func TestTaggedMessagesReachTheirOwnTab(t *testing.T) {
	tb, _, _ := newTestTabs(t)
	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})

	first, second := tb.list[0].m, tb.list[1].m
	// Tab 2 is on screen; a turn that finishes in tab 1 still belongs to tab 1.
	tb.Update(forTab{id: first.id, msg: evText{text: "an answer for the tab you left"}})

	if !strings.Contains(first.tr.render(), "an answer for the tab you left") {
		t.Error("the message did not land in the tab it was addressed to")
	}
	if strings.Contains(second.tr.render(), "an answer for the tab you left") {
		t.Error("the message landed in the tab that happened to be on screen")
	}
	if !tb.list[0].unseen {
		t.Error("a tab that spoke while you were elsewhere is not marked")
	}
	// Looking at it is what clears the mark.
	tb.Update(altKey('1'))
	if tb.list[0].unseen {
		t.Error("the mark survived being looked at")
	}
}

func TestMessagesForAClosedTabAreDropped(t *testing.T) {
	tb, _, _ := newTestTabs(t)
	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
	gone := tb.list[1].m.id

	tb.Update(closeTabMsg{})

	// Nothing to assert but that it does not panic or go to the wrong tab:
	// an agent shut down mid-sentence can still have one event in flight.
	tb.Update(forTab{id: gone, msg: evText{text: "too late"}})
	if strings.Contains(tb.cur().m.tr.render(), "too late") {
		t.Error("a dead tab's message was delivered to the survivor")
	}
}

func TestAltDigitAndStepMoveAlongTheStrip(t *testing.T) {
	tb, _, _ := newTestTabs(t)
	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})

	tb.Update(altKey('1'))
	if tb.active != 0 {
		t.Fatalf("alt+1 left active = %d, want 0", tb.active)
	}
	tb.Update(altKey('3'))
	if tb.active != 2 {
		t.Fatalf("alt+3 left active = %d, want 2", tb.active)
	}
	// A number with no tab under it does nothing rather than something.
	tb.Update(altKey('9'))
	if tb.active != 2 {
		t.Errorf("alt+9 with three tabs left active = %d, want 2", tb.active)
	}

	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlPgDown})
	if tb.active != 0 {
		t.Errorf("ctrl+pgdown off the end = %d, want to wrap to 0", tb.active)
	}
	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlPgUp})
	if tb.active != 2 {
		t.Errorf("ctrl+pgup off the start = %d, want to wrap to 2", tb.active)
	}
}

func TestClosingATabReleasesItAndPicksAnother(t *testing.T) {
	tb, _, closed := newTestTabs(t)
	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})

	tb.Update(altKey('w'))

	if got := len(tb.list); got != 1 {
		t.Fatalf("tabs after alt+w = %d, want 1", got)
	}
	if *closed != 1 {
		t.Errorf("Close called %d times, want 1 — a closed tab's supervisor is still running", *closed)
	}
	if tb.active != 0 {
		t.Errorf("active = %d, want 0", tb.active)
	}
	// Back to one tab: the strip goes away and the row comes back.
	if tb.cur().m.height != 24 {
		t.Errorf("height after the strip went = %d, want 24", tb.cur().m.height)
	}
}

func TestClosingTheLastTabLeavesCodey(t *testing.T) {
	tb, _, _ := newTestTabs(t)

	_, cmd := tb.Update(closeTabMsg{})

	if !tb.cur().m.quitting {
		t.Fatal("closing the only tab did not ask to leave")
	}
	if cmd == nil {
		t.Fatal("no command came back from closing the last tab")
	}
	if _, ok := cmd().(tea.QuitMsg); !ok {
		t.Error("closing the last tab did not quit the program")
	}
}

func TestABusyTabRefusesToClose(t *testing.T) {
	tb, _, closed := newTestTabs(t)
	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
	tb.cur().m.phase = phaseRunning

	tb.Update(altKey('w'))

	if got := len(tb.list); got != 2 {
		t.Fatalf("tabs = %d, want the busy one still open", got)
	}
	if *closed != 0 {
		t.Error("a running tab was torn down under the turn")
	}
	if !strings.Contains(tb.cur().m.tr.render(), "interrupt it") {
		t.Error("nothing was said about why the tab stayed")
	}
}

func TestTheWizardKeepsItsKeys(t *testing.T) {
	tb, opened, _ := newTestTabs(t)
	tb.cur().m.wiz = &wizard{}

	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})

	if *opened != 0 || len(tb.list) != 1 {
		t.Error("ctrl+t opened a tab behind the wizard, which owns the whole screen")
	}
}

func TestTabTakesItsNameFromWhatWasAsked(t *testing.T) {
	tb, _, _ := newTestTabs(t)
	m := tb.cur().m

	m.startTurn("fix the parser\nand the lexer", "fix the parser")

	if m.title != "fix the parser" {
		t.Fatalf("title = %q, want the first line of the prompt", m.title)
	}
	// It is the opening prompt, not the latest one: a tab that renamed itself
	// every turn is a tab you have to find again every turn.
	m.startTurn("now do the other thing", "now do the other thing")
	if m.title != "fix the parser" {
		t.Errorf("title = %q, want it unchanged by the second turn", m.title)
	}
}

func TestClickingTheStripSwitchesTabs(t *testing.T) {
	tb, _, _ := newTestTabs(t)
	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})

	tb.Update(tea.MouseMsg{X: 1, Y: 0, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft})

	if tb.active != 0 {
		t.Errorf("a click on the first label left active = %d, want 0", tb.active)
	}
}

func TestMouseBelowTheStripIsMovedIntoTheConversation(t *testing.T) {
	tb, _, _ := newTestTabs(t)
	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})

	m := tb.cur().m
	m.tr.addUser("a line to click on")
	m.sync()

	// The strip is a row the conversation does not know about; what it is
	// asked about has to be the row inside its own frame.
	before := m.vp.YOffset
	tb.Update(tea.MouseMsg{X: 2, Y: 3, Action: tea.MouseActionPress, Button: tea.MouseButtonWheelUp})
	if m.vp.YOffset > before {
		t.Error("a wheel below the strip did something other than scroll the conversation")
	}
}

func TestSpeaksIsAboutThingsWorthSaying(t *testing.T) {
	if speaks(evUsage{}) {
		t.Error("token counts marked a tab; they arrive constantly and say nothing")
	}
	if !speaks(approvalMsg{}) {
		t.Error("an approval waiting in another tab is exactly what a mark is for")
	}
}

func TestACommandFromATabIsAddressedToIt(t *testing.T) {
	tb, _, _ := newTestTabs(t)
	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
	m := tb.list[1].m

	msg := m.tag(func() tea.Msg { return turnDone{} })()
	env, ok := msg.(forTab)
	if !ok {
		t.Fatalf("a tab's command produced %T, not a message addressed to a tab", msg)
	}
	if env.id != m.id {
		t.Errorf("addressed to tab %d, want %d", env.id, m.id)
	}

	// A model with no strip around it — a test, or the single-session case —
	// sends what it always sent.
	plain := newTestModel(t)
	if _, wrapped := plain.tag(func() tea.Msg { return turnDone{} })().(forTab); wrapped {
		t.Error("an untabbed model wrapped its messages")
	}
}

func TestALocalRunInABackgroundTabComesBackToIt(t *testing.T) {
	tb, _, _ := newTestTabs(t)
	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
	second := tb.list[1].m
	second.opts.Root = t.TempDir()

	cmd := second.runLocal(localShell, "echo hello")
	// Back to the first tab: the command is still running in the second.
	tb.Update(altKey('1'))

	for _, msg := range drain(t, second, cmd) {
		if env, ok := msg.(forTab); ok && env.id == second.id {
			if _, isDone := env.msg.(localDone); isDone {
				return
			}
		}
	}
	t.Fatal("the finished command did not come back addressed to the tab that started it")
}

func TestAnApprovalWaitingInAnotherTabIsSaidOutLoud(t *testing.T) {
	tb, _, _ := newTestTabs(t)
	tb.Update(tea.KeyMsg{Type: tea.KeyCtrlT})
	background := tb.list[0].m

	tb.Update(forTab{id: background.id, msg: approvalMsg{}})

	// The agent that asked is stopped until it is answered, so a mark on a tab
	// is not enough: the tab you are in has to say where the question is.
	if !strings.Contains(tb.cur().m.tr.render(), "tab 1 is waiting") {
		t.Error("nothing said that another tab is blocked on an approval")
	}
}