1package config
2
3import (
4 "os"
5 "path/filepath"
6 "testing"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10)
11
12func loadConfigFromTOML(t *testing.T, content string) *Config {
13 t.Helper()
14 home := t.TempDir()
15 t.Setenv("HOME", home)
16 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
17 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
18 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
19 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
20
21 configFile, err := GetConfigFile()
22 require.NoError(t, err)
23 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
24 require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
25
26 mgr, err := NewManager()
27 require.NoError(t, err)
28 require.NoError(t, mgr.Load())
29 return mgr.Get()
30}
31
32// unusedKey returns a valid key sequence that no default action in the map uses.
33func unusedKey(spec actionMapSpec) string {
34 if spec.canonKey != nil {
35 return "<C-x>"
36 }
37 return "ctrl+alt+f12"
38}
39
40func TestActionMapSpecs_CoverEveryActionMap(t *testing.T) {
41 cfg := DefaultConfig()
42 wantKeys := []string{
43 "workspace.shortcuts.actions",
44 "workspace.prefix_mode.actions",
45 "workspace.pane_mode.actions",
46 "workspace.tab_mode.actions",
47 "workspace.vim_mode.actions",
48 "workspace.resize_mode.actions",
49 "session.session_mode.actions",
50 }
51 var gotKeys []string
52 for _, spec := range actionMapSpecs() {
53 gotKeys = append(gotKeys, spec.key)
54 assert.NotEmpty(t, *spec.actions(cfg), spec.key)
55 }
56 assert.ElementsMatch(t, wantKeys, gotKeys)
57}
58
59func TestCompleteDefaultActions_AllMaps(t *testing.T) {
60 for _, spec := range actionMapSpecs() {
61 t.Run(spec.key, func(t *testing.T) {
62 defaults := DefaultConfig()
63 defaultActions := *spec.actions(defaults)
64
65 // Partial user map: a single custom action on an unused key.
66 cfg := DefaultConfig()
67 *spec.actions(cfg) = map[string]ActionBinding{
68 "custom-user-action": {Keys: []string{unusedKey(spec)}, Desc: "custom"},
69 }
70 completeDefaultActions(cfg)
71
72 got := *spec.actions(cfg)
73 assert.Contains(t, got, "custom-user-action")
74 assert.Len(t, got, len(defaultActions)+1)
75 for name, binding := range defaultActions {
76 assert.Equal(t, binding, got[name], name)
77 }
78 require.NoError(t, validateConfig(cfg))
79 })
80 }
81}
82
83func TestCompleteDefaultActions_UserOverrideWins(t *testing.T) {
84 for _, spec := range actionMapSpecs() {
85 t.Run(spec.key, func(t *testing.T) {
86 defaults := DefaultConfig()
87 var name string
88 for n, b := range *spec.actions(defaults) {
89 if len(b.Keys) > 0 {
90 name = n
91 break
92 }
93 }
94 require.NotEmpty(t, name)
95
96 cfg := DefaultConfig()
97 *spec.actions(cfg) = map[string]ActionBinding{
98 name: {Keys: []string{unusedKey(spec)}, Desc: "mine"},
99 }
100 completeDefaultActions(cfg)
101
102 got := (*spec.actions(cfg))[name]
103 assert.Equal(t, []string{unusedKey(spec)}, got.Keys)
104 assert.Equal(t, "mine", got.Desc)
105 })
106 }
107}
108
109func TestCompleteDefaultActions_SkipsDefaultsConflictingWithUserKeys(t *testing.T) {
110 cfg := DefaultConfig()
111 // The user key canonicalizes to the same vim sequence as the default "shift+j"
112 // key of vim-scroll-down-fast; the user binds it to a custom action.
113 cfg.Workspace.VimMode.Actions = map[string]ActionBinding{
114 "my-action": {Keys: []string{"shift+j"}, Desc: "mine"},
115 }
116 completeDefaultActions(cfg)
117
118 got := cfg.Workspace.VimMode.Actions
119 assert.Contains(t, got, "my-action")
120 assert.NotContains(t, got, "vim-scroll-down-fast")
121 assert.Contains(t, got, "vim-scroll-up-fast")
122 require.NoError(t, validateConfig(cfg))
123}
124
125func TestCompleteDefaultActions_DoesNotMutateSharedMaps(t *testing.T) {
126 cfg := DefaultConfig()
127 original := map[string]ActionBinding{"custom": {Keys: []string{"ctrl+alt+f12"}}}
128 cfg.Workspace.TabMode.Actions = original
129 completeDefaultActions(cfg)
130 assert.Len(t, original, 1)
131 assert.Greater(t, len(cfg.Workspace.TabMode.Actions), 1)
132}
133
134func TestManagerLoad_PartialVimActionsGetMergedDefaults(t *testing.T) {
135 cfg := loadConfigFromTOML(t, `
136[workspace.vim_mode.actions.cancel]
137keys = ["escape"]
138desc = "Cancel"
139`)
140 defaults := DefaultConfig().Workspace.VimMode.Actions
141 assert.Len(t, cfg.Workspace.VimMode.Actions, len(defaults))
142 for name := range defaults {
143 assert.Contains(t, cfg.Workspace.VimMode.Actions, name)
144 }
145}
146
147func TestManagerLoad_UserKeysOverrideDefaultAndConflictingDefaultSkipped(t *testing.T) {
148 // Rebind scroll-left to "j", which is the default key of scroll-down.
149 cfg := loadConfigFromTOML(t, `
150[workspace.vim_mode.actions.vim-scroll-left]
151keys = ["j"]
152desc = "Mine"
153`)
154 actions := cfg.Workspace.VimMode.Actions
155 assert.Equal(t, []string{"j"}, actions["vim-scroll-left"].Keys)
156 assert.NotContains(t, actions, "vim-scroll-down")
157 assert.Contains(t, actions, "vim-scroll-up")
158}
159
160func TestManagerLoad_EmptyKeysDisablesActionAndIsNotRefilled(t *testing.T) {
161 cfg := loadConfigFromTOML(t, `
162[workspace.vim_mode.actions.vim-scroll-left]
163keys = []
164desc = "Disabled"
165
166[workspace.pane_mode.actions.stack-pane]
167keys = []
168`)
169 left, ok := cfg.Workspace.VimMode.Actions["vim-scroll-left"]
170 require.True(t, ok)
171 assert.Empty(t, left.Keys)
172 assert.Equal(t, "Disabled", left.Desc)
173
174 stack, ok := cfg.Workspace.PaneMode.Actions["stack-pane"]
175 require.True(t, ok)
176 assert.Empty(t, stack.Keys)
177
178 // Other defaults are still completed.
179 assert.Contains(t, cfg.Workspace.VimMode.Actions, "vim-scroll-right")
180 assert.Contains(t, cfg.Workspace.PaneMode.Actions, "eject-pane-to-window")
181}
182
183func TestValidateModalModeActions_EmptyKeysAreDisabledNotInvalid(t *testing.T) {
184 errs := validateModalModeActions("tab_mode", 0, map[string]ActionBinding{
185 "new-tab": {Keys: []string{}},
186 "other": {Keys: nil},
187 })
188 assert.Empty(t, errs)
189}
190
191func TestMigrator_DisabledActionIsNotReportedMissing(t *testing.T) {
192 m := NewMigrator()
193 missing := m.detectMissingDefaultActions(map[string]any{
194 "workspace.pane_mode.actions": map[string]any{
195 "stack-pane": map[string]any{"keys": []any{}, "desc": "off"},
196 },
197 })
198 assert.NotContains(t, missing, "workspace.pane_mode.actions.stack-pane")
199 assert.Contains(t, missing, "workspace.pane_mode.actions.eject-pane-to-window")
200}
201
202func TestMigrator_MergeSkipsDefaultsConflictingWithUserKeys(t *testing.T) {
203 m := NewMigrator()
204 raw := map[string]any{
205 "workspace": map[string]any{
206 "vim_mode": map[string]any{
207 "actions": map[string]any{
208 "vim-scroll-left": map[string]any{"keys": []any{"j"}, "desc": "mine"},
209 },
210 },
211 },
212 }
213 m.mergeMissingDefaultActions(raw)
214 actions := m.getNestedValue(raw, "workspace.vim_mode.actions").(map[string]any)
215 assert.NotContains(t, actions, "vim-scroll-down")
216 assert.Contains(t, actions, "vim-scroll-up")
217}
218
219func TestManagerSave_KeepsDisabledActionsDisabledAcrossReload(t *testing.T) {
220 home := t.TempDir()
221 t.Setenv("HOME", home)
222 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
223 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
224 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
225 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
226
227 configFile, err := GetConfigFile()
228 require.NoError(t, err)
229 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
230 require.NoError(t, os.WriteFile(configFile, []byte(`
231[workspace.vim_mode.actions.cancel]
232keys = ["escape"]
233
234[workspace.pane_mode.actions.stack-pane]
235keys = []
236`), 0o644))
237
238 mgr, err := NewManager()
239 require.NoError(t, err)
240 require.NoError(t, mgr.Load())
241
242 cfg := mgr.Get()
243 require.NoError(t, mgr.Save(cfg))
244
245 reloaded, err := NewManager()
246 require.NoError(t, err)
247 require.NoError(t, reloaded.Load())
248 got := reloaded.Get()
249
250 stack, ok := got.Workspace.PaneMode.Actions["stack-pane"]
251 require.True(t, ok)
252 assert.Empty(t, stack.Keys)
253 assert.Len(t, got.Workspace.VimMode.Actions, len(DefaultConfig().Workspace.VimMode.Actions))
254}
255
256func TestManagerLoad_UnderscoreActionNamesAreCanonicalizedBeforeCompletion(t *testing.T) {
257 cfg := loadConfigFromTOML(t, `
258[workspace.pane_mode.actions.stack_pane]
259keys = ["ctrl+alt+f12"]
260desc = "Mine"
261
262[workspace.resize_mode.actions.resize_increase_left]
263keys = ["z"]
264
265[workspace.prefix_mode.actions.enter_resize_mode]
266keys = ["ctrl+alt+f11"]
267
268[workspace.pane_mode.actions.focus_left]
269keys = ["ctrl+alt+f10"]
270
271[workspace.shortcuts.actions.toggle_floating_pane]
272keys = ["alt+g"]
273`)
274
275 pane := cfg.Workspace.PaneMode.Actions
276 assert.NotContains(t, pane, "stack_pane")
277 assert.Equal(t, []string{"ctrl+alt+f12"}, pane["stack-pane"].Keys)
278 assert.Equal(t, "Mine", pane["stack-pane"].Desc)
279 assert.NotContains(t, pane, "focus_left")
280 assert.Equal(t, []string{"ctrl+alt+f10"}, pane["focus-left"].Keys)
281
282 resize := cfg.Workspace.ResizeMode.Actions
283 assert.NotContains(t, resize, "resize_increase_left")
284 assert.Equal(t, []string{"z"}, resize["resize-increase-left"].Keys)
285 assert.Contains(t, resize, "resize-increase-right", "other defaults are still completed")
286
287 prefix := cfg.Workspace.PrefixMode.Actions
288 assert.NotContains(t, prefix, "enter_resize_mode")
289 assert.Equal(t, []string{"ctrl+alt+f11"}, prefix["enter-resize-mode"].Keys)
290
291 shortcuts := cfg.Workspace.Shortcuts.Actions
292 assert.NotContains(t, shortcuts, "toggle_floating_pane")
293 assert.Equal(t, []string{"alt+g"}, shortcuts["toggle-floating-pane"].Keys)
294
295 for _, spec := range actionMapSpecs() {
296 for name := range *spec.actions(cfg) {
297 assert.NotContains(t, name, "_", "%s.%s", spec.key, name)
298 }
299 }
300}
301
302func TestManagerLoad_HyphenActionNameWinsOverUnderscoreDuplicate(t *testing.T) {
303 cfg := loadConfigFromTOML(t, `
304[workspace.pane_mode.actions.stack_pane]
305keys = ["ctrl+alt+f12"]
306
307[workspace.pane_mode.actions.stack-pane]
308keys = ["ctrl+alt+f11"]
309`)
310 assert.Equal(t, []string{"ctrl+alt+f11"}, cfg.Workspace.PaneMode.Actions["stack-pane"].Keys)
311 assert.NotContains(t, cfg.Workspace.PaneMode.Actions, "stack_pane")
312}
313
314func TestCompleteDefaultActions_AliasSpellingsOfAUserKeyBlockTheDefault(t *testing.T) {
315 tests := []struct {
316 name string
317 userKey string
318 wantSkipped string
319 }{
320 {name: "right vs arrowright", userKey: "right", wantSkipped: "split-right"},
321 {name: "Right vs arrowright", userKey: "Right", wantSkipped: "split-right"},
322 {name: "uppercase letter vs explicit shift", userKey: "shift+M", wantSkipped: "move-pane-to-next-tab"},
323 {name: "explicit shift vs uppercase letter", userKey: "M", wantSkipped: "move-pane-to-next-tab"},
324 }
325 for _, tt := range tests {
326 t.Run(tt.name, func(t *testing.T) {
327 cfg := DefaultConfig()
328 cfg.Workspace.PaneMode.Actions = map[string]ActionBinding{
329 "close-pane": {Keys: []string{tt.userKey}},
330 }
331 completeDefaultActions(cfg)
332
333 got := cfg.Workspace.PaneMode.Actions
334 assert.Equal(t, []string{tt.userKey}, got["close-pane"].Keys)
335 if tt.wantSkipped != "" {
336 assert.NotContains(t, got, tt.wantSkipped)
337 }
338 require.NoError(t, validateConfig(cfg), "completed config must stay valid")
339 })
340 }
341}
342
343func TestCompleteDefaultActions_ControlSpellingBlocksGlobalDefault(t *testing.T) {
344 cfg := DefaultConfig()
345 cfg.Workspace.Shortcuts.Actions = map[string]ActionBinding{
346 "new-tab": {Keys: []string{"Control+r"}},
347 }
348 completeDefaultActions(cfg)
349
350 // Default reload is ["ctrl+r", "f5"]; "Control+r" is the same key as
351 // "ctrl+r", so the user owns it and reload is not added back.
352 assert.NotContains(t, cfg.Workspace.Shortcuts.Actions, "reload")
353 assert.Contains(t, cfg.Workspace.Shortcuts.Actions, "hard-reload")
354 assert.Contains(t, cfg.Workspace.Shortcuts.Actions, "open-omnibox")
355 require.NoError(t, validateConfig(cfg))
356}
357
358func TestManagerLoad_AliasConflictIsResolvedDeterministically(t *testing.T) {
359 cfg := loadConfigFromTOML(t, `
360[workspace.pane_mode.actions.close-pane]
361keys = ["right"]
362`)
363 pane := cfg.Workspace.PaneMode.Actions
364 assert.Equal(t, []string{"right"}, pane["close-pane"].Keys)
365 assert.NotContains(t, pane, "split-right", "arrowright is the same key as right")
366 assert.Contains(t, pane, "split-left")
367}
368
369func TestValidateModalModeActions_DetectsAliasDuplicates(t *testing.T) {
370 tests := []struct {
371 name string
372 keysA string
373 keysB string
374 wantCanon string
375 }{
376 {name: "right and arrowright", keysA: "right", keysB: "arrowright", wantCanon: "right"},
377 {name: "control and ctrl", keysA: "control+t", keysB: "ctrl+t", wantCanon: "ctrl+t"},
378 {name: "uppercase and explicit shift", keysA: "M", keysB: "shift+m", wantCanon: "shift+m"},
379 {name: "modifier order and case", keysA: "Shift+Ctrl+T", keysB: "ctrl+shift+t", wantCanon: "ctrl+shift+t"},
380 }
381 for _, tt := range tests {
382 t.Run(tt.name, func(t *testing.T) {
383 errs := validateModalModeActions("pane_mode", 0, map[string]ActionBinding{
384 "action-a": {Keys: []string{tt.keysA}},
385 "action-b": {Keys: []string{tt.keysB}},
386 })
387 require.Len(t, errs, 1)
388 assert.Contains(t, errs[0], "duplicate key binding '"+tt.wantCanon+"' found in pane_mode")
389 assert.Contains(t, errs[0], "action-a")
390 assert.Contains(t, errs[0], "action-b")
391 })
392 }
393}
394
395func TestValidateModalModeActions_SpellingsOfOneKeyInOneActionAreNotDuplicates(t *testing.T) {
396 // The defaults list "M" and "shift+m" together.
397 errs := validateModalModeActions("pane_mode", 0, map[string]ActionBinding{
398 "move-pane-to-next-tab": {Keys: []string{"M", "shift+m"}},
399 })
400 assert.Empty(t, errs)
401}