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_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
820 m := NewMigrator()
821
822 value := m.defaultValueForKey("workspace.shortcuts.actions.toggle-floating-pane")
823
824 action, ok := value.(ActionBinding)
825 require.True(t, ok)
826 assert.Equal(t, []string{"alt+f"}, action.Keys)
827}
828
829func TestMigrator_DefaultValueForKey_PaneModeAction(t *testing.T) {
830 m := NewMigrator()
831
832 value := m.defaultValueForKey("workspace.pane_mode.actions.eject-pane-to-window")
833
834 action, ok := value.(ActionBinding)
835 require.True(t, ok)
836 assert.Equal(t, []string{"w"}, action.Keys)
837}
838
839func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) {
840 m := NewMigrator()
841
842 value := m.defaultValueForKey("history.max_entries")
843 assert.Equal(t, 10000, value)
844}
845
846func TestMigrator_GetNestedValue(t *testing.T) {
847 m := NewMigrator()
848
849 config := map[string]any{
850 "level1": map[string]any{
851 "level2": map[string]any{
852 "level3": "deep_value",
853 },
854 "simple": "simple_value",
855 },
856 "top": "top_value",
857 }
858
859 tests := []struct {
860 name string
861 key string
862 expected any
863 }{
864 {
865 name: "top level key",
866 key: "top",
867 expected: "top_value",
868 },
869 {
870 name: "nested key",
871 key: "level1.simple",
872 expected: "simple_value",
873 },
874 {
875 name: "deeply nested key",
876 key: "level1.level2.level3",
877 expected: "deep_value",
878 },
879 {
880 name: "non-existent key",
881 key: "nonexistent",
882 expected: nil,
883 },
884 {
885 name: "non-existent nested key",
886 key: "level1.nonexistent",
887 expected: nil,
888 },
889 }
890
891 for _, tt := range tests {
892 t.Run(tt.name, func(t *testing.T) {
893 result := m.getNestedValue(config, tt.key)
894 assert.Equal(t, tt.expected, result)
895 })
896 }
897}
898
899func TestMigrator_SetNestedValue(t *testing.T) {
900 m := NewMigrator()
901
902 t.Run("set top level value", func(t *testing.T) {
903 config := make(map[string]any)
904 m.setNestedValue(config, "top", "value")
905 assert.Equal(t, "value", config["top"])
906 })
907
908 t.Run("set nested value with existing parent", func(t *testing.T) {
909 config := map[string]any{
910 "parent": map[string]any{},
911 }
912 m.setNestedValue(config, "parent.child", "value")
913 parent := config["parent"].(map[string]any)
914 assert.Equal(t, "value", parent["child"])
915 })
916
917 t.Run("set nested value creates intermediate maps", func(t *testing.T) {
918 config := make(map[string]any)
919 m.setNestedValue(config, "a.b.c", "deep_value")
920
921 a := config["a"].(map[string]any)
922 b := a["b"].(map[string]any)
923 assert.Equal(t, "deep_value", b["c"])
924 })
925}
926
927func TestMigrator_DeleteNestedKey(t *testing.T) {
928 m := NewMigrator()
929
930 t.Run("delete top level key", func(t *testing.T) {
931 config := map[string]any{
932 "keep": "value",
933 "delete": "value",
934 }
935 m.deleteNestedKey(config, "delete")
936 assert.Contains(t, config, "keep")
937 assert.NotContains(t, config, "delete")
938 })
939
940 t.Run("delete nested key", func(t *testing.T) {
941 config := map[string]any{
942 "parent": map[string]any{
943 "keep": "value",
944 "delete": "value",
945 },
946 }
947 m.deleteNestedKey(config, "parent.delete")
948 parent := config["parent"].(map[string]any)
949 assert.Contains(t, parent, "keep")
950 assert.NotContains(t, parent, "delete")
951 })
952
953 t.Run("delete nested key cleans up empty parents", func(t *testing.T) {
954 config := map[string]any{
955 "keep": "value",
956 "parent": map[string]any{
957 "child": map[string]any{
958 "leaf": "value",
959 },
960 },
961 }
962 m.deleteNestedKey(config, "parent.child.leaf")
963 // Both parent and child should be removed since they're empty
964 assert.NotContains(t, config, "parent")
965 assert.Contains(t, config, "keep")
966 })
967
968 t.Run("delete non-existent key is no-op", func(t *testing.T) {
969 config := map[string]any{
970 "keep": "value",
971 }
972 m.deleteNestedKey(config, "nonexistent")
973 assert.Contains(t, config, "keep")
974 })
975}
976
977func TestMigrator_Migrate_IsIdempotentForExternalThemePathAndEmptyProfiles(t *testing.T) {
978 home := t.TempDir()
979 t.Setenv("HOME", home)
980 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
981 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
982 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
983 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
984
985 configFile, err := GetConfigFile()
986 require.NoError(t, err)
987 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
988
989 content, err := encodeConfigToTOML(DefaultConfig())
990 require.NoError(t, err)
991 require.Contains(t, content, "path = '")
992 require.Contains(t, content, "[workspace.floating_pane.profiles]")
993 content = strings.Replace(content, "\n [workspace.floating_pane.profiles]\n", "", 1)
994 require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
995
996 m := NewMigrator()
997 firstChanges, err := m.DetectChanges()
998 require.NoError(t, err)
999 assertContainsChange(t, firstChanges, port.KeyChangeAdded, "", "workspace.floating_pane.profiles")
1000
1001 appliedKeys, err := m.Migrate()
1002 require.NoError(t, err)
1003 require.NotEmpty(t, appliedKeys, "first migration should apply the missing profiles default")
1004
1005 secondChanges, err := m.DetectChanges()
1006 require.NoError(t, err)
1007 assert.Empty(t, secondChanges, "second migration detection should report no changes after a successful migration")
1008
1009 secondAppliedKeys, err := m.Migrate()
1010 require.NoError(t, err)
1011 assert.Empty(t, secondAppliedKeys, "second migration should be a no-op")
1012}
1013
1014func assertContainsChange(t *testing.T, changes []port.KeyChange, changeType port.KeyChangeType, oldKey, newKey string) {
1015 t.Helper()
1016
1017 for _, change := range changes {
1018 if change.Type == changeType && change.OldKey == oldKey && change.NewKey == newKey {
1019 return
1020 }
1021 }
1022 assert.Failf(t, "missing expected change", "type=%v old=%q new=%q changes=%v", changeType, oldKey, newKey, changes)
1023}
1024
1025// --- Migrate-path popup → browsing_contexts transform tests ---
1026
1027func TestMigrator_GetUserConfigKeysWithValues_TransformsLegacyPopups(t *testing.T) {
1028 m := NewMigrator()
1029
1030 tmpDir := t.TempDir()
1031 configFile := filepath.Join(tmpDir, "config.toml")
1032
1033 content := `
1034[workspace]
1035new_pane_url = "about:blank"
1036
1037[workspace.popups]
1038behavior = "tabbed"
1039placement = "left"
1040oauth_auto_close = false
1041`
1042 err := os.WriteFile(configFile, []byte(content), 0o644)
1043 require.NoError(t, err)
1044
1045 keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1046 require.NoError(t, err)
1047
1048 // Legacy popups keys should NOT appear after transform.
1049 _, hasPopupsBehavior := keysWithValues["workspace.popups.behavior"]
1050 assert.False(t, hasPopupsBehavior, "workspace.popups.behavior should not exist after transform")
1051
1052 // Browsing_contexts flattened keys should carry the legacy values.
1053 assert.Equal(t, "tabbed", keysWithValues["workspace.browsing_contexts.behavior"])
1054 assert.Equal(t, "left", keysWithValues["workspace.browsing_contexts.placement"])
1055 assert.Equal(t, false, keysWithValues["workspace.browsing_contexts.oauth_auto_close"])
1056}
1057
1058func TestMigrator_GetUserConfigKeysWithValues_BrowsingContextsAlreadyPresent(t *testing.T) {
1059 m := NewMigrator()
1060
1061 tmpDir := t.TempDir()
1062 configFile := filepath.Join(tmpDir, "config.toml")
1063
1064 // Both popups and browsing_contexts present — transform should be a no-op for popups.
1065 content := `
1066[workspace]
1067new_pane_url = "about:blank"
1068
1069[workspace.popups]
1070behavior = "tabbed"
1071
1072[workspace.browsing_contexts]
1073behavior = "split"
1074placement = "right"
1075`
1076 err := os.WriteFile(configFile, []byte(content), 0o644)
1077 require.NoError(t, err)
1078
1079 keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1080 require.NoError(t, err)
1081
1082 // Browsing_contexts flattened keys should retain their original values.
1083 assert.Equal(t, "split", keysWithValues["workspace.browsing_contexts.behavior"])
1084 assert.Equal(t, "right", keysWithValues["workspace.browsing_contexts.placement"])
1085
1086 // Popups should still exist (transform skipped when browsing_contexts already present).
1087 _, hasPopups := keysWithValues["workspace.popups.behavior"]
1088 assert.True(t, hasPopups, "popups should still exist when browsing_contexts already present")
1089}
1090
1091func TestMigrator_GetUserConfigKeysWithValues_NoPopupsKey(t *testing.T) {
1092 m := NewMigrator()
1093
1094 tmpDir := t.TempDir()
1095 configFile := filepath.Join(tmpDir, "config.toml")
1096
1097 content := `
1098[workspace]
1099new_pane_url = "about:blank"
1100
1101[workspace.browsing_contexts]
1102behavior = "stacked"
1103`
1104 err := os.WriteFile(configFile, []byte(content), 0o644)
1105 require.NoError(t, err)
1106
1107 keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
1108 require.NoError(t, err)
1109
1110 // Should only have browsing_contexts, no popups.
1111 assert.Equal(t, "stacked", keysWithValues["workspace.browsing_contexts.behavior"])
1112
1113 _, hasPopups := keysWithValues["workspace.popups.behavior"]
1114 assert.False(t, hasPopups, "popups should not exist when absent from config")
1115}
1116
1117func TestMigrator_DetectChanges_ReportsLegacyBrowsingContextsRename(t *testing.T) {
1118 home := t.TempDir()
1119 t.Setenv("HOME", home)
1120 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1121 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1122 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1123 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1124
1125 configFile, err := GetConfigFile()
1126 require.NoError(t, err)
1127 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1128
1129 content, err := encodeConfigToTOML(DefaultConfig())
1130 require.NoError(t, err)
1131 content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1132 require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1133
1134 m := NewMigrator()
1135 changes, err := m.DetectChanges()
1136 require.NoError(t, err)
1137
1138 found := false
1139 for _, change := range changes {
1140 if change.Type == port.KeyChangeRenamed &&
1141 change.OldKey == "workspace.popups.behavior" &&
1142 change.NewKey == "workspace.browsing_contexts.behavior" {
1143 found = true
1144 break
1145 }
1146 }
1147 assert.True(t, found, "expected legacy workspace.popups.behavior rename to be reported")
1148}
1149
1150func TestMigrator_Migrate_AppliesLegacyBrowsingContextsRename(t *testing.T) {
1151 home := t.TempDir()
1152 t.Setenv("HOME", home)
1153 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1154 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1155 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1156 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1157
1158 configFile, err := GetConfigFile()
1159 require.NoError(t, err)
1160 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1161
1162 content, err := encodeConfigToTOML(DefaultConfig())
1163 require.NoError(t, err)
1164 content = strings.Replace(content, "[workspace.browsing_contexts]", "[workspace.popups]", 1)
1165 require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644))
1166
1167 m := NewMigrator()
1168 appliedKeys, err := m.Migrate()
1169 require.NoError(t, err)
1170 assert.Contains(t, appliedKeys, "workspace.popups.behavior -> workspace.browsing_contexts.behavior")
1171
1172 migrated, err := os.ReadFile(configFile)
1173 require.NoError(t, err)
1174 assert.Contains(t, string(migrated), "[workspace.browsing_contexts]")
1175 assert.NotContains(t, string(migrated), "[workspace.popups]")
1176}
1177
1178func TestMigrator_DetectChanges_MixedBrowsingContextsDoesNotDoubleReportLegacyRename(t *testing.T) {
1179 home := t.TempDir()
1180 t.Setenv("HOME", home)
1181 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1182 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1183 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1184 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1185
1186 configFile, err := GetConfigFile()
1187 require.NoError(t, err)
1188 require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1189 require.NoError(t, os.WriteFile(configFile, []byte(`
1190[workspace.popups]
1191placement = "left"
1192
1193[workspace.browsing_contexts]
1194behavior = "tabbed"
1195`), 0o644))
1196
1197 m := NewMigrator()
1198 changes, err := m.DetectChanges()
1199 require.NoError(t, err)
1200
1201 renameCount := 0
1202 for _, change := range changes {
1203 if change.Type == port.KeyChangeRenamed &&
1204 change.OldKey == "workspace.popups.placement" &&
1205 change.NewKey == "workspace.browsing_contexts.placement" {
1206 renameCount++
1207 }
1208 assert.False(t,
1209 change.Type == port.KeyChangeRemoved && change.OldKey == "workspace.popups.placement",
1210 "legacy popup placement should be reported as a rename, not also a removal",
1211 )
1212 assert.False(t,
1213 change.Type == port.KeyChangeAdded && change.NewKey == "workspace.browsing_contexts.placement",
1214 "canonical browsing_contexts placement should be reported as a rename, not also an addition",
1215 )
1216 }
1217 assert.Equal(t, 1, renameCount)
1218}
1219
1220func TestMigrator_ReadRawConfig(t *testing.T) {
1221 m := NewMigrator()
1222
1223 t.Run("read TOML config", func(t *testing.T) {
1224 tmpDir := t.TempDir()
1225 configFile := filepath.Join(tmpDir, "config.toml")
1226
1227 content := `
1228[section]
1229key = "value"
1230number = 42
1231`
1232 err := os.WriteFile(configFile, []byte(content), 0o644)
1233 require.NoError(t, err)
1234
1235 config, err := m.readRawConfig(configFile)
1236 require.NoError(t, err)
1237
1238 section := config["section"].(map[string]any)
1239 assert.Equal(t, "value", section["key"])
1240 assert.Equal(t, int64(42), section["number"])
1241 })
1242
1243 t.Run("read YAML config", func(t *testing.T) {
1244 tmpDir := t.TempDir()
1245 configFile := filepath.Join(tmpDir, "config.yaml")
1246
1247 content := `
1248section:
1249 key: value
1250 number: 42
1251`
1252 err := os.WriteFile(configFile, []byte(content), 0o644)
1253 require.NoError(t, err)
1254
1255 config, err := m.readRawConfig(configFile)
1256 require.NoError(t, err)
1257
1258 section := config["section"].(map[string]any)
1259 assert.Equal(t, "value", section["key"])
1260 assert.Equal(t, 42, section["number"])
1261 })
1262
1263 t.Run("read JSON config", func(t *testing.T) {
1264 tmpDir := t.TempDir()
1265 configFile := filepath.Join(tmpDir, "config.json")
1266
1267 content := `{
1268 "section": {
1269 "key": "value",
1270 "number": 42
1271 }
1272}`
1273 err := os.WriteFile(configFile, []byte(content), 0o644)
1274 require.NoError(t, err)
1275
1276 config, err := m.readRawConfig(configFile)
1277 require.NoError(t, err)
1278
1279 section := config["section"].(map[string]any)
1280 assert.Equal(t, "value", section["key"])
1281 // JSON numbers are float64
1282 assert.InDelta(t, 42.0, section["number"].(float64), 0.001)
1283 })
1284}