Files
kitty/kittens/command_palette/main_test.go
Daniel M German 6fa122af11 Fix action triggering by using kitten output instead of remote control
The RC approach sent actions targeting the overlay window itself rather
than the underlying window. Switch to the standard kitten output pattern:
the Go kitten outputs the selected action definition via
KittenOutputSerializer, and handle_result in Python calls boss.combine()
with the correct target window after the overlay closes.
2026-02-22 21:12:36 -08:00

310 lines
9.2 KiB
Go

package command_palette
import (
"encoding/json"
"strings"
"testing"
"github.com/kovidgoyal/kitty/tools/fzf"
)
func sampleInputJSON() string {
return `{
"modes": {
"": {
"Copy/paste": [
{"key": "ctrl+shift+c", "action": "copy_to_clipboard", "action_display": "copy_to_clipboard", "definition": "copy_to_clipboard", "help": "Copy the selected text from the active window to the clipboard", "long_help": ""},
{"key": "ctrl+shift+v", "action": "paste_from_clipboard", "action_display": "paste_from_clipboard", "definition": "paste_from_clipboard", "help": "Paste from the clipboard to the active window", "long_help": ""}
],
"Scrolling": [
{"key": "ctrl+shift+up", "action": "scroll_line_up", "action_display": "scroll_line_up", "definition": "scroll_line_up", "help": "Scroll up one line", "long_help": ""},
{"key": "ctrl+shift+down", "action": "scroll_line_down", "action_display": "scroll_line_down", "definition": "scroll_line_down", "help": "Scroll down one line", "long_help": ""}
],
"Window management": [
{"key": "ctrl+shift+enter", "action": "new_window", "action_display": "new_window", "definition": "new_window", "help": "Open a new window", "long_help": ""}
]
},
"mw": {
"Miscellaneous": [
{"key": "left", "action": "neighboring_window", "action_display": "neighboring_window left", "definition": "neighboring_window left", "help": "Focus neighbor window", "long_help": ""},
{"key": "esc", "action": "pop_keyboard_mode", "action_display": "pop_keyboard_mode", "definition": "pop_keyboard_mode", "help": "Pop keyboard mode", "long_help": ""}
]
}
},
"mouse": [
{"key": "left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection normal", "definition": "mouse_selection normal", "help": "", "long_help": ""},
{"key": "ctrl+left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection rectangle", "definition": "mouse_selection rectangle", "help": "", "long_help": ""}
],
"mode_order": ["", "mw"],
"category_order": {
"": ["Copy/paste", "Scrolling", "Window management"],
"mw": ["Miscellaneous"]
}
}`
}
func newTestHandler() *Handler {
h := &Handler{}
if err := json.Unmarshal([]byte(sampleInputJSON()), &h.input_data); err != nil {
panic("test data JSON is invalid: " + err.Error())
}
h.flattenBindings()
h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME)
return h
}
func TestFlattenAllBindings(t *testing.T) {
h := newTestHandler()
// 5 default mode + 2 mw mode + 2 mouse = 9
if len(h.all_items) != 9 {
t.Fatalf("Expected 9 items, got %d", len(h.all_items))
}
}
func TestDefaultModeComesFirst(t *testing.T) {
h := newTestHandler()
// First 5 items should be from default mode
for i := 0; i < 5; i++ {
if h.all_items[i].binding.Mode != "" {
t.Fatalf("Item %d should be from default mode, got mode=%q", i, h.all_items[i].binding.Mode)
}
}
}
func TestCategoryOrderPreserved(t *testing.T) {
h := newTestHandler()
// Verify categories appear in the order specified by category_order
var categories []string
seen := map[string]bool{}
for _, item := range h.all_items {
if item.binding.Mode != "" || item.binding.IsMouse {
continue
}
cat := item.binding.Category
if !seen[cat] {
categories = append(categories, cat)
seen[cat] = true
}
}
expected := []string{"Copy/paste", "Scrolling", "Window management"}
if len(categories) != len(expected) {
t.Fatalf("Expected %d categories, got %d: %v", len(expected), len(categories), categories)
}
for i, cat := range categories {
if cat != expected[i] {
t.Fatalf("Category %d: expected %q, got %q", i, expected[i], cat)
}
}
}
func TestCustomModePresent(t *testing.T) {
h := newTestHandler()
found := false
for _, item := range h.all_items {
if item.binding.Mode == "mw" {
found = true
break
}
}
if !found {
t.Fatal("Expected to find items from 'mw' mode")
}
}
func TestMouseBindingsMarkedCorrectly(t *testing.T) {
h := newTestHandler()
mouseCount := 0
for _, item := range h.all_items {
if item.binding.IsMouse {
mouseCount++
if item.binding.Category != "Mouse actions" {
t.Fatalf("Mouse binding should have category 'Mouse actions', got %q", item.binding.Category)
}
}
}
if mouseCount != 2 {
t.Fatalf("Expected 2 mouse bindings, got %d", mouseCount)
}
}
func TestFilterNoQueryReturnsAll(t *testing.T) {
h := newTestHandler()
h.query = ""
h.updateFilter()
if len(h.filtered_idx) != len(h.all_items) {
t.Fatalf("With no query, expected %d items, got %d", len(h.all_items), len(h.filtered_idx))
}
for i, idx := range h.filtered_idx {
if idx != i {
t.Fatalf("Expected sequential order, got index %d at position %d", idx, i)
}
}
}
func TestFilterMatchesSubset(t *testing.T) {
h := newTestHandler()
h.query = "clipboard"
h.updateFilter()
if len(h.filtered_idx) == 0 {
t.Fatal("Expected matches for 'clipboard'")
}
if len(h.filtered_idx) >= len(h.all_items) {
t.Fatal("Expected fewer matches than total items")
}
// Verify all returned items actually contain relevant text
for _, idx := range h.filtered_idx {
text := strings.ToLower(h.all_items[idx].searchText)
if !strings.Contains(text, "clipboard") {
// FZF does fuzzy matching, so this is a soft check —
// the characters should at least be present
}
}
}
func TestFilterNonsenseReturnsEmpty(t *testing.T) {
h := newTestHandler()
h.query = "zzzznonexistent"
h.updateFilter()
if len(h.filtered_idx) != 0 {
t.Fatalf("Expected no matches for nonsense, got %d", len(h.filtered_idx))
}
}
func TestFilterResetsSelectionAndScroll(t *testing.T) {
h := newTestHandler()
h.query = ""
h.updateFilter()
h.selected_idx = 3
h.scroll_offset = 5
h.query = "scroll"
h.updateFilter()
if h.selected_idx != 0 {
t.Fatalf("Expected selection reset to 0, got %d", h.selected_idx)
}
if h.scroll_offset != 0 {
t.Fatalf("Expected scroll offset reset to 0, got %d", h.scroll_offset)
}
}
func TestSelectedBindingValid(t *testing.T) {
h := newTestHandler()
h.updateFilter()
b := h.selectedBinding()
if b == nil {
t.Fatal("Expected non-nil binding")
}
if b.Key == "" || b.Action == "" {
t.Fatal("Binding should have non-empty key and action")
}
}
func TestSelectedBindingNilWhenEmpty(t *testing.T) {
h := newTestHandler()
h.query = "zzzznonexistent"
h.updateFilter()
if b := h.selectedBinding(); b != nil {
t.Fatal("Expected nil binding when no matches")
}
}
func TestSelectedBindingNilWhenNegativeIndex(t *testing.T) {
h := newTestHandler()
h.updateFilter()
h.selected_idx = -1
if b := h.selectedBinding(); b != nil {
t.Fatal("Expected nil binding for negative index")
}
}
func TestSelectedBindingNilWhenOverflowIndex(t *testing.T) {
h := newTestHandler()
h.updateFilter()
h.selected_idx = len(h.filtered_idx) + 10
if b := h.selectedBinding(); b != nil {
t.Fatal("Expected nil binding for overflow index")
}
}
func TestSearchTextContainsKeyAndAction(t *testing.T) {
h := newTestHandler()
for i, item := range h.all_items {
if !strings.Contains(item.searchText, item.binding.Key) {
t.Fatalf("Item %d: search text %q should contain key %q", i, item.searchText, item.binding.Key)
}
if !strings.Contains(item.searchText, item.binding.ActionDisplay) {
t.Fatalf("Item %d: search text %q should contain action %q", i, item.searchText, item.binding.ActionDisplay)
}
}
}
func TestHelpTextPreserved(t *testing.T) {
h := newTestHandler()
helpCount := 0
for _, item := range h.all_items {
if item.binding.Help != "" {
helpCount++
}
}
if helpCount == 0 {
t.Fatal("Expected at least some bindings to have help text")
}
// All keyboard bindings in our sample data have help text
if helpCount < 7 {
t.Fatalf("Expected at least 7 bindings with help text, got %d", helpCount)
}
}
func TestEmptyInputData(t *testing.T) {
h := &Handler{}
emptyJSON := `{"modes": {}, "mouse": [], "mode_order": [], "category_order": {}}`
if err := json.Unmarshal([]byte(emptyJSON), &h.input_data); err != nil {
t.Fatal(err)
}
h.flattenBindings()
h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME)
h.updateFilter()
if len(h.all_items) != 0 {
t.Fatalf("Expected 0 items for empty data, got %d", len(h.all_items))
}
if len(h.filtered_idx) != 0 {
t.Fatalf("Expected 0 filtered items, got %d", len(h.filtered_idx))
}
if b := h.selectedBinding(); b != nil {
t.Fatal("Expected nil binding for empty data")
}
}
func TestFallbackOrderingWithoutExplicitOrder(t *testing.T) {
// Test that the kitten handles missing mode_order/category_order gracefully
h := &Handler{}
noOrderJSON := `{
"modes": {
"": {
"Scrolling": [{"key": "up", "action": "scroll", "action_display": "scroll", "help": "", "long_help": ""}],
"Copy/paste": [{"key": "c", "action": "copy", "action_display": "copy", "help": "", "long_help": ""}]
}
},
"mouse": []
}`
if err := json.Unmarshal([]byte(noOrderJSON), &h.input_data); err != nil {
t.Fatal(err)
}
h.flattenBindings()
if len(h.all_items) != 2 {
t.Fatalf("Expected 2 items, got %d", len(h.all_items))
}
// Without explicit order, categories should be sorted alphabetically
cat0 := h.all_items[0].binding.Category
cat1 := h.all_items[1].binding.Category
if cat0 > cat1 {
t.Fatalf("Expected alphabetical category order, got %q then %q", cat0, cat1)
}
}