|
| 1 | +"""Regression coverage for promoting leader commands to direct keys (#321).""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import pytest |
| 6 | + |
| 7 | +from sqlit.core.key_router import resolve_action |
| 8 | +from sqlit.core.keymap import get_keymap, set_keymap |
| 9 | +from sqlit.core.vim import VimMode |
| 10 | +from sqlit.domains.shell.app.keymap_manager import KeymapManager |
| 11 | +from sqlit.domains.shell.state import UIStateMachine |
| 12 | + |
| 13 | +from .test_keymap_manager import MockSettingsStore |
| 14 | +from .test_state_machine import make_context |
| 15 | + |
| 16 | + |
| 17 | +def load_bindings(actions, *, leader=None): |
| 18 | + manager = KeymapManager(settings_store=MockSettingsStore()) |
| 19 | + provider = manager._build_provider_from_payload( |
| 20 | + {"keymap": {"action_keys": actions, "leader_commands": leader or {}}}, "test" |
| 21 | + ) |
| 22 | + set_keymap(provider) |
| 23 | + return provider |
| 24 | + |
| 25 | + |
| 26 | +def route(key="ctrl+g", **context): |
| 27 | + ctx = make_context(**context) |
| 28 | + machine = UIStateMachine() |
| 29 | + return resolve_action(key, ctx, is_allowed=lambda action: machine.check_action(ctx, action)) |
| 30 | + |
| 31 | + |
| 32 | +@pytest.mark.parametrize("action", ["edit_query_in_editor", "format_query", "telescope", "change_theme"]) |
| 33 | +def test_direct_command_preserves_leader_binding(action): |
| 34 | + original = get_keymap().leader(action) |
| 35 | + load_bindings({"query_normal": {action: "ctrl+g"}}) |
| 36 | + assert route(focus="query") == action |
| 37 | + assert get_keymap().leader(action) == original |
| 38 | + assert route(focus="results") is None |
| 39 | + assert route(focus="query", vim_mode=VimMode.INSERT) is None |
| 40 | + assert route(original, focus="query", leader_pending=True) == f"leader_{action}" |
| 41 | + |
| 42 | + |
| 43 | +def test_aliases_and_independent_leader_unbinding(): |
| 44 | + provider = load_bindings( |
| 45 | + {"query_normal": {"telescope": ["ctrl+g", "ctrl+t"]}}, |
| 46 | + leader={"leader": {"telescope": None}}, |
| 47 | + ) |
| 48 | + assert provider.keys_for_action("telescope") == ["ctrl+g", "ctrl+t"] |
| 49 | + assert provider.leader("telescope") is None |
| 50 | + assert route(focus="query") == "telescope" |
| 51 | + assert route("ctrl+t", focus="query") == "telescope" |
| 52 | + |
| 53 | + |
| 54 | +@pytest.mark.parametrize("value", [None, "", []]) |
| 55 | +def test_unbinding_direct_command_keeps_leader(value): |
| 56 | + load_bindings({"query_normal": {"telescope": value}}) |
| 57 | + assert route(focus="query") is None |
| 58 | + assert get_keymap().leader("telescope") == "space" |
| 59 | + |
| 60 | + |
| 61 | +@pytest.mark.parametrize( |
| 62 | + ("action", "context", "expected"), |
| 63 | + [ |
| 64 | + ("format_query", {"focus": "results"}, None), |
| 65 | + ("format_query", {"focus": "query"}, "format_query"), |
| 66 | + ("disconnect", {"has_connection": False}, None), |
| 67 | + ("disconnect", {"has_connection": True}, "disconnect"), |
| 68 | + ("cancel_operation", {"query_executing": False}, None), |
| 69 | + ("cancel_operation", {"query_executing": True}, "cancel_operation"), |
| 70 | + ("telescope", {"modal_open": True}, None), |
| 71 | + ("show_help", {"focus": "query", "vim_mode": VimMode.INSERT}, None), |
| 72 | + ], |
| 73 | +) |
| 74 | +def test_direct_command_respects_guards_and_state_restrictions(action, context, expected): |
| 75 | + load_bindings({"global": {action: "ctrl+g"}}) |
| 76 | + assert route(**context) == expected |
| 77 | + |
| 78 | + |
| 79 | +@pytest.mark.parametrize( |
| 80 | + "actions", |
| 81 | + [ |
| 82 | + {"query_normal": {"telescope": "enter"}}, |
| 83 | + {"query_normal": {"telescope": "ctrl+g", "change_theme": "ctrl+g"}}, |
| 84 | + {"query_normal": {"telescope": "q"}}, # shadows navigation focus_query |
| 85 | + ], |
| 86 | +) |
| 87 | +def test_promoted_command_uses_existing_conflict_detection(actions): |
| 88 | + with pytest.raises(ValueError, match=r"conflict|multiple actions|shadow"): |
| 89 | + load_bindings(actions) |
| 90 | + |
| 91 | + |
| 92 | +def test_unbinding_conflicting_default_allows_promotion(): |
| 93 | + load_bindings({"query_normal": {"execute_query": None, "telescope": "enter"}}) |
| 94 | + assert route("enter", focus="query") == "telescope" |
| 95 | + |
| 96 | + |
| 97 | +@pytest.mark.parametrize("scope", ["made_up_state", "connection_editor", "error_dialog"]) |
| 98 | +def test_promotions_reject_unknown_or_screen_local_scopes(scope): |
| 99 | + with pytest.raises(ValueError, match="Unknown action"): |
| 100 | + load_bindings({scope: {"telescope": "ctrl+g"}}) |
| 101 | + |
| 102 | + |
| 103 | +@pytest.mark.parametrize("action", ["word", "line", "not_an_action"]) |
| 104 | +def test_submenu_motions_and_unknown_actions_are_not_promoted(action): |
| 105 | + with pytest.raises(ValueError, match="Unknown action"): |
| 106 | + load_bindings({"query_normal": {action: "ctrl+g"}}) |
| 107 | + |
| 108 | + |
| 109 | +@pytest.mark.parametrize("action", ["edit_query_in_editor", "telescope", "change_theme"]) |
| 110 | +@pytest.mark.parametrize("key", ["ctrl+g", "ctrl+a", "ctrl+c", "ctrl+v"]) |
| 111 | +async def test_direct_key_and_leader_key_dispatch_in_headless_app(tmp_path, monkeypatch, action, key): |
| 112 | + import json |
| 113 | + |
| 114 | + from sqlit.domains.shell.app import keymap_manager |
| 115 | + |
| 116 | + from .test_leader import _make_app |
| 117 | + |
| 118 | + path = tmp_path / "keymap.json" |
| 119 | + path.write_text(json.dumps({"keymap": {"action_keys": {"query_normal": {action: key}}}})) |
| 120 | + monkeypatch.setattr(keymap_manager, "DEFAULT_KEYMAP_FILE", path) |
| 121 | + app = _make_app() |
| 122 | + calls = [] |
| 123 | + monkeypatch.setattr(app, f"action_{action}", lambda: calls.append(action)) |
| 124 | + async with app.run_test(size=(100, 35)) as pilot: |
| 125 | + app.action_focus_query() |
| 126 | + await pilot.pause() |
| 127 | + await pilot.press(key) |
| 128 | + assert calls == [action] |
| 129 | + await pilot.press("space", get_keymap().leader(action)) |
| 130 | + assert calls == [action, action] |
| 131 | + |
| 132 | + |
| 133 | +@pytest.mark.parametrize("override_order", ["default", "insert_first", "autocomplete_first"]) |
| 134 | +async def test_autocomplete_binding_takes_precedence_over_insert_command(tmp_path, monkeypatch, override_order): |
| 135 | + import json |
| 136 | + |
| 137 | + from sqlit.domains.shell.app import keymap_manager |
| 138 | + |
| 139 | + from .test_leader import _make_app |
| 140 | + |
| 141 | + path = tmp_path / "keymap.json" |
| 142 | + actions = {"query_insert": {"telescope": "ctrl+j"}} |
| 143 | + if override_order != "default": |
| 144 | + actions["autocomplete"] = {"autocomplete_next": "ctrl+j"} |
| 145 | + if override_order == "autocomplete_first": |
| 146 | + actions = dict(reversed(list(actions.items()))) |
| 147 | + path.write_text(json.dumps({"keymap": {"action_keys": actions}})) |
| 148 | + monkeypatch.setattr(keymap_manager, "DEFAULT_KEYMAP_FILE", path) |
| 149 | + app = _make_app() |
| 150 | + calls = [] |
| 151 | + monkeypatch.setattr(app, "action_telescope", lambda: calls.append("telescope")) |
| 152 | + monkeypatch.setattr(app, "action_autocomplete_next", lambda: calls.append("autocomplete_next")) |
| 153 | + async with app.run_test(size=(100, 35)) as pilot: |
| 154 | + app.action_focus_query() |
| 155 | + await pilot.pause() |
| 156 | + app.action_enter_insert_mode() |
| 157 | + await pilot.pause() |
| 158 | + app._autocomplete_visible = True |
| 159 | + assert app._get_input_context().autocomplete_visible |
| 160 | + assert app._get_input_context().vim_mode == VimMode.INSERT |
| 161 | + assert app.check_action("autocomplete_next", ()) is True |
| 162 | + await pilot.press("ctrl+j") |
| 163 | + assert calls == ["autocomplete_next"] |
| 164 | + app._autocomplete_visible = False |
| 165 | + await pilot.press("ctrl+j") |
| 166 | + assert calls == ["autocomplete_next", "telescope"] |
0 commit comments