1package config
2
3import (
4 "testing"
5
6 "github.com/spf13/viper"
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
9)
10
11var legacyVimModeActions = []string{
12 "vim-scroll-left",
13 "vim-scroll-down",
14 "vim-scroll-up",
15 "vim-scroll-right",
16 "vim-scroll-down-fast",
17 "vim-scroll-up-fast",
18 "confirm",
19 "cancel",
20}
21
22var sequenceVimModeActions = map[string]string{
23 "heading-next": "]]",
24 "heading-prev": "[[",
25 "focus-input": "gi",
26 "code-next": "]c",
27 "code-prev": "[c",
28 "table-next": "]t",
29 "image-next": "]i",
30 "list-next": "]l",
31 "outline": "gO",
32 "yank-section": "yah",
33 "half-page-down": "<C-d>",
34 "half-page-up": "<C-u>",
35}
36
37func TestDefaultConfig_VimModeDefaults(t *testing.T) {
38 cfg := DefaultConfig()
39
40 // VimMode must exist in workspace config.
41 vimMode := cfg.Workspace.VimMode
42 assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut)
43 assert.Equal(t, 0, vimMode.TimeoutMilliseconds)
44 assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
45
46 // All required actions must exist with exact key bindings.
47 require.NotNil(t, vimMode.Actions)
48 require.Len(t, legacyVimModeActions, 8)
49 require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
50 requireActionBinding(t, vimMode.Actions, "vim-scroll-left", []string{"h"})
51 requireActionBinding(t, vimMode.Actions, "vim-scroll-down", []string{"j"})
52 requireActionBinding(t, vimMode.Actions, "vim-scroll-up", []string{"k"})
53 requireActionBinding(t, vimMode.Actions, "vim-scroll-right", []string{"l"})
54 requireActionBinding(t, vimMode.Actions, "vim-scroll-down-fast", []string{"shift+j"})
55 requireActionBinding(t, vimMode.Actions, "vim-scroll-up-fast", []string{"shift+k"})
56 requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"})
57 requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"})
58 for _, action := range legacyVimModeActions {
59 _, ok := vimMode.Actions[action]
60 require.True(t, ok, "missing legacy vim mode action %q", action)
61 }
62}
63
64func TestVimModeSequenceDefaults(t *testing.T) {
65 cfg := DefaultConfig()
66 vimMode := cfg.Workspace.VimMode
67
68 assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
69 require.Len(t, sequenceVimModeActions, 12)
70
71 for action, key := range sequenceVimModeActions {
72 requireActionBinding(t, vimMode.Actions, action, []string{key})
73 }
74}
75
76func TestVimModeConfig_DefaultsLoadThroughViper(t *testing.T) {
77 mgr := &Manager{viper: viper.New()}
78 mgr.setDefaults()
79
80 cfg, err := mgr.unmarshalConfig()
81 require.NoError(t, err)
82
83 vimMode := cfg.Workspace.VimMode
84 assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut)
85 assert.Equal(t, 0, vimMode.TimeoutMilliseconds)
86 assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
87 require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
88 requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"})
89 requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"})
90 requireActionBinding(t, vimMode.Actions, "half-page-down", []string{"<C-d>"})
91 requireActionBinding(t, vimMode.Actions, "heading-next", []string{"]]"})
92}
93
94func TestDefaultConfig_VimModeDeepClone(t *testing.T) {
95 first := DefaultConfig()
96 second := DefaultConfig()
97
98 first.Workspace.VimMode.SequenceTimeoutMilliseconds = 999
99 first.Workspace.VimMode.Actions["confirm"] = ActionBinding{Keys: []string{"mutated"}, Desc: "mutated"}
100 delete(first.Workspace.VimMode.Actions, "heading-next")
101
102 assert.Equal(t, 500, second.Workspace.VimMode.SequenceTimeoutMilliseconds)
103 requireActionBinding(t, second.Workspace.VimMode.Actions, "confirm", []string{"enter"})
104 requireActionBinding(t, second.Workspace.VimMode.Actions, "heading-next", []string{"]]"})
105 require.Len(t, second.Workspace.VimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
106}
107
108func TestVimModeConfig_GetKeyBindings(t *testing.T) {
109 cfg := DefaultConfig()
110 bindings := cfg.Workspace.VimMode.GetKeyBindings()
111
112 assert.Equal(t, "vim-scroll-left", bindings["h"])
113 assert.Equal(t, "vim-scroll-down", bindings["j"])
114 assert.Equal(t, "vim-scroll-up", bindings["k"])
115 assert.Equal(t, "vim-scroll-right", bindings["l"])
116 assert.Equal(t, "vim-scroll-down-fast", bindings["shift+j"])
117 assert.Equal(t, "vim-scroll-up-fast", bindings["shift+k"])
118 assert.Equal(t, "confirm", bindings["enter"])
119 assert.Equal(t, "cancel", bindings["escape"])
120 assert.Equal(t, "heading-next", bindings["]]"])
121 assert.Equal(t, "heading-prev", bindings["[["])
122 assert.Equal(t, "focus-input", bindings["gi"])
123 assert.Equal(t, "code-next", bindings["]c"])
124 assert.Equal(t, "code-prev", bindings["[c"])
125 assert.Equal(t, "table-next", bindings["]t"])
126 assert.Equal(t, "image-next", bindings["]i"])
127 assert.Equal(t, "list-next", bindings["]l"])
128 assert.Equal(t, "outline", bindings["gO"])
129 assert.Equal(t, "yank-section", bindings["yah"])
130 assert.Equal(t, "half-page-down", bindings["<C-d>"])
131 assert.Equal(t, "half-page-up", bindings["<C-u>"])
132 assert.Len(t, bindings, 20)
133}
134
135func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {
136 tests := []struct {
137 name string
138 mutate func(*Config)
139 want string
140 }{
141 {
142 name: "negative timeout fails",
143 mutate: func(cfg *Config) {
144 cfg.Workspace.VimMode.TimeoutMilliseconds = -1
145 },
146 want: "workspace.vim_mode.timeout_ms must be non-negative",
147 },
148 {
149 name: "zero timeout ok",
150 mutate: func(_ *Config) {
151 },
152 want: "",
153 },
154 {
155 name: "positive timeout ok",
156 mutate: func(cfg *Config) {
157 cfg.Workspace.VimMode.TimeoutMilliseconds = 5000
158 },
159 want: "",
160 },
161 }
162
163 for _, tt := range tests {
164 t.Run(tt.name, func(t *testing.T) {
165 cfg := DefaultConfig()
166 tt.mutate(cfg)
167 err := validateConfig(cfg)
168 if tt.want == "" {
169 require.NoError(t, err)
170 } else {
171 require.Error(t, err)
172 assert.Contains(t, err.Error(), tt.want)
173 }
174 })
175 }
176}
177
178func TestValidateVimMode_SequenceTimeout(t *testing.T) {
179 tests := []struct {
180 name string
181 mutate func(*Config)
182 want string
183 }{
184 {
185 name: "negative sequence timeout fails",
186 mutate: func(cfg *Config) {
187 cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = -1
188 },
189 want: "workspace.vim_mode.sequence_timeout_ms must be non-negative",
190 },
191 {
192 name: "zero sequence timeout ok",
193 mutate: func(cfg *Config) {
194 cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 0
195 },
196 want: "",
197 },
198 {
199 name: "positive sequence timeout ok",
200 mutate: func(cfg *Config) {
201 cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 750
202 },
203 want: "",
204 },
205 }
206
207 for _, tt := range tests {
208 t.Run(tt.name, func(t *testing.T) {
209 cfg := DefaultConfig()
210 tt.mutate(cfg)
211 err := validateConfig(cfg)
212 if tt.want == "" {
213 require.NoError(t, err)
214 } else {
215 require.Error(t, err)
216 assert.Contains(t, err.Error(), tt.want)
217 }
218 })
219 }
220}
221
222func TestValidateVimMode_NilActionsFail(t *testing.T) {
223 cfg := DefaultConfig()
224 cfg.Workspace.VimMode.Actions = nil
225
226 err := validateConfig(cfg)
227 require.Error(t, err)
228 assert.Contains(t, err.Error(), "workspace.vim_mode.actions cannot be empty")
229}
230
231func TestValidateVimMode_DuplicateKeyBindings(t *testing.T) {
232 cfg := DefaultConfig()
233 // Add a duplicate key binding that conflicts with an existing one.
234 action := "custom-action"
235 cfg.Workspace.VimMode.Actions[action] = ActionBinding{
236 Keys: []string{"h"}, // "h" is already used by vim-scroll-left
237 Desc: "Custom action",
238 }
239
240 err := validateConfig(cfg)
241 require.Error(t, err)
242 assert.Contains(t, err.Error(), "duplicate key binding 'h' found in vim_mode actions")
243 assert.Contains(t, err.Error(), "vim-scroll-left")
244 assert.Contains(t, err.Error(), action)
245}
246
247func TestValidateVimMode_MalformedBindings(t *testing.T) {
248 malformed := []string{"<C-d", "<Nope>", "<C->"}
249 for _, key := range malformed {
250 t.Run(key, func(t *testing.T) {
251 cfg := DefaultConfig()
252 cfg.Workspace.VimMode.Actions["broken"] = ActionBinding{
253 Keys: []string{key},
254 Desc: "Broken binding",
255 }
256
257 err := validateConfig(cfg)
258 require.Error(t, err)
259 assert.Contains(t, err.Error(), "broken")
260 assert.Contains(t, err.Error(), key)
261 })
262 }
263}
264
265func TestValidateVimMode_LegacyEnterEscapeRemainCompatible(t *testing.T) {
266 cfg := DefaultConfig()
267 requireActionBinding(t, cfg.Workspace.VimMode.Actions, "confirm", []string{"enter"})
268 requireActionBinding(t, cfg.Workspace.VimMode.Actions, "cancel", []string{"escape"})
269 require.NoError(t, validateConfig(cfg))
270
271 assert.Equal(t, "<Return>", canonicalizeVimModeBinding("enter"))
272 assert.Equal(t, "<Escape>", canonicalizeVimModeBinding("escape"))
273 assert.Equal(t, "<C-d>", canonicalizeVimModeBinding("<C-d>"))
274}
275
276func TestValidateVimMode_CanonicalAliasConflicts(t *testing.T) {
277 tests := []struct {
278 name string
279 actionA string
280 keysA []string
281 actionB string
282 keysB []string
283 canon string
284 }{
285 {
286 name: "CR and Return",
287 actionA: "alias-a",
288 keysA: []string{"<CR>"},
289 actionB: "alias-b",
290 keysB: []string{"<Return>"},
291 canon: "<Return>",
292 },
293 {
294 name: "enter and Return",
295 actionA: "alias-a",
296 keysA: []string{"enter"},
297 actionB: "alias-b",
298 keysB: []string{"<Return>"},
299 canon: "<Return>",
300 },
301 {
302 name: "escape and Escape",
303 actionA: "alias-a",
304 keysA: []string{"escape"},
305 actionB: "alias-b",
306 keysB: []string{"<Escape>"},
307 canon: "<Escape>",
308 },
309 {
310 name: "J and shift+j",
311 actionA: "alias-a",
312 keysA: []string{"J"},
313 actionB: "alias-b",
314 keysB: []string{"shift+j"},
315 canon: "J",
316 },
317 {
318 name: "C-d and ctrl+d",
319 actionA: "alias-a",
320 keysA: []string{"<C-d>"},
321 actionB: "alias-b",
322 keysB: []string{"ctrl+d"},
323 canon: "<C-d>",
324 },
325 }
326
327 for _, tt := range tests {
328 t.Run(tt.name, func(t *testing.T) {
329 cfg := DefaultConfig()
330 // Drop defaults that would collide with the aliases under test.
331 delete(cfg.Workspace.VimMode.Actions, "half-page-down")
332 delete(cfg.Workspace.VimMode.Actions, "vim-scroll-down-fast")
333 delete(cfg.Workspace.VimMode.Actions, "confirm")
334 delete(cfg.Workspace.VimMode.Actions, "cancel")
335 cfg.Workspace.VimMode.Actions[tt.actionA] = ActionBinding{Keys: tt.keysA, Desc: "alias a"}
336 cfg.Workspace.VimMode.Actions[tt.actionB] = ActionBinding{Keys: tt.keysB, Desc: "alias b"}
337
338 err := validateConfig(cfg)
339 require.Error(t, err)
340 assert.Contains(t, err.Error(), "duplicate key binding '"+tt.canon+"' found in vim_mode actions")
341 assert.Contains(t, err.Error(), tt.actionA)
342 assert.Contains(t, err.Error(), tt.actionB)
343 })
344 }
345}
346
347func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) {
348 cfg := DefaultConfig()
349 // Override an action with no key bindings.
350 cfg.Workspace.VimMode.Actions = map[string]ActionBinding{
351 "vim-scroll-left": {Keys: []string{}, Desc: "No keys"},
352 }
353
354 err := validateConfig(cfg)
355 require.Error(t, err)
356 assert.Contains(t, err.Error(), "workspace.vim_mode.actions.vim-scroll-left must have at least one key binding")
357}
358
359func TestValidateModalModeActions_RegressionTable(t *testing.T) {
360 tests := []struct {
361 name string
362 modePath string
363 timeout int
364 actions map[string]ActionBinding
365 want string
366 }{
367 {
368 name: "page negative timeout",
369 modePath: "vim_mode",
370 timeout: -1,
371 actions: map[string]ActionBinding{"vim-scroll-left": {Keys: []string{"h"}}},
372 want: "workspace.vim_mode.timeout_ms must be non-negative",
373 },
374 {
375 name: "pane empty actions",
376 modePath: "pane_mode",
377 timeout: 0,
378 actions: nil,
379 want: "workspace.pane_mode.actions cannot be empty",
380 },
381 {
382 name: "tab empty keys",
383 modePath: "tab_mode",
384 timeout: 0,
385 actions: map[string]ActionBinding{"new-tab": {Keys: []string{}}},
386 want: "workspace.tab_mode.actions.new-tab must have at least one key binding",
387 },
388 {
389 name: "page duplicate vim binding",
390 modePath: "vim_mode",
391 timeout: 0,
392 actions: map[string]ActionBinding{
393 "vim-scroll-left": {Keys: []string{"h"}},
394 "vim-scroll-right": {Keys: []string{"h"}},
395 },
396 want: "duplicate key binding 'h' found in vim_mode actions",
397 },
398 {
399 name: "page canonical vim set ok",
400 modePath: "vim_mode",
401 timeout: 0,
402 actions: map[string]ActionBinding{
403 "vim-scroll-left": {Keys: []string{"h"}},
404 "vim-scroll-down": {Keys: []string{"j"}},
405 "vim-scroll-up": {Keys: []string{"k"}},
406 "vim-scroll-right": {Keys: []string{"l"}},
407 "vim-scroll-down-fast": {Keys: []string{"shift+j"}},
408 "vim-scroll-up-fast": {Keys: []string{"shift+k"}},
409 "confirm": {Keys: []string{"enter"}},
410 "cancel": {Keys: []string{"escape"}},
411 },
412 want: "",
413 },
414 }
415
416 for _, tt := range tests {
417 t.Run(tt.name, func(t *testing.T) {
418 errs := validateModalModeActions(tt.modePath, tt.timeout, tt.actions)
419 if tt.want == "" {
420 assert.Empty(t, errs)
421 return
422 }
423 require.NotEmpty(t, errs)
424 assert.Contains(t, errs[0], tt.want)
425 })
426 }
427}
428
429func TestValidateVimMode_DefaultsAccept(t *testing.T) {
430 cfg := DefaultConfig()
431 require.NoError(t, validateConfig(cfg))
432}
433
434func TestSchemaProvider_VimModeKeys(t *testing.T) {
435 provider := &SchemaProvider{}
436 schema := provider.GetSchema()
437
438 var foundActivation, foundTimeout, foundSequenceTimeout, foundActions bool
439 for _, key := range schema {
440 switch key.Key {
441 case "workspace.vim_mode.activation_shortcut":
442 foundActivation = true
443 assert.Equal(t, "string", key.Type)
444 assert.Equal(t, "ctrl+y", key.Default)
445 assert.Equal(t, SectionWorkspace, key.Section)
446 case "workspace.vim_mode.timeout_ms":
447 foundTimeout = true
448 assert.Equal(t, "int", key.Type)
449 assert.Equal(t, "0", key.Default)
450 assert.Equal(t, ">=0", key.Range)
451 assert.Equal(t, SectionWorkspace, key.Section)
452 case "workspace.vim_mode.sequence_timeout_ms":
453 foundSequenceTimeout = true
454 assert.Equal(t, "int", key.Type)
455 assert.Equal(t, "500", key.Default)
456 assert.Equal(t, ">=0", key.Range)
457 assert.Equal(t, SectionWorkspace, key.Section)
458 case "workspace.vim_mode.actions.<action>":
459 foundActions = true
460 assert.Equal(t, "object", key.Type)
461 assert.Equal(t, SectionWorkspace, key.Section)
462 }
463 }
464
465 assert.True(t, foundActivation, "missing workspace.vim_mode.activation_shortcut schema key")
466 assert.True(t, foundTimeout, "missing workspace.vim_mode.timeout_ms schema key")
467 assert.True(t, foundSequenceTimeout, "missing workspace.vim_mode.sequence_timeout_ms schema key")
468 assert.True(t, foundActions, "missing workspace.vim_mode.actions.<action> schema key")
469}
470
471func TestSchemaExposesVimModeSequenceTimeout(t *testing.T) {
472 provider := &SchemaProvider{}
473 schema := provider.GetSchema()
474
475 var found bool
476 for _, key := range schema {
477 if key.Key != "workspace.vim_mode.sequence_timeout_ms" {
478 continue
479 }
480 found = true
481 assert.Equal(t, "int", key.Type)
482 assert.Equal(t, "500", key.Default)
483 assert.Equal(t, ">=0", key.Range)
484 assert.Equal(t, SectionWorkspace, key.Section)
485 }
486 assert.True(t, found, "schema must expose workspace.vim_mode.sequence_timeout_ms")
487}