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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user