返回 DeepSeek-Reasonix
quick_picker_test.go
根目录 / internal / cli / quick_picker_test.go
1 package cli
2
3 import (
4 "strings"
5 "testing"
6
7 tea "charm.land/bubbletea/v2"
8
9 "reasonix/internal/command"
10 "reasonix/internal/config"
11 "reasonix/internal/control"
12 "reasonix/internal/event"
13 "reasonix/internal/provider"
14 "reasonix/internal/skill"
15 )
16
17 func TestQuickPickerNavigationFilterAndConfirm(t *testing.T) {
18 p := &quickPicker{
19 title: "Select model",
20 items: []quickPickerItem{
21 {ID: "one", Label: "Alpha"},
22 {ID: "two", Label: "Beta model"},
23 {ID: "three", Label: "Gamma"},
24 },
25 }
26 p.handleKey(tea.KeyPressMsg{Code: tea.KeyDown})
27 if p.selected != 1 {
28 t.Fatalf("down selected = %d, want 1", p.selected)
29 }
30 p.handleKey(tea.KeyPressMsg{Code: tea.KeyUp})
31 if p.selected != 0 {
32 t.Fatalf("up selected = %d, want 0", p.selected)
33 }
34 p.handleKey(tea.KeyPressMsg{Code: 'b', Text: "b"})
35 if got := p.filteredItems(); len(got) != 1 || got[0].ID != "two" {
36 t.Fatalf("filtered items = %+v, want Beta only", got)
37 }
38 result := p.handleKey(tea.KeyPressMsg{Code: tea.KeyEnter})
39 if result.choice == nil || result.choice.ID != "two" {
40 t.Fatalf("enter choice = %+v, want two", result.choice)
41 }
42 }
43
44 func TestQuickPickerVimKeysBecomeTextAfterSearchStarts(t *testing.T) {
45 p := &quickPicker{
46 selected: 1,
47 items: []quickPickerItem{
48 {ID: "one", Label: "Alpha"},
49 {ID: "two", Label: "Beta"},
50 {ID: "three", Label: "Gamma"},
51 },
52 }
53 p.handleKey(tea.KeyPressMsg{Code: 'k', Text: "k"})
54 if p.selected != 0 || p.query != "" {
55 t.Fatalf("empty-query k = selected %d, query %q; want navigation to 0", p.selected, p.query)
56 }
57 p.handleKey(tea.KeyPressMsg{Code: 'j', Text: "j"})
58 if p.selected != 1 || p.query != "" {
59 t.Fatalf("empty-query j = selected %d, query %q; want navigation to 1", p.selected, p.query)
60 }
61
62 p.handleKey(tea.KeyPressMsg{Code: 'a', Text: "a"})
63 p.handleKey(tea.KeyPressMsg{Code: 'j', Text: "j"})
64 p.handleKey(tea.KeyPressMsg{Code: 'k', Text: "k"})
65 if p.query != "ajk" {
66 t.Fatalf("search query = %q, want ajk", p.query)
67 }
68 }
69
70 func TestQuickPickerWindowTracksSelection(t *testing.T) {
71 start, end := quickPickerWindow(20, 18)
72 if start != 12 || end != 20 {
73 t.Fatalf("window = [%d,%d), want [12,20)", start, end)
74 }
75 }
76
77 func TestQuickPickerRendersWithinNarrowPanel(t *testing.T) {
78 p := &quickPicker{
79 title: "Select model",
80 items: []quickPickerItem{{
81 ID: "long", Label: strings.Repeat("very-long-model-name-", 5),
82 Description: strings.Repeat("provider description ", 5), Status: "active",
83 }},
84 }
85 out := p.render(32)
86 for _, line := range strings.Split(out, "\n") {
87 if got := visibleWidth(line); got > 32 {
88 t.Fatalf("rendered line width = %d, want <= 32: %q", got, line)
89 }
90 }
91 }
92
93 func TestQuickPickerEscCancels(t *testing.T) {
94 p := &quickPicker{items: []quickPickerItem{{ID: "one", Label: "One"}}}
95 if result := p.handleKey(tea.KeyPressMsg{Code: tea.KeyEsc}); !result.cancelled {
96 t.Fatal("Esc should cancel picker")
97 }
98 }
99
100 // TestQuickPickerModelSwitch verifies that selecting a model from the
101 // quickPicker returns a non-nil tea.Cmd that produces a modelSwitchMsg.
102 // Regression test for the bug where handleQuickPickerKey set
103 // m.pendingModelSwitch but returned nil as the tea.Cmd, causing the async
104 // controller build to never execute.
105 func TestQuickPickerModelSwitch(t *testing.T) {
106 oldCtrl := control.New(control.Options{Label: "old"})
107 newCtrl := control.New(control.Options{Label: "new-model", Commands: []command.Command{{Name: "cmd"}}, Skills: []skill.Skill{{Name: "sk"}}})
108 m := newChatTUI(oldCtrl, "", make(chan event.Event, 1), 100)
109 m.modelRef = "provider/old-model"
110 m.quickPick = &quickPicker{
111 kind: quickPickerModel,
112 title: "Select model",
113 items: []quickPickerItem{
114 {ID: "provider/new-model", Label: "provider/new-model"},
115 },
116 selected: 0,
117 }
118 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
119 return newCtrl, nil
120 }
121
122 // Simulate pressing Enter in the quickPicker
123 next, cmd := m.handleQuickPickerKey(tea.KeyPressMsg{Code: tea.KeyEnter})
124 m2 := next.(chatTUI)
125
126 if cmd == nil {
127 t.Fatal("quickPick model switch did not schedule a controller build (cmd is nil)")
128 }
129 if !m2.modelSwitchPending {
130 t.Fatal("quickPick model switch did not set modelSwitchPending")
131 }
132 if m2.pendingModelSwitch == nil {
133 t.Fatal("quickPick model switch did not set pendingModelSwitch")
134 }
135 if m2.ctrl != oldCtrl {
136 t.Fatal("controller changed before the replacement build completed")
137 }
138
139 // Execute the cmd and verify it produces a modelSwitchMsg
140 msg := cmd()
141 if msg == nil {
142 t.Fatal("controller build cmd returned nil message")
143 }
144 swMsg, ok := msg.(modelSwitchMsg)
145 if !ok {
146 t.Fatalf("controller build returned %T, want modelSwitchMsg", msg)
147 }
148 if swMsg.err != nil {
149 t.Fatalf("controller build failed: %v", swMsg.err)
150 }
151 if swMsg.ref != "provider/new-model" {
152 t.Fatalf("modelSwitchMsg ref = %q, want %q", swMsg.ref, "provider/new-model")
153 }
154 if swMsg.ctrl != newCtrl {
155 t.Fatal("modelSwitchMsg did not carry the new controller")
156 }
157 if swMsg.oldCtrl != oldCtrl {
158 t.Fatal("modelSwitchMsg did not carry the old controller")
159 }
160 }
161
162 func TestQuickPickerProviderSingleModelSwitch(t *testing.T) {
163 isolateUserConfig(t)
164 cfg := config.Default()
165 cfg.Providers = []config.ProviderEntry{{
166 Name: "new-provider",
167 Kind: "openai",
168 BaseURL: "http://localhost:1234/v1",
169 Model: "new-model",
170 }}
171 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
172 t.Fatalf("save provider config: %v", err)
173 }
174
175 oldCtrl := control.New(control.Options{Label: "old"})
176 newCtrl := control.New(control.Options{Label: "new-model"})
177 m := newChatTUI(oldCtrl, "", make(chan event.Event, 1), 100)
178 m.modelRef = "old-provider/old-model"
179 m.quickPick = &quickPicker{
180 kind: quickPickerProvider,
181 title: "Select provider",
182 items: []quickPickerItem{{ID: "new-provider", Label: "new-provider"}},
183 selected: 0,
184 }
185 m.buildController = func(_ controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
186 return newCtrl, nil
187 }
188
189 next, cmd := m.handleQuickPickerKey(tea.KeyPressMsg{Code: tea.KeyEnter})
190 m2 := next.(chatTUI)
191 if cmd == nil {
192 t.Fatal("provider quickPick did not schedule a controller build (cmd is nil)")
193 }
194 if !m2.modelSwitchPending || m2.pendingModelSwitch == nil {
195 t.Fatal("provider quickPick did not retain the pending model switch")
196 }
197
198 msg := cmd()
199 swMsg, ok := msg.(modelSwitchMsg)
200 if !ok {
201 t.Fatalf("controller build returned %T, want modelSwitchMsg", msg)
202 }
203 if swMsg.err != nil {
204 t.Fatalf("controller build failed: %v", swMsg.err)
205 }
206 if swMsg.ref != "new-provider/new-model" {
207 t.Fatalf("modelSwitchMsg ref = %q, want %q", swMsg.ref, "new-provider/new-model")
208 }
209 if swMsg.ctrl != newCtrl || swMsg.oldCtrl != oldCtrl {
210 t.Fatal("modelSwitchMsg did not carry the expected controllers")
211 }
212 }
213
213 lines GO