Vectrum
Sign In
466 lines · 14.3 KB · Go
package tui

import (
	"strings"
	"testing"

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

	"codey/internal/approval"
	"codey/internal/ask"
)

// question queues a question the way the bridge would and returns the channel
// the model is expected to answer on.
func question(m *model, req ask.Request) chan ask.Response {
	reply := make(chan ask.Response, 1)
	m.Update(askMsg{req: req, reply: reply})
	return reply
}

func oneQuestion() ask.Request {
	return ask.Request{Questions: []ask.Question{{
		Header:   "Storage",
		Question: "How should sessions be stored?",
		Options: []ask.Option{
			{Label: "Postgres", Description: "what the rest of the stack runs"},
			{Label: "SQLite", Description: "no new service to operate"},
		},
	}}}
}

// answered reads the reply the model should have sent. A question that leaves
// nothing on the channel has stranded the agent, which is the failure this
// whole mechanism exists to avoid.
func answered(t *testing.T, reply chan ask.Response) ask.Response {
	t.Helper()
	select {
	case r := <-reply:
		return r
	default:
		t.Fatal("no answer was sent: the agent is still blocked on the channel")
		return ask.Response{}
	}
}

func TestAskPutsTheQuestionOnScreen(t *testing.T) {
	m := newTestModel(t)
	question(m, oneQuestion())

	if m.phase != phaseAsking {
		t.Fatalf("phase = %v, want phaseAsking", m.phase)
	}
	out := plain(m.askView())
	for _, want := range []string{"Storage", "How should sessions be stored?", "Postgres", "SQLite", "something else"} {
		if !strings.Contains(out, want) {
			t.Errorf("the box does not show %q:\n%s", want, out)
		}
	}
	if !strings.Contains(plain(m.activity()), "question") {
		t.Errorf("the activity line should say a question is waiting, got %q", plain(m.activity()))
	}
}

func TestAskEnterChoosesTheRowUnderTheCursor(t *testing.T) {
	m := newTestModel(t)
	reply := question(m, oneQuestion())

	m.Update(key("down"))
	m.Update(key("enter"))

	got := answered(t, reply)
	if got.Dismissed {
		t.Fatal("choosing an option is not a dismissal")
	}
	if len(got.Answers) != 1 || len(got.Answers[0].Chosen) != 1 || got.Answers[0].Chosen[0] != "SQLite" {
		t.Fatalf("answers = %+v, want the second option", got.Answers)
	}
	if got.Answers[0].Question != "How should sessions be stored?" {
		t.Error("the answer should carry the question, so the model reads it in context")
	}
	if m.phase == phaseAsking {
		t.Error("the box should be gone once it is answered")
	}
}

// A digit is the quick way to answer, and it must mean the same thing as
// walking to that row and pressing enter.
func TestAskDigitAnswersOutright(t *testing.T) {
	m := newTestModel(t)
	reply := question(m, oneQuestion())

	m.Update(key("1"))

	got := answered(t, reply)
	if len(got.Answers) != 1 || len(got.Answers[0].Chosen) != 1 || got.Answers[0].Chosen[0] != "Postgres" {
		t.Fatalf("answers = %+v, want the first option", got.Answers)
	}
}

// A digit past the end of the list is not a row. It must not answer anything,
// because the option it would land on does not exist.
func TestAskIgnoresADigitPastTheList(t *testing.T) {
	m := newTestModel(t)
	reply := question(m, oneQuestion())

	m.Update(key("9"))

	select {
	case r := <-reply:
		t.Fatalf("9 answered the question with %+v", r)
	default:
	}
}

func TestAskEscDismisses(t *testing.T) {
	m := newTestModel(t)
	reply := question(m, oneQuestion())

	m.Update(key("esc"))

	got := answered(t, reply)
	if !got.Dismissed {
		t.Fatal("esc should report a dismissal, not an empty answer")
	}
	if m.phase == phaseAsking {
		t.Error("the box should be gone")
	}
	if !strings.Contains(plain(m.tr.render()), "dismissed") {
		t.Error("the transcript should record that the question went unanswered")
	}
}

