package tui
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/johannSo/codey/internal/approval"
"github.com/johannSo/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)
}
}