package app import ( "encoding/json" "strings" "testing" "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, keyMsg{Type: keyDown}) m = applyProposeKey(m, keyMsg{Type: 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, keyMsg{Type: 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, keyMsg{Type: keyRunes, Runes: []rune("e")}) if !m.proposeTyping { t.Fatalf("expected typing mode after 'e'") } for _, r := range "do it manually" { m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}}) } m = applyProposeKey(m, keyMsg{Type: 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, keyMsg{Type: 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 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 }