"""Unit tests for the Keyboard control.""" import pytest from fasthtml.components import Div from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.core.commands import Command from myfasthtml.core.utils import make_html_id from myfasthtml.test.matcher import matches, find, find_one, AnyValue, TestScript from .conftest import root_instance @pytest.fixture def cmd(): return Command("test_keyboard_cmd", "Test keyboard command", None, lambda: None) class TestKeyboardBehaviour: """Tests for Keyboard behavior and logic.""" def test_i_can_add_combination_with_default_enabled(self, root_instance, cmd): """Test that enabled defaults to True when not specified in add(). Why this matters: - All combinations should be active by default without requiring explicit opt-in. """ kb = Keyboard(root_instance) kb.add("esc", cmd) assert kb.combinations["esc"]["enabled"] is True def test_i_can_add_combination_with_enabled_false(self, root_instance, cmd): """Test that enabled=False is correctly stored in the combination definition. Why this matters: - Combinations can be declared inactive at init time, which controls the initial data-enabled value in the rendered DOM. """ kb = Keyboard(root_instance) kb.add("esc", cmd, enabled=False) assert kb.combinations["esc"]["enabled"] is False class TestKeyboardRender: """Tests for Keyboard HTML rendering.""" @pytest.fixture def keyboard(self, root_instance): return Keyboard(root_instance) def test_keyboard_layout_is_rendered(self, keyboard, cmd): """Test that render() returns a tuple of (Script, Div). Why these elements matter: - tuple length 2: render() must produce exactly a script and a control div - script tag: the JavaScript call that registers keyboard shortcuts - div tag: the DOM control div used by JS to check enabled state at runtime """ keyboard.add("esc", cmd) result = keyboard.render() assert len(result) == 2 script, control_div = result assert script.tag == "script" assert control_div.tag == "div" def test_i_can_render_script_with_control_div_id(self, keyboard, cmd): """Test that the rendered script includes controlDivId as the second argument. Why this matters: - The JS function add_keyboard_support() now requires 3 args: elementId, controlDivId, combinationsJson. The controlDivId links the keyboard registry entry to its DOM control div for enabled-state lookups. """ keyboard.add("esc", cmd) script, _ = keyboard.render() expected_prefix = ( f"add_keyboard_support('{keyboard._parent.get_id()}', '{keyboard.get_id()}', " ) assert matches(script, TestScript(expected_prefix)) def test_i_can_render_control_div_attributes(self, keyboard, cmd): """Test that the control div has the correct id and name attributes. Why these attributes matter: - id=keyboard.get_id(): the JS uses this ID to look up enabled state - name="keyboard": semantic marker for readability and DOM inspection """ keyboard.add("esc", cmd) _, control_div = keyboard.render() expected = Div(id=keyboard.get_id(), name="keyboard") assert matches(control_div, expected) def test_i_can_render_one_child_per_combination(self, keyboard, cmd): """Test that the control div contains exactly one child div per combination. Why this matters: - Each combination needs its own div so the JS can check its enabled state independently via data-combination attribute lookup. """ keyboard.add("esc", cmd) keyboard.add("ctrl+s", cmd) _, control_div = keyboard.render() children = find(control_div, Div(data_combination=AnyValue())) assert len(children) == 2, "Should have one child div per registered combination" @pytest.mark.parametrize("enabled, expected_value", [ (True, "true"), (False, "false"), ]) def test_i_can_render_combination_enabled_state(self, keyboard, cmd, enabled, expected_value): """Test that data-enabled reflects the enabled flag passed to add(). Why this matters: - The JS reads data-enabled at keypress time to decide whether to trigger the combination. The rendered value must match the Python flag. """ keyboard.add("esc", cmd, enabled=enabled) _, control_div = keyboard.render() child = find_one(control_div, Div(data_combination="esc")) assert matches(child, Div(data_enabled=expected_value)) def test_i_can_render_child_id_sanitizes_combination(self, keyboard, cmd): """Test that the child div id is derived from make_html_id on the combination string. Why this matters: - Combination strings like 'ctrl+s' contain characters invalid in HTML IDs. make_html_id() sanitizes them ('+' → '-'), enabling targeted OOB swaps via mk_enable/mk_disable without referencing the full control div. """ keyboard.add("ctrl+s", cmd) _, control_div = keyboard.render() expected_id = f"{keyboard.get_id()}-{make_html_id('ctrl+s')}" children = find(control_div, Div(id=expected_id)) assert len(children) == 1, f"Expected exactly one child with id '{expected_id}'" @pytest.mark.parametrize("method_name, expected_enabled", [ ("mk_enable", "true"), ("mk_disable", "false"), ]) def test_i_can_mk_enable_and_disable(self, keyboard, method_name, expected_enabled): """Test that mk_enable/mk_disable return a correct OOB swap div. Why these attributes matter: - id: must match the child div id so HTMX replaces the right element - data-combination: allows the JS to identify the combination - data-enabled: the new state to apply - hx-swap-oob: triggers the out-of-band swap without a full page update """ result = getattr(keyboard, method_name)("esc") expected = Div( id=f"{keyboard.get_id()}-{make_html_id('esc')}", data_combination="esc", data_enabled=expected_enabled, hx_swap_oob="true" ) assert matches(result, expected)