Files
correx/apps/tui-go/internal/app/clarcard_test.go
T
kami 8cfc590c7e fix(tui): soft-wrap clarification question prompts
The clarification modal hard-truncated each question's head line with
clip(), so long prompts (e.g. an API question and an Architecture
question) were cut at the modal edge and unreadable. Wrap the prompt
across rows instead: wrapPrompt() greedily word-wraps with a narrower
first line (marker + header chip eat into it) and full-width
continuation lines, indented to align under the prompt.
2026-07-07 18:29:56 +04:00

142 lines
4.5 KiB
Go

package app
import (
"strings"
"testing"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
func clarModel() Model {
m := NewModel(ws.New("", 0)) // unconnected; Send just buffers
m.width, m.height = 120, 40
m.theme = NewTheme(SoftBlue)
m.selectedID = "s1"
m.sessionEntered = true
m.applyServer(protocol.ServerMessage{
Type: protocol.TypeClarification,
SessionID: "s1",
RequestID: "req-1",
StageID: "analyst",
Questions: []protocol.ClarificationQuestionDto{
{ID: "stack", Prompt: "Which stack?", Options: []string{"React", "Vue"}, Header: "Stack"},
},
})
return m
}
func TestClarificationEntersStateAndRenders(t *testing.T) {
m := clarModel()
if m.displayState() != StateClarification {
t.Fatalf("want StateClarification, got %v", m.displayState())
}
out := m.clarificationModal()
for _, want := range []string{"clarifying questions", "Which stack?", "React", "Vue", "Stack", "custom"} {
if !strings.Contains(out, want) {
t.Fatalf("modal missing %q:\n%s", want, out)
}
}
}
func TestClarificationLongPromptSoftWraps(t *testing.T) {
m := clarModel()
m.width, m.height = 60, 40 // narrow modal so a long prompt must wrap
longPrompt := "Which API surface should the web UI talk to for streaming session " +
"events and how should it authenticate against the server endpoints?"
m.applyServer(protocol.ServerMessage{
Type: protocol.TypeClarification,
SessionID: "s1",
RequestID: "req-2",
StageID: "analyst",
Questions: []protocol.ClarificationQuestionDto{
{ID: "api", Prompt: longPrompt, Options: []string{"REST", "WebSocket"}, Header: "API"},
},
})
out := m.clarificationModal()
// Every word of the prompt must survive — nothing clipped at the modal edge.
for _, word := range strings.Fields(longPrompt) {
if !strings.Contains(out, word) {
t.Fatalf("wrapped prompt dropped %q:\n%s", word, out)
}
}
// And it must actually span multiple rendered rows (i.e. it wrapped, not overflowed).
if len(strings.Split(strings.TrimRight(out, "\n"), "\n")) < 4 {
t.Fatalf("expected the long prompt to wrap across rows:\n%s", out)
}
}
func TestWrapPromptFirstLineNarrowerThanContinuation(t *testing.T) {
lines := wrapPrompt("one two three four five six", 7, 20)
if lines[0] != "one two" {
t.Fatalf("first line should honor firstW=7, got %q", lines[0])
}
if len(lines) < 2 {
t.Fatalf("expected continuation lines, got %v", lines)
}
}
func TestClarificationSelectAndSubmit(t *testing.T) {
m := clarModel()
// move the option cursor to "Vue" (index 1) and select it
m = applyClarKey(m, keyMsg{Type: keyRight})
m = applyClarKey(m, keyMsg{Type: keySpace})
if !m.clarChosenAt(0, 1) {
t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen)
}
// submit
m = applyClarKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Clar != nil {
t.Fatalf("expected clarification cleared after submit")
}
if m.displayState() != StateInSession {
t.Fatalf("want StateInSession after submit, got %v", m.displayState())
}
}
func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) {
m := clarModel()
m.applyServer(protocol.ServerMessage{
Type: protocol.TypeClarification, SessionID: "s1", RequestID: "req-2", StageID: "analyst",
Questions: []protocol.ClarificationQuestionDto{
{ID: "a", Prompt: "Q A?", Options: []string{"x"}},
{ID: "b", Prompt: "Q B?", Options: []string{"y"}},
},
})
// answer only the first question, then attempt submit
m = applyClarKey(m, keyMsg{Type: keySpace})
m = applyClarKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Clar == nil {
t.Fatalf("expected clarification to remain with partial answers")
}
if m.clarFocus != 1 {
t.Fatalf("expected focus nudged to first unanswered (1), got %d", m.clarFocus)
}
}
func TestClarificationCustomTextAnswer(t *testing.T) {
m := clarModel()
m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")})
if !m.clarTyping {
t.Fatalf("expected typing mode after 'e'")
}
for _, r := range "Solid" {
m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}})
}
m = applyClarKey(m, keyMsg{Type: keyEnter})
if m.clarTyping {
t.Fatalf("expected typing committed")
}
if m.clarTextAt(0) != "Solid" {
t.Fatalf("want custom text 'Solid', got %q", m.clarTextAt(0))
}
if !m.clarAnswered(0, m.session("s1").Clar.Questions[0]) {
t.Fatalf("expected question answered via custom text")
}
}
func applyClarKey(m Model, k keyMsg) Model {
updated, _ := m.handleClarificationKey(k)
return updated.(Model)
}