Improve tutorial help navigation

This commit is contained in:
Storm Dragon
2026-08-13 09:29:43 -04:00
parent e7f69b2159
commit 4ed431640e
20 changed files with 910 additions and 234 deletions
@@ -101,6 +101,98 @@ def test_bare_key_taps_advance_shortcut_repeat(monkeypatch):
assert manager.env["input"]["shortcut_repeat"] == 3
@pytest.mark.unit
def test_tutorial_toggle_runs_while_tutorial_mode_is_active():
manager = FenrirManager.__new__(FenrirManager)
command_manager = Mock()
command_manager.command_exists.return_value = False
manager.environment = {
"runtime": {
"HelpManager": Mock(
is_tutorial_mode=Mock(return_value=True)
),
"CommandManager": command_manager,
"ReadAllManager": Mock(is_active=Mock(return_value=False)),
"VmenuManager": Mock(get_active=Mock(return_value=False)),
"SpeechHistoryManager": Mock(is_active=Mock(return_value=False)),
}
}
manager.handle_execute_command({"data": "TOGGLE_TUTORIAL_MODE"})
command_manager.run_command.assert_called_once_with(
"TOGGLE_TUTORIAL_MODE", "commands"
)
command_manager.execute_command.assert_not_called()
@pytest.mark.unit
def test_tutorial_mode_swallows_input_on_ignored_screen():
manager, input_manager = create_handle_input_manager(
no_key_pressed=False
)
manager.environment["runtime"]["HelpManager"].is_tutorial_mode.return_value = (
True
)
manager.environment["runtime"]["ScreenManager"].is_ignored_screen.return_value = (
True
)
manager.handle_input(
{"data": {"event_name": "KEY_A", "event_state": 1}}
)
input_manager.clear_event_buffer.assert_called()
input_manager.write_event_buffer.assert_not_called()
input_manager.key_echo.assert_called_once_with(
{"event_name": "KEY_A", "event_state": 1}
)
manager.detect_shortcut_command.assert_called_once_with()
@pytest.mark.unit
def test_tutorial_mode_promotes_non_fenrir_chord_to_command():
manager = FenrirManager.__new__(FenrirManager)
manager.modifierInput = False
manager.modifier_prefix_input = False
manager.singleKeyCommand = False
manager.command = ""
input_manager = Mock(
is_key_press=Mock(return_value=False),
curr_input_has_command_modifier=Mock(return_value=False),
curr_input_is_modifier_prefix=Mock(return_value=False),
no_key_pressed=Mock(return_value=False),
get_curr_shortcut=Mock(
return_value=str([1, ["KEY_CTRL", "KEY_S"]])
),
get_command_for_shortcut=Mock(return_value="SPELL_CHECK"),
)
event_manager = Mock()
manager.environment = {
"input": {
"key_forward": 0,
"prev_input": ["KEY_CTRL"],
"curr_input": ["KEY_CTRL", "KEY_S"],
},
"runtime": {
"InputManager": input_manager,
"EventManager": event_manager,
"HelpManager": Mock(
is_tutorial_mode=Mock(return_value=True)
),
"VmenuManager": Mock(get_active=Mock(return_value=False)),
"DiffReviewManager": Mock(is_active=Mock(return_value=False)),
"SpeechHistoryManager": Mock(is_active=Mock(return_value=False)),
},
}
manager.detect_shortcut_command()
event_manager.put_to_event_queue.assert_called_once_with(
FenrirEventType.execute_command, "SPELL_CHECK"
)
@pytest.mark.unit
def test_bare_key_taps_resolve_progressive_shortcuts(monkeypatch):
manager = create_input_manager()
+202
View File
@@ -0,0 +1,202 @@
import ast
from unittest.mock import Mock
import pytest
from fenrirscreenreader.core.commandManager import CommandManager
from fenrirscreenreader.core.helpManager import HelpManager
def create_help_manager(capture_succeeds=True):
manager = HelpManager()
bindings = {
str([1, ["KEY_F1", "KEY_FENRIR"]]): "TOGGLE_TUTORIAL_MODE",
str([1, ["KEY_FENRIR", "KEY_S"]]): "SPELL_CHECK",
str([1, ["KEY_FENRIR", "KEY_W"]]): "READ_WORD",
str([2, ["KEY_FENRIR", "KEY_W"]]): "READ_WORD",
str([1, ["KEY_SCRIPT", "KEY_U"]]): (
"MUSICPLAYER__-__KEY_U"
),
}
raw_bindings = {
shortcut: ast.literal_eval(shortcut) for shortcut in bindings
}
command_manager = Mock()
descriptions = {
"SPELL_CHECK": "spell check",
"READ_WORD": "read current word",
"UNBOUND_ACTION": "an action without a shortcut",
"MUSICPLAYER__-__KEY_U": "music player plugin",
}
command_manager.get_command_description.side_effect = (
lambda command, section="commands": descriptions.get(command, "")
)
input_manager = Mock()
input_manager.set_help_capture.return_value = capture_succeeds
internal_command = Mock()
internal_command.help_visible = False
plugin_command = Mock()
plugin_command.help_category = "plugins"
manager.initialize(
{
"general": {"tutorialMode": False},
"bindings": bindings.copy(),
"rawBindings": {
shortcut: [raw[0], raw[1].copy()]
for shortcut, raw in raw_bindings.items()
},
"commands": {
"commands": {
"READ_WORD": Mock(),
"SPELL_CHECK": Mock(),
"TOGGLE_TUTORIAL_MODE": Mock(),
"UNBOUND_ACTION": Mock(),
"00_INIT_COMMANDS": internal_command,
"MUSICPLAYER__-__KEY_U": plugin_command,
}
},
"runtime": {
"CommandManager": command_manager,
"DebugManager": Mock(),
"InputDriver": Mock(refresh_grabs=Mock()),
"InputManager": input_manager,
"VmenuManager": Mock(get_active=Mock(return_value=False)),
},
}
)
return manager, bindings, raw_bindings
@pytest.mark.unit
def test_help_lists_bound_actions_once_with_all_shortcuts():
manager, _bindings, raw_bindings = create_help_manager()
manager.set_tutorial_mode(True)
active_text = " ".join(manager.help_lists["active"])
assert active_text.count("read word") == 1
assert "fenrir, w" in active_text
assert "2 times fenrir, w" in active_text
assert "unbound action" not in active_text
assert "musicplayer" not in active_text
assert "00 init commands" not in active_text
assert all(
"00 init commands" not in entry
for entry in manager.help_lists["unbound"]
)
assert manager.help_lists["unbound"] == [
"unbound action, Shortcuts unbound, Description an action without a shortcut"
]
assert manager.help_lists["plugins"] == [
"musicplayer, Shortcuts script, u, Description music player plugin"
]
assert raw_bindings[str([1, ["KEY_FENRIR", "KEY_S"]])] == [
1,
["KEY_FENRIR", "KEY_S"],
]
@pytest.mark.unit
def test_help_navigation_defaults_to_active_and_starts_before_first_item():
manager, _bindings, _raw_bindings = create_help_manager()
manager.set_tutorial_mode(True)
assert manager.get_help_section_name() == "Active actions"
assert manager.get_help_for_current_index() == "Active actions"
manager.next_index()
assert manager.get_help_for_current_index() == manager.help_lists["active"][0]
assert manager.select_help_section("unbound") == "Unbound actions"
assert manager.get_help_for_current_index() == "Unbound actions"
manager.prev_index()
assert manager.get_help_for_current_index() == manager.help_lists["unbound"][-1]
assert manager.next_help_section() == "Plugins"
assert manager.next_help_section() == "Active actions"
assert manager.prev_help_section() == "Plugins"
@pytest.mark.unit
def test_help_installs_and_restores_modal_bindings_exactly():
manager, bindings, raw_bindings = create_help_manager()
manager.set_tutorial_mode(True)
assert (
manager.env["bindings"][str([1, ["KEY_LEFT"]])]
== "PREV_HELP_SECTION"
)
assert (
manager.env["bindings"][str([1, ["KEY_RIGHT"]])]
== "NEXT_HELP_SECTION"
)
assert manager.env["bindings"][str([1, ["KEY_ESC"]])] == "TOGGLE_TUTORIAL_MODE"
assert manager.env["rawBindings"][str([1, ["KEY_RIGHT"]])] == [
1,
["KEY_RIGHT"],
]
manager.env["runtime"]["InputManager"].set_help_capture.assert_called_once_with(
True
)
manager.set_tutorial_mode(False)
assert manager.env["bindings"] == bindings
assert manager.env["rawBindings"] == raw_bindings
manager.env["runtime"]["InputManager"].set_help_capture.assert_called_with(
False
)
@pytest.mark.unit
def test_help_records_degraded_capture_without_refusing_mode():
manager, _bindings, _raw_bindings = create_help_manager(
capture_succeeds=False
)
manager.set_tutorial_mode(True)
assert manager.is_tutorial_mode() is True
assert manager.is_capture_degraded() is True
@pytest.mark.unit
def test_help_does_not_claim_exit_when_capture_release_fails():
manager, bindings, raw_bindings = create_help_manager()
manager.set_tutorial_mode(True)
manager.env["runtime"]["InputManager"].set_help_capture.return_value = False
assert manager.set_tutorial_mode(False) is False
assert manager.is_tutorial_mode() is True
assert manager.env["bindings"] != bindings
assert manager.env["rawBindings"] != raw_bindings
@pytest.mark.unit
def test_command_description_is_presented_on_ignored_screen_in_help():
command_manager = CommandManager()
output_manager = Mock()
command_manager.env = {
"runtime": {
"ScreenManager": Mock(
is_ignored_screen=Mock(return_value=True)
),
"HelpManager": Mock(
is_tutorial_mode=Mock(return_value=True)
),
"DebugManager": Mock(),
"OutputManager": output_manager,
}
}
command_manager.command_exists = Mock(return_value=True)
command_manager.get_command_description = Mock(
return_value="spell check"
)
command_manager.execute_command("SPELL_CHECK", "commands")
output_manager.present_text.assert_called_once_with(
"spell check", interrupt=False
)
@@ -126,6 +126,14 @@ def test_progressive_review_bindings(layout, character_keys, word_keys):
assert bindings[str([2, word_keys])] == "REVIEW_CURR_WORD_SPELL"
assert bindings[str([3, word_keys])] == "REVIEW_CURR_WORD_PHONETIC"
@pytest.mark.unit
@pytest.mark.parametrize("layout", ["desktop", "laptop"])
def test_layouts_do_not_bind_removed_keyboard_layout_cycle(layout):
bindings = load_bindings(layout)
assert "CYCLE_KEYBOARD_LAYOUT" not in bindings.values()
removed_commands = {
"REVIEW_PREV_CHAR_PHONETIC",
"REVIEW_NEXT_CHAR_PHONETIC",
+34
View File
@@ -0,0 +1,34 @@
import importlib.util
from pathlib import Path
from unittest.mock import Mock
import pytest
COMMAND_PATH = (
Path(__file__).resolve().parents[2]
/ "src"
/ "fenrirscreenreader"
/ "commands"
/ "commands"
/ "toggle_vmenu_mode.py"
)
def load_command():
spec = importlib.util.spec_from_file_location(
"fenrir_toggle_vmenu_mode", COMMAND_PATH
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.command()
@pytest.mark.unit
def test_get_description_does_not_toggle_vmenu_mode():
vmenu_manager = Mock()
command = load_command()
command.initialize({"runtime": {"VmenuManager": vmenu_manager}})
assert command.get_description() == "Entering or Leaving v menu mode."
vmenu_manager.toggle_vmenu_mode.assert_not_called()
+75
View File
@@ -189,6 +189,29 @@ def test_x11_should_emit_unbound_mapped_keys_for_speech_interrupt():
assert x11.should_emit_key("KEY_ENTER") is True
@pytest.mark.unit
@pytest.mark.parametrize(
("keysym_name", "key_name"),
[
("-", "KEY_MINUS"),
("=", "KEY_EQUAL"),
("[", "KEY_LEFTBRACE"),
("]", "KEY_RIGHTBRACE"),
("\\", "KEY_BACKSLASH"),
(";", "KEY_SEMICOLON"),
("'", "KEY_APOSTROPHE"),
("`", "KEY_GRAVE"),
(",", "KEY_COMMA"),
(".", "KEY_DOT"),
("/", "KEY_SLASH"),
],
)
def test_x11_maps_literal_punctuation_keysyms(keysym_name, key_name):
x11 = X11Driver()
assert x11.keysym_name_to_key_name(keysym_name) == key_name
@pytest.mark.unit
def test_x11_build_passive_grabs_for_fenrir_keys_and_shortcuts():
x11 = X11Driver()
@@ -372,6 +395,58 @@ def test_x11_write_event_buffer_does_not_replay_key_release():
assert x11.env["input"]["event_buffer"] == []
@pytest.mark.unit
def test_x11_help_capture_grabs_and_releases_keyboard():
x11 = X11Driver()
x11._initialized = True
x11.window = Mock()
x11.window.grab_keyboard.return_value = X.GrabSuccess
x11.display = Mock()
x11.env = {"runtime": {"DebugManager": Mock()}}
assert x11.set_help_capture(True) is True
x11.window.grab_keyboard.assert_called_once_with(
False,
X.GrabModeAsync,
X.GrabModeAsync,
X.CurrentTime,
)
assert x11.help_keyboard_grabbed is True
assert x11.set_help_capture(False) is True
x11.display.ungrab_keyboard.assert_called_once_with(X.CurrentTime)
x11.display.sync.assert_called_once_with()
assert x11.help_keyboard_grabbed is False
@pytest.mark.unit
def test_x11_help_capture_reports_failed_grab():
x11 = X11Driver()
x11._initialized = True
x11.window = Mock()
x11.window.grab_keyboard.return_value = X.AlreadyGrabbed
x11.display = Mock()
x11.env = {"runtime": {"DebugManager": Mock()}}
assert x11.set_help_capture(True) is False
assert x11.help_keyboard_grabbed is False
@pytest.mark.unit
def test_x11_help_capture_retries_failed_keyboard_release():
x11 = X11Driver()
x11._initialized = True
x11.help_keyboard_grabbed = True
x11.window = Mock()
x11.display = Mock()
x11.display.sync.side_effect = RuntimeError("ungrab failed")
x11.env = {"runtime": {"DebugManager": Mock()}}
assert x11.set_help_capture(False) is False
assert x11.display.ungrab_keyboard.call_count == 3
assert x11.help_keyboard_grabbed is True
@pytest.mark.unit
def test_x11_map_event_keeps_x_event_time_for_replay():
x11 = X11Driver()