// The last row is always there and is how an answer nobody offered gets in.
func TestAskTypesAnAnswerOfYourOwn(t *testing.T) {
	m := newTestModel(t)
	reply := question(m, oneQuestion())

	m.Update(key("3")) // the "something else" row
	if !m.asking[0].typing {
		t.Fatal("the free-text field should be open")
	}
	if !strings.Contains(plain(m.askView()), "esc for the options") {
		t.Error("the box should say how to get back to the list")
	}
	typeIn(m, "neither: keep it in memory")
	m.Update(key("enter"))

	got := answered(t, reply)
	if got.Answers[0].Other != "neither: keep it in memory" {
		t.Fatalf("Other = %q, want what was typed", got.Answers[0].Other)
	}
	if len(got.Answers[0].Chosen) != 0 {
		t.Error("a typed answer to a one-answer question replaces the options rather than joining them")
	}
}

// Enter on an empty free-text field is not an answer: it puts the options
// back rather than sending nothing.
func TestAskEmptyFreeTextReturnsToTheOptions(t *testing.T) {
	m := newTestModel(t)
	reply := question(m, oneQuestion())

	m.Update(key("3"))
	m.Update(key("enter"))

	if m.asking[0].typing {
		t.Error("the field should have closed")
	}
	select {
	case r := <-reply:
		t.Fatalf("an empty field answered the question with %+v", r)
	default:
	}
}

func TestAskMultiSelectTicksSeveral(t *testing.T) {
	m := newTestModel(t)
	reply := question(m, ask.Request{Questions: []ask.Question{{
		Header:      "Scope",
		Question:    "What should the change cover?",
		MultiSelect: true,
		Options:     []ask.Option{{Label: "the CLI"}, {Label: "the TUI"}, {Label: "the docs"}},
	}}})

	m.Update(key(" ")) // tick the CLI
	m.Update(key("down"))
	m.Update(key(" ")) // tick the TUI
	m.Update(key("enter"))

	got := answered(t, reply)
	if want := []string{"the CLI", "the TUI"}; strings.Join(got.Answers[0].Chosen, ",") != strings.Join(want, ",") {
		t.Fatalf("chosen = %v, want %v", got.Answers[0].Chosen, want)
	}
}

// Enter with nothing ticked takes the row you are on. Requiring space first
// would be a ceremony for the common case of one answer.
func TestAskMultiSelectEnterTakesTheCursorRow(t *testing.T) {
	m := newTestModel(t)
	reply := question(m, ask.Request{Questions: []ask.Question{{
		Header:      "Scope",
		Question:    "What should the change cover?",
		MultiSelect: true,
		Options:     []ask.Option{{Label: "the CLI"}, {Label: "the TUI"}},
	}}})

	m.Update(key("down"))
	m.Update(key("enter"))

	got := answered(t, reply)
	if len(got.Answers[0].Chosen) != 1 || got.Answers[0].Chosen[0] != "the TUI" {
		t.Fatalf("chosen = %v, want the row the cursor was on", got.Answers[0].Chosen)
	}
}

func TestAskWalksSeveralQuestionsAndGoesBack(t *testing.T) {
	m := newTestModel(t)
	reply := question(m, ask.Request{Questions: []ask.Question{
		{Header: "Storage", Question: "Where?", Options: []ask.Option{{Label: "Postgres"}, {Label: "SQLite"}}},
		{Header: "Tests", Question: "How far?", Options: []ask.Option{{Label: "unit"}, {Label: "end to end"}}},
	}})

	if !strings.Contains(plain(m.askView()), "question 1 of 2") {
		t.Error("a request with several questions should say where in it you are")
	}
	m.Update(key("1")) // Postgres, on to the second question

	select {
	case <-reply:
		t.Fatal("the request was answered before the second question was")
	default:
	}
	if m.asking[0].q != 1 {
		t.Fatalf("q = %d, want the second question", m.asking[0].q)
	}

	// Back to the first, change the answer, forward again.
	m.Update(key("left"))
	if m.asking[0].q != 0 {
		t.Fatal("left should go back to the previous question")
	}
	m.Update(key("2")) // SQLite this time
	m.Update(key("1")) // unit tests

	got := answered(t, reply)
	if len(got.Answers) != 2 {
		t.Fatalf("got %d answers, want one per question", len(got.Answers))
	}
	if got.Answers[0].Chosen[0] != "SQLite" {
		t.Errorf("answer 1 = %v, want the changed answer", got.Answers[0].Chosen)
	}
	if got.Answers[1].Chosen[0] != "unit" {
		t.Errorf("answer 2 = %v", got.Answers[1].Chosen)
	}
}

// An approval and a question can be waiting at once — a subagent raises one
// while the session's agent has asked the other. The approval goes first,
// and answering it must reveal the question rather than the prompt box.
func TestAskWaitsBehindAnApproval(t *testing.T) {
	m := newTestModel(t)
	reply := question(m, oneQuestion())
	pend(m, approval.Request{Tool: "bash", Summary: "run a shell command", Detail: "go test ./...", Rememberable: true})

	if m.phase != phaseApproving {
		t.Fatalf("phase = %v, want the approval to be on top", m.phase)
	}
	m.Update(key("y"))

	if m.phase != phaseAsking {
		t.Fatalf("phase = %v, want the question back on screen", m.phase)
	}
	m.Update(key("1"))
	answered(t, reply)
	if m.phase != phaseIdle {
		t.Errorf("phase = %v, want the session back where it started", m.phase)
	}
}

// The tool call that asked is part of the turn. When the turn ends without
// it, nobody is left to read the answer, so the question goes with it.
func TestAskIsDroppedWhenTheTurnEnds(t *testing.T) {
	m := newTestModel(t)
	reply := question(m, oneQuestion())

	m.Update(turnDone{})

	got := answered(t, reply)
	if !got.Dismissed {
		t.Fatal("a question whose turn ended is dismissed, not answered")
	}
	if m.phase == phaseAsking {
		t.Errorf("phase = %v, want the box gone with the turn", m.phase)
	}
}

// A subagent's question belongs to its own turn, which the session's turn
// ending says nothing about.
func TestAskFromASubagentSurvivesTheTurn(t *testing.T) {
	m := newTestModel(t)
	req := oneQuestion()
	req.Agent = "a1"
	reply := question(m, req)

	m.Update(turnDone{})

	select {
	case r := <-reply:
		t.Fatalf("the subagent's question was dropped with the session's turn: %+v", r)
	default:
	}
	if m.phase != phaseAsking {
		t.Errorf("phase = %v, want the question still up", m.phase)
	}
	if !strings.Contains(plain(m.askView()), "a1") {
		t.Error("the box should say which agent is asking")
	}
}

// A click on an option is a choice, the way it is everywhere else in the
// chrome — including a click on the description under the label.
func TestAskClickChooses(t *testing.T) {
	m := newTestModel(t)
	reply := question(m, oneQuestion())
	m.mouseOn = true

	// The row is found in the frame rather than counted from a constant: the
	// point of the test is that the hit map agrees with what was drawn, and
	// arithmetic repeated here could be wrong in exactly the same way.
	secs := m.layout()
	row := m.height - rowsOf(secs)
	for _, s := range secs {
		if s.kind == secAsk {
			break
		}
		row += lipgloss.Height(s.text)
	}
	for i, line := range strings.Split(plain(m.askView()), "\n") {
		if strings.Contains(line, "what the rest of the stack runs") {
			row += i
			break
		}
	}
	m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: 4, Y: row})

	got := answered(t, reply)
	if len(got.Answers[0].Chosen) != 1 || got.Answers[0].Chosen[0] != "Postgres" {
		t.Fatalf("chosen = %v, want the option whose description was clicked", got.Answers[0].Chosen)
	}
}

func TestAskWheelMovesTheCursorWithoutAnswering(t *testing.T) {
	m := newTestModel(t)
	reply := question(m, oneQuestion())
	m.mouseOn = true

	m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown, X: 4, Y: m.height - 3})

	select {
	case r := <-reply:
		t.Fatalf("the wheel answered the question with %+v", r)
	default:
	}
	if m.asking[0].idx == 0 {
		t.Error("the wheel should have moved the cursor")
	}
}

func TestAskWrapsTheQuestionRatherThanCuttingIt(t *testing.T) {
	long := "Should the retry live in the client where every call gets it or in the caller where only the ones that need it pay for it?"
	got := wrapText(long, 40)
	for _, line := range strings.Split(got, "\n") {
		if len(line) > 40 {
			t.Fatalf("line of %d columns: %q", len(line), line)
		}
	}
	if strings.Join(strings.Fields(got), " ") != long {
		t.Error("wrapping must not lose or add words")
	}
}

// The box is chrome, and chrome that draws past the edge of the terminal
// smears the frame. The model writes the labels, so their length is not
// something this side gets to assume anything about.
func TestAskBoxFitsTheTerminal(t *testing.T) {
	long := ask.Request{Questions: []ask.Question{{
		Header:   "A header nobody should have written this long",
		Question: "Sessions have to live somewhere once the process exits, and the answer decides what has to be running before Codey will start at all. Where should they go?",
		Options: []ask.Option{
			{Label: "Postgres (recommended)", Description: "what the rest of the stack already runs, so there is no new service to operate or back up"},
			{Label: "SQLite", Description: "a file on disk"},
		},
	}}}
	for _, width := range []int{24, 40, 60, 100} {
		m := newTestModel(t)
		m.Update(tea.WindowSizeMsg{Width: width, Height: 24})
		question(m, long)
		for _, state := range []string{"list", "typing"} {
			if state == "typing" {
				m.Update(key("3"))
			}
			for _, line := range strings.Split(m.askView(), "\n") {
				if w := lipgloss.Width(line); w > width {
					t.Errorf("at width %d the %s box drew %d cells: %q", width, state, w, plain(line))
				}
			}
		}
	}
}

// ctrl-c at a question abandons the question and the turn it belongs to: the
// answer would go to a tool call that is about to stop existing.
func TestAskCtrlCDismissesAndInterrupts(t *testing.T) {
	m := newTestModel(t)
	cancelled := false
	m.cancel = func() { cancelled = true }
	m.beforeModal = phaseRunning
	m.phase = phaseRunning
	reply := question(m, oneQuestion())

	m.Update(key("ctrl+c"))

	got := answered(t, reply)
	if !got.Dismissed {
		t.Fatal("ctrl-c is not an answer")
	}
	if !cancelled {
		t.Error("the turn the question belonged to should have been interrupted")
	}
}

// With nothing running there is no turn to cancel, and ctrl-c is only the
// dismissal. Cancelling here would tear down the next turn's context.
func TestAskCtrlCWhenIdleOnlyDismisses(t *testing.T) {
	m := newTestModel(t)
	cancelled := false
	m.cancel = func() { cancelled = true }
	reply := question(m, oneQuestion())

	m.Update(key("ctrl+c"))

	if got := answered(t, reply); !got.Dismissed {
		t.Fatal("ctrl-c should dismiss")
	}
	if cancelled {
		t.Error("nothing was running, so nothing should have been cancelled")
	}
}

// Going back to a question puts the cursor on the answer you gave, because
// that is the thing you came back to change.
func TestAskGoingBackStartsOnTheAnswerGiven(t *testing.T) {
	m := newTestModel(t)
	question(m, ask.Request{Questions: []ask.Question{
		{Header: "Storage", Question: "Where?", Options: []ask.Option{{Label: "Postgres"}, {Label: "SQLite"}}},
		{Header: "Tests", Question: "How far?", Options: []ask.Option{{Label: "unit"}, {Label: "end to end"}}},
	}})

	m.Update(key("2")) // SQLite, on to the second question
	m.Update(key("left"))

	if got := m.asking[0].idx; got != 1 {
		t.Errorf("cursor = %d, want the row holding the answer already given", got)
	}
}