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