mirror of
https://github.com/kovidgoyal/kitty.git
synced 2026-05-13 08:26:56 +00:00
1395 lines
46 KiB
Go
1395 lines
46 KiB
Go
package command_palette
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// testBinding creates a Binding where Action, ActionDisplay, and Definition all
|
|
// equal action. Covers 90% of test bindings.
|
|
func testBinding(key, action, help string) Binding {
|
|
return Binding{
|
|
Key: key,
|
|
Action: action,
|
|
ActionDisplay: action,
|
|
Definition: action,
|
|
Help: help,
|
|
}
|
|
}
|
|
|
|
// testMouseBinding creates a mouse Binding where ActionDisplay differs from
|
|
// Action. Action is derived as the first word of actionDisplay.
|
|
func testMouseBinding(key, actionDisplay string) Binding {
|
|
action := actionDisplay
|
|
if before, _, ok := strings.Cut(actionDisplay, " "); ok {
|
|
action = before
|
|
}
|
|
return Binding{
|
|
Key: key,
|
|
Action: action,
|
|
ActionDisplay: actionDisplay,
|
|
Definition: actionDisplay,
|
|
}
|
|
}
|
|
|
|
// testHandlerBuilder constructs a Handler with programmatic data (no JSON round-trip).
|
|
type testHandlerBuilder struct {
|
|
input InputData
|
|
}
|
|
|
|
func newBuilder() *testHandlerBuilder {
|
|
return &testHandlerBuilder{
|
|
input: InputData{
|
|
Modes: make(map[string]map[string][]Binding),
|
|
CategoryOrder: make(map[string][]string),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (b *testHandlerBuilder) addBinding(mode, category string, binding Binding) *testHandlerBuilder {
|
|
if b.input.Modes[mode] == nil {
|
|
b.input.Modes[mode] = make(map[string][]Binding)
|
|
b.input.ModeOrder = append(b.input.ModeOrder, mode)
|
|
}
|
|
cats := b.input.Modes[mode]
|
|
if _, ok := cats[category]; !ok {
|
|
b.input.CategoryOrder[mode] = append(b.input.CategoryOrder[mode], category)
|
|
}
|
|
cats[category] = append(cats[category], binding)
|
|
return b
|
|
}
|
|
|
|
func (b *testHandlerBuilder) addMouse(binding Binding) *testHandlerBuilder {
|
|
b.input.Mouse = append(b.input.Mouse, binding)
|
|
return b
|
|
}
|
|
|
|
func (b *testHandlerBuilder) build() *Handler {
|
|
h := &Handler{}
|
|
h.input_data = b.input
|
|
h.flattenBindings()
|
|
h.show_unmapped = true
|
|
return h
|
|
}
|
|
|
|
func newTestHandler() *Handler {
|
|
return newBuilder().
|
|
addBinding("", "Copy/paste", testBinding("ctrl+shift+c", "copy_to_clipboard", "Copy the selected text from the active window to the clipboard")).
|
|
addBinding("", "Copy/paste", testBinding("ctrl+shift+v", "paste_from_clipboard", "Paste from the clipboard to the active window")).
|
|
addBinding("", "Scrolling", testBinding("ctrl+shift+up", "scroll_line_up", "Scroll up one line")).
|
|
addBinding("", "Scrolling", testBinding("ctrl+shift+down", "scroll_line_down", "Scroll down one line")).
|
|
addBinding("", "Window management", testBinding("ctrl+shift+enter", "new_window", "Open a new window")).
|
|
addBinding("mw", "Miscellaneous", Binding{
|
|
Key: "left", Action: "neighboring_window",
|
|
ActionDisplay: "neighboring_window left",
|
|
Definition: "neighboring_window left", Help: "Focus neighbor window",
|
|
}).
|
|
addBinding("mw", "Miscellaneous", testBinding("esc", "pop_keyboard_mode", "Pop keyboard mode")).
|
|
addMouse(Binding{
|
|
Key: "left press ungrabbed", Action: "mouse_selection",
|
|
ActionDisplay: "mouse_selection normal",
|
|
Definition: "mouse_selection normal",
|
|
}).
|
|
addMouse(Binding{
|
|
Key: "ctrl+left press ungrabbed", Action: "mouse_selection",
|
|
ActionDisplay: "mouse_selection rectangle",
|
|
Definition: "mouse_selection rectangle",
|
|
}).
|
|
build()
|
|
}
|
|
|
|
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 := range 5 {
|
|
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.show_unmapped = true // show all items including unmapped
|
|
h.query = ""
|
|
h.updateFilter()
|
|
if len(h.filtered_idx) != len(h.all_items) {
|
|
t.Fatalf("With no query and show_unmapped=true, 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 contain relevant text in at least one column
|
|
for _, idx := range h.filtered_idx {
|
|
item := &h.all_items[idx]
|
|
found := strings.Contains(strings.ToLower(item.keyText), "clipboard") ||
|
|
strings.Contains(strings.ToLower(item.actionText), "clipboard") ||
|
|
strings.Contains(strings.ToLower(item.categoryText), "clipboard")
|
|
if !found {
|
|
t.Fatalf("Matched item %q has no column containing 'clipboard'", item.actionText)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// keyText = key (or unmappedLabel for empty key), actionText = action_display, categoryText = category
|
|
expectedKey := item.binding.Key
|
|
if expectedKey == "" {
|
|
expectedKey = unmappedLabel
|
|
}
|
|
if !strings.Contains(item.keyText, expectedKey) {
|
|
t.Fatalf("Item %d: keyText %q should contain key %q", i, item.keyText, expectedKey)
|
|
}
|
|
if !strings.Contains(item.actionText, item.binding.ActionDisplay) {
|
|
t.Fatalf("Item %d: actionText %q should contain action %q", i, item.actionText, 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 := newBuilder().build()
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestTruncateToWidth(t *testing.T) {
|
|
// Short string: no truncation
|
|
s := "hello"
|
|
got := truncateToWidth(s, 10)
|
|
if got != s {
|
|
t.Fatalf("Expected %q unchanged, got %q", s, got)
|
|
}
|
|
|
|
// Exact width: no truncation
|
|
got = truncateToWidth("hello", 5)
|
|
if got != "hello" {
|
|
t.Fatalf("Expected %q unchanged at exact width, got %q", "hello", got)
|
|
}
|
|
|
|
// Over width: truncated with ellipsis
|
|
got = truncateToWidth("hello world", 8)
|
|
if !strings.HasSuffix(got, "...") {
|
|
t.Fatalf("Expected truncated string to end with '...', got %q", got)
|
|
}
|
|
if len([]rune(got)) > 8 {
|
|
t.Fatalf("Expected truncated string to be at most 8 runes, got %d in %q", len([]rune(got)), got)
|
|
}
|
|
|
|
// Long key like a mouse binding should be truncated
|
|
longKey := "ctrl+shift+left press ungrabbed"
|
|
got = truncateToWidth(longKey, maxKeyDisplayWidth)
|
|
if len([]rune(got)) > maxKeyDisplayWidth {
|
|
t.Fatalf("Key should be truncated to maxKeyDisplayWidth, got len=%d: %q", len([]rune(got)), got)
|
|
}
|
|
if !strings.HasSuffix(got, "...") {
|
|
t.Fatalf("Truncated key should end with '...', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestGroupedResultsModeHeaderFormat(t *testing.T) {
|
|
h := newTestHandler()
|
|
h.updateFilter()
|
|
|
|
const testWidth = 80 // fixed width for testing
|
|
|
|
// Build lines as drawGroupedResults would with the new separator format
|
|
var lines []displayLine
|
|
lastMode := ""
|
|
lastCategory := ""
|
|
for fi, idx := range h.filtered_idx {
|
|
b := &h.all_items[idx].binding
|
|
if b.Mode != lastMode {
|
|
lastMode = b.Mode
|
|
lastCategory = ""
|
|
if b.Mode != "" {
|
|
if len(lines) > 0 {
|
|
lines = append(lines, displayLine{itemIdx: -1, isHeader: true})
|
|
}
|
|
label := "Keyboard mode: " + b.Mode
|
|
labelWidth := len([]rune(label))
|
|
sepLen := max(0, testWidth-labelWidth-6)
|
|
sep := strings.Repeat("\u2500", sepLen)
|
|
lines = append(lines, displayLine{
|
|
text: fmt.Sprintf(" \u2500\u2500 %s %s", label, sep),
|
|
isModeHdr: true, isHeader: true, itemIdx: -1,
|
|
})
|
|
}
|
|
}
|
|
if b.Mode == "" && b.Category != lastCategory {
|
|
lastCategory = b.Category
|
|
lines = append(lines, displayLine{isHeader: true, itemIdx: -1})
|
|
}
|
|
lines = append(lines, displayLine{itemIdx: fi})
|
|
}
|
|
|
|
// There should be a mode header for the "mw" mode
|
|
found := false
|
|
for _, l := range lines {
|
|
if l.isModeHdr && strings.Contains(l.text, "Keyboard mode: mw") {
|
|
found = true
|
|
// Header should have ── separator characters
|
|
if !strings.Contains(l.text, "\u2500\u2500") {
|
|
t.Fatalf("Mode header should contain separator ── but got %q", l.text)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("Expected to find 'Keyboard mode: mw' mode header")
|
|
}
|
|
}
|
|
|
|
func TestGroupedResultsNoCategoryHeadersForNonDefaultMode(t *testing.T) {
|
|
h := newTestHandler()
|
|
h.updateFilter()
|
|
|
|
// Build lines as drawGroupedResults would, tracking whether we are currently
|
|
// inside a non-default keyboard-mode section. Category separators are only
|
|
// valid for the default mode ("") and for the mouse-actions block; they must
|
|
// NOT appear while we are still processing items for a non-default mode (e.g.
|
|
// "mw"). Once we transition back to Mode=="" (e.g. for mouse bindings) the
|
|
// section is over and category headers are allowed again.
|
|
var lines []displayLine
|
|
lastMode := ""
|
|
lastCategory := ""
|
|
for fi, idx := range h.filtered_idx {
|
|
b := &h.all_items[idx].binding
|
|
if b.Mode != lastMode {
|
|
lastMode = b.Mode
|
|
lastCategory = ""
|
|
if b.Mode != "" {
|
|
if len(lines) > 0 {
|
|
lines = append(lines, displayLine{itemIdx: -1, isHeader: true})
|
|
}
|
|
lines = append(lines, displayLine{
|
|
text: fmt.Sprintf(" Keyboard mode: %s", b.Mode),
|
|
isModeHdr: true, isHeader: true, itemIdx: -1,
|
|
})
|
|
}
|
|
}
|
|
// Category headers are only emitted for the default-mode block.
|
|
if b.Mode == "" && b.Category != lastCategory {
|
|
lastCategory = b.Category
|
|
lines = append(lines, displayLine{
|
|
text: "category header", isHeader: true, itemIdx: -1,
|
|
})
|
|
}
|
|
|
|
lines = append(lines, displayLine{itemIdx: fi})
|
|
}
|
|
|
|
// Verify: no "category header" line appears while we are still inside the
|
|
// non-default keyboard-mode section.
|
|
nonDefaultActive := false
|
|
for _, l := range lines {
|
|
if l.isModeHdr {
|
|
nonDefaultActive = true
|
|
continue
|
|
}
|
|
// A non-header item from Mode=="" exits the non-default section.
|
|
if nonDefaultActive && !l.isHeader {
|
|
if l.itemIdx >= 0 && l.itemIdx < len(h.filtered_idx) {
|
|
idx := h.filtered_idx[l.itemIdx]
|
|
if h.all_items[idx].binding.Mode == "" {
|
|
nonDefaultActive = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRowToFilteredIdx(t *testing.T) {
|
|
h := newTestHandler()
|
|
h.updateFilter()
|
|
h.results_start_y = 2
|
|
h.results_height = 20
|
|
|
|
// Populate display_lines with known structure
|
|
h.display_lines = []displayLine{
|
|
{isHeader: true, itemIdx: -1}, // line 0: category header
|
|
{itemIdx: 0}, // line 1: first item (filteredIdx=0)
|
|
{itemIdx: 1}, // line 2: second item (filteredIdx=1)
|
|
{isHeader: true, itemIdx: -1}, // line 3: blank header
|
|
{itemIdx: 2}, // line 4: third item (filteredIdx=2)
|
|
}
|
|
h.scroll_offset = 0
|
|
|
|
// cellY=1 → screenRow=2 = results_start_y → lineIdx=0 = header → -1
|
|
if fi := h.rowToFilteredIdx(1); fi != -1 {
|
|
t.Fatalf("Expected -1 for header row, got %d", fi)
|
|
}
|
|
|
|
// cellY=2 → screenRow=3 → lineIdx=1 = first item → filteredIdx=0
|
|
if fi := h.rowToFilteredIdx(2); fi != 0 {
|
|
t.Fatalf("Expected filteredIdx=0 for first item row, got %d", fi)
|
|
}
|
|
|
|
// cellY=3 → screenRow=4 → lineIdx=2 = second item → filteredIdx=1
|
|
if fi := h.rowToFilteredIdx(3); fi != 1 {
|
|
t.Fatalf("Expected filteredIdx=1 for second item row, got %d", fi)
|
|
}
|
|
|
|
// cellY=4 → screenRow=5 → lineIdx=3 = blank header → -1
|
|
if fi := h.rowToFilteredIdx(4); fi != -1 {
|
|
t.Fatalf("Expected -1 for blank header row, got %d", fi)
|
|
}
|
|
|
|
// Click above results area (cellY=0 → screenRow=1 < results_start_y=2): should return -1
|
|
if fi := h.rowToFilteredIdx(0); fi != -1 {
|
|
t.Fatalf("Expected -1 for row above results, got %d", fi)
|
|
}
|
|
|
|
// Click below results area (cellY=22 → screenRow=23 >= results_start_y+results_height=22): should return -1
|
|
if fi := h.rowToFilteredIdx(22); fi != -1 {
|
|
t.Fatalf("Expected -1 for row below results, got %d", fi)
|
|
}
|
|
}
|
|
|
|
func TestScrollAdjustRevealsSectionHeader(t *testing.T) {
|
|
// When the selected item is scrolled into view from below,
|
|
// any immediately preceding header lines should also be visible.
|
|
lines := []displayLine{
|
|
{isHeader: true, itemIdx: -1}, // line 0: category header
|
|
{itemIdx: 0}, // line 1: first item
|
|
{itemIdx: 1}, // line 2: second item
|
|
{isHeader: true, itemIdx: -1}, // line 3: blank
|
|
{isHeader: true, itemIdx: -1}, // line 4: category header 2
|
|
{itemIdx: 2}, // line 5: third item
|
|
}
|
|
|
|
h := &Handler{}
|
|
h.filtered_idx = []int{0, 1, 2}
|
|
h.selected_idx = 0 // first item (at line 1)
|
|
h.scroll_offset = 4 // currently scrolled past the first item
|
|
|
|
// Call the scroll adjustment logic from drawLines
|
|
selectedLineIdx := -1
|
|
for i, dl := range lines {
|
|
if dl.itemIdx == h.selected_idx {
|
|
selectedLineIdx = i
|
|
break
|
|
}
|
|
}
|
|
if selectedLineIdx != 1 {
|
|
t.Fatalf("Expected selectedLineIdx=1, got %d", selectedLineIdx)
|
|
}
|
|
|
|
maxRows := 10
|
|
if selectedLineIdx < h.scroll_offset {
|
|
h.scroll_offset = selectedLineIdx
|
|
for h.scroll_offset > 0 && lines[h.scroll_offset-1].isHeader {
|
|
h.scroll_offset--
|
|
}
|
|
}
|
|
if selectedLineIdx >= h.scroll_offset+maxRows {
|
|
h.scroll_offset = selectedLineIdx - maxRows + 1
|
|
}
|
|
h.scroll_offset = max(0, h.scroll_offset)
|
|
h.scroll_offset = min(h.scroll_offset, max(0, len(lines)-maxRows))
|
|
|
|
// scroll_offset should be 0 so the category header at line 0 is visible
|
|
if h.scroll_offset != 0 {
|
|
t.Fatalf("Expected scroll_offset=0 to show category header, got %d", h.scroll_offset)
|
|
}
|
|
}
|
|
|
|
func TestDisplayItemFieldsPopulated(t *testing.T) {
|
|
h := newTestHandler()
|
|
for i, item := range h.all_items {
|
|
if item.binding.IsMouse {
|
|
continue
|
|
}
|
|
expectedKey := item.binding.Key
|
|
if expectedKey == "" {
|
|
expectedKey = unmappedLabel
|
|
}
|
|
if item.keyText != expectedKey {
|
|
t.Fatalf("Item %d: keyText=%q expected %q", i, item.keyText, expectedKey)
|
|
}
|
|
if item.actionText != item.binding.ActionDisplay {
|
|
t.Fatalf("Item %d: actionText=%q expected %q", i, item.actionText, item.binding.ActionDisplay)
|
|
}
|
|
if item.categoryText != item.binding.Category {
|
|
t.Fatalf("Item %d: categoryText=%q expected %q", i, item.categoryText, item.binding.Category)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFilterSingleColumnMatch(t *testing.T) {
|
|
// "scroll" is in action_display column only, not in key or category.
|
|
// With per-column matching it should still match the action column.
|
|
h := newTestHandler()
|
|
h.query = "scroll"
|
|
h.updateFilter()
|
|
if len(h.filtered_idx) == 0 {
|
|
t.Fatal("Expected matches for 'scroll' against action column")
|
|
}
|
|
// All matched items should have 'scroll' in exactly one column, not spread across columns
|
|
for _, idx := range h.filtered_idx {
|
|
item := &h.all_items[idx]
|
|
colMatch := strings.Contains(strings.ToLower(item.keyText), "scroll") ||
|
|
strings.Contains(strings.ToLower(item.actionText), "scroll") ||
|
|
strings.Contains(strings.ToLower(item.categoryText), "scroll")
|
|
if !colMatch {
|
|
t.Fatalf("Matched item %q has no column containing 'scroll'", item.actionText)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFilterMatchInfosParallelToFilteredIdx(t *testing.T) {
|
|
h := newTestHandler()
|
|
h.query = "clipboard"
|
|
h.updateFilter()
|
|
if len(h.filtered_idx) == 0 {
|
|
t.Fatal("Expected some matches")
|
|
}
|
|
if len(h.match_infos) != len(h.filtered_idx) {
|
|
t.Fatalf("match_infos length %d != filtered_idx length %d", len(h.match_infos), len(h.filtered_idx))
|
|
}
|
|
for i, mi := range h.match_infos {
|
|
if len(mi.keyPositions) == 0 && len(mi.actionPositions) == 0 && len(mi.categoryPositions) == 0 {
|
|
t.Fatalf("match_infos[%d] has no positions in any column", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFilterMatchInfosNilWhenNoQuery(t *testing.T) {
|
|
h := newTestHandler()
|
|
h.query = ""
|
|
h.updateFilter()
|
|
if h.match_infos != nil {
|
|
t.Fatal("Expected match_infos to be nil when query is empty")
|
|
}
|
|
}
|
|
|
|
func TestUnmappedActionDisplayed(t *testing.T) {
|
|
// Inject an item with an empty key (unmapped action) and verify display
|
|
h := newBuilder().
|
|
addBinding("", "Miscellaneous", testBinding("ctrl+n", "new_window", "Open new window")).
|
|
addBinding("", "Miscellaneous", testBinding("", "scroll_home", "Scroll to top")).
|
|
build()
|
|
|
|
if len(h.all_items) != 2 {
|
|
t.Fatalf("Expected 2 items, got %d", len(h.all_items))
|
|
}
|
|
// Find the unmapped item
|
|
var unmapped *DisplayItem
|
|
for i := range h.all_items {
|
|
if h.all_items[i].binding.Key == "" {
|
|
unmapped = &h.all_items[i]
|
|
break
|
|
}
|
|
}
|
|
if unmapped == nil {
|
|
t.Fatal("Expected to find unmapped item")
|
|
}
|
|
// keyText should be unmappedLabel, not empty
|
|
if unmapped.keyText != unmappedLabel {
|
|
t.Fatalf("Expected keyText=%q for unmapped item, got %q", unmappedLabel, unmapped.keyText)
|
|
}
|
|
|
|
// With show_unmapped=true, unmapped action should be searchable
|
|
h.show_unmapped = true
|
|
h.query = "scroll_home"
|
|
h.updateFilter()
|
|
if len(h.filtered_idx) == 0 {
|
|
t.Fatal("Expected unmapped action to be found by action name search when show_unmapped=true")
|
|
}
|
|
|
|
// With show_unmapped=false, unmapped action should be hidden
|
|
h.show_unmapped = false
|
|
h.query = ""
|
|
h.updateFilter()
|
|
for _, idx := range h.filtered_idx {
|
|
if h.all_items[idx].binding.Key == "" {
|
|
t.Fatal("Expected unmapped action to be hidden when show_unmapped=false")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShowUnmappedToggle(t *testing.T) {
|
|
// TestShowUnmappedToggle creates a handler with both mapped and unmapped items
|
|
// and verifies that the show_unmapped flag correctly filters the display.
|
|
h := newBuilder().
|
|
addBinding("", "Copy/paste", testBinding("ctrl+c", "copy", "Copy")).
|
|
addBinding("", "Copy/paste", testBinding("", "paste_from_buffer", "Paste from buffer")).
|
|
build()
|
|
h.show_unmapped = false // override default from build()
|
|
|
|
if len(h.all_items) != 2 {
|
|
t.Fatalf("Expected 2 items in all_items, got %d", len(h.all_items))
|
|
}
|
|
|
|
// With show_unmapped=false, only mapped items should appear
|
|
h.show_unmapped = false
|
|
h.updateFilter()
|
|
if len(h.filtered_idx) != 1 {
|
|
t.Fatalf("With show_unmapped=false, expected 1 item, got %d", len(h.filtered_idx))
|
|
}
|
|
if h.all_items[h.filtered_idx[0]].binding.Key == "" {
|
|
t.Fatal("Filtered item should not be unmapped when show_unmapped=false")
|
|
}
|
|
|
|
// With show_unmapped=true, both items should appear
|
|
h.show_unmapped = true
|
|
h.updateFilter()
|
|
if len(h.filtered_idx) != 2 {
|
|
t.Fatalf("With show_unmapped=true, expected 2 items, got %d", len(h.filtered_idx))
|
|
}
|
|
|
|
// Toggle back to false with a query active; unmapped should still be hidden
|
|
h.show_unmapped = false
|
|
h.query = "paste"
|
|
h.updateFilter()
|
|
for _, idx := range h.filtered_idx {
|
|
if h.all_items[idx].binding.Key == "" {
|
|
t.Fatal("Unmapped item should not appear in search results when show_unmapped=false")
|
|
}
|
|
}
|
|
}
|
|
|
|
// newMultiTokenTestHandler creates a handler with items designed to test
|
|
// multi-token search. It has items where different tokens match different
|
|
// columns, enabling cross-column and RRF ranking tests.
|
|
func newMultiTokenTestHandler() *Handler {
|
|
return newBuilder().
|
|
addBinding("", "Copy/paste", testBinding("ctrl+shift+c", "copy_to_clipboard", "Copy selected text")).
|
|
addBinding("", "Copy/paste", testBinding("ctrl+shift+v", "paste_from_clipboard", "Paste from clipboard")).
|
|
addBinding("", "Scrolling", testBinding("ctrl+shift+up", "scroll_line_up", "Scroll up one line")).
|
|
addBinding("", "Scrolling", testBinding("ctrl+shift+down", "scroll_line_down", "Scroll down one line")).
|
|
addBinding("", "Scrolling", testBinding("ctrl+shift+page_up", "scroll_page_up", "Scroll up one page")).
|
|
addBinding("", "Scrolling", testBinding("ctrl+shift+home", "scroll_home", "Scroll to top")).
|
|
addBinding("", "Window management", testBinding("ctrl+shift+enter", "new_window", "Open a new window")).
|
|
addBinding("", "Window management", testBinding("ctrl+shift+w", "close_window", "Close the active window")).
|
|
addBinding("", "Tab management", testBinding("ctrl+shift+t", "new_tab", "Open a new tab")).
|
|
addBinding("", "Tab management", testBinding("ctrl+shift+q", "close_tab", "Close the active tab")).
|
|
build()
|
|
}
|
|
|
|
func TestMultiTokenAllMatchRanksAbovePartial(t *testing.T) {
|
|
// An item matching ALL tokens should rank above an item matching only SOME tokens.
|
|
// "scroll up" should rank scroll_line_up and scroll_page_up (both tokens match)
|
|
// above scroll_home or scroll_line_down (only "scroll" matches).
|
|
h := newMultiTokenTestHandler()
|
|
h.query = "scroll up"
|
|
h.updateFilter()
|
|
|
|
if len(h.filtered_idx) == 0 {
|
|
t.Fatal("Expected matches for 'scroll up'")
|
|
}
|
|
|
|
// Items matching both "scroll" and "up" should appear before items matching only one token.
|
|
// scroll_line_up and scroll_page_up match both; scroll_line_down and scroll_home match only "scroll".
|
|
topResults := make([]string, 0)
|
|
for i, idx := range h.filtered_idx {
|
|
action := h.all_items[idx].binding.ActionDisplay
|
|
if i < 2 {
|
|
topResults = append(topResults, action)
|
|
}
|
|
}
|
|
for _, action := range topResults {
|
|
if !strings.Contains(action, "scroll") || !strings.Contains(action, "up") {
|
|
t.Fatalf("Top result %q should match both 'scroll' and 'up'", action)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMultiTokenCrossColumnMatch(t *testing.T) {
|
|
// A query with tokens that match different columns should find the item.
|
|
// "ctrl+shift+c copy" — "ctrl+shift+c" matches the key column,
|
|
// "copy" matches the action column.
|
|
h := newMultiTokenTestHandler()
|
|
h.query = "ctrl+shift+c copy"
|
|
h.updateFilter()
|
|
|
|
if len(h.filtered_idx) == 0 {
|
|
t.Fatal("Expected matches for cross-column query 'ctrl+shift+c copy'")
|
|
}
|
|
|
|
// copy_to_clipboard (key=ctrl+shift+c, action=copy_to_clipboard) should be the top result
|
|
topAction := h.all_items[h.filtered_idx[0]].binding.ActionDisplay
|
|
if topAction != "copy_to_clipboard" {
|
|
t.Fatalf("Expected top result to be 'copy_to_clipboard', got %q", topAction)
|
|
}
|
|
}
|
|
|
|
func TestMultiTokenCrossColumnCategoryMatch(t *testing.T) {
|
|
// A token matching the category column combined with a token matching the action column.
|
|
// "window close" — "window" matches category "Window management",
|
|
// "close" matches action "close_window".
|
|
h := newMultiTokenTestHandler()
|
|
h.query = "window close"
|
|
h.updateFilter()
|
|
|
|
if len(h.filtered_idx) == 0 {
|
|
t.Fatal("Expected matches for 'window close'")
|
|
}
|
|
|
|
// close_window should rank highly since both tokens match
|
|
found := false
|
|
for _, idx := range h.filtered_idx[:min(3, len(h.filtered_idx))] {
|
|
if h.all_items[idx].binding.ActionDisplay == "close_window" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("Expected 'close_window' in top results for 'window close'")
|
|
}
|
|
}
|
|
|
|
func TestMultiTokenExtraWhitespace(t *testing.T) {
|
|
// Extra whitespace in the query should not produce empty/ghost tokens.
|
|
// " scroll up " should behave the same as "scroll up".
|
|
h := newMultiTokenTestHandler()
|
|
h.query = "scroll up"
|
|
h.updateFilter()
|
|
normalResults := make([]int, len(h.filtered_idx))
|
|
copy(normalResults, h.filtered_idx)
|
|
|
|
h.query = " scroll up "
|
|
h.updateFilter()
|
|
|
|
if len(h.filtered_idx) != len(normalResults) {
|
|
t.Fatalf("Extra whitespace changed result count: %d vs %d", len(h.filtered_idx), len(normalResults))
|
|
}
|
|
for i := range h.filtered_idx {
|
|
if h.filtered_idx[i] != normalResults[i] {
|
|
t.Fatalf("Extra whitespace changed result order at position %d", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMultiTokenAllWhitespaceIsEmptyQuery(t *testing.T) {
|
|
// A query that is only whitespace should behave like an empty query:
|
|
// return all items in original order with no match_infos.
|
|
h := newMultiTokenTestHandler()
|
|
h.query = " "
|
|
h.updateFilter()
|
|
|
|
if len(h.filtered_idx) != len(h.all_items) {
|
|
t.Fatalf("All-whitespace query should return all %d items, got %d", len(h.all_items), len(h.filtered_idx))
|
|
}
|
|
if h.match_infos != nil {
|
|
t.Fatal("All-whitespace query should produce nil match_infos")
|
|
}
|
|
}
|
|
|
|
func TestMultiTokenSingleTokenRegression(t *testing.T) {
|
|
// A single-token query (no spaces) should produce the same results as before.
|
|
h := newMultiTokenTestHandler()
|
|
h.query = "clipboard"
|
|
h.updateFilter()
|
|
|
|
if len(h.filtered_idx) == 0 {
|
|
t.Fatal("Expected matches for single token 'clipboard'")
|
|
}
|
|
// All results should have "clipboard" matched in at least one column
|
|
for _, idx := range h.filtered_idx {
|
|
item := &h.all_items[idx]
|
|
anyMatch := strings.Contains(strings.ToLower(item.keyText), "clipboard") ||
|
|
strings.Contains(strings.ToLower(item.actionText), "clipboard") ||
|
|
strings.Contains(strings.ToLower(item.categoryText), "clipboard")
|
|
if !anyMatch {
|
|
t.Fatalf("Matched item %q has no column containing 'clipboard'", item.actionText)
|
|
}
|
|
}
|
|
// match_infos should still be parallel to filtered_idx
|
|
if len(h.match_infos) != len(h.filtered_idx) {
|
|
t.Fatalf("match_infos length %d != filtered_idx length %d", len(h.match_infos), len(h.filtered_idx))
|
|
}
|
|
}
|
|
|
|
func TestMultiTokenNoMatchReturnsEmpty(t *testing.T) {
|
|
// When no item matches any token, the result should be empty.
|
|
h := newMultiTokenTestHandler()
|
|
h.query = "zzzzz xxxxx"
|
|
h.updateFilter()
|
|
|
|
if len(h.filtered_idx) != 0 {
|
|
t.Fatalf("Expected no matches for nonsense multi-token query, got %d", len(h.filtered_idx))
|
|
}
|
|
}
|
|
|
|
func TestMultiTokenPartialMatchStillShown(t *testing.T) {
|
|
// Items matching only some tokens should still appear,
|
|
// but ranked lower than items matching all tokens.
|
|
h := newMultiTokenTestHandler()
|
|
h.query = "scroll zzzznonexistent"
|
|
h.updateFilter()
|
|
|
|
// "scroll" matches several items, "zzzznonexistent" matches nothing.
|
|
// Items matching "scroll" should still appear.
|
|
if len(h.filtered_idx) == 0 {
|
|
t.Fatal("Expected partial matches when one token matches and one doesn't")
|
|
}
|
|
|
|
// Verify that matched items are related to "scroll"
|
|
for _, idx := range h.filtered_idx {
|
|
item := &h.all_items[idx]
|
|
anyMatch := strings.Contains(strings.ToLower(item.keyText), "scroll") ||
|
|
strings.Contains(strings.ToLower(item.actionText), "scroll") ||
|
|
strings.Contains(strings.ToLower(item.categoryText), "scroll")
|
|
if !anyMatch {
|
|
t.Fatalf("Matched item %q has no column containing 'scroll'", item.actionText)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMultiTokenMatchInfosTrackMultipleColumns(t *testing.T) {
|
|
// When tokens match different columns, match_infos should reflect
|
|
// positions in each matched column so highlighting works correctly.
|
|
h := newMultiTokenTestHandler()
|
|
h.query = "tab close"
|
|
h.updateFilter()
|
|
|
|
if len(h.filtered_idx) == 0 {
|
|
t.Fatal("Expected matches for 'tab close'")
|
|
}
|
|
if len(h.match_infos) != len(h.filtered_idx) {
|
|
t.Fatalf("match_infos length %d != filtered_idx length %d", len(h.match_infos), len(h.filtered_idx))
|
|
}
|
|
|
|
// Find close_tab — "tab" matches category "Tab management" and action "close_tab",
|
|
// "close" matches action "close_tab". match_infos must have positions in multiple columns.
|
|
for fi, idx := range h.filtered_idx {
|
|
if h.all_items[idx].binding.ActionDisplay == "close_tab" {
|
|
mi := h.match_infos[fi]
|
|
if len(mi.actionPositions) == 0 {
|
|
t.Fatal("close_tab: expected match positions in action column")
|
|
}
|
|
if len(mi.categoryPositions) == 0 {
|
|
t.Fatal("close_tab: expected match positions in category column for 'tab' in 'Tab management'")
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("Expected close_tab in results for 'tab close'")
|
|
}
|
|
|
|
func TestMultiTokenOrderIndependence(t *testing.T) {
|
|
// Token order should not matter: "close window" and "window close"
|
|
// should produce the same set of results (possibly in different order,
|
|
// but the same items).
|
|
h := newMultiTokenTestHandler()
|
|
|
|
h.query = "window close"
|
|
h.updateFilter()
|
|
results1 := make(map[int]bool)
|
|
for _, idx := range h.filtered_idx {
|
|
results1[idx] = true
|
|
}
|
|
|
|
h.query = "close window"
|
|
h.updateFilter()
|
|
results2 := make(map[int]bool)
|
|
for _, idx := range h.filtered_idx {
|
|
results2[idx] = true
|
|
}
|
|
|
|
if len(results1) != len(results2) {
|
|
t.Fatalf("Token order changed result count: %d vs %d", len(results1), len(results2))
|
|
}
|
|
for idx := range results1 {
|
|
if !results2[idx] {
|
|
t.Fatalf("Item %d present in 'window close' but not 'close window'", idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// topActions returns the action_display names of the first n results after
|
|
// running query on h. It is a test helper for verifying ranking.
|
|
func topActions(h *Handler, query string, n int) []string {
|
|
h.query = query
|
|
h.updateFilter()
|
|
var result []string
|
|
for i, idx := range h.filtered_idx {
|
|
if i >= n {
|
|
break
|
|
}
|
|
result = append(result, h.all_items[idx].binding.ActionDisplay)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func TestQueryRankingScrollUp(t *testing.T) {
|
|
h := newMultiTokenTestHandler()
|
|
top := topActions(h, "scroll up", 4)
|
|
if len(top) < 4 {
|
|
t.Fatalf("Expected at least 4 results for 'scroll up', got %d", len(top))
|
|
}
|
|
// Top 2 should match both "scroll" and "up", with scroll_line_up first (shorter)
|
|
for _, action := range top[:2] {
|
|
if !strings.Contains(action, "scroll") || !strings.Contains(action, "up") {
|
|
t.Fatalf("Top result %q should match both 'scroll' and 'up'", action)
|
|
}
|
|
}
|
|
if top[0] != "scroll_line_up" {
|
|
t.Fatalf("Expected scroll_line_up first, got %q", top[0])
|
|
}
|
|
if top[1] != "scroll_page_up" {
|
|
t.Fatalf("Expected scroll_page_up second, got %q", top[1])
|
|
}
|
|
// Items matching only "scroll" (not "up") should rank below
|
|
for _, action := range top[2:] {
|
|
if strings.Contains(action, "up") {
|
|
continue // other "up" matches are fine here
|
|
}
|
|
if !strings.Contains(action, "scroll") {
|
|
t.Fatalf("Lower result %q should still contain 'scroll'", action)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryRankingNewWindow(t *testing.T) {
|
|
h := newMultiTokenTestHandler()
|
|
top := topActions(h, "new window", 3)
|
|
if len(top) == 0 {
|
|
t.Fatal("Expected results for 'new window'")
|
|
}
|
|
if top[0] != "new_window" {
|
|
t.Fatalf("Expected new_window first, got %q", top[0])
|
|
}
|
|
// close_window should not appear above new_window
|
|
for i, action := range top {
|
|
if action == "close_window" && i == 0 {
|
|
t.Fatal("close_window should not be the top result for 'new window'")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryRankingCloseTab(t *testing.T) {
|
|
h := newMultiTokenTestHandler()
|
|
top := topActions(h, "close tab", 3)
|
|
if len(top) == 0 {
|
|
t.Fatal("Expected results for 'close tab'")
|
|
}
|
|
if top[0] != "close_tab" {
|
|
t.Fatalf("Expected close_tab first, got %q", top[0])
|
|
}
|
|
}
|
|
|
|
func TestQueryRankingSingleToken(t *testing.T) {
|
|
h := newMultiTokenTestHandler()
|
|
top := topActions(h, "clipboard", 2)
|
|
if len(top) < 2 {
|
|
t.Fatalf("Expected at least 2 results for 'clipboard', got %d", len(top))
|
|
}
|
|
// copy_to_clipboard is shorter than paste_from_clipboard
|
|
if top[0] != "copy_to_clipboard" {
|
|
t.Fatalf("Expected copy_to_clipboard first, got %q", top[0])
|
|
}
|
|
if top[1] != "paste_from_clipboard" {
|
|
t.Fatalf("Expected paste_from_clipboard second, got %q", top[1])
|
|
}
|
|
}
|
|
|
|
func TestQueryRankingExactActionMatch(t *testing.T) {
|
|
h := newMultiTokenTestHandler()
|
|
top := topActions(h, "new_tab", 1)
|
|
if len(top) == 0 {
|
|
t.Fatal("Expected results for 'new_tab'")
|
|
}
|
|
if top[0] != "new_tab" {
|
|
t.Fatalf("Expected new_tab first, got %q", top[0])
|
|
}
|
|
}
|
|
|
|
// newMouseTestHandler creates a handler with realistic mouse bindings matching
|
|
// the actual kitty command palette data, to test ranking of mouse_selection queries.
|
|
// Includes keyboard bindings with "selection" in their names to ensure mouse_selection
|
|
// items rank above them for the query "mouse selection".
|
|
func newMouseTestHandler() *Handler {
|
|
return newBuilder().
|
|
addBinding("", "Scrolling", testBinding("ctrl+shift+up", "scroll_line_up", "Scroll up")).
|
|
addBinding("", "Copy/paste", testBinding("ctrl+shift+c", "copy_to_clipboard", "Copy selected text")).
|
|
addBinding("", "Copy/paste", testBinding("shift+insert", "paste_selection", "Paste from primary selection")).
|
|
addBinding("", "Copy/paste", testBinding("ctrl+shift+v", "paste_from_clipboard", "Paste from clipboard")).
|
|
addBinding("", "Copy/paste", testBinding("", "copy_or_interrupt", "Copy selection or interrupt")).
|
|
addBinding("", "Copy/paste", testBinding("", "copy_and_clear_or_interrupt", "Copy selection and clear")).
|
|
addBinding("", "Copy/paste", testBinding("", "pass_selection_to_program", "Pass selection to program")).
|
|
addMouse(testMouseBinding("shift+left click ungrabbed", "mouse_handle_click selection link prompt")).
|
|
addMouse(testMouseBinding("shift+left click grabbed ungrabbed", "mouse_handle_click selection link prompt")).
|
|
addMouse(testMouseBinding("ctrl+shift+left release grabbed ungrabbed", "mouse_handle_click link")).
|
|
addMouse(testMouseBinding("shift+middle release ungrabbed grabbed", "paste_selection")).
|
|
addMouse(testMouseBinding("middle release ungrabbed", "paste_from_selection")).
|
|
addMouse(testMouseBinding("left drag ungrabbed", "mouse_selection")).
|
|
addMouse(testMouseBinding("shift+left drag ungrabbed", "mouse_selection")).
|
|
addMouse(testMouseBinding("left triplepress ungrabbed", "mouse_selection line")).
|
|
addMouse(testMouseBinding("shift+left doublepress ungrabbed", "mouse_selection word")).
|
|
addMouse(testMouseBinding("shift+left triplepress ungrabbed", "mouse_selection line_from_point")).
|
|
addMouse(testMouseBinding("shift+left triplepress+grabbed", "mouse_selection line_from_point")).
|
|
addMouse(testMouseBinding("right press ungrabbed", "mouse_selection extend")).
|
|
addMouse(testMouseBinding("shift+left press ungrabbed", "mouse_selection extend")).
|
|
addMouse(testMouseBinding("left press ungrabbed", "mouse_selection normal")).
|
|
addMouse(testMouseBinding("ctrl+left press ungrabbed", "mouse_selection rectangle")).
|
|
addMouse(testMouseBinding("ctrl+shift+right press ungrabbed", "mouse_selection rectangle extend")).
|
|
addMouse(testMouseBinding("ctrl+shift+left press ungrabbed", "mouse_selection rectangle extend")).
|
|
addMouse(testMouseBinding("shift+left triplepress", "mouse_selection line_from_point")).
|
|
addMouse(testMouseBinding("left press", "mouse_selection normal")).
|
|
build()
|
|
}
|
|
|
|
// searchResult captures the full display state of a single result row:
|
|
// all three visible columns plus which columns have highlighted match positions.
|
|
type searchResult struct {
|
|
key string // key binding
|
|
action string // action_display
|
|
category string // category
|
|
// Which columns have highlighted (matched) character positions.
|
|
keyMatch bool
|
|
actionMatch bool
|
|
categoryMatch bool
|
|
}
|
|
|
|
// queryResults runs query on h and returns the full display state of each result.
|
|
func queryResults(h *Handler, query string) []searchResult {
|
|
h.query = query
|
|
h.updateFilter()
|
|
results := make([]searchResult, len(h.filtered_idx))
|
|
for i, idx := range h.filtered_idx {
|
|
item := &h.all_items[idx]
|
|
results[i] = searchResult{
|
|
key: item.keyText,
|
|
action: item.actionText,
|
|
category: item.categoryText,
|
|
keyMatch: len(h.match_infos[i].keyPositions) > 0,
|
|
actionMatch: len(h.match_infos[i].actionPositions) > 0,
|
|
categoryMatch: len(h.match_infos[i].categoryPositions) > 0,
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
func TestQueryRankingMouseSelection(t *testing.T) {
|
|
h := newMouseTestHandler()
|
|
results := queryResults(h, "mouse selection")
|
|
|
|
if len(results) == 0 {
|
|
t.Fatal("Expected results for 'mouse selection'")
|
|
}
|
|
|
|
// Bare "mouse_selection" (shortest, exact match for both tokens) must rank
|
|
// above all suffixed variants like mouse_selection line/word/extend/normal.
|
|
if results[0].action != "mouse_selection" {
|
|
t.Fatalf("Expected bare 'mouse_selection' first, got %q", results[0].action)
|
|
}
|
|
|
|
// All mouse_selection variants (action starts with "mouse_selection") must
|
|
// rank above any non-mouse_selection item.
|
|
lastMouseSelection := -1
|
|
firstOther := -1
|
|
for i, r := range results {
|
|
if strings.HasPrefix(r.action, "mouse_selection") {
|
|
lastMouseSelection = i
|
|
} else if firstOther == -1 {
|
|
firstOther = i
|
|
}
|
|
}
|
|
if firstOther != -1 && firstOther < lastMouseSelection {
|
|
t.Fatalf("Non-mouse_selection item %q at position %d ranks above mouse_selection item at position %d",
|
|
results[firstOther].action, firstOther+1, lastMouseSelection+1)
|
|
}
|
|
|
|
// Every mouse_selection result must have action column highlighted (both
|
|
// "mouse" and "selection" appear in the action text).
|
|
for i, r := range results {
|
|
if !strings.HasPrefix(r.action, "mouse_selection") {
|
|
continue
|
|
}
|
|
if !r.actionMatch {
|
|
t.Fatalf("Result %d (%s): mouse_selection item must have action column highlighted", i+1, r.action)
|
|
}
|
|
}
|
|
|
|
// mouse_handle_click also matches both "mouse" and "selection" in its action
|
|
// text, but it's a longer string so it should rank below mouse_selection items.
|
|
for i, r := range results {
|
|
if strings.HasPrefix(r.action, "mouse_handle_click") {
|
|
if i < lastMouseSelection {
|
|
t.Fatalf("Result %d (%s): should rank below all mouse_selection variants (last at %d)",
|
|
i+1, r.action, lastMouseSelection+1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryRankingMouseSelectionSingleToken(t *testing.T) {
|
|
h := newMouseTestHandler()
|
|
results := queryResults(h, "mouse")
|
|
if len(results) == 0 {
|
|
t.Fatal("Expected results for 'mouse'")
|
|
}
|
|
// Bare "mouse_selection" (shortest action with "mouse") should be first
|
|
if results[0].action != "mouse_selection" {
|
|
t.Fatalf("Expected bare 'mouse_selection' first, got %q", results[0].action)
|
|
}
|
|
// Items matching only via category (paste_selection, paste_from_selection)
|
|
// should rank below items matching "mouse" in the action column.
|
|
lastActionMatch := -1
|
|
for i, r := range results {
|
|
if r.actionMatch {
|
|
lastActionMatch = i
|
|
}
|
|
}
|
|
for i, r := range results {
|
|
if !r.actionMatch && r.categoryMatch && i < lastActionMatch {
|
|
t.Fatalf("Result %d (%s): category-only match should rank below action matches", i+1, r.action)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryRankingShorterMatchFirst(t *testing.T) {
|
|
h := newMouseTestHandler()
|
|
// "mouse_selection normal" (shorter) should rank above "mouse_selection rectangle" (longer)
|
|
// when both match equally well
|
|
top := topActions(h, "mouse_selection normal", 1)
|
|
if len(top) == 0 {
|
|
t.Fatal("Expected results")
|
|
}
|
|
if top[0] != "mouse_selection normal" {
|
|
t.Fatalf("Expected 'mouse_selection normal' first, got %q", top[0])
|
|
}
|
|
}
|
|
|
|
func TestQueryMatchInfoColumns(t *testing.T) {
|
|
// Verify match_infos correctly tracks positions in all 3 columns: key, action, category.
|
|
h := newMultiTokenTestHandler()
|
|
|
|
// "ctrl clipboard" — "ctrl" matches key column (ctrl+shift+c), "clipboard" matches action
|
|
h.query = "ctrl clipboard"
|
|
h.updateFilter()
|
|
if len(h.filtered_idx) == 0 {
|
|
t.Fatal("Expected matches for 'ctrl clipboard'")
|
|
}
|
|
|
|
// Find copy_to_clipboard in results
|
|
for fi, idx := range h.filtered_idx {
|
|
if h.all_items[idx].binding.ActionDisplay != "copy_to_clipboard" {
|
|
continue
|
|
}
|
|
mi := h.match_infos[fi]
|
|
// Key column (col 0) should have positions for "ctrl"
|
|
if len(mi.keyPositions) == 0 {
|
|
t.Fatal("copy_to_clipboard: expected match positions in key column for 'ctrl'")
|
|
}
|
|
// Action column (col 1) should have positions for "clipboard"
|
|
if len(mi.actionPositions) == 0 {
|
|
t.Fatal("copy_to_clipboard: expected match positions in action column for 'clipboard'")
|
|
}
|
|
return
|
|
}
|
|
t.Fatal("Expected copy_to_clipboard in results")
|
|
}
|
|
|
|
func TestQueryMatchInfoCategoryColumn(t *testing.T) {
|
|
// Verify the category column (col 2) gets match positions when a token matches it.
|
|
h := newMultiTokenTestHandler()
|
|
|
|
// "tab close" — "tab" matches category "Tab management", "close" matches action close_tab
|
|
h.query = "tab close"
|
|
h.updateFilter()
|
|
if len(h.filtered_idx) == 0 {
|
|
t.Fatal("Expected matches for 'tab close'")
|
|
}
|
|
for fi, idx := range h.filtered_idx {
|
|
if h.all_items[idx].binding.ActionDisplay != "close_tab" {
|
|
continue
|
|
}
|
|
mi := h.match_infos[fi]
|
|
// Action column (col 1) should have positions for "close" and/or "tab"
|
|
if len(mi.actionPositions) == 0 {
|
|
t.Fatal("close_tab: expected match positions in action column")
|
|
}
|
|
// Category column (col 2) should have positions for "tab" in "Tab management"
|
|
if len(mi.categoryPositions) == 0 {
|
|
t.Fatal("close_tab: expected match positions in category column for 'tab'")
|
|
}
|
|
return
|
|
}
|
|
t.Fatal("Expected close_tab in results")
|
|
}
|
|
|
|
func TestQueryMatchInfoKeyColumn(t *testing.T) {
|
|
// Verify the key column (col 0) gets match positions when searching by key binding.
|
|
h := newMouseTestHandler()
|
|
|
|
// "left press" — matches key column for mouse bindings
|
|
h.query = "left press"
|
|
h.updateFilter()
|
|
if len(h.filtered_idx) == 0 {
|
|
t.Fatal("Expected matches for 'left press'")
|
|
}
|
|
// At least one result should have positions in the key column
|
|
foundKeyMatch := false
|
|
for fi := range h.filtered_idx {
|
|
mi := h.match_infos[fi]
|
|
if len(mi.keyPositions) > 0 {
|
|
foundKeyMatch = true
|
|
break
|
|
}
|
|
}
|
|
if !foundKeyMatch {
|
|
t.Fatal("Expected at least one result with match positions in key column for 'left press'")
|
|
}
|
|
}
|
|
|
|
func TestQueryRankingShorterActionFirst(t *testing.T) {
|
|
// When 2 tokens both match in the action column of item A,
|
|
// A should rank above item B that also matches both tokens but has a
|
|
// longer action string. This verifies that shorter matches are preferred.
|
|
h := newBuilder().
|
|
addBinding("", "Window management", testBinding("ctrl+n", "new_window", "Open a new window")).
|
|
addBinding("", "Window management", testBinding("ctrl+w", "close_active", "Close the active pane")).
|
|
addBinding("", "Miscellaneous", testBinding("ctrl+shift+n", "new_os_window", "Open new OS window")).
|
|
build()
|
|
|
|
// "new window" — both tokens match new_window's action coherently,
|
|
// while new_os_window also matches but is longer.
|
|
top := topActions(h, "new window", 2)
|
|
if len(top) < 2 {
|
|
t.Fatalf("Expected at least 2 results, got %d", len(top))
|
|
}
|
|
// new_window should beat new_os_window (shorter action string)
|
|
if top[0] != "new_window" {
|
|
t.Fatalf("Expected new_window first (shorter match), got %q", top[0])
|
|
}
|
|
}
|
|
|
|
func TestQueryRankingCrossColumnVsCategoryOnly(t *testing.T) {
|
|
// An item matching tokens across key+action columns should rank above
|
|
// an item that only matches via the category column.
|
|
h := newBuilder().
|
|
addBinding("", "Scrolling", testBinding("ctrl+shift+up", "scroll_line_up", "Scroll up")).
|
|
addBinding("", "Scrolling", testBinding("page_up", "scroll_page_up", "Scroll one page up")).
|
|
addBinding("", "Scroll buffer", Binding{
|
|
Key: "ctrl+l", Action: "clear_terminal",
|
|
ActionDisplay: "clear_terminal reset active",
|
|
Definition: "clear_terminal reset active", Help: "Clear screen",
|
|
}).
|
|
build()
|
|
|
|
// "scroll up" — scroll_line_up and scroll_page_up match both tokens in action;
|
|
// clear_terminal only matches "scroll" via its category "Scroll buffer".
|
|
top := topActions(h, "scroll up", 3)
|
|
if len(top) < 2 {
|
|
t.Fatalf("Expected at least 2 results, got %d", len(top))
|
|
}
|
|
// Both scroll_*_up actions should rank above clear_terminal
|
|
for i, action := range top[:2] {
|
|
if !strings.Contains(action, "scroll") || !strings.Contains(action, "up") {
|
|
t.Fatalf("Result %d: expected scroll_*_up variant, got %q", i+1, action)
|
|
}
|
|
}
|
|
// If clear_terminal appears, it should be last
|
|
for i, action := range top {
|
|
if action == "clear_terminal" && i < 2 {
|
|
t.Fatalf("clear_terminal (category-only match) should rank below action matches, but got position %d", i+1)
|
|
}
|
|
}
|
|
}
|