diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py index cd17d26..38573e5 100644 --- a/src/myfasthtml/controls/helpers.py +++ b/src/myfasthtml/controls/helpers.py @@ -1,8 +1,8 @@ from fasthtml.components import * -from myfasthtml.core.bindings import Binding, BooleanConverter, DetectionMode, UpdateMode +from myfasthtml.core.bindings import Binding from myfasthtml.core.commands import Command -from myfasthtml.core.utils import merge_classes, get_default_ft_attr, is_checkbox +from myfasthtml.core.utils import merge_classes class mk: @@ -36,29 +36,12 @@ class mk: return ft @staticmethod - def manage_binding(ft, binding: Binding): + def manage_binding(ft, binding: Binding, ft_attr=None): if not binding: return ft - if ft.tag in ["input"]: - # update the component to post on the correct route input and forms only - htmx = binding.get_htmx_params() - ft.attrs |= htmx - - # update the binding with the ft - ft_attr = binding.ft_attr or get_default_ft_attr(ft) - ft_name = ft.attrs.get("name") - - if is_checkbox(ft): - data_converter = BooleanConverter() - detection_mode = DetectionMode.AttributePresence - update_mode = UpdateMode.AttributePresence - else: - data_converter = None - detection_mode = None - update_mode = None - - binding.bind_ft(ft, ft_name, ft_attr, data_converter, detection_mode, update_mode) # force the ft + binding.bind_ft(ft, ft_attr) + binding.init() return ft @staticmethod diff --git a/src/myfasthtml/core/bindings.py b/src/myfasthtml/core/bindings.py index 1866d6b..eed7157 100644 --- a/src/myfasthtml/core/bindings.py +++ b/src/myfasthtml/core/bindings.py @@ -7,7 +7,7 @@ from fasthtml.fastapp import fast_app from myutils.observable import make_observable, bind, collect_return_values, unbind from myfasthtml.core.constants import Routes, ROUTE_ROOT -from myfasthtml.core.utils import get_default_attr +from myfasthtml.core.utils import get_default_attr, get_default_ft_attr, is_checkbox, is_radio bindings_app, bindings_rt = fast_app() logger = logging.getLogger("Bindings") @@ -61,27 +61,29 @@ class AttrPresentDetection(AttrChangedDetection): class FtUpdate: - def update(self, ft, ft_name, ft_attr, old, new): + def update(self, ft, ft_name, ft_attr, old, new, converter): pass class ValueChangeFtUpdate(FtUpdate): - def update(self, ft, ft_name, ft_attr, old, new): + def update(self, ft, ft_name, ft_attr, old, new, converter): # simple mode, just update the text or the attribute + new_to_use = converter.convert(new) if converter else new if ft_attr is None: - ft.children = (new,) + ft.children = (new_to_use,) else: - ft.attrs[ft_attr] = new + ft.attrs[ft_attr] = new_to_use return ft class AttributePresenceFtUpdate(FtUpdate): - def update(self, ft, ft_name, ft_attr, old, new): + def update(self, ft, ft_name, ft_attr, old, new, converter): # attribute presence mode, toggle the attribute (add or remove it) + new_to_use = converter.convert(new) if converter else new if ft_attr is None: - ft.children = (bool(new),) + ft.children = (bool(new_to_use),) else: - ft.attrs[ft_attr] = "true" if new else None # FastHtml auto remove None attributes + ft.attrs[ft_attr] = "true" if new_to_use else None # FastHtml auto remove None attributes return ft @@ -104,6 +106,14 @@ class BooleanConverter(DataConverter): return False +class RadioConverter(DataConverter): + def __init__(self, radio_value): + self.radio_value = radio_value + + def convert(self, data): + return data == self.radio_value + + class Binding: def __init__(self, data: Any, attr: str = None): """ @@ -136,8 +146,8 @@ class Binding: def bind_ft(self, ft, - name, attr=None, + name=None, data_converter: DataConverter = None, detection_mode: DetectionMode = None, update_mode: UpdateMode = None): @@ -159,18 +169,37 @@ class Binding: if self._is_active: self.deactivate() + if ft.tag in ["input"]: + # I must not force the htmx + if {"hx-post", "hx_post"} & set(ft.attrs.keys()): + raise ValueError(f"Binding '{self.id}': htmx post already set on input.") + + # update the component to post on the correct route input and forms only + htmx = self.get_htmx_params() + ft.attrs |= htmx + # Configure UI elements self.ft = self._safe_ft(ft) - self.ft_name = name - self.ft_attr = attr + self.ft_name = name or ft.attrs.get("name") + self.ft_attr = attr or get_default_ft_attr(ft) + + if is_checkbox(ft): + default_data_converter = BooleanConverter() + default_detection_mode = DetectionMode.AttributePresence + default_update_mode = UpdateMode.AttributePresence + elif is_radio(ft): + default_data_converter = RadioConverter(ft.attrs["value"]) + default_detection_mode = DetectionMode.ValueChange + default_update_mode = UpdateMode.AttributePresence + else: + default_data_converter = None + default_detection_mode = DetectionMode.ValueChange + default_update_mode = UpdateMode.ValueChange # Update optional parameters if provided - if data_converter is not None: - self.data_converter = data_converter - if detection_mode is not None: - self.detection_mode = detection_mode - if update_mode is not None: - self.update_mode = update_mode + self.data_converter = data_converter or default_data_converter + self.detection_mode = detection_mode or default_detection_mode + self.update_mode = update_mode or default_update_mode # Create strategy objects self._detection = self._factory(self.detection_mode) @@ -187,6 +216,16 @@ class Binding: "hx-vals": f'{{"b_id": "{self.id}"}}', } + def init(self): + """ + Initialise the UI element with the value of the data + :return: + """ + old_value = None # to complicated to retrieve as it depends on the nature of self.ft + new_value = getattr(self.data, self.data_attr) + self.notify(old_value, new_value) + return self + def notify(self, old, new): """ Callback when the data attribute changes. @@ -204,16 +243,21 @@ class Binding: return None logger.debug(f"Binding '{self.id}': Changing from '{old}' to '{new}'") - self.ft = self._update.update(self.ft, self.ft_name, self.ft_attr, old, new) + self.ft = self._update.update(self.ft, self.ft_name, self.ft_attr, old, new, self.data_converter) self.ft.attrs["hx-swap-oob"] = "true" return self.ft def update(self, values: dict): + """ + Called by the FastHTML router when a request is received. + :param values: + :return: the list of updated elements (all elements that are bound to this binding) + """ logger.debug(f"Binding '{self.id}': Updating with {values=}.") matches, value = self._detection.matches(values) if matches: - setattr(self.data, self.data_attr, self.data_converter.convert(value) if self.data_converter else value) + setattr(self.data, self.data_attr, value) res = collect_return_values(self.data) return res diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index e976a82..7b791a8 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -113,8 +113,17 @@ def is_checkbox(elt): return False +def is_radio(elt): + if isinstance(elt, (FT, MyFT)): + return elt.tag == "input" and elt.attrs.get("type", None) == "radio" + elif isinstance(elt, Tag): + return elt.name == "input" and elt.attrs.get("type", None) == "radio" + else: + return False + + @utils_rt(Routes.Commands) -def post(session: str, c_id: str): +def post(session, c_id: str): """ Default routes for all commands. :param session: @@ -131,7 +140,7 @@ def post(session: str, c_id: str): @utils_rt(Routes.Bindings) -def post(session: str, b_id: str, values: dict): +def post(session, b_id: str, values: dict): """ Default routes for all bindings. :param session: diff --git a/src/myfasthtml/test/matcher.py b/src/myfasthtml/test/matcher.py index c340cc4..9108c3a 100644 --- a/src/myfasthtml/test/matcher.py +++ b/src/myfasthtml/test/matcher.py @@ -14,10 +14,13 @@ class Predicate: raise NotImplementedError def __str__(self): - return f"{self.__class__.__name__}({self.value})" + return f"{self.__class__.__name__}({self.value if self.value is not None else ''})" + + def __repr__(self): + return f"{self.__class__.__name__}({self.value if self.value is not None else ''})" def __eq__(self, other): - if not isinstance(other, Predicate): + if type(self) is not type(other): return False return self.value == other.value @@ -25,7 +28,24 @@ class Predicate: return hash(self.value) -class StartsWith(Predicate): +class AttrPredicate(Predicate): + """ + Predicate that validates an attribute value. + It's given as a value of an attribute. + """ + pass + + +class ChildrenPredicate(Predicate): + """ + Predicate given as a child of an element. + """ + + def to_debug(self, element): + return element + + +class StartsWith(AttrPredicate): def __init__(self, value): super().__init__(value) @@ -33,7 +53,7 @@ class StartsWith(Predicate): return actual.startswith(self.value) -class Contains(Predicate): +class Contains(AttrPredicate): def __init__(self, value): super().__init__(value) @@ -41,7 +61,7 @@ class Contains(Predicate): return self.value in actual -class DoesNotContain(Predicate): +class DoesNotContain(AttrPredicate): def __init__(self, value): super().__init__(value) @@ -49,16 +69,35 @@ class DoesNotContain(Predicate): return self.value not in actual +class Empty(ChildrenPredicate): + def __init__(self): + super().__init__(None) + + def validate(self, actual): + return len(actual.children) == 0 and len(actual.attrs) == 0 + + +class AttributeForbidden(ChildrenPredicate): + """ + To validate that an attribute is not present in an element. + """ + + def __init__(self, value): + super().__init__(value) + + def validate(self, actual): + return self.value not in actual.attrs or actual.attrs[self.value] is None + + def to_debug(self, element): + element.attrs[self.value] = "** NOT ALLOWED **" + return element + + @dataclass class DoNotCheck: desc: str = None -@dataclass -class Empty: - desc: str = None - - class ErrorOutput: def __init__(self, path, element, expected): self.path = path @@ -99,12 +138,13 @@ class ErrorOutput: self._add_to_output(error_str) # render the children - if len(self.expected.children) > 0: + expected_children = [c for c in self.expected.children if not isinstance(c, ChildrenPredicate)] + if len(expected_children) > 0: self.indent += " " element_index = 0 - for expected_child in self.expected.children: + for expected_child in expected_children: if hasattr(expected_child, "tag"): - if element_index < len(self.element.children): + if element_index < len(expected_children): # display the child element_child = self.element.children[element_index] child_str = self._str_element(element_child, expected_child, keep_open=False) @@ -122,7 +162,8 @@ class ErrorOutput: self._add_to_output(child_str) else: - self._add_to_output(expected_child) + if expected_child in self.element.children: + self._add_to_output(expected_child) self.indent = self.indent[:-2] self._add_to_output(")") @@ -145,9 +186,8 @@ class ErrorOutput: # the attributes are compared to the expected element elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in [attr_name for attr_name in expected.attrs if attr_name is not None]} - elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items()) - # + elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items()) tag_str = f"({element.tag} {elt_attrs_str}" # manage the closing tag @@ -157,8 +197,8 @@ class ErrorOutput: tag_str += "..." if elt_attrs_str == "" else " ..." else: # close the tag if there are no children - if len(element.children) == 0: tag_str += ")" - + if len([c for c in element.children if not isinstance(c, Predicate)]) == 0: tag_str += ")" + return tag_str def _detect_error(self, element, expected): @@ -307,16 +347,18 @@ def matches(actual, expected, path=""): _actual=actual.tag, _expected=expected.tag) - # special case when the expected element is empty - if len(expected.children) > 0 and isinstance(expected.children[0], Empty): - assert len(actual.children) == 0, _error_msg("Actual is not empty:", _actual=actual) - assert len(actual.attrs) == 0, _error_msg("Actual is not empty:", _actual=actual) - return True + # special conditions + for predicate in [c for c in expected.children if isinstance(c, ChildrenPredicate)]: + assert predicate.validate(actual), \ + _error_msg(f"The condition '{predicate}' is not satisfied.", + _actual=actual, + _expected=predicate.to_debug(expected)) # compare the attributes for expected_attr, expected_value in expected.attrs.items(): assert expected_attr in actual.attrs, _error_msg(f"'{expected_attr}' is not found in Actual.", - _actual=actual.attrs) + _actual=actual, + _expected=expected) if isinstance(expected_value, Predicate): assert expected_value.validate(actual.attrs[expected_attr]), \ @@ -327,14 +369,15 @@ def matches(actual, expected, path=""): else: assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \ _error_msg(f"The values are different for '{expected_attr}': ", - _actual=actual.attrs[expected_attr], - _expected=expected.attrs[expected_attr]) + _actual=actual, + _expected=expected) # compare the children - if len(actual.children) < len(expected.children): + expected_children = [c for c in expected.children if not isinstance(c, Predicate)] + if len(actual.children) < len(expected_children): _assert_error("Actual is lesser than expected: ", _actual=actual, _expected=expected) - for actual_child, expected_child in zip(actual.children, expected.children): + for actual_child, expected_child in zip(actual.children, expected_children): assert matches(actual_child, expected_child, path=path) else: diff --git a/src/myfasthtml/test/testclient.py b/src/myfasthtml/test/testclient.py index 7cb5d80..802cd3e 100644 --- a/src/myfasthtml/test/testclient.py +++ b/src/myfasthtml/test/testclient.py @@ -300,7 +300,7 @@ class TestableElement: self.fields[name] = raw_value elif name not in self.fields: # If no radio is checked yet, don't set a default - pass + self.fields[name] = None elif input_type == 'number': # Number: int or float based on value @@ -1104,6 +1104,9 @@ class TestableRadio(TestableControl): source: The source HTML or BeautifulSoup Tag. """ super().__init__(client, source, "input") + nb_radio_buttons = len(self.element.find_all("input", type="radio")) + assert nb_radio_buttons > 0, "No radio buttons found." + assert nb_radio_buttons < 2, "Only one radio button per name is supported." self._radio_value = self.my_ft.attrs.get('value', '') @property @@ -1573,6 +1576,9 @@ class MyTestClient: f"Found {len(remaining)} forms (with the specified fields). Expected exactly 1." ) + def find_input(self, identifier: str) -> TestableInput: + pass + def get_content(self) -> str: """ Get the raw HTML content of the last opened page. diff --git a/tests/controls/test_manage_binding.py b/tests/controls/test_manage_binding.py new file mode 100644 index 0000000..718dbf5 --- /dev/null +++ b/tests/controls/test_manage_binding.py @@ -0,0 +1,706 @@ +""" +Comprehensive binding tests for all bindable FastHTML components. + +This test suite covers: +- Input (text) - already tested +- Checkbox - already tested +- Textarea +- Select (single) +- Select (multiple) +- Range (slider) +- Radio buttons +- Button +- Input with Datalist (combobox) +""" + +from dataclasses import dataclass + +import pytest +from fasthtml.components import ( + Input, Label, Textarea, Select, Option, Button, Datalist +) +from fasthtml.fastapp import fast_app + +from myfasthtml.controls.helpers import mk +from myfasthtml.core.bindings import Binding +from myfasthtml.test.matcher import matches, AttributeForbidden +from myfasthtml.test.testclient import MyTestClient + + +@dataclass +class Data: + value: str = "hello world" + + +@dataclass +class NumericData: + value: int = 50 + + +@dataclass +class BoolData: + value: bool = True + + +@dataclass +class ListData: + value: list = None + + def __post_init__(self): + if self.value is None: + self.value = [] + + +@pytest.fixture() +def user(): + test_app, rt = fast_app(default_hdrs=False) + user = MyTestClient(test_app) + return user + + +@pytest.fixture() +def rt(user): + return user.app.route + + +class TestBindingTextarea: + """Tests for binding Textarea components.""" + + def test_i_can_bind_textarea(self, user, rt): + """ + Textarea should bind bidirectionally with data. + Value changes should update the label. + """ + + @rt("/") + def index(): + data = Data("Initial text") + textarea_elt = Textarea(name="textarea_name") + label_elt = Label() + mk.manage_binding(textarea_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return textarea_elt, label_elt + + user.open("/") + user.should_see("Initial text") + + testable_textarea = user.find_element("textarea") + testable_textarea.send("New multiline\ntext content") + user.should_see("New multiline\ntext content") + + def test_i_can_bind_textarea_with_empty_initial_value(self, user, rt): + """ + Textarea with empty initial value should update correctly. + """ + + @rt("/") + def index(): + data = Data("") + textarea_elt = Textarea(name="textarea_name") + label_elt = Label() + mk.manage_binding(textarea_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return textarea_elt, label_elt + + user.open("/") + user.should_see("") # Empty initially + + testable_textarea = user.find_element("textarea") + testable_textarea.send("First content") + user.should_see("First content") + + def test_textarea_append_works_with_binding(self, user, rt): + """ + Appending text to textarea should trigger binding update. + """ + + @rt("/") + def index(): + data = Data("Start") + textarea_elt = Textarea(name="textarea_name") + label_elt = Label() + mk.manage_binding(textarea_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return textarea_elt, label_elt + + user.open("/") + user.should_see("Start") + + testable_textarea = user.find_element("textarea") + testable_textarea.append(" + More") + user.should_see("Start + More") + + def test_textarea_clear_works_with_binding(self, user, rt): + """ + Clearing textarea should update binding to empty string. + """ + + @rt("/") + def index(): + data = Data("Content to clear") + textarea_elt = Textarea(name="textarea_name") + label_elt = Label() + mk.manage_binding(textarea_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return textarea_elt, label_elt + + user.open("/") + user.should_see("Content to clear") + + testable_textarea = user.find_element("textarea") + testable_textarea.clear() + user.should_not_see("Content to clear") + + +class TestBindingSelect: + """Tests for binding Select components (single selection).""" + + def test_i_can_bind_select_single(self, user, rt): + """ + Single select should bind with data. + Selecting an option should update the label. + """ + + @rt("/") + def index(): + data = Data("option1") + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name" + ) + label_elt = Label() + mk.manage_binding(select_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return select_elt, label_elt + + user.open("/") + user.should_see("option1") + + testable_select = user.find_element("select") + testable_select.select("option2") + user.should_see("option2") + + testable_select.select("option3") + user.should_see("option3") + + def test_i_can_bind_select_by_text(self, user, rt): + """ + Selecting by visible text should work with binding. + """ + + @rt("/") + def index(): + data = Data("opt1") + select_elt = Select( + Option("First Option", value="opt1"), + Option("Second Option", value="opt2"), + Option("Third Option", value="opt3"), + name="select_name" + ) + label_elt = Label() + mk.manage_binding(select_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return select_elt, label_elt + + user.open("/") + user.should_see("opt1") + + testable_select = user.find_element("select") + testable_select.select_by_text("Second Option") + user.should_see("opt2") + + def test_select_with_default_selected_option(self, user, rt): + """ + Select with a pre-selected option should initialize correctly. + """ + + @rt("/") + def index(): + data = Data("option2") + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2", selected=True), + Option("Option 3", value="option3"), + name="select_name" + ) + label_elt = Label() + mk.manage_binding(select_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return select_elt, label_elt + + user.open("/") + user.should_see("option2") + + +class TestBindingSelectMultiple: + """Tests for binding Select components with multiple selection.""" + + def test_i_can_bind_select_multiple(self, user, rt): + """ + Multiple select should bind with list data. + Selecting multiple options should update the label. + """ + + @rt("/") + def index(): + data = ListData(["option1"]) + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name", + multiple=True + ) + label_elt = Label() + mk.manage_binding(select_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return select_elt, label_elt + + user.open("/") + user.should_see("['option1']") + + testable_select = user.find_element("select") + testable_select.select("option2") + user.should_see("['option1', 'option2']") + + testable_select.select("option3") + user.should_see("['option1', 'option2', 'option3']") + + def test_i_can_deselect_from_multiple_select(self, user, rt): + """ + Deselecting options from multiple select should update binding. + """ + + @rt("/") + def index(): + data = ListData(["option1", "option2"]) + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name", + multiple=True + ) + label_elt = Label() + mk.manage_binding(select_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return select_elt, label_elt + + user.open("/") + user.should_see("['option1', 'option2']") + + testable_select = user.find_element("select") + testable_select.deselect("option1") + user.should_see("['option2']") + + +class TestBindingRange: + """Tests for binding Range (slider) components.""" + + def test_i_can_bind_range(self, user, rt): + """ + Range input should bind with numeric data. + Changing the slider should update the label. + """ + + @rt("/") + def index(): + data = NumericData(50) + range_elt = Input( + type="range", + name="range_name", + min="0", + max="100", + value="50" + ) + label_elt = Label() + mk.manage_binding(range_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return range_elt, label_elt + + user.open("/") + user.should_see("50") + + testable_range = user.find_element("input[type='range']") + testable_range.set(75) + user.should_see("75") + + testable_range.set(25) + user.should_see("25") + + def test_range_increase_decrease(self, user, rt): + """ + Increasing and decreasing range should update binding. + """ + + @rt("/") + def index(): + data = NumericData(50) + range_elt = Input( + type="range", + name="range_name", + min="0", + max="100", + step="10", + value="50" + ) + label_elt = Label() + mk.manage_binding(range_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return range_elt, label_elt + + user.open("/") + user.should_see("50") + + testable_range = user.find_element("input[type='range']") + testable_range.increase() + user.should_see("60") + + testable_range.increase() + user.should_see("70") + + testable_range.decrease() + user.should_see("60") + + def test_range_clamping_to_min_max(self, user, rt): + """ + Range values should be clamped to min/max bounds. + """ + + @rt("/") + def index(): + data = NumericData(50) + range_elt = Input( + type="range", + name="range_name", + min="0", + max="100", + value="50" + ) + label_elt = Label() + mk.manage_binding(range_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return range_elt, label_elt + + user.open("/") + + testable_range = user.find_element("input[type='range']") + testable_range.set(150) # Above max + user.should_see("100") + + testable_range.set(-10) # Below min + user.should_see("0") + + +class TestBindingRadio: + """Tests for binding Radio button components.""" + + def test_i_can_bind_radio_buttons(self): + data = Data() + radio1 = Input(type="radio", name="radio_name", value="option1") + radio2 = Input(type="radio", name="radio_name", value="option2") + radio3 = Input(type="radio", name="radio_name", value="option3") + + binding = Binding(data) + mk.manage_binding(radio1, binding) + mk.manage_binding(radio2, Binding(data)) + mk.manage_binding(radio3, Binding(data)) + + res = binding.update({"radio_name": "option1"}) # option1 is selected + expected = [ + Input(type="radio", name="radio_name", value="option1", checked="true"), + Input(AttributeForbidden("checked"), type="radio", name="radio_name", value="option2"), + Input(AttributeForbidden("checked"), type="radio", name="radio_name", value="option3"), + ] + assert matches(res, expected) + + def test_i_can_bind_radio_buttons_and_label(self, user, rt): + """ + Radio buttons should bind with data. + Selecting a radio should update the label. + """ + + @rt("/") + def index(): + data = Data() + radio1 = Input(type="radio", name="radio_name", value="option1", checked="true") + radio2 = Input(type="radio", name="radio_name", value="option2") + radio3 = Input(type="radio", name="radio_name", value="option3") + label_elt = Label() + + mk.manage_binding(radio1, Binding(data)) + mk.manage_binding(radio2, Binding(data)) + mk.manage_binding(radio3, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + + return radio1, radio2, radio3, label_elt + + user.open("/") + + # Select second radio + testable_radio2 = user.find_element("input[value='option2']") + testable_radio2.select() + user.should_see("option2") + + # Select third radio + testable_radio3 = user.find_element("input[value='option3']") + testable_radio3.select() + user.should_see("option3") + + def test_radio_initial_state(self, user, rt): + """ + Radio buttons should initialize with correct checked state. + """ + + @rt("/") + def index(): + data = Data("option2") + radio1 = Input(type="radio", name="radio_name", value="option1") + radio2 = Input(type="radio", name="radio_name", value="option2", checked=True) + radio3 = Input(type="radio", name="radio_name", value="option3") + label_elt = Label() + + mk.manage_binding(radio1, Binding(data)) + mk.manage_binding(radio2, Binding(data)) + mk.manage_binding(radio3, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + + return radio1, radio2, radio3, label_elt + + user.open("/") + user.should_see("option2") + + +class TestBindingButton: + """Tests for binding Button components.""" + + def test_i_can_click_button_with_binding(self, user, rt): + """ + Clicking a button with HTMX should trigger binding updates. + """ + + @rt("/") + def index(): + data = Data("initial") + button_elt = Button("Click me", hx_post="/update", hx_vals='{"action": "clicked"}') + label_elt = Label() + + mk.manage_binding(button_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + + return button_elt, label_elt + + @rt("/update") + def update(action: str): + data = Data("button clicked") + label_elt = Label() + mk.manage_binding(label_elt, Binding(data)) + return label_elt + + user.open("/") + user.should_see("initial") + + testable_button = user.find_element("button") + testable_button.click() + user.should_see("button clicked") + + def test_button_without_htmx_does_nothing(self, user, rt): + """ + Button without HTMX should not trigger updates. + """ + + @rt("/") + def index(): + data = Data("initial") + button_elt = Button("Plain button") # No HTMX + label_elt = Label() + + mk.manage_binding(button_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + + return button_elt, label_elt + + user.open("/") + user.should_see("initial") + + testable_button = user.find_element("button") + result = testable_button.click() + assert result is None # No HTMX, no response + + +class TestBindingDatalist: + """Tests for binding Input with Datalist (combobox).""" + + def test_i_can_bind_input_with_datalist(self, user, rt): + """ + Input with datalist should allow both free text and suggestions. + """ + + @rt("/") + def index(): + data = Data("") + datalist = Datalist( + Option(value="suggestion1"), + Option(value="suggestion2"), + Option(value="suggestion3"), + id="suggestions" + ) + input_elt = Input( + name="input_name", + list="suggestions" + ) + label_elt = Label() + + mk.manage_binding(input_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + + return input_elt, datalist, label_elt + + user.open("/") + user.should_see("") + + testable_input = user.find_element("input[list='suggestions']") + + # Can type free text + testable_input.send("custom value") + user.should_see("custom value") + + # Can select from suggestions + testable_input.select_suggestion("suggestion2") + user.should_see("suggestion2") + + def test_datalist_suggestions_are_available(self, user, rt): + """ + Datalist suggestions should be accessible for validation. + """ + + @rt("/") + def index(): + data = Data("") + datalist = Datalist( + Option(value="apple"), + Option(value="banana"), + Option(value="cherry"), + id="fruits" + ) + input_elt = Input( + name="input_name", + list="fruits" + ) + label_elt = Label() + + mk.manage_binding(input_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + + return input_elt, datalist, label_elt + + user.open("/") + + testable_input = user.find_element("input[list='fruits']") + + # Check that suggestions are available + suggestions = testable_input.suggestions + assert "apple" in suggestions + assert "banana" in suggestions + assert "cherry" in suggestions + + +class TestBindingEdgeCases: + """Tests for edge cases and special scenarios.""" + + def test_multiple_components_bind_to_same_data(self, user, rt): + """ + Multiple different components can bind to the same data object. + """ + + @rt("/") + def index(): + data = Data("synchronized") + + input_elt = Input(name="input_name") + textarea_elt = Textarea(name="textarea_name") + label_elt = Label() + + mk.manage_binding(input_elt, Binding(data)) + mk.manage_binding(textarea_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + + return input_elt, textarea_elt, label_elt + + user.open("/") + user.should_see("synchronized") + + # Change via input + testable_input = user.find_element("input") + testable_input.send("changed via input") + user.should_see("changed via input") + + # Change via textarea + testable_textarea = user.find_element("textarea") + testable_textarea.send("changed via textarea") + user.should_see("changed via textarea") + + def test_component_without_name_attribute(self, user, rt): + """ + Component without name attribute should handle gracefully. + """ + + @rt("/") + def index(): + data = Data("test") + # Input without name - should not crash + input_elt = Input() # No name attribute + label_elt = Label() + + mk.manage_binding(label_elt, Binding(data)) + + return input_elt, label_elt + + user.open("/") + user.should_see("test") + + def test_binding_with_initial_empty_string(self, user, rt): + """ + Binding should work correctly with empty string initial values. + """ + + @rt("/") + def index(): + data = Data("") + input_elt = Input(name="input_name") + label_elt = Label() + + mk.manage_binding(input_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + + return input_elt, label_elt + + user.open("/") + + testable_input = user.find_element("input") + testable_input.send("now has value") + user.should_see("now has value") + + def test_binding_with_special_characters(self, user, rt): + """ + Binding should handle special characters correctly. + """ + + @rt("/") + def index(): + data = Data("Hello") + input_elt = Input(name="input_name") + label_elt = Label() + + mk.manage_binding(input_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + + return input_elt, label_elt + + user.open("/") + + testable_input = user.find_element("input") + testable_input.send("Special: <>&\"'") + user.should_see("Special: <>&\"'") diff --git a/tests/core/test_bindings.py b/tests/core/test_bindings.py index fa93903..bc1cc6c 100644 --- a/tests/core/test_bindings.py +++ b/tests/core/test_bindings.py @@ -280,20 +280,6 @@ def test_i_cannot_activate_without_configuration(data): binding.activate() -def test_activation_validates_ft_name(data): - """ - Activation should fail if ft_name is not configured. - """ - elt = Label("hello", id="label_id") - binding = Binding(data, "value") - binding.ft = elt - binding._detection = binding._factory(DetectionMode.ValueChange) - binding._update = binding._factory(UpdateMode.ValueChange) - - with pytest.raises(ValueError, match="ft_name is required"): - binding.activate() - - def test_activation_validates_strategies(data): """ Activation should fail if detection/update strategies are not initialized. @@ -387,3 +373,11 @@ def test_multiple_bindings_can_coexist(data): data.value = "final" assert elt1.children[0] == "updated" # Not changed assert elt2.attrs["value"] == "final" # Changed + + +def test_i_cannot_bind_when_htmx_post_already_set(data): + elt = Input(name="input_elt", hx_post="/some/url") + binding = Binding(data, "value") + + with pytest.raises(ValueError, match="htmx post already set on input"): + binding.bind_ft(elt, name="label_name") diff --git a/tests/test_integration.py b/tests/test_integration.py index 8dcefda..b43b1e2 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -94,8 +94,8 @@ class TestingBindings: testable_input = user.find_element("input") testable_input.check() - user.should_see("True") + user.should_see("true") testable_input.uncheck() - user.should_see("False") + user.should_see("false") diff --git a/tests/testclient/test_matches.py b/tests/testclient/test_matches.py index f28d972..fc41ce4 100644 --- a/tests/testclient/test_matches.py +++ b/tests/testclient/test_matches.py @@ -3,7 +3,7 @@ from fastcore.basics import NotStr from fasthtml.components import * from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \ - ErrorComparisonOutput + ErrorComparisonOutput, AttributeForbidden from myfasthtml.test.testclient import MyFT @@ -23,6 +23,7 @@ from myfasthtml.test.testclient import MyFT ([Div(), Span()], DoNotCheck()), (NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked (Div(), Div(Empty())), + (Div(attr1="value1"), Div(AttributeForbidden("attr2"))), (Div(123), Div(123)), (Div(Span(123)), Div(Span(123))), (Div(Span(123)), Div(DoNotCheck())), @@ -49,9 +50,9 @@ def test_i_can_match(actual, expected): (Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"), (Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"), (NotStr("456"), NotStr("123"), "Notstr values are different"), - (Div(attr="value"), Div(Empty()), "Actual is not empty"), - (Div(120), Div(Empty()), "Actual is not empty"), - (Div(Span()), Div(Empty()), "Actual is not empty"), + (Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"), + (Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"), + (Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"), (Div(), Div(Span()), "Actual is lesser than expected"), (Div(), Div(123), "Actual is lesser than expected"), (Div(Span()), Div(Div()), "The elements are different"), @@ -59,6 +60,7 @@ def test_i_can_match(actual, expected): (Div(123), Div(456), "The values are different"), (Div(Span(), Span()), Div(Span(), Div()), "The elements are different"), (Div(Span(Div())), Div(Span(Span())), "The elements are different"), + (Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"), ]) def test_i_can_detect_errors(actual, expected, error_message): with pytest.raises(AssertionError) as exc_info: diff --git a/tests/testclient/test_teastable_radio.py b/tests/testclient/test_teastable_radio.py index 0d5bb72..3c13336 100644 --- a/tests/testclient/test_teastable_radio.py +++ b/tests/testclient/test_teastable_radio.py @@ -16,14 +16,9 @@ This test suite covers: from dataclasses import dataclass import pytest -from fasthtml.components import ( - Input, Label -) from fasthtml.fastapp import fast_app -from myfasthtml.controls.helpers import mk -from myfasthtml.core.bindings import Binding -from myfasthtml.test.testclient import MyTestClient +from myfasthtml.test.testclient import MyTestClient, TestableRadio @dataclass @@ -43,66 +38,67 @@ def rt(test_app): @pytest.fixture -def user(test_app): +def test_client(test_app): return MyTestClient(test_app) -class TestBindingRadio: - """Tests for binding Radio button components.""" +def test_i_can_read_not_selected_radio(test_client): + html = '''''' - def test_i_can_bind_radio_buttons(self, user, rt): - """ - Radio buttons should bind with data. - Selecting a radio should update the label. - """ - - @rt("/") - def index(): - data = Data("option1") - radio1 = Input(type="radio", name="radio_name", value="option1", checked=True) - radio2 = Input(type="radio", name="radio_name", value="option2") - radio3 = Input(type="radio", name="radio_name", value="option3") - label_elt = Label() - - mk.manage_binding(radio1, Binding(data)) - mk.manage_binding(radio2, Binding(data)) - mk.manage_binding(radio3, Binding(data)) - mk.manage_binding(label_elt, Binding(data)) - - return radio1, radio2, radio3, label_elt - - user.open("/") - user.should_see("option1") - - # Select second radio - testable_radio2 = user.find_element("input[value='option2']") - testable_radio2.select() - user.should_see("option2") - - # Select third radio - testable_radio3 = user.find_element("input[value='option3']") - testable_radio3.select() - user.should_see("option3") + input_elt = TestableRadio(test_client, html) - def test_radio_initial_state(self, user, rt): - """ - Radio buttons should initialize with correct checked state. - """ - - @rt("/") - def index(): - data = Data("option2") - radio1 = Input(type="radio", name="radio_name", value="option1") - radio2 = Input(type="radio", name="radio_name", value="option2", checked=True) - radio3 = Input(type="radio", name="radio_name", value="option3") - label_elt = Label() - - mk.manage_binding(radio1, Binding(data)) - mk.manage_binding(radio2, Binding(data)) - mk.manage_binding(radio3, Binding(data)) - mk.manage_binding(label_elt, Binding(data)) - - return radio1, radio2, radio3, label_elt - - user.open("/") - user.should_see("option2") + assert input_elt.name == "radio_name" + assert input_elt.value is None + + +def test_i_can_read_selected_radio(test_client): + html = '''''' + + input_elt = TestableRadio(test_client, html) + + assert input_elt.name == "radio_name" + assert input_elt.value == "option1" + + +def test_i_cannot_read_radio_with_multiple_values(test_client): + html = ''' + + + ''' + + with pytest.raises(AssertionError) as exc_info: + TestableRadio(test_client, html) + + assert "Only one radio button per name is supported" in str(exc_info.value) + + +def test_i_cannot_read_radio_when_no_radio_button(test_client): + html = ''' + ''' + + with pytest.raises(AssertionError) as exc_info: + TestableRadio(test_client, html) + + assert "No radio buttons found" in str(exc_info.value) + + +def test_i_can_read_input_with_label(test_client): + html = '''''' + + input_elt = TestableRadio(test_client, html) + assert input_elt.fields_mapping == {"John Doe": "username"} + assert input_elt.name == "username" + assert input_elt.value is None + + +def test_i_can_send_values(test_client, rt): + html = '''''' + + @rt('/submit') + def post(username: str): + return f"Input received {username=}" + + input_elt = TestableRadio(test_client, html) + input_elt.select() + + assert test_client.get_content() == "Input received username='john_doe'" diff --git a/tests/testclient/test_testable.py b/tests/testclient/test_testable.py index dfadf45..fda00d9 100644 --- a/tests/testclient/test_testable.py +++ b/tests/testclient/test_testable.py @@ -15,12 +15,15 @@ This test suite covers: from dataclasses import dataclass +import pytest from fasthtml.components import ( Input, Label, Textarea ) +from fasthtml.fastapp import fast_app from myfasthtml.controls.helpers import mk from myfasthtml.core.bindings import Binding +from myfasthtml.test.testclient import MyTestClient @dataclass @@ -47,6 +50,22 @@ class ListData: self.value = [] +@pytest.fixture +def test_app(): + test_app, rt = fast_app(default_hdrs=False) + return test_app + + +@pytest.fixture +def rt(test_app): + return test_app.route + + +@pytest.fixture +def user(test_app): + return MyTestClient(test_app) + + class TestBindingEdgeCases: """Tests for edge cases and special scenarios.""" diff --git a/tests/testclient/test_testable_input.py b/tests/testclient/test_testable_input.py index 2922ebe..50c1722 100644 --- a/tests/testclient/test_testable_input.py +++ b/tests/testclient/test_testable_input.py @@ -49,3 +49,10 @@ def test_i_can_send_values(test_client, rt): input_elt.send("another name") assert test_client.get_content() == "Input received username='another name'" + + +def i_can_find_input_by_name(test_client): + html = '''''' + + test_client.find_input("username") + assert False \ No newline at end of file