package tui import ( "os" "path/filepath" "strings" "testing" tea "charm.land/bubbletea/v2" "gitgud.io/mike/mpv-manager/pkg/config" "gitgud.io/mike/mpv-manager/pkg/hotkeys" ) // sandboxConfigHome isolates all native home/config locations so code // resolving user config paths (input.conf, modernz.conf, mpv-manager.json) // operates inside a sandbox instead of the real user configuration. func sandboxConfigHome(t *testing.T) string { t.Helper() tmp := t.TempDir() for _, key := range []string{"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME"} { t.Setenv(key, tmp) } if _, err := config.Reload(); err != nil { t.Fatalf("reload isolated config: %v", err) } return tmp } // pressKey delivers a key message to the model and returns the updated model. func pressKey(t *testing.T, m *Model, msg tea.KeyPressMsg) *Model { t.Helper() updated, _ := m.Update(msg) um, ok := updated.(*Model) if !ok { t.Fatalf("expected *Model, got %T", updated) } return um } func keyRunes(s string) tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyExtended, Text: s} } // writeInputConf writes an input.conf into the sandboxed mpv config dir. func writeInputConf(t *testing.T, content string) string { t.Helper() dir := config.GetMPVConfigPath() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("failed to create mpv config dir: %v", err) } path := filepath.Join(dir, "input.conf") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("failed to write input.conf: %v", err) } return path } func readFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("failed to read %s: %v", path, err) } return string(data) } // selectBinding moves the bindings list cursor to the item with the given key. func selectBinding(t *testing.T, m *Model, key string) { t.Helper() for i, item := range m.hotkeysBindingsList.Items() { if item.(bindingItem).key == key { m.hotkeysBindingsList.Select(i) return } } t.Fatalf("binding %q not found in list", key) } func newBindingsModel(t *testing.T, content string) (*Model, string) { t.Helper() sandboxConfigHome(t) path := writeInputConf(t, content) m := NewModel() m.setupHotkeysBindingsList() m.state = StateHotkeysBindings return m, path } func TestBindingsListLoadsFromInputConf(t *testing.T) { m, _ := newBindingsModel(t, "SPACE cycle pause\nF1 show-text hi\n# a comment\n\n[section]\n") items := m.hotkeysBindingsList.Items() if len(items) != 2 { t.Fatalf("expected 2 binding items, got %d", len(items)) } if items[0].(bindingItem).key != "SPACE" || items[0].(bindingItem).command != "cycle pause" { t.Errorf("unexpected first item: %+v", items[0]) } if items[1].(bindingItem).key != "F1" { t.Errorf("unexpected second item: %+v", items[1]) } } func TestBindingsEscReturnsToPresetList(t *testing.T) { m, _ := newBindingsModel(t, "SPACE cycle pause\n") m = pressKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) if m.state != StateHotkeysPreset { t.Fatalf("expected StateHotkeysPreset after Esc, got %v", m.state) } } func TestEditBindingInvalidCommandKeepsState(t *testing.T) { m, path := newBindingsModel(t, "SPACE cycle pause\n") before := readFile(t, path) selectBinding(t, m, "SPACE") m = pressKey(t, m, keyRunes("e")) if m.state != StateHotkeysBindingEdit { t.Fatalf("expected StateHotkeysBindingEdit, got %v", m.state) } // Empty command fails validation and must not touch state or file m.bindingTextInput.SetValue("") m = pressKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) if m.state != StateHotkeysBindingEdit { t.Fatalf("expected to stay in StateHotkeysBindingEdit, got %v", m.state) } if !strings.HasPrefix(m.bindingStatusMsg, "✗") { t.Errorf("expected error status, got %q", m.bindingStatusMsg) } if after := readFile(t, path); after != before { t.Errorf("input.conf changed despite invalid input:\nbefore: %q\nafter: %q", before, after) } } func TestEditBindingConflictPromptAndOverwrite(t *testing.T) { m, path := newBindingsModel(t, "SPACE cycle pause\n") selectBinding(t, m, "SPACE") m = pressKey(t, m, keyRunes("e")) m.bindingTextInput.SetValue("cycle mute") m = pressKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) // Existing binding has a different command: expect overwrite prompt if m.state != StateHotkeysBindings { t.Fatalf("expected StateHotkeysBindings, got %v", m.state) } if m.bindingConfirm != "overwrite" { t.Fatalf("expected overwrite confirmation, got %q", m.bindingConfirm) } // Declining leaves the file untouched m = pressKey(t, m, keyRunes("n")) if m.bindingConfirm != "" { t.Fatalf("expected confirmation cleared, got %q", m.bindingConfirm) } if content := readFile(t, path); !strings.Contains(content, "SPACE cycle pause") { t.Errorf("expected original binding preserved, got %q", content) } // Confirming writes the new command selectBinding(t, m, "SPACE") m = pressKey(t, m, keyRunes("e")) m.bindingTextInput.SetValue("cycle mute") m = pressKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) if m.bindingConfirm != "overwrite" { t.Fatalf("expected overwrite confirmation, got %q", m.bindingConfirm) } m = pressKey(t, m, keyRunes("y")) content := readFile(t, path) if !strings.Contains(content, "SPACE cycle mute") { t.Errorf("expected overwritten binding in file, got %q", content) } if b := hotkeys.FindBinding(m.inputConf, "SPACE"); b == nil || b.Command != "cycle mute" { t.Errorf("expected in-memory binding updated, got %+v", b) } if !strings.HasPrefix(m.bindingStatusMsg, "✓") { t.Errorf("expected success status, got %q", m.bindingStatusMsg) } } func TestAddBindingFlow(t *testing.T) { m, path := newBindingsModel(t, "SPACE cycle pause\n") m = pressKey(t, m, keyRunes("a")) if m.state != StateHotkeysBindingAddKey { t.Fatalf("expected StateHotkeysBindingAddKey, got %v", m.state) } // Invalid key name (contains whitespace) is rejected inline m.bindingTextInput.SetValue("bad key") m = pressKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) if m.state != StateHotkeysBindingAddKey { t.Fatalf("expected to stay in StateHotkeysBindingAddKey, got %v", m.state) } if !strings.HasPrefix(m.bindingStatusMsg, "✗") { t.Errorf("expected error status, got %q", m.bindingStatusMsg) } // Valid key name advances to the command input m.bindingTextInput.SetValue("Ctrl+RIGHT") m = pressKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) if m.state != StateHotkeysBindingAddCmd { t.Fatalf("expected StateHotkeysBindingAddCmd, got %v", m.state) } if m.bindingNewKey != "Ctrl+RIGHT" { t.Errorf("expected bindingNewKey %q, got %q", "Ctrl+RIGHT", m.bindingNewKey) } // Valid command saves the new binding (no conflict) m.bindingTextInput.SetValue("seek 10") m = pressKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) if m.state != StateHotkeysBindings { t.Fatalf("expected StateHotkeysBindings, got %v", m.state) } if m.bindingConfirm != "" { t.Errorf("expected no confirmation prompt, got %q", m.bindingConfirm) } if content := readFile(t, path); !strings.Contains(content, "Ctrl+RIGHT seek 10") { t.Errorf("expected new binding in file, got %q", content) } if len(m.hotkeysBindingsList.Items()) != 2 { t.Errorf("expected 2 bindings in list, got %d", len(m.hotkeysBindingsList.Items())) } } func TestAddBindingExistingKeyPromptsOverwrite(t *testing.T) { m, _ := newBindingsModel(t, "SPACE cycle pause\n") m = pressKey(t, m, keyRunes("a")) m.bindingTextInput.SetValue("SPACE") m = pressKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) if m.state != StateHotkeysBindingAddCmd { t.Fatalf("expected StateHotkeysBindingAddCmd, got %v", m.state) } m.bindingTextInput.SetValue("cycle mute") m = pressKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) if m.bindingConfirm != "overwrite" { t.Fatalf("expected overwrite confirmation for existing key, got %q", m.bindingConfirm) } if m.bindingEditKey != "SPACE" || m.bindingNewCmd != "cycle mute" { t.Errorf("unexpected pending overwrite: key=%q cmd=%q", m.bindingEditKey, m.bindingNewCmd) } } func TestDeleteBinding(t *testing.T) { m, path := newBindingsModel(t, "SPACE cycle pause\nF1 show-text hi\n") selectBinding(t, m, "F1") m = pressKey(t, m, keyRunes("d")) if m.bindingConfirm != "delete" { t.Fatalf("expected delete confirmation, got %q", m.bindingConfirm) } // Cancel first: binding must survive m = pressKey(t, m, keyRunes("n")) if content := readFile(t, path); !strings.Contains(content, "F1 show-text hi") { t.Errorf("expected binding preserved after cancel, got %q", content) } // Confirm deletes the binding selectBinding(t, m, "F1") m = pressKey(t, m, keyRunes("d")) m = pressKey(t, m, keyRunes("y")) content := readFile(t, path) if strings.Contains(content, "F1") { t.Errorf("expected F1 binding removed, got %q", content) } if !strings.Contains(content, "SPACE cycle pause") { t.Errorf("expected SPACE binding preserved, got %q", content) } if len(m.hotkeysBindingsList.Items()) != 1 { t.Errorf("expected 1 binding left in list, got %d", len(m.hotkeysBindingsList.Items())) } } func TestBindingsEditorEntryFromPresetList(t *testing.T) { sandboxConfigHome(t) m := NewModel() m.setupHotkeysPresetList() m.state = StateHotkeysPreset // Find and select the edit-bindings entry found := false for i, item := range m.hotkeysPresetList.Items() { if item.(hotkeyPresetItem).id == "edit-bindings" { m.hotkeysPresetList.Select(i) found = true break } } if !found { t.Fatal("edit-bindings entry not found in preset list") } m = pressKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) if m.state != StateHotkeysBindings { t.Fatalf("expected StateHotkeysBindings, got %v", m.state) } }