Files
correx/apps/tui-go/internal/app/proposecard_test.go
T
kami b563d9841d feat(tui-go): workflow-propose choice panel
Render a workflow.proposed frame as a single-select picker with a manual-answer
slot: ↑↓/jk to choose, enter to launch, e for custom, esc to peek away. Picking
a candidate sends StartSession (and focuses the launched session); the custom
slot continues the conversation via ChatInput. Reuses the modal helpers from the
clarification view.
2026-06-14 13:29:34 +04:00

143 lines
4.4 KiB
Go

package app
import (
"encoding/json"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
func proposeModel() (Model, *ws.Client) {
client := ws.New("", 0) // unconnected; Send just buffers, Drain reads it back
m := NewModel(client)
m.width, m.height = 120, 40
m.theme = NewTheme(SoftBlue)
m.selectedID = "s1"
m.sessionEntered = true
m.applyServer(protocol.ServerMessage{
Type: protocol.TypeWorkflowProposed,
SessionID: "s1",
ProposalID: "prop-1",
Prompt: "Run one of these?",
OriginalRequest: "find papers on event sourcing",
Candidates: []protocol.ProposedWorkflowDto{
{WorkflowID: "research", Reason: "gather + report"},
{WorkflowID: "role_pipeline", Reason: "full build"},
},
})
return m, client
}
func TestProposeEntersStateAndRenders(t *testing.T) {
m, _ := proposeModel()
if m.displayState() != StateWorkflowPropose {
t.Fatalf("want StateWorkflowPropose, got %v", m.displayState())
}
out := m.proposeModal()
for _, want := range []string{"workflow suggestion", "Run one of these?", "research", "gather + report", "role_pipeline", "something else"} {
if !strings.Contains(out, want) {
t.Fatalf("modal missing %q:\n%s", want, out)
}
}
}
func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
m, client := proposeModel()
// move cursor to the second candidate (role_pipeline) and launch it
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyDown})
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter})
if s := m.session("s1"); s == nil || s.Propose != nil {
t.Fatalf("expected proposal cleared after launch")
}
if !m.pendingWorkflowFocus {
t.Fatalf("expected pendingWorkflowFocus set so the launched session is focused")
}
wf, input := decodeStart(t, client)
if wf != "role_pipeline" {
t.Fatalf("launched wrong workflow: %q", wf)
}
if input != "find papers on event sourcing" {
t.Fatalf("workflow not seeded with original request: %q", input)
}
}
func TestProposeFirstCandidateIsDefault(t *testing.T) {
m, client := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) // no nav → first candidate
wf, _ := decodeStart(t, client)
if wf != "research" {
t.Fatalf("expected first candidate launched by default, got %q", wf)
}
}
func TestProposeCustomAnswerContinuesChat(t *testing.T) {
m, client := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
if !m.proposeTyping {
t.Fatalf("expected typing mode after 'e'")
}
for _, r := range "do it manually" {
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter})
if s := m.session("s1"); s == nil || s.Propose != nil {
t.Fatalf("expected proposal cleared after custom answer")
}
if m.pendingWorkflowFocus {
t.Fatalf("a custom answer must not launch a workflow")
}
frames := client.Drain()
if len(frames) != 1 {
t.Fatalf("expected exactly one frame, got %d", len(frames))
}
var raw map[string]any
if err := json.Unmarshal(frames[0], &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if raw["type"] != "com.correx.apps.server.protocol.ClientMessage.ChatInput" {
t.Fatalf("expected ChatInput, got %v", raw["type"])
}
if raw["text"] != "do it manually" {
t.Fatalf("custom text not sent: %v", raw["text"])
}
}
func TestProposeEscDismisses(t *testing.T) {
m, _ := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEsc})
if m.displayState() != StateInSession {
t.Fatalf("expected StateInSession after esc, got %v", m.displayState())
}
if s := m.session("s1"); s == nil || s.Propose == nil {
t.Fatalf("esc should peek away, not discard the proposal")
}
}
func applyProposeKey(m Model, k tea.KeyMsg) Model {
updated, _ := m.handleProposeKey(k)
return updated.(Model)
}
func decodeStart(t *testing.T, client *ws.Client) (workflowID, input string) {
t.Helper()
frames := client.Drain()
if len(frames) != 1 {
t.Fatalf("expected exactly one frame, got %d", len(frames))
}
var raw map[string]any
if err := json.Unmarshal(frames[0], &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if raw["type"] != "com.correx.apps.server.protocol.ClientMessage.StartSession" {
t.Fatalf("expected StartSession, got %v", raw["type"])
}
wf, _ := raw["workflowId"].(string)
in, _ := raw["input"].(string)
return wf, in
}