返回 DeepSeek-Reasonix
select_test.go
根目录 / internal / cli / select_test.go
1 package cli
2
3 import (
4 "bytes"
5 "fmt"
6 "os"
7 "strings"
8 "testing"
9
10 "github.com/charmbracelet/colorprofile"
11 )
12
13 func TestFrameLines(t *testing.T) {
14 tests := []struct {
15 name string
16 filtered int
17 termRows int
18 searching bool
19 wantLines int
20 }{
21 {
22 name: "small list no search",
23 filtered: 5,
24 termRows: 24,
25 searching: false,
26 wantLines: 4 + 5, // fixed(4) + items(5)
27 },
28 {
29 name: "small list with search",
30 filtered: 5,
31 termRows: 24,
32 searching: true,
33 wantLines: 5 + 5, // fixed(5 search) + items(5)
34 },
35 {
36 name: "list exceeds terminal height",
37 filtered: 100,
38 termRows: 24,
39 searching: false,
40 wantLines: 4 + 20, // fixed(4) + viewport(24-4=20)
41 },
42 {
43 name: "list exceeds terminal with search",
44 filtered: 100,
45 termRows: 24,
46 searching: true,
47 wantLines: 5 + 19, // fixed(5) + viewport(24-5=19)
48 },
49 {
50 name: "empty list",
51 filtered: 0,
52 termRows: 24,
53 searching: false,
54 wantLines: 4 + 0, // fixed(4) + viewport(0 items)
55 },
56 {
57 name: "tiny terminal",
58 filtered: 10,
59 termRows: 8,
60 searching: false,
61 wantLines: 4 + 4, // fixed(4) + viewport(8-4=4)
62 },
63 {
64 name: "tiny terminal with search",
65 filtered: 10,
66 termRows: 8,
67 searching: true,
68 wantLines: 5 + 3, // fixed(5) + viewport(8-5=3)
69 },
70 }
71 for _, tt := range tests {
72 t.Run(tt.name, func(t *testing.T) {
73 got := FrameLines(tt.filtered, tt.termRows, tt.searching)
74 if got != tt.wantLines {
75 t.Errorf("FrameLines(%d, %d, %v) = %d, want %d",
76 tt.filtered, tt.termRows, tt.searching, got, tt.wantLines)
77 }
78 })
79 }
80 }
81
82 func TestFrameLinesNeverExceedsTerminal(t *testing.T) {
83 // The frame must never print more lines than the terminal has rows.
84 // Otherwise the terminal scrolls and cursor repositioning drifts.
85 termRows := 24
86 for _, searching := range []bool{false, true} {
87 for n := range 201 {
88 lines := FrameLines(n, termRows, searching)
89 if lines > termRows {
90 t.Errorf("FrameLines(%d, %d, searching=%v) = %d, exceeds terminal",
91 n, termRows, searching, lines)
92 }
93 }
94 }
95 }
96
97 func TestMaxViewportBounds(t *testing.T) {
98 // Viewport must be at least 1 even on impossibly small terminals.
99 vp := maxViewport(10, 3, false)
100 if vp < 1 {
101 t.Errorf("maxViewport(10, 3, false) = %d, want >= 1", vp)
102 }
103 // When items < available rows, viewport equals items.
104 vp = maxViewport(3, 24, false)
105 if vp != 3 {
106 t.Errorf("maxViewport(3, 24, false) = %d, want 3", vp)
107 }
108 }
109
110 func TestFilterMenuItems(t *testing.T) {
111 items := []menuItem{
112 {name: "deepseek-v4", desc: "DeepSeek V4"},
113 {name: "gpt-4o", desc: "OpenAI GPT-4o"},
114 {name: "mimo-pro", desc: "MiMo Pro"},
115 }
116 // Empty query returns all.
117 if got := filterMenuItems(items, ""); len(got) != 3 {
118 t.Errorf("empty query: got %d, want 3", len(got))
119 }
120 // Case-insensitive match on name.
121 if got := filterMenuItems(items, "GPT"); len(got) != 1 || got[0].name != "gpt-4o" {
122 t.Errorf("GPT: got %v", got)
123 }
124 // Case-insensitive match on desc.
125 if got := filterMenuItems(items, "mimo"); len(got) != 1 || got[0].name != "mimo-pro" {
126 t.Errorf("mimo: got %v", got)
127 }
128 // No match.
129 if got := filterMenuItems(items, "claude"); len(got) != 0 {
130 t.Errorf("claude: got %d, want 0", len(got))
131 }
132 }
133
134 // A frame line that soft-wraps occupies more rows than redraw counts, so a
135 // narrow terminal used to stack a fresh copy of the header and the wrapped
136 // rows on every keypress. Every line the frame emits must fit its width.
137 func TestMenuFrameLinesFitTerminalWidth(t *testing.T) {
138 prev := activeColorProfile
139 activeColorProfile = colorprofile.ANSI256
140 t.Cleanup(func() { activeColorProfile = prev })
141
142 items := []menuItem{
143 {name: "deepseek-pro", desc: "openai · 1 models · key missing"},
144 {name: "Add Anthropic-compatible provider", desc: "Add third-party Anthropic compatible model"},
145 {name: "中转站", desc: "自定义 OpenAI 兼容模型 · 密钥缺失"},
146 }
147 for _, cols := range []int{1, 8, 40, 60} {
148 var buf bytes.Buffer
149 f := menuFrame{w: &buf, cols: cols}
150 f.header("Provider configuration", "(↑/↓ · Enter · q to cancel; / to search)")
151 f.searchBar("a query that is much longer than the narrowest terminal")
152 for i, it := range items {
153 f.row(i == 0, fmt.Sprintf("%-10s", it.name), it.desc)
154 }
155 out := buf.String()
156 if strings.Count(out, "\n") != strings.Count(out, "\r\n") {
157 t.Errorf("cols=%d: a line ends with a bare LF, which leaves the cursor mid-row in raw mode", cols)
158 }
159 lines := strings.Split(strings.TrimSuffix(out, "\r\n"), "\r\n")
160 if got, want := len(lines), 3+len(items); got != want {
161 t.Fatalf("cols=%d: wrote %d lines, want %d", cols, got, want)
162 }
163 for _, line := range lines {
164 if !strings.HasPrefix(line, "\r\033[K") {
165 t.Errorf("cols=%d: line %q does not start on a cleared row", cols, line)
166 }
167 if w := visibleWidth(line); w > cols {
168 t.Errorf("cols=%d: line %q spans %d cells and would wrap", cols, line, w)
169 }
170 }
171 }
172 }
173
174 func TestMenuFrameClipsLongLinesWithEllipsis(t *testing.T) {
175 prev := activeColorProfile
176 activeColorProfile = colorprofile.ANSI256
177 t.Cleanup(func() { activeColorProfile = prev })
178
179 var buf bytes.Buffer
180 f := menuFrame{w: &buf, cols: 24}
181 f.row(true, "deepseek-pro", "openai · 1 models · key missing")
182 got := strings.TrimSuffix(buf.String(), "\r\n")
183 if !strings.HasSuffix(got, "…"+ansiReset) {
184 t.Errorf("clipped row %q should end with an ellipsis inside the reverse-video span", got)
185 }
186 if w := visibleWidth(got); w != 24 {
187 t.Errorf("clipped row spans %d cells, want 24", w)
188 }
189
190 buf.Reset()
191 f.row(false, "deepseek-pro", "openai")
192 if got := buf.String(); strings.Contains(got, "…") {
193 t.Errorf("row that fits was clipped: %q", got)
194 }
195
196 buf.Reset()
197 menuFrame{w: &buf, cols: 0}.row(true, "deepseek-pro", "openai · 1 models · key missing")
198 if got := buf.String(); strings.Contains(got, "…") {
199 t.Errorf("unknown width must leave the row unclipped: %q", got)
200 }
201 }
202
203 func TestTermSizeFallsBackToVT100(t *testing.T) {
204 r, w, err := os.Pipe()
205 if err != nil {
206 t.Fatal(err)
207 }
208 defer r.Close()
209 defer w.Close()
210 if cols, rows := termSize(int(r.Fd())); cols != 80 || rows != 24 {
211 t.Errorf("termSize(pipe) = %d×%d, want 80×24", cols, rows)
212 }
213 }
214
214 lines GO