test(tui): add BDD keyboard navigation for SlashCommandOverlay (#10442)

Implements keyboard navigation support for SlashCommandOverlay:
- navigate_up(): move selection up, clamped to index 0
- navigate_down(): move selection down, clamped to last command
- select_current(): return the currently highlighted SlashCommandSpec
- dismiss(): clear overlay and reset state (Escape key action)
- selected_index: tracks current highlighted position

Adds BDD feature file and step definitions covering all navigation
scenarios including boundary conditions (clamp at 0 and max index).

Closes #10442
This commit is contained in:
2026-04-19 09:41:18 +00:00
committed by Forgejo
parent f829e68911
commit a74c495a1f
3 changed files with 208 additions and 1 deletions
@@ -0,0 +1,117 @@
"""Step definitions for tdd_slash_overlay_keyboard_nav.feature.
These steps verify that SlashCommandOverlay supports keyboard navigation
per issue #10442: navigate_up, navigate_down, select_current, dismiss.
"""
from __future__ import annotations
from behave import given, then, when
from cleveragents.tui.slash_catalog import SlashCommandSpec
_TEST_COMMANDS: list[SlashCommandSpec] = [
SlashCommandSpec(command="help", group="Utility", description="Show help"),
SlashCommandSpec(command="settings", group="Utility", description="Open settings"),
SlashCommandSpec(command="clear", group="Utility", description="Clear display"),
]
@given("the overlay has commands loaded")
def step_overlay_has_commands(context: object) -> None:
"""Load test commands into the overlay."""
context.overlay.set_commands("", _TEST_COMMANDS)
context.test_commands = _TEST_COMMANDS
@given("the overlay selected_index is set to {index:d}")
def step_set_selected_index(context: object, index: int) -> None:
"""Set the overlay selected_index to a specific value."""
context.overlay.selected_index = index
@when("I call navigate_down on the overlay")
def step_navigate_down(context: object) -> None:
"""Call navigate_down on the overlay."""
context.overlay.navigate_down()
@when("I call navigate_up on the overlay")
def step_navigate_up(context: object) -> None:
"""Call navigate_up on the overlay."""
context.overlay.navigate_up()
@when("I call navigate_down on the overlay many times")
def step_navigate_down_many(context: object) -> None:
"""Call navigate_down many times to test boundary."""
for _ in range(20):
context.overlay.navigate_down()
@when("I call select_current on the overlay")
def step_select_current(context: object) -> None:
"""Call select_current on the overlay and store result."""
context.selected_command = context.overlay.select_current()
@then("the overlay should have a navigate_up method")
def step_has_navigate_up(context: object) -> None:
"""Verify navigate_up method exists."""
assert hasattr(context.overlay, "navigate_up"), "Must have navigate_up"
assert callable(context.overlay.navigate_up), "navigate_up must be callable"
@then("the overlay should have a navigate_down method")
def step_has_navigate_down(context: object) -> None:
"""Verify navigate_down method exists."""
assert hasattr(context.overlay, "navigate_down"), "Must have navigate_down"
assert callable(context.overlay.navigate_down), "navigate_down must be callable"
@then("the overlay should have a select_current method")
def step_has_select_current(context: object) -> None:
"""Verify select_current method exists."""
assert hasattr(context.overlay, "select_current"), "Must have select_current"
assert callable(context.overlay.select_current), "select_current must be callable"
@then("the overlay should have a dismiss method")
def step_has_dismiss(context: object) -> None:
"""Verify dismiss method exists."""
assert hasattr(context.overlay, "dismiss"), "Must have dismiss"
assert callable(context.overlay.dismiss), "dismiss must be callable"
@then("the overlay should have a selected_index attribute")
def step_has_selected_index(context: object) -> None:
"""Verify selected_index attribute exists."""
assert hasattr(context.overlay, "selected_index"), "Must have selected_index"
@then("the overlay selected_index should be {expected:d}")
def step_selected_index_equals(context: object, expected: int) -> None:
"""Verify the overlay selected_index equals the expected value."""
actual = context.overlay.selected_index
assert actual == expected, (
f"Expected selected_index={expected}, got {actual}"
)
@then("the overlay selected_index should not exceed the command count")
def step_selected_index_bounded(context: object) -> None:
"""Verify selected_index does not exceed the number of commands."""
count = len(context.test_commands)
actual = context.overlay.selected_index
assert actual < count, (
f"selected_index={actual} must be < command count={count}"
)
@then("the selected command should be the second command in the list")
def step_selected_is_second(context: object) -> None:
"""Verify select_current returned the second command."""
expected = context.test_commands[1]
assert context.selected_command == expected, (
f"Expected {expected!r}, got {context.selected_command!r}"
)
@@ -0,0 +1,60 @@
@tdd_issue @tdd_issue_10442
Feature: TDD Issue #10442 — SlashCommandOverlay keyboard navigation
As a developer
I want to verify that SlashCommandOverlay supports keyboard navigation
So that users can navigate the slash command list with up/down/Enter/Escape
Background:
Given the slash command overlay module is imported
Scenario: SlashCommandOverlay has navigate_up method
Given I have a SlashCommandOverlay instance
Then the overlay should have a navigate_up method
Scenario: SlashCommandOverlay has navigate_down method
Given I have a SlashCommandOverlay instance
Then the overlay should have a navigate_down method
Scenario: SlashCommandOverlay has select_current method
Given I have a SlashCommandOverlay instance
Then the overlay should have a select_current method
Scenario: SlashCommandOverlay has dismiss method
Given I have a SlashCommandOverlay instance
Then the overlay should have a dismiss method
Scenario: SlashCommandOverlay has selected_index attribute
Given I have a SlashCommandOverlay instance
Then the overlay should have a selected_index attribute
Scenario: navigate_down increments selected_index
Given I have a SlashCommandOverlay instance
And the overlay has commands loaded
When I call navigate_down on the overlay
Then the overlay selected_index should be 1
Scenario: navigate_up decrements selected_index
Given I have a SlashCommandOverlay instance
And the overlay has commands loaded
And the overlay selected_index is set to 2
When I call navigate_up on the overlay
Then the overlay selected_index should be 1
Scenario: navigate_up does not go below zero
Given I have a SlashCommandOverlay instance
And the overlay has commands loaded
When I call navigate_up on the overlay
Then the overlay selected_index should be 0
Scenario: navigate_down does not exceed command count
Given I have a SlashCommandOverlay instance
And the overlay has commands loaded
When I call navigate_down on the overlay many times
Then the overlay selected_index should not exceed the command count
Scenario: select_current returns the currently selected command
Given I have a SlashCommandOverlay instance
And the overlay has commands loaded
And the overlay selected_index is set to 1
When I call select_current on the overlay
Then the selected command should be the second command in the list
@@ -30,7 +30,12 @@ _COMMAND_COL_WIDTH = 28 # characters reserved for the command name column
class SlashCommandOverlay(_StaticBase):
"""Renderable list of slash command candidates."""
"""Renderable list of slash command candidates with keyboard navigation."""
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self.selected_index: int = 0
self._commands: list[SlashCommandSpec] = []
def set_commands(self, query: str, commands: list[SlashCommandSpec]) -> None:
filtered = (
@@ -38,6 +43,8 @@ class SlashCommandOverlay(_StaticBase):
if query
else list(commands)
)
self._commands = filtered
self.selected_index = 0
lines = [f"/{query}"]
for spec in filtered[:12]:
name_col = f" /{spec.command}"
@@ -46,3 +53,26 @@ class SlashCommandOverlay(_StaticBase):
if len(lines) == 1:
lines.append(" (no commands)")
self.update("\n".join(lines))
def navigate_up(self) -> None:
"""Move selection up by one, clamped to zero."""
if self.selected_index > 0:
self.selected_index -= 1
def navigate_down(self) -> None:
"""Move selection down by one, clamped to last command."""
max_index = len(self._commands) - 1
if self.selected_index < max_index:
self.selected_index += 1
def select_current(self) -> SlashCommandSpec | None:
"""Return the currently selected SlashCommandSpec, or None if empty."""
if not self._commands:
return None
return self._commands[self.selected_index]
def dismiss(self) -> None:
"""Dismiss/hide the overlay (Escape key action)."""
self.update("")
self._commands = []
self.selected_index = 0