1package modes
2
3import (
4 "bytes"
5 "fmt"
6 "io"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/patriceckhart/zot/packages/tui"
12)
13
14// settingsTestItems builds a list long enough to overflow a short
15// terminal. Every entry carries a description that wraps, so an entry
16// costs more than one row and the window has to account for it.
17func settingsTestItems(n int) []settingsItem {
18 items := make([]settingsItem, 0, n)
19 for i := 0; i < n; i++ {
20 items = append(items, settingsItem{
21 key: fmt.Sprintf("item_%02d", i),
22 label: fmt.Sprintf("entry %02d", i),
23 desc: "description text that wraps onto several rows at the widths these tests render so every entry costs more than a single line",
24 })
25 }
26 return items
27}
28
29func renderedPlain(lines []string) string { return stripANSIBytes(strings.Join(lines, "\n")) }
30
31func TestSettingsDialogWindowsListToMaxRows(t *testing.T) {
32 d := newSettingsDialog()
33 d.Open(settingsTestItems(20))
34 d.MaxRows = 12
35
36 lines := d.Render(tui.Dark, 80)
37 if len(lines) > d.MaxRows {
38 t.Fatalf("rendered %d rows, want <= %d", len(lines), d.MaxRows)
39 }
40 text := renderedPlain(lines)
41 for _, want := range []string{"entry 00", "entry 01", "more below"} {
42 if !strings.Contains(text, want) {
43 t.Fatalf("top of window missing %q:\n%s", want, text)
44 }
45 }
46 if strings.Contains(text, "entry 19") {
47 t.Fatalf("window rendered the far tail of the list:\n%s", text)
48 }
49}
50
51func TestSettingsDialogWindowFollowsCursorToBothEnds(t *testing.T) {
52 d := newSettingsDialog()
53 d.Open(settingsTestItems(20))
54 d.MaxRows = 10
55
56 for i := 1; i < 20; i++ {
57 d.HandleKey(tui.Key{Kind: tui.KeyDown})
58 lines := d.Render(tui.Dark, 80)
59 if len(lines) > d.MaxRows {
60 t.Fatalf("cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
61 }
62 if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
63 t.Fatalf("cursor entry %02d scrolled out of view:\n%s", i, text)
64 }
65 }
66 top := d.Render(tui.Dark, 80)
67 if text := renderedPlain(top); !strings.Contains(text, "more above") {
68 t.Fatalf("bottom of list missing the above marker:\n%s", text)
69 }
70
71 for i := 18; i >= 0; i-- {
72 d.HandleKey(tui.Key{Kind: tui.KeyUp})
73 lines := d.Render(tui.Dark, 80)
74 if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
75 t.Fatalf("cursor entry %02d scrolled out of view on the way back:\n%s", i, text)
76 }
77 }
78 if text := renderedPlain(d.Render(tui.Dark, 80)); !strings.Contains(text, "entry 00") {
79 t.Fatalf("first entry not visible after scrolling back up:\n%s", text)
80 }
81}
82
83func TestSettingsDialogOptionsWindowFollowsCursor(t *testing.T) {
84 item := settingsItem{key: "theme", label: "color theme"}
85 for i := 0; i < 30; i++ {
86 item.options = append(item.options, settingsOption{
87 value: fmt.Sprintf("opt_%02d", i),
88 label: fmt.Sprintf("option %02d", i),
89 desc: "option description that also wraps onto several rows at the widths these tests render",
90 })
91 }
92 d := newSettingsDialog()
93 d.OpenDirectOption(item)
94 d.MaxRows = 14
95
96 for i := 1; i < 30; i++ {
97 d.HandleKey(tui.Key{Kind: tui.KeyDown})
98 lines := d.Render(tui.Dark, 80)
99 if len(lines) > d.MaxRows {
100 t.Fatalf("option cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
101 }
102 if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("option %02d", i)) {
103 t.Fatalf("option %02d scrolled out of view:\n%s", i, text)
104 }
105 }
106 last := d.Render(tui.Dark, 80)
107 if text := renderedPlain(last); !strings.Contains(text, "more above") || !strings.Contains(text, "option 29") {
108 t.Fatalf("bottom of option list not windowed on the cursor:\n%s", text)
109 }
110}
111
112func TestSettingsDialogKeepsCursorRowOnTinyTerminal(t *testing.T) {
113 d := newSettingsDialog()
114 d.Open(settingsTestItems(20))
115 // Four rows is the dialog's floor: header, hint, one row of list,
116 // rule. The cursor entry must still be the row that survives.
117 d.MaxRows = 4
118 for i := 0; i < 20; i++ {
119 lines := d.Render(tui.Dark, 80)
120 if len(lines) > d.MaxRows {
121 t.Fatalf("cursor at %d: rendered %d rows, want <= %d", i, len(lines), d.MaxRows)
122 }
123 if text := renderedPlain(lines); !strings.Contains(text, fmt.Sprintf("entry %02d", i)) {
124 t.Fatalf("cursor entry %02d missing at the row floor:\n%s", i, text)
125 }
126 d.HandleKey(tui.Key{Kind: tui.KeyDown})
127 }
128}
129
130func TestSettingsOptionPickerFitsShortTerminal(t *testing.T) {
131 for _, rows := range []int{12, 14, 16, 17, 24} {
132 t.Run(fmt.Sprintf("rows=%d", rows), func(t *testing.T) {
133 term := &shortTestTerminal{cols: 80, rows: rows}
134 i := NewInteractive(InteractiveConfig{Terminal: term})
135 i.settingsDialog.OpenDirectOption(settingsItem{
136 label: "reasoning level", desc: "reasoning depth for reasoning-capable models",
137 options: []settingsOption{{label: "off"}, {label: "low"}},
138 })
139 i.redraw()
140
141 lines := i.settingsDialog.Render(i.cfg.Theme, 80)
142 if len(lines) > i.settingsDialog.MaxRows {
143 t.Fatalf("option picker rendered %d rows, cap %d", len(lines), i.settingsDialog.MaxRows)
144 }
145 text := renderedPlain(lines)
146 if !strings.Contains(text, "off") {
147 t.Fatalf("selected option missing from window:\n%s", text)
148 }
149 if i.settingsDialog.MaxRows >= 5 && !strings.Contains(text, "reasoning depth") {
150 t.Fatalf("description missing when it fits:\n%s", text)
151 }
152 written := strings.Count(term.String(), "\r\n") + 1
153 band := written - len(i.cachedChatLocked(80))
154 if band > rows-1 {
155 t.Fatalf("bottom band is %d rows for a %d-row terminal", band, rows)
156 }
157 })
158 }
159}
160
161func TestSettingsDialogRendersEverythingWithoutMaxRows(t *testing.T) {
162 d := newSettingsDialog()
163 d.Open(settingsTestItems(20))
164
165 text := renderedPlain(d.Render(tui.Dark, 80))
166 for _, want := range []string{"entry 00", "entry 19"} {
167 if !strings.Contains(text, want) {
168 t.Fatalf("unbounded render missing %q:\n%s", want, text)
169 }
170 }
171 if strings.Contains(text, "more above") || strings.Contains(text, "more below") {
172 t.Fatalf("unbounded render drew window markers:\n%s", text)
173 }
174}
175
176// shortTestTerminal is a Terminal with a configurable size so the
177// layout can be exercised at heights a real window would report.
178type shortTestTerminal struct {
179 bytes.Buffer
180 cols, rows int
181}
182
183func (t *shortTestTerminal) Size() (int, int) { return t.cols, t.rows }
184func (t *shortTestTerminal) OnResize(func()) {}
185func (t *shortTestTerminal) SetNonblock(bool) error { return nil }
186func (t *shortTestTerminal) ReadByte() (byte, error) {
187 return 0, io.EOF
188}
189func (t *shortTestTerminal) EnterRaw() (func() error, error) {
190 return func() error { return nil }, nil
191}
192func (t *shortTestTerminal) PeekByteTimeout(time.Duration) (byte, bool, error) {
193 return 0, false, io.EOF
194}
195
196func TestSettingsDialogIsCappedToShortTerminal(t *testing.T) {
197 for _, rows := range []int{12, 14, 16, 24, 40, 60} {
198 t.Run(fmt.Sprintf("rows=%d", rows), func(t *testing.T) {
199 term := &shortTestTerminal{cols: 80, rows: rows}
200 i := NewInteractive(InteractiveConfig{Terminal: term})
201 i.openSettingsDialog()
202 if !i.settingsDialog.Active() {
203 t.Fatal("settings dialog did not open")
204 }
205 i.redraw()
206
207 if i.settingsDialog.MaxRows <= 0 || i.settingsDialog.MaxRows >= term.rows {
208 t.Fatalf("dialog cap = %d rows for a %d-row terminal", i.settingsDialog.MaxRows, term.rows)
209 }
210 lines := i.settingsDialog.Render(i.cfg.Theme, 80)
211 if len(lines) > i.settingsDialog.MaxRows {
212 t.Fatalf("dialog rendered %d rows, cap %d", len(lines), i.settingsDialog.MaxRows)
213 }
214 text := renderedPlain(lines)
215 if !strings.Contains(text, "render images when supported") {
216 t.Fatalf("first settings entry missing from the window:\n%s", text)
217 }
218
219 // The band the dialog draws into must fit above the
220 // terminal's bottom edge: written lines are the chat, the
221 // bottom band, and the renderer's trailing margin row. A
222 // taller band scrolls the top of the dialog out of reach
223 // again.
224 written := strings.Count(term.String(), "\r\n") + 1
225 band := written - len(i.cachedChatLocked(80))
226 if band > term.rows-1 {
227 t.Fatalf("bottom band is %d rows for a %d-row terminal; dialog would be clipped", band, term.rows)
228 }
229 })
230 }
231}