1package config
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 port "github.com/bnema/dumber/internal/application/port"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12)
13
14func TestMigrator_NewMigrator(t *testing.T) {
15 m := NewMigrator()
16 require.NotNil(t, m)
17 require.NotNil(t, m.defaultViper)
18 require.NotNil(t, m.defaultConfig)
19}
20
21func TestMigrator_GetAllDefaultKeys(t *testing.T) {
22 m := NewMigrator()
23 keys := m.getAllDefaultKeys()
24
25 // Should have many default keys
26 assert.Greater(t, len(keys), 50, "should have many default keys")
27
28 // Should not include database.path (it's dynamically set)
29 for _, key := range keys {
30 assert.NotEqual(t, "database.path", key, "should not include database.path")
31 }
32
33 // Should include some known keys
34 keySet := make(map[string]bool)
35 for _, k := range keys {
36 keySet[k] = true
37 }
38
39 assert.True(t, keySet["history.max_entries"], "should include history.max_entries")
40 assert.True(t, keySet["logging.level"], "should include logging.level")
41 assert.True(t, keySet["appearance.sans_font"], "should include appearance.sans_font")
42}
43
44func TestMigrator_GetUserConfigKeys(t *testing.T) {
45 m := NewMigrator()
46
47 // Create a temp config file
48 tmpDir := t.TempDir()
49 configFile := filepath.Join(tmpDir, "config.toml")
50
51 content := `
52[history]
53max_entries = 5000
54retention_period_days = 180
55
56[logging]
57level = "debug"
58
59[appearance]
60sans_font = "Arial"
61`
62 err := os.WriteFile(configFile, []byte(content), 0o644)
63 require.NoError(t, err)
64
65 keys, err := m.getUserConfigKeys(configFile)
66 require.NoError(t, err)
67
68 // Should have the keys we defined
69 assert.True(t, keys["history.max_entries"])
70 assert.True(t, keys["history.retention_period_days"])
71 assert.True(t, keys["logging.level"])
72 assert.True(t, keys["appearance.sans_font"])
73
74 // Should not have keys we didn't define
75 assert.False(t, keys["history.cleanup_interval_days"])
76 assert.False(t, keys["logging.format"])
77}
78
79func TestMigrator_FlattenMap(t *testing.T) {
80 m := NewMigrator()
81
82 tests := []struct {
83 name string
84 input map[string]any
85 prefix string
86 expected []string
87 }{
88 {
89 name: "simple flat map",
90 input: map[string]any{
91 "key1": "value1",
92 "key2": 123,
93 },
94 prefix: "",
95 expected: []string{"key1", "key2"},
96 },
97 {
98 name: "nested map",
99 input: map[string]any{
100 "parent": map[string]any{
101 "child1": "value1",
102 "child2": "value2",
103 },
104 },
105 prefix: "",
106 expected: []string{"parent.child1", "parent.child2"},
107 },
108 {
109 name: "with prefix",
110 input: map[string]any{
111 "key": "value",
112 },
113 prefix: "section",
114 expected: []string{"section.key"},
115 },
116 {
117 name: "search_shortcuts treated as single key",
118 input: map[string]any{
119 "search_shortcuts": map[string]any{
120 "g": "https://google.com/?q=%s",
121 "ddg": "https://duckduckgo.com/?q=%s",
122 },
123 },
124 prefix: "",
125 expected: []string{"search_shortcuts"}, // Should be treated as user data
126 },
127 {
128 name: "actions map treated as single key",
129 input: map[string]any{
130 "pane_mode": map[string]any{
131 "actions": map[string]any{
132 "split-right": []string{"r"},
133 },
134 },
135 },
136 prefix: "",
137 expected: []string{"pane_mode.actions"}, // Actions treated as user data
138 },
139 }
140
141 for _, tt := range tests {
142 t.Run(tt.name, func(t *testing.T) {
143 keys := make(map[string]bool)
144 m.flattenMap(tt.input, tt.prefix, keys)
145
146 for _, expectedKey := range tt.expected {
147 assert.True(t, keys[expectedKey], "should contain key: %s", expectedKey)
148 }
149 })
150 }
151}
152
153func TestMigrator_IsUserDataSection(t *testing.T) {
154 m := NewMigrator()
155
156 tests := []struct {
157 name string
158 keyPath string
159 expected bool
160 }{
161 {
162 name: "search_shortcuts is user data",
163 keyPath: "search_shortcuts",
164 expected: true,
165 },
166 {
167 name: "pane_mode.actions is user data",
168 keyPath: "pane_mode.actions",
169 expected: true,
170 },
171 {
172 name: "tab_mode.actions is user data",
173 keyPath: "tab_mode.actions",
174 expected: true,
175 },
176 {
177 name: "history is not user data",
178 keyPath: "history",
179 expected: false,
180 },
181 {
182 name: "history.max_entries is not user data",
183 keyPath: "history.max_entries",
184 expected: false,
185 },
186 {
187 name: "appearance.sans_font is not user data",
188 keyPath: "appearance.sans_font",
189 expected: false,
190 },
191 }
192
193 for _, tt := range tests {
194 t.Run(tt.name, func(t *testing.T) {
195 result := m.isUserDataSection(tt.keyPath)
196 assert.Equal(t, tt.expected, result)
197 })
198 }
199}
200
201func TestMigrator_FindMissingKeys(t *testing.T) {
202 m := NewMigrator()
203
204 tests := []struct {
205 name string
206 defaultKeys []string
207 userKeys map[string]bool
208 expected []string
209 }{
210 {
211 name: "all keys present",
212 defaultKeys: []string{"key1", "key2"},
213 userKeys: map[string]bool{"key1": true, "key2": true},
214 expected: []string{},
215 },
216 {
217 name: "some keys missing",
218 defaultKeys: []string{"key1", "key2", "key3"},
219 userKeys: map[string]bool{"key1": true},
220 expected: []string{"key2", "key3"},
221 },
222 {
223 name: "all keys missing",
224 defaultKeys: []string{"key1", "key2"},
225 userKeys: map[string]bool{},
226 expected: []string{"key1", "key2"},
227 },
228 {
229 name: "parent key covers children",
230 defaultKeys: []string{"parent.child1", "parent.child2"},
231 userKeys: map[string]bool{"parent": true},
232 expected: []string{}, // Parent covers children
233 },
234 {
235 name: "child keys cover parent (struct defaults like palettes)",
236 defaultKeys: []string{"appearance.dark_palette"},
237 userKeys: map[string]bool{"appearance.dark_palette.background": true, "appearance.dark_palette.accent": true},
238 expected: []string{}, // Child keys mean parent is defined
239 },
240 }
241
242 for _, tt := range tests {
243 t.Run(tt.name, func(t *testing.T) {
244 result := m.findMissingKeys(tt.defaultKeys, tt.userKeys)
245 assert.ElementsMatch(t, tt.expected, result)
246 })
247 }
248}
249
250func TestMigrator_GetKeyInfo(t *testing.T) {
251 m := NewMigrator()
252
253 tests := []struct {
254 name string
255 key string
256 expectedType string
257 }{
258 {
259 name: "bool key",
260 key: "debug.enable_devtools",
261 expectedType: "bool",
262 },
263 {
264 name: "int key",
265 key: "history.max_entries",
266 expectedType: "int",
267 },
268 {
269 name: "string key",
270 key: "logging.level",
271 expectedType: "string",
272 },
273 {
274 name: "float key",
275 key: "default_webpage_zoom",
276 expectedType: "float",
277 },
278 }
279
280 for _, tt := range tests {
281 t.Run(tt.name, func(t *testing.T) {
282 info := m.GetKeyInfo(tt.key)
283 assert.Equal(t, tt.key, info.Key)
284 assert.Equal(t, tt.expectedType, info.Type)
285 assert.NotEmpty(t, info.DefaultValue)
286 })
287 }
288}
289
290func TestMigrator_GetTypeName(t *testing.T) {
291 m := NewMigrator()
292
293 tests := []struct {
294 name string
295 value any
296 expected string
297 }{
298 {name: "bool", value: true, expected: "bool"},
299 {name: "int", value: 42, expected: "int"},
300 {name: "int64", value: int64(42), expected: "int"},
301 {name: "float64", value: 3.14, expected: "float"},
302 {name: "string", value: "hello", expected: "string"},
303 {name: "slice", value: []any{"a", "b"}, expected: "list"},
304 {name: "map", value: map[string]any{"a": 1}, expected: "map"},
305 {name: "nil", value: nil, expected: "unknown"},
306 }
307
308 for _, tt := range tests {
309 t.Run(tt.name, func(t *testing.T) {
310 result := m.getTypeName(tt.value)
311 assert.Equal(t, tt.expected, result)
312 })
313 }
314}
315
316func TestMigrator_FormatValue(t *testing.T) {
317 m := NewMigrator()
318
319 tests := []struct {
320 name string
321 value any
322 expected string
323 }{
324 {name: "true", value: true, expected: "true"},
325 {name: "false", value: false, expected: "false"},
326 {name: "int", value: 42, expected: "42"},
327 {name: "float", value: 3.14, expected: "3.14"},
328 {name: "string", value: "hello", expected: `"hello"`},
329 {name: "empty string", value: "", expected: `""`},
330 {name: "empty slice", value: []any{}, expected: "[]"},
331 {name: "slice with items", value: []any{"a", "b", "c"}, expected: "[3 items]"},
332 {name: "empty map", value: map[string]any{}, expected: "{}"},
333 {name: "map with entries", value: map[string]any{"a": 1, "b": 2}, expected: "{2 entries}"},
334 {name: "nil", value: nil, expected: "null"},
335 }
336
337 for _, tt := range tests {
338 t.Run(tt.name, func(t *testing.T) {
339 result := m.formatValue(tt.value)
340 assert.Equal(t, tt.expected, result)
341 })
342 }
343}
344
345func TestKeysAreSimilar(t *testing.T) {
346 tests := []struct {
347 name string
348 oldKey string
349 newKey string
350 expected bool
351 }{
352 {
353 name: "identical keys",
354 oldKey: "section.pane_mode_color",
355 newKey: "section.pane_mode_color",
356 expected: true,
357 },
358 {
359 name: "substring match - old contains new",
360 oldKey: "section.pane_mode_border_color",
361 newKey: "section.pane_mode_color",
362 expected: true,
363 },
364 {
365 name: "substring match - new contains old",
366 oldKey: "section.mode_color",
367 newKey: "section.pane_mode_color",
368 expected: true,
369 },
370 {
371 name: "token matching - border removed",
372 oldKey: "section.tab_mode_border_color",
373 newKey: "section.tab_mode_color",
374 expected: true,
375 },
376 {
377 name: "different parent paths",
378 oldKey: "section_a.pane_mode_color",
379 newKey: "section_b.pane_mode_color",
380 expected: false,
381 },
382 {
383 name: "different depth",
384 oldKey: "pane_mode_color",
385 newKey: "section.pane_mode_color",
386 expected: false,
387 },
388 {
389 name: "completely different keys",
390 oldKey: "section.foo_bar",
391 newKey: "section.baz_qux",
392 expected: false,
393 },
394 {
395 name: "top level keys rejected",
396 oldKey: "foo",
397 newKey: "bar",
398 expected: false,
399 },
400 {
401 name: "no common tokens",
402 oldKey: "section.aaa_bbb",
403 newKey: "section.ccc_ddd",
404 expected: false,
405 },
406 {
407 name: "single token difference allowed",
408 oldKey: "section.pane_mode_border_width",
409 newKey: "section.mode_border_width",
410 expected: true,
411 },
412 {
413 name: "duplicate tokens in old key - no double counting",
414 oldKey: "section.mode_mode_mode",
415 newKey: "section.mode_other_another",
416 expected: false, // oldTokens=[mode,mode,mode], newTokens=[mode,other,another], matches=1 (only one mode can match), minTokens=3, needs 1>=2 which is false
417 },
418 {
419 name: "enough common tokens with duplicates",
420 oldKey: "section.pane_mode_border",
421 newKey: "section.pane_mode_color",
422 expected: true, // oldTokens=[pane,mode,border], newTokens=[pane,mode,color], matches=2, minTokens=3, 2>=2 && 2>0 = true
423 },
424 }
425
426 for _, tt := range tests {
427 t.Run(tt.name, func(t *testing.T) {
428 result := keysAreSimilar(tt.oldKey, tt.newKey)
429 assert.Equal(t, tt.expected, result, "keysAreSimilar(%q, %q)", tt.oldKey, tt.newKey)
430 })
431 }
432}
433
434func TestMigrator_TypesAreCompatible(t *testing.T) {
435 m := NewMigrator()
436
437 tests := []struct {
438 name string
439 oldKey string
440 newKey string
441 expected bool
442 }{
443 {
444 name: "color to color - compatible (using real key)",
445 oldKey: "workspace.styling.pane_mode_border_color",
446 newKey: "workspace.styling.pane_mode_color",
447 expected: true,
448 },
449 {
450 name: "width to width - compatible (using real key)",
451 oldKey: "workspace.styling.pane_mode_border_width",
452 newKey: "workspace.styling.mode_border_width",
453 expected: true,
454 },
455 {
456 name: "width suffix to color type - incompatible",
457 oldKey: "workspace.styling.some_width",
458 newKey: "workspace.styling.pane_mode_color", // real key with string type
459 expected: false,
460 },
461 {
462 name: "color suffix to int type - incompatible",
463 oldKey: "workspace.styling.some_color",
464 newKey: "workspace.styling.mode_border_width", // real key with int type
465 expected: false,
466 },
467 {
468 name: "generic key to string type - compatible",
469 oldKey: "workspace.styling.some_setting",
470 newKey: "workspace.styling.pane_mode_color", // real key with string type
471 expected: true,
472 },
473 {
474 name: "non-existent new key - incompatible",
475 oldKey: "section.old_key",
476 newKey: "section.nonexistent_key_xyz",
477 expected: false,
478 },
479 }
480
481 for _, tt := range tests {
482 t.Run(tt.name, func(t *testing.T) {
483 result := m.typesAreCompatible(tt.oldKey, tt.newKey)
484 assert.Equal(t, tt.expected, result, "typesAreCompatible(%q, %q)", tt.oldKey, tt.newKey)
485 })
486 }
487}
488
489func TestMigrator_FindDeprecatedKeys(t *testing.T) {
490 m := NewMigrator()
491
492 tests := []struct {
493 name string
494 userKeys map[string]any
495 defaultKeys map[string]bool
496 expected []string
497 }{
498 {
499 name: "no deprecated keys",
500 userKeys: map[string]any{
501 "history.max_entries": 1000,
502 "logging.level": "info",
503 },
504 defaultKeys: map[string]bool{
505 "history.max_entries": true,
506 "logging.level": true,
507 },
508 expected: []string{},
509 },
510 {
511 name: "one deprecated key",
512 userKeys: map[string]any{
513 "history.max_entries": 1000,
514 "old.deprecated_key": "value",
515 },
516 defaultKeys: map[string]bool{
517 "history.max_entries": true,
518 },
519 expected: []string{"old.deprecated_key"},
520 },
521 {
522 name: "multiple deprecated keys",
523 userKeys: map[string]any{
524 "old.key1": "a",
525 "old.key2": "b",
526 "old.key3": "c",
527 },
528 defaultKeys: map[string]bool{
529 "new.key1": true,
530 },
531 expected: []string{"old.key1", "old.key2", "old.key3"},
532 },
533 {
534 name: "parent key exists in defaults",
535 userKeys: map[string]any{
536 "parent.child": "value",
537 },
538 defaultKeys: map[string]bool{
539 "parent.child": true,
540 "parent.other_child": true,
541 },
542 expected: []string{},
543 },
544 {
545 name: "database.path ignored as deprecated",
546 userKeys: map[string]any{
547 "database.path": "",
548 },
549 defaultKeys: map[string]bool{
550 "history.max_entries": true,
551 },
552 expected: []string{},
553 },
554 }
555
556 for _, tt := range tests {
557 t.Run(tt.name, func(t *testing.T) {
558 result := m.findDeprecatedKeys(tt.userKeys, tt.defaultKeys)
559 assert.ElementsMatch(t, tt.expected, result)
560 })
561 }
562}
563
564func TestMigrator_MatchRenamedKeys(t *testing.T) {
565 m := NewMigrator()
566
567 tests := []struct {
568 name string
569 deprecated []string
570 missing []string
571 expectedRenames map[string]string
572 expectedUnmatchedOld []string
573 expectedUnmatchedNew []string
574 }{
575 {
576 name: "no keys to match",
577 deprecated: []string{},
578 missing: []string{},
579 expectedRenames: map[string]string{},
580 expectedUnmatchedOld: nil,
581 expectedUnmatchedNew: nil,
582 },
583 {
584 name: "simple rename match (using real keys)",
585 deprecated: []string{"workspace.styling.pane_mode_border_color"},
586 missing: []string{"workspace.styling.pane_mode_color"},
587 expectedRenames: map[string]string{
588 "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
589 },
590 expectedUnmatchedOld: nil,
591 expectedUnmatchedNew: nil,
592 },
593 {
594 name: "no match possible - different types (width vs color)",
595 deprecated: []string{"workspace.styling.pane_mode_border_width"},
596 missing: []string{"workspace.styling.pane_mode_color"},
597 expectedRenames: map[string]string{},
598 expectedUnmatchedOld: []string{"workspace.styling.pane_mode_border_width"},
599 expectedUnmatchedNew: []string{"workspace.styling.pane_mode_color"},
600 },
601 {
602 name: "no match possible - different parents",
603 deprecated: []string{"old_section.some_key"},
604 missing: []string{"new_section.some_key"},
605 expectedRenames: map[string]string{},
606 expectedUnmatchedOld: []string{"old_section.some_key"},
607 expectedUnmatchedNew: []string{"new_section.some_key"},
608 },
609 {
610 name: "multiple renames (using real keys)",
611 deprecated: []string{"workspace.styling.pane_mode_border_color", "workspace.styling.tab_mode_border_color"},
612 missing: []string{"workspace.styling.pane_mode_color", "workspace.styling.tab_mode_color"},
613 expectedRenames: map[string]string{
614 "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
615 "workspace.styling.tab_mode_border_color": "workspace.styling.tab_mode_color",
616 },
617 expectedUnmatchedOld: nil,
618 expectedUnmatchedNew: nil,
619 },
620 }
621
622 for _, tt := range tests {
623 t.Run(tt.name, func(t *testing.T) {
624 renames, unmatchedOld, unmatchedNew := m.matchRenamedKeys(tt.deprecated, tt.missing)
625 assert.Equal(t, tt.expectedRenames, renames, "renames mismatch")
626 assert.ElementsMatch(t, tt.expectedUnmatchedOld, unmatchedOld, "unmatched old mismatch")
627 assert.ElementsMatch(t, tt.expectedUnmatchedNew, unmatchedNew, "unmatched new mismatch")
628 })
629 }
630}
631
632func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) {
633 // This test would need to temporarily modify the config path
634 // which is difficult without refactoring. Skip for now.
635 t.Skip("requires config path mocking")
636}
637
638func TestMigrator_Migrate_NoConfigFile(t *testing.T) {
639 // This test would need to temporarily modify the config path
640 // which is difficult without refactoring. Skip for now.
641 t.Skip("requires config path mocking")
642}
643
644func TestMigrator_GetConfigType(t *testing.T) {
645 m := NewMigrator()
646
647 tests := []struct {
648 name string
649 configFile string
650 expected string
651 }{
652 {
653 name: "toml file",
654 configFile: "/path/to/config.toml",
655 expected: "toml",
656 },
657 {
658 name: "yaml file",
659 configFile: "/path/to/config.yaml",
660 expected: "yaml",
661 },
662 {
663 name: "yml file",
664 configFile: "/path/to/config.yml",
665 expected: "yaml",
666 },
667 {
668 name: "json file",
669 configFile: "/path/to/config.json",
670 expected: "json",
671 },
672 {
673 name: "unknown extension defaults to toml",
674 configFile: "/path/to/config.unknown",
675 expected: "toml",
676 },
677 {
678 name: "no extension defaults to toml",
679 configFile: "/path/to/config",
680 expected: "toml",
681 },
682 }
683
684 for _, tt := range tests {
685 t.Run(tt.name, func(t *testing.T) {
686 result := m.getConfigType(tt.configFile)
687 assert.Equal(t, tt.expected, result)
688 })
689 }
690}
691
692func TestMigrator_DetectMissingDefaultActions(t *testing.T) {
693 m := NewMigrator()
694
695 shortcutActions := make(map[string]any, len(m.defaultConfig.Workspace.Shortcuts.Actions))
696 for actionName, value := range m.defaultConfig.Workspace.Shortcuts.Actions {
697 shortcutActions[actionName] = value
698 }
699 delete(shortcutActions, "toggle-floating-pane")
700
701 paneActions := make(map[string]any, len(m.defaultConfig.Workspace.PaneMode.Actions))
702 for actionName, value := range m.defaultConfig.Workspace.PaneMode.Actions {
703 paneActions[actionName] = value
704 }
705 delete(paneActions, "eject-pane-to-window")
706
707 missing := m.detectMissingDefaultActions(map[string]any{
708 "workspace.shortcuts.actions": shortcutActions,
709 "workspace.pane_mode.actions": paneActions,
710 })
711
712 assert.Equal(t, []string{
713 "workspace.pane_mode.actions.eject-pane-to-window",
714 "workspace.shortcuts.actions.toggle-floating-pane",
715 }, missing)
716}
717
718func TestMigrator_MergeMissingDefaultActions(t *testing.T) {
719 m := NewMigrator()
720
721 rawConfig := map[string]any{
722 "workspace": map[string]any{
723 "shortcuts": map[string]any{
724 "actions": map[string]any{
725 "close_pane": map[string]any{
726 "keys": []string{"ctrl+w", "ctrl+i"},
727 "desc": "Custom close pane",
728 },
729 "next_tab": map[string]any{
730 "keys": []string{"ctrl+tab"},
731 "desc": "Switch to next tab",
732 },
733 "previous_tab": map[string]any{
734 "keys": []string{"ctrl+shift+tab"},
735 "desc": "Switch to previous tab",
736 },
737 "consume_or_expel_left": map[string]any{
738 "keys": []string{"alt+["},
739 "desc": "Consume/expel pane left",
740 },
741 "consume_or_expel_right": map[string]any{
742 "keys": []string{"alt+]"},
743 "desc": "Consume/expel pane right",
744 },
745 "consume_or_expel_up": map[string]any{
746 "keys": []string{"alt+{"},
747 "desc": "Consume/expel pane up",
748 },
749 "consume_or_expel_down": map[string]any{
750 "keys": []string{"alt+}"},
751 "desc": "Consume/expel pane down",
752 },
753 },
754 },
755 },
756 }
757
758 m.mergeMissingDefaultActions(rawConfig)
759
760 actionsAny := m.getNestedValue(rawConfig, "workspace.shortcuts.actions")
761 actions, ok := actionsAny.(map[string]any)
762 require.True(t, ok)
763
764 toggleAny, hasToggle := actions["toggle-floating-pane"]
765 assert.True(t, hasToggle)
766 assert.NotNil(t, toggleAny)
767
768 closePaneAny, hasClosePane := actions["close_pane"]
769 require.True(t, hasClosePane)
770 closePane, ok := closePaneAny.(map[string]any)
771 require.True(t, ok)
772 assert.Equal(t, "Custom close pane", closePane["desc"])
773 assert.NotContains(t, actions, "close-pane")
774 assert.NotContains(t, actions, "next-tab")
775 assert.NotContains(t, actions, "previous-tab")
776 assert.NotContains(t, actions, "consume-or-expel-left")
777 assert.NotContains(t, actions, "consume-or-expel-right")
778 assert.NotContains(t, actions, "consume-or-expel-up")
779 assert.NotContains(t, actions, "consume-or-expel-down")
780}
781
782func TestMigrator_MergeMissingDefaultActions_PaneMode(t *testing.T) {
783 m := NewMigrator()
784
785 rawConfig := map[string]any{
786 "workspace": map[string]any{
787 "pane_mode": map[string]any{
788 "actions": map[string]any{
789 "stack-pane": map[string]any{
790 "keys": []string{"s"},
791 "desc": "Stack pane with sibling",
792 },
793 "move-pane-to-tab": map[string]any{
794 "keys": []string{"m"},
795 "desc": "Custom move pane",
796 },
797 },
798 },
799 },
800 }
801
802 m.mergeMissingDefaultActions(rawConfig)
803
804 actionsAny := m.getNestedValue(rawConfig, "workspace.pane_mode.actions")
805 actions, ok := actionsAny.(map[string]any)
806 require.True(t, ok)
807
808 ejectAny, hasEject := actions["eject-pane-to-window"]
809 assert.True(t, hasEject)
810 assert.NotNil(t, ejectAny)
811
812 moveAny, hasMove := actions["move-pane-to-tab"]
813 require.True(t, hasMove)
814 move, ok := moveAny.(map[string]any)
815 require.True(t, ok)
816 assert.Equal(t, "Custom move pane", move["desc"])
817}
818
819func TestMigrator_MergeMissingDefaultActions_VimMode(t *testing.T) {
820 m := NewMigrator()
821
822 rawConfig := map[string]any{
823 "workspace": map[string]any{
824 "vim_mode": map[string]any{
825 "actions": map[string]any{
826 "heading-next": map[string]any{
827 "keys": []string{"]]"},
828 "desc": "Custom next heading",
829 },
830 },
831 },
832 },
833 }
834
835 m.mergeMissingDefaultActions(rawConfig)
836
837 actionsAny := m.getNestedValue(rawConfig, "workspace.vim_mode.actions")
838 actions, ok := actionsAny.(map[string]any)
839 require.True(t, ok)
840
841 focusAny, hasFocus := actions["focus-input"]
842 require.True(t, hasFocus)
843 focus, ok := focusAny.(ActionBinding)
844 require.True(t, ok)
845 assert.Equal(t, []string{"gi"}, focus.Keys)
846
847 headingAny, hasHeading := actions["heading-next"]
848 require.True(t, hasHeading)
849 heading, ok := headingAny.(map[string]any)
850 require.True(t, ok)
851 assert.Equal(t, "Custom next heading", heading["desc"])
852}
853
854func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
855 m := NewMigrator()
856
857 value := m.defaultValueForKey("workspace.shortcuts.actions.toggle-floating-pane")
858
859 action, ok := value.(ActionBinding)
860 require.True(t, ok)
861 assert.Equal(t, []string{"alt+f"}, action.Keys)
862}
863
864func TestMigrator_DefaultValueForKey_PaneModeAction(t *testing.T) {
865 m := NewMigrator()
866
867 value := m.defaultValueForKey("workspace.pane_mode.actions.eject-pane-to-window")
868
869 action, ok := value.(ActionBinding)
870 require.True(t, ok)
871 assert.Equal(t, []string{"w"}, action.Keys)
872}
873
874func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) {
875 m := NewMigrator()
876
877 value := m.defaultValueForKey("history.max_entries")
878 assert.Equal(t, 10000, value)
879}
880
881func TestMigrator_GetNestedValue(t *testing.T) {
882 m := NewMigrator()
883
884 config := map[string]any{
885 "level1": map[string]any{
886 "level2": map[string]any{
887 "level3": "deep_value",
888 },
889 "simple": "simple_value",
890 },
891 "top": "top_value",
892 }
893
894 tests := []struct {
895 name string
896 key string
897 expected any
898 }{
899 {
900 name: "top level key",
901 key: "top",
902 expected: "top_value",
903 },
904 {
905 name: "nested key",
906 key: "level1.simple",
907 expected: "simple_value",
908 },
909 {
910 name: "deeply nested key",
911 key: "level1.level2.level3",
912 expected: "deep_value",
913 },
914 {
915 name: "non-existent key",
916 key: "nonexistent",
917 expected: nil,
918 },
919 {
920 name: "non-existent nested key",
921 key: "level1.nonexistent",
922 expected: nil,
923 },
924 }
925
926 for _, tt := range tests {
927 t.Run(tt.name, func(t *testing.T) {
928 result := m.getNestedValue(config, tt.key)
929 assert.Equal(t, tt.expected, result)
930 })
931 }
932}
933
934func TestMigrator_SetNestedValue(t *testing.T) {
935 m := NewMigrator()
936
937 t.Run("set top level value", func(t *testing.T) {
938 config := make(map[string]any)
939 m.setNestedValue(config, "top", "value")
940 assert.Equal(t, "value", config["top"])
941 })
942
943 t.Run("set nested value with existing parent", func(t *testing.T) {
944 config := map[string]any{
945 "parent": map[string]any{},
946 }
947 m.setNestedValue(config, "parent.child", "value")
948 parent := config["parent"].(map[string]any)
949 assert.Equal(t, "value", parent["child"])
950 })
951
952 t.Run("set nested value creates intermediate maps", func(t *testing.T) {
953 config := make(map[string]any)
954 m.setNestedValue(config, "a.b.c", "deep_value")
955
956 a := config["a"].(map[string]any)
957 b := a["b"].(map[string]any)
958 assert.Equal(t, "deep_value", b["c"])
959 })
960}
961
962func TestMigrator_DeleteNestedKey(t *testing.T) {
963 m := NewMigrator()
964
965 t.Run("delete top level key", func(t *testing.T) {
966 config := map[string]any{
967 "keep": "value",
968 "delete": "value",
969 }
970 m.deleteNestedKey(config, "delete")
971 assert.Contains(t, config, "keep")
972 assert.NotContains(t, config, "delete")
973 })
974
975 t.Run("delete nested key", func(t *testing.T) {
976 config := map[string]any{
977 "parent": map[string]any{
978 "keep": "value",
979 "delete": "value",
980 },
981 }
982 m.deleteNestedKey(config, "parent.delete")
983 parent := config["parent"].(map[string]any)
984 assert.Contains(t, parent, "keep")
985 assert.NotContains(t, parent, "delete")
986 })
987
988 t.Run("delete nested key cleans up empty parents", func(t *testing.T) {
989 config := map[string]any{
990 "keep": "value",
991 "parent": map[string]any{
992 "child": map[string]any{
993 "leaf": "value",
994 },
995 },
996 }
997 m.deleteNestedKey(config, "parent.child.leaf")
998 // Both parent and child should be removed since they're empty
999 assert.NotContains(t, config, "parent")
1000 assert.Contains(t, config, "keep")
1001 })
1002
1003 t.Run("delete non-existent key is no-op", func(t *testing.T) {
1004 config := map[string]any{
1005 "keep": "value",
1006 }
1007 m.deleteNestedKey(config, "nonexistent")
1008 assert.Contains(t, config, "keep")
1009 })
1010}
1011
1012func TestMigrator_Migrate_IsIdempotentForExternalThemePathAndEmptyProfiles(t *testing.T) {
1013 home := t.TempDir()
1014 t.Setenv("HOME", home)
1015 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1016 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1017 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1018 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1019
1020 configFile, err := GetConfigFile()
1021 require.NoError(t, err)
1022 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1023
1024 content, err := encodeConfigToTOML(DefaultConfig())
1025 require.NoError(t, err)
1026 require.Contains(t, content, "path = '")
1027 require.Contains(t, content, "[workspace.floating_pane.profiles]")
1028 content = strings.Replace(content, "\n [workspace.floating_pane.profiles]\n", "", 1)
1029 require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1030
1031 m := NewMigrator()
1032 firstChanges, err := m.DetectChanges()
1033 require.NoError(t, err)
1034 assertContainsChange(t, firstChanges, port.KeyChangeAdded, "", "workspace.floating_pane.profiles")
1035
1036 appliedKeys, err := m.Migrate()
1037 require.NoError(t, err)
1038 require.NotEmpty(t, appliedKeys, "first migration should apply the missing profiles default")
1039
1040 secondChanges, err := m.DetectChanges()
1041 require.NoError(t, err)
1042 assert.Empty(t, secondChanges, "second migration detection should report no changes after a successful migration")
1043
1044 secondAppliedKeys, err := m.Migrate()
1045 require.NoError(t, err)
1046 assert.Empty(t, secondAppliedKeys, "second migration should be a no-op")
1047}
1048
1049func assertContainsChange(t *testing.T, changes []port.KeyChange, changeType port.KeyChangeType, oldKey, newKey string) {
1050 t.Helper()
1051
1052 for _, change := range changes {
1053 if change.Type == changeType && change.OldKey == oldKey && change.NewKey == newKey {
1054 return
1055 }
1056 }
1057 assert.Failf(t, "missing expected change", "type=%v old=%q new=%q changes=%v", changeType, oldKey, newKey, changes)
1058}
1059
1060// --- Migrate-path popup → browsing_contexts transform tests ---
1061
1062func TestMigrator_GetUserConfigKeysWithValues_TransformsLegacyPopups(t *testing.T) {
1063 m := NewMigrator()
1064
1065 tmpDir := t.TempDir()
1066 configFile := filepath.Join(tmpDir, "config.toml")
1067
1068 content := `
1069[workspace]
1070new_pane_url = "about:blank"
1071
1072[workspace.popups]
1073behavior = "tabbed"
1074placement = "left"
1075oauth_auto_close = false
1076`
1077 err := os.WriteFile(configFile, []byte(content), 0o644)
1078 require.NoError(t, err)
1079
1080 keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1081 require.NoError(t, err)
1082
1083 // Legacy popups keys should NOT appear after transform.
1084 _, hasPopupsBehavior := keysWithValues["workspace.popups.behavior"]
1085 assert.False(t, hasPopupsBehavior, "workspace.popups.behavior should not exist after transform")
1086
1087 // Browsing_contexts flattened keys should carry the legacy values.
1088 assert.Equal(t, "tabbed", keysWithValues["workspace.browsing_contexts.behavior"])
1089 assert.Equal(t, "left", keysWithValues["workspace.browsing_contexts.placement"])
1090 assert.Equal(t, false, keysWithValues["workspace.browsing_contexts.oauth_auto_close"])
1091}
1092
1093func TestMigrator_GetUserConfigKeysWithValues_BrowsingContextsAlreadyPresent(t *testing.T) {
1094 m := NewMigrator()
1095
1096 tmpDir := t.TempDir()
1097 configFile := filepath.Join(tmpDir, "config.toml")
1098
1099 // Both popups and browsing_contexts present — transform should be a no-op for popups.
1100 content := `
1101[workspace]
1102new_pane_url = "about:blank"
1103
1104[workspace.popups]
1105behavior = "tabbed"
1106
1107[workspace.browsing_contexts]
1108behavior = "split"
1109placement = "right"
1110`
1111 err := os.WriteFile(configFile, []byte(content), 0o644)
1112 require.NoError(t, err)
1113
1114 keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1115 require.NoError(t, err)
1116
1117 // Browsing_contexts flattened keys should retain their original values.
1118 assert.Equal(t, "split", keysWithValues["workspace.browsing_contexts.behavior"])
1119 assert.Equal(t, "right", keysWithValues["workspace.browsing_contexts.placement"])
1120
1121 // Popups should still exist (transform skipped when browsing_contexts already present).
1122 _, hasPopups := keysWithValues["workspace.popups.behavior"]
1123 assert.True(t, hasPopups, "popups should still exist when browsing_contexts already present")
1124}
1125
1126func TestMigrator_GetUserConfigKeysWithValues_NoPopupsKey(t *testing.T) {
1127 m := NewMigrator()
1128
1129 tmpDir := t.TempDir()
1130 configFile := filepath.Join(tmpDir, "config.toml")
1131
1132 content := `
1133[workspace]
1134new_pane_url = "about:blank"
1135
1136[workspace.browsing_contexts]
1137behavior = "stacked"
1138`
1139 err := os.WriteFile(configFile, []byte(content), 0o644)
1140 require.NoError(t, err)
1141
1142 keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1143 require.NoError(t, err)
1144
1145 // Should only have browsing_contexts, no popups.
1146 assert.Equal(t, "stacked", keysWithValues["workspace.browsing_contexts.behavior"])
1147
1148 _, hasPopups := keysWithValues["workspace.popups.behavior"]
1149 assert.False(t, hasPopups, "popups should not exist when absent from config")
1150}
1151
1152func TestMigrator_DetectChanges_ReportsLegacyBrowsingContextsRename(t *testing.T) {
1153 home := t.TempDir()
1154 t.Setenv("HOME", home)
1155 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1156 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1157 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1158 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1159
1160 configFile, err := GetConfigFile()
1161 require.NoError(t, err)
1162 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1163
1164 content, err := encodeConfigToTOML(DefaultConfig())
1165 require.NoError(t, err)
1166 content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1167 require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1168
1169 m := NewMigrator()
1170 changes, err := m.DetectChanges()
1171 require.NoError(t, err)
1172
1173 found := false
1174 for _, change := range changes {
1175 if change.Type == port.KeyChangeRenamed &&
1176 change.OldKey == "workspace.popups.behavior" &&
1177 change.NewKey == "workspace.browsing_contexts.behavior" {
1178 found = true
1179 break
1180 }
1181 }
1182 assert.True(t, found, "expected legacy workspace.popups.behavior rename to be reported")
1183}
1184
1185func TestMigrator_Migrate_AppliesLegacyBrowsingContextsRename(t *testing.T) {
1186 home := t.TempDir()
1187 t.Setenv("HOME", home)
1188 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1189 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1190 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1191 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1192
1193 configFile, err := GetConfigFile()
1194 require.NoError(t, err)
1195 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1196
1197 content, err := encodeConfigToTOML(DefaultConfig())
1198 require.NoError(t, err)
1199 content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1200 require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1201
1202 m := NewMigrator()
1203 appliedKeys, err := m.Migrate()
1204 require.NoError(t, err)
1205 assert.Contains(t, appliedKeys, "workspace.popups.behavior -> workspace.browsing_contexts.behavior")
1206
1207 migrated, err := os.ReadFile(configFile)
1208 require.NoError(t, err)
1209 assert.Contains(t, string(migrated), "[workspace.browsing_contexts]")
1210 assert.NotContains(t, string(migrated), "[workspace.popups]")
1211}
1212
1213func TestMigrator_Migrate_RenamesLegacyPageModeToVimMode(t *testing.T) {
1214 home := t.TempDir()
1215 t.Setenv("HOME", home)
1216 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1217 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1218 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1219 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1220
1221 configFile, err := GetConfigFile()
1222 require.NoError(t, err)
1223 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1224 require.NoError(t, os.WriteFile(configFile, []byte(`
1225[workspace.page_mode]
1226activation_shortcut = "ctrl+alt+y"
1227timeout_ms = 250
1228
1229[workspace.page_mode.actions.page-scroll-down]
1230keys = ["n"]
1231desc = "Scroll down"
1232
1233[workspace.page_mode.actions.page_scroll_up]
1234keys = ["u"]
1235desc = "Scroll up"
1236
1237[workspace.page_mode.actions.page_scroll_down_fast]
1238keys = ["d"]
1239desc = "Scroll down fast"
1240`), 0o644))
1241
1242 migrator := NewMigrator()
1243 applied, err := migrator.Migrate()
1244 require.NoError(t, err)
1245 assert.Contains(t, applied, "workspace.page_mode.activation_shortcut -> workspace.vim_mode.activation_shortcut")
1246 assert.Contains(t, applied, "workspace.page_mode.timeout_ms -> workspace.vim_mode.timeout_ms")
1247 assert.Contains(t, applied, "workspace.page_mode.actions -> workspace.vim_mode.actions")
1248
1249 migrated, err := os.ReadFile(configFile)
1250 require.NoError(t, err)
1251 assert.Contains(t, string(migrated), "[workspace.vim_mode]")
1252 assert.Contains(t, string(migrated), "activation_shortcut = 'ctrl+alt+y'")
1253 assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down]")
1254 assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-up]")
1255 assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down-fast]")
1256 assert.Contains(t, string(migrated), "keys = ['u']")
1257 assert.NotContains(t, string(migrated), "page_mode")
1258 assert.NotContains(t, string(migrated), "page-scroll-down")
1259}
1260
1261func TestMigrator_DetectChanges_CanonicalizesLegacyVimActionNames(t *testing.T) {
1262 home := t.TempDir()
1263 t.Setenv("HOME", home)
1264 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1265 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1266 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1267 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1268
1269 configFile, err := GetConfigFile()
1270 require.NoError(t, err)
1271 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1272 require.NoError(t, os.WriteFile(configFile, []byte(`
1273[workspace.page_mode.actions.page_scroll_down_fast]
1274keys = ["d"]
1275desc = "Scroll down fast"
1276`), 0o644))
1277
1278 changes, err := NewMigrator().DetectChanges()
1279 require.NoError(t, err)
1280
1281 foundRename := false
1282 for _, change := range changes {
1283 if change.Type == port.KeyChangeRenamed &&
1284 change.OldKey == "workspace.page_mode.actions" &&
1285 change.NewKey == "workspace.vim_mode.actions" {
1286 foundRename = true
1287 }
1288 }
1289 assert.True(t, foundRename, "DetectChanges must report the canonical Vim Mode actions key")
1290 assert.Equal(t, "vim-scroll-down-fast", legacyVimActionName("page_scroll_down_fast"))
1291}
1292
1293func TestMigrator_Migrate_MergesLegacyPageModeActionsIntoExistingVimMode(t *testing.T) {
1294 home := t.TempDir()
1295 t.Setenv("HOME", home)
1296 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1297 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1298 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1299 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1300
1301 configFile, err := GetConfigFile()
1302 require.NoError(t, err)
1303 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1304 require.NoError(t, os.WriteFile(configFile, []byte(`
1305[workspace.page_mode.actions.page-scroll-down]
1306keys = ["n"]
1307
1308[workspace.page_mode.actions.page-scroll-up]
1309keys = ["u"]
1310
1311[workspace.vim_mode.actions.vim-scroll-down]
1312keys = ["j"]
1313`), 0o644))
1314
1315 _, err = NewMigrator().Migrate()
1316 require.NoError(t, err)
1317
1318 migrated, err := os.ReadFile(configFile)
1319 require.NoError(t, err)
1320 assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-down]\n keys = ['j']")
1321 assert.Contains(t, string(migrated), "[workspace.vim_mode.actions.vim-scroll-up]\n keys = ['u']")
1322 assert.NotContains(t, string(migrated), "page_mode")
1323}
1324
1325func TestMigrator_DetectChanges_MixedBrowsingContextsDoesNotDoubleReportLegacyRename(t *testing.T) {
1326 home := t.TempDir()
1327 t.Setenv("HOME", home)
1328 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1329 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1330 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1331 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1332
1333 configFile, err := GetConfigFile()
1334 require.NoError(t, err)
1335 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1336 require.NoError(t, os.WriteFile(configFile, []byte(`
1337[workspace.popups]
1338placement = "left"
1339
1340[workspace.browsing_contexts]
1341behavior = "tabbed"
1342`), 0o644))
1343
1344 m := NewMigrator()
1345 changes, err := m.DetectChanges()
1346 require.NoError(t, err)
1347
1348 renameCount := 0
1349 for _, change := range changes {
1350 if change.Type == port.KeyChangeRenamed &&
1351 change.OldKey == "workspace.popups.placement" &&
1352 change.NewKey == "workspace.browsing_contexts.placement" {
1353 renameCount++
1354 }
1355 assert.False(t,
1356 change.Type == port.KeyChangeRemoved && change.OldKey == "workspace.popups.placement",
1357 "legacy popup placement should be reported as a rename, not also a removal",
1358 )
1359 assert.False(t,
1360 change.Type == port.KeyChangeAdded && change.NewKey == "workspace.browsing_contexts.placement",
1361 "canonical browsing_contexts placement should be reported as a rename, not also an addition",
1362 )
1363 }
1364 assert.Equal(t, 1, renameCount)
1365}
1366
1367func TestMigrator_ReadRawConfig(t *testing.T) {
1368 m := NewMigrator()
1369
1370 t.Run("read TOML config", func(t *testing.T) {
1371 tmpDir := t.TempDir()
1372 configFile := filepath.Join(tmpDir, "config.toml")
1373
1374 content := `
1375[section]
1376key = "value"
1377number = 42
1378`
1379 err := os.WriteFile(configFile, []byte(content), 0o644)
1380 require.NoError(t, err)
1381
1382 config, err := m.readRawConfig(configFile)
1383 require.NoError(t, err)
1384
1385 section := config["section"].(map[string]any)
1386 assert.Equal(t, "value", section["key"])
1387 assert.Equal(t, int64(42), section["number"])
1388 })
1389
1390 t.Run("read YAML config", func(t *testing.T) {
1391 tmpDir := t.TempDir()
1392 configFile := filepath.Join(tmpDir, "config.yaml")
1393
1394 content := `
1395section:
1396 key: value
1397 number: 42
1398`
1399 err := os.WriteFile(configFile, []byte(content), 0o644)
1400 require.NoError(t, err)
1401
1402 config, err := m.readRawConfig(configFile)
1403 require.NoError(t, err)
1404
1405 section := config["section"].(map[string]any)
1406 assert.Equal(t, "value", section["key"])
1407 assert.Equal(t, 42, section["number"])
1408 })
1409
1410 t.Run("read JSON config", func(t *testing.T) {
1411 tmpDir := t.TempDir()
1412 configFile := filepath.Join(tmpDir, "config.json")
1413
1414 content := `{
1415 "section": {
1416 "key": "value",
1417 "number": 42
1418 }
1419}`
1420 err := os.WriteFile(configFile, []byte(content), 0o644)
1421 require.NoError(t, err)
1422
1423 config, err := m.readRawConfig(configFile)
1424 require.NoError(t, err)
1425
1426 section := config["section"].(map[string]any)
1427 assert.Equal(t, "value", section["key"])
1428 // JSON numbers are float64
1429 assert.InDelta(t, 42.0, section["number"].(float64), 0.001)
1430 })
1431}
1432
1433func TestMigrator_Migrate_RenamesCustomFavoritesSidebarShortcut(t *testing.T) {
1434 home := t.TempDir()
1435 t.Setenv("HOME", home)
1436 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1437 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1438 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1439 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1440
1441 configFile, err := GetConfigFile()
1442 require.NoError(t, err)
1443 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1444 require.NoError(t, os.WriteFile(configFile, []byte(`
1445[workspace.shortcuts.actions.toggle-favorites-systemview]
1446keys = []
1447desc = "Custom favorites shortcut"
1448`), 0o644))
1449
1450 migrator := NewMigrator()
1451 result, err := migrator.CheckMigration()
1452 require.NoError(t, err)
1453 require.NotNil(t, result)
1454 assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-sidebar")
1455
1456 changes, err := migrator.DetectChanges()
1457 require.NoError(t, err)
1458 assertContainsChange(t, changes, port.KeyChangeRenamed,
1459 "workspace.shortcuts.actions.toggle-favorites-systemview",
1460 "workspace.shortcuts.actions.toggle-favorites-sidebar")
1461
1462 applied, err := migrator.Migrate()
1463 require.NoError(t, err)
1464 assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview -> workspace.shortcuts.actions.toggle-favorites-sidebar")
1465
1466 mgr, err := NewManager()
1467 require.NoError(t, err)
1468 require.NoError(t, mgr.Load())
1469 _, ok := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
1470 assert.False(t, ok)
1471 favorites := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
1472 assert.Empty(t, favorites.Keys)
1473 assert.Equal(t, "Custom favorites shortcut", favorites.Desc)
1474}
1475
1476func TestMigrator_Migrate_UpgradesLegacyFavoritesSidebarDefault(t *testing.T) {
1477 home := t.TempDir()
1478 t.Setenv("HOME", home)
1479 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1480 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1481 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1482 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1483
1484 configFile, err := GetConfigFile()
1485 require.NoError(t, err)
1486 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1487 require.NoError(t, os.WriteFile(configFile, []byte(`
1488[workspace.shortcuts.actions.toggle-favorites-systemview]
1489keys = []
1490desc = "Toggle Favorites in right split"
1491`), 0o644))
1492
1493 migrator := NewMigrator()
1494 result, err := migrator.CheckMigration()
1495 require.NoError(t, err)
1496 require.NotNil(t, result)
1497 assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-sidebar")
1498 assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-current-page-favorite")
1499
1500 changes, err := migrator.DetectChanges()
1501 require.NoError(t, err)
1502 assertContainsChange(t, changes, port.KeyChangeRenamed,
1503 "workspace.shortcuts.actions.toggle-favorites-systemview",
1504 "workspace.shortcuts.actions.toggle-favorites-sidebar")
1505 assertContainsChange(t, changes, port.KeyChangeAdded, "", "workspace.shortcuts.actions.toggle-current-page-favorite")
1506
1507 applied, err := migrator.Migrate()
1508 require.NoError(t, err)
1509 assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview -> workspace.shortcuts.actions.toggle-favorites-sidebar")
1510 assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-current-page-favorite")
1511
1512 raw, err := migrator.readRawConfig(configFile)
1513 require.NoError(t, err)
1514 workspace, ok := raw["workspace"].(map[string]any)
1515 require.True(t, ok)
1516 shortcuts, ok := workspace["shortcuts"].(map[string]any)
1517 require.True(t, ok)
1518 actions, ok := shortcuts["actions"].(map[string]any)
1519 require.True(t, ok)
1520
1521 expectedActions := DefaultConfig().Workspace.Shortcuts.Actions
1522 _, ok = actions["toggle-favorites-systemview"]
1523 assert.False(t, ok)
1524 favorites, ok := actions["toggle-favorites-sidebar"].(map[string]any)
1525 require.True(t, ok)
1526 assert.Equal(t, []any{"ctrl+b"}, favorites["keys"])
1527 assert.Equal(t, expectedActions["toggle-favorites-sidebar"].Desc, favorites["desc"])
1528 currentPage, ok := actions["toggle-current-page-favorite"].(map[string]any)
1529 require.True(t, ok)
1530 assert.Equal(t, []any{"ctrl+d"}, currentPage["keys"])
1531 assert.Equal(t, expectedActions["toggle-current-page-favorite"].Desc, currentPage["desc"])
1532
1533 mgr, err := NewManager()
1534 require.NoError(t, err)
1535 require.NoError(t, mgr.Load())
1536 cfg := mgr.Get()
1537 require.NotNil(t, cfg)
1538 _, ok = cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
1539 assert.False(t, ok)
1540 assert.Equal(t, []string{"ctrl+b"}, cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"].Keys)
1541 assert.Equal(t, []string{"ctrl+d"}, cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"].Keys)
1542}