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) } }