package hotkeys import ( "strings" "testing" ) // --------------------------------------------------------------------------- // TestValidateKeyName // --------------------------------------------------------------------------- func TestValidateKeyName(t *testing.T) { valid := []string{ // Single characters (letters, digits, punctuation) "q", "Q", "9", ",", ".", "/", "*", "<", ">", "]", "{", "}", "_", "`", "=", "+", // Named keys "SPACE", "ENTER", "TAB", "UP", "DOWN", "LEFT", "RIGHT", "HOME", "END", "PGUP", "PGDWN", "INS", "DEL", "ESC", "BS", "BACKSPACE", "SHARP", "PRINT", "F1", "F8", "F13", "F24", "KP0", "KP9", "KP_ADD", "KP_SUBTRACT", "KP_MULTIPLY", "KP_DIVIDE", "KP_ENTER", "MBTN_LEFT", "MBTN_LEFT_DBL", "WHEEL_UP", "WHEEL_DOWN", "GAMEPAD_ACTION_DOWN", "PLAY", "PAUSE", "STOP", "REWIND", "FORWARD", "PLAYPAUSE", "PLAYONLY", "PAUSEONLY", "NEXT", "PREV", "MUTE", "VOLUME_UP", "VOLUME_DOWN", "CLOSE_WIN", "POWER", // Modifier combos (modifiers are matched case-insensitively) "ctrl+q", "Ctrl+X", "Ctrl+Shift+UP", "Shift+Ctrl+F1", "Alt+KP0", "Meta+A", "ctrl++", "ctrl+-", "Alt+ENTER", "Alt+F4", "Shift+Ctrl+BS", "Ctrl+Shift+RIGHT", // Bare modifier names are accepted as keys (documented choice: // recent mpv supports modifier-only bindings). "ctrl", "Ctrl", "Shift", "Alt", "Meta", } for _, key := range valid { t.Run("valid/"+key, func(t *testing.T) { if err := ValidateKeyName(key); err != nil { t.Errorf("ValidateKeyName(%q) = %v, expected nil", key, err) } }) } invalid := []struct { name string key string }{ {"empty", ""}, {"modifier without key", "Ctrl+"}, {"leading plus", "+q"}, {"doubled plus", "Ctrl++q"}, {"trailing plus after combo", "Ctrl+Shift+"}, {"unknown modifier", "Windows+q"}, {"unknown modifier with space", "Bad Mod+q"}, {"space around plus", "Ctrl +q"}, {"trailing space", "q "}, {"leading tab", "\tq"}, {"newline injection", "q\nquit"}, {"newline in combo", "Ctrl+\n"}, {"control char", "UP\x00"}, {"non-ASCII", "→"}, {"unknown lowercase name", "foo"}, {"mixed-case named key", "Tab"}, {"comment delimiter", "#"}, } for _, tt := range invalid { t.Run("invalid/"+tt.name, func(t *testing.T) { if err := ValidateKeyName(tt.key); err == nil { t.Errorf("ValidateKeyName(%q) = nil, expected error", tt.key) } }) } } // TestValidateKeyNameCaseSensitivity verifies that case is not normalized: // mpv key names are case-sensitive, so "space" is not "SPACE". func TestValidateKeyNameCaseSensitivity(t *testing.T) { if err := ValidateKeyName("space"); err == nil { t.Error("ValidateKeyName(\"space\") = nil, expected error (mpv names are case-sensitive)") } if err := ValidateKeyName("Space"); err == nil { t.Error("ValidateKeyName(\"Space\") = nil, expected error (mpv names are case-sensitive)") } if err := ValidateKeyName("SPACE"); err != nil { t.Errorf("ValidateKeyName(\"SPACE\") = %v, expected nil", err) } } // TestValidateKeyNamePresetKeys ensures every key used by the embedded // hotkey presets passes validation. func TestValidateKeyNamePresetKeys(t *testing.T) { for _, preset := range GetPresetList() { t.Run(preset.ID, func(t *testing.T) { content, err := LoadPreset(preset.ID) if err != nil { t.Fatalf("LoadPreset(%q) error: %v", preset.ID, err) } config, err := ParseInputConf(content) if err != nil { t.Fatalf("ParseInputConf(%q) error: %v", preset.ID, err) } checked := 0 for _, b := range config.Bindings { if b.IsComment || b.IsEmpty || b.IsSection || b.Key == "" { continue } if err := ValidateKeyName(b.Key); err != nil { t.Errorf("preset %q key %q failed validation: %v", preset.ID, b.Key, err) } if b.Command != "" { if err := ValidateBindingCommand(b.Command); err != nil { t.Errorf("preset %q command %q failed validation: %v", preset.ID, b.Command, err) } } checked++ } if checked == 0 { t.Errorf("preset %q: no bindings checked", preset.ID) } }) } } // --------------------------------------------------------------------------- // TestValidateBindingCommand // --------------------------------------------------------------------------- func TestValidateBindingCommand(t *testing.T) { valid := []string{ "quit", "seek 5", "seek -5", "cycle pause", "cycle audio # switch audio track", `show-text "a #b"`, `show-text "hi"`, "set volume 100", "add sub-delay 0.1", "seek 5 # inline comment", } for _, cmd := range valid { t.Run("valid/"+cmd, func(t *testing.T) { if err := ValidateBindingCommand(cmd); err != nil { t.Errorf("ValidateBindingCommand(%q) = %v, expected nil", cmd, err) } }) } invalid := []struct { name string cmd string }{ {"empty", ""}, {"whitespace only", " "}, {"tab only", "\t"}, {"newline injection", "seek 5\nquit"}, {"carriage return", "seek\r5"}, {"tab separator", "seek\t5"}, {"NUL byte", "seek\x005"}, } for _, tt := range invalid { t.Run("invalid/"+tt.name, func(t *testing.T) { if err := ValidateBindingCommand(tt.cmd); err == nil { t.Errorf("ValidateBindingCommand(%q) = nil, expected error", tt.cmd) } }) } } // --------------------------------------------------------------------------- // TestSetBindingValidation // --------------------------------------------------------------------------- func TestSetBindingValidation(t *testing.T) { t.Run("rejects invalid key", func(t *testing.T) { config, _ := ParseInputConf("SPACE cycle pause\n") origLen := len(config.Bindings) err := SetBinding(config, "Bad Mod+q", "cycle pause", "") if err == nil { t.Fatal("SetBinding with invalid key: expected error, got nil") } if len(config.Bindings) != origLen { t.Errorf("SetBinding with invalid key modified config: len = %d, expected %d", len(config.Bindings), origLen) } }) t.Run("rejects empty key", func(t *testing.T) { config, _ := ParseInputConf("SPACE cycle pause\n") if err := SetBinding(config, "", "cycle pause", ""); err == nil { t.Error("SetBinding with empty key: expected error, got nil") } }) t.Run("rejects newline injection in command", func(t *testing.T) { config, _ := ParseInputConf("SPACE cycle pause\n") origLen := len(config.Bindings) err := SetBinding(config, "q", "quit\nshow-text injected", "") if err == nil { t.Fatal("SetBinding with newline in command: expected error, got nil") } if len(config.Bindings) != origLen { t.Errorf("SetBinding with invalid command modified config: len = %d, expected %d", len(config.Bindings), origLen) } }) t.Run("rejects empty command", func(t *testing.T) { config, _ := ParseInputConf("SPACE cycle pause\n") if err := SetBinding(config, "q", "", ""); err == nil { t.Error("SetBinding with empty command: expected error, got nil") } }) t.Run("rejects control char in comment", func(t *testing.T) { config, _ := ParseInputConf("SPACE cycle pause\n") if err := SetBinding(config, "q", "quit", "bad\ncomment"); err == nil { t.Error("SetBinding with newline in comment: expected error, got nil") } }) t.Run("accepts and round-trips valid binding", func(t *testing.T) { config, _ := ParseInputConf("SPACE cycle pause\n") if err := SetBinding(config, "Ctrl+q", "quit", "Quit playback"); err != nil { t.Fatalf("SetBinding with valid input: unexpected error: %v", err) } // Round-trip through serialize → parse. reparsed, err := ParseInputConf(SerializeInputConf(config)) if err != nil { t.Fatalf("ParseInputConf after serialize: %v", err) } b := FindBinding(reparsed, "Ctrl+q") if b == nil { t.Fatal("round-trip: Ctrl+q binding not found") } if b.Command != "quit" { t.Errorf("round-trip: Command = %q, expected %q", b.Command, "quit") } if !strings.Contains(b.Comment, "Quit playback") { t.Errorf("round-trip: Comment = %q, expected to contain %q", b.Comment, "Quit playback") } }) t.Run("updates existing binding with validation", func(t *testing.T) { config, _ := ParseInputConf("q quit\n") if err := SetBinding(config, "q", "quit-watch-later", ""); err != nil { t.Fatalf("SetBinding update: unexpected error: %v", err) } b := FindBinding(config, "q") if b == nil || b.Command != "quit-watch-later" { t.Errorf("SetBinding update: Command = %q, expected %q", b.Command, "quit-watch-later") } // Invalid update must leave the existing binding untouched. if err := SetBinding(config, "q", "quit\nquit", ""); err == nil { t.Error("SetBinding update with invalid command: expected error, got nil") } b = FindBinding(config, "q") if b == nil || b.Command != "quit-watch-later" { t.Errorf("SetBinding invalid update changed binding: Command = %q", b.Command) } }) }