From ad2823042c3b2e56500fd555f50b1f979c4bf5d9 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sat, 8 Nov 2025 19:58:47 +0100 Subject: [PATCH] I can bind select --- src/myfasthtml/controls/helpers.py | 13 +- src/myfasthtml/core/bindings.py | 11 +- src/myfasthtml/core/utils.py | 30 ++- src/myfasthtml/{docs => examples}/__init__.py | 0 src/myfasthtml/examples/binding_checkbox.py | 51 ++++ src/myfasthtml/examples/binding_input.py | 33 +++ src/myfasthtml/examples/binding_radio.py | 47 ++++ src/myfasthtml/examples/binding_select.py | 46 ++++ src/myfasthtml/examples/binding_textarea.py | 33 +++ src/myfasthtml/{docs => examples}/clickme.py | 52 ++-- .../command_with_htmx_params.py | 50 ++-- .../{docs => examples}/helloworld.py | 30 +-- src/myfasthtml/test/matcher.py | 69 ++++-- src/myfasthtml/test/testclient.py | 203 +--------------- tests/auth/test_utils.py | 14 ++ tests/controls/test_manage_binding.py | 112 +++++++-- tests/testclient/test_matches.py | 20 +- tests/testclient/test_mytestclient.py | 1 - tests/testclient/test_teastable_radio.py | 15 -- tests/testclient/test_testable_input.py | 2 +- tests/testclient/test_testable_select.py | 228 ++++-------------- tests/testclient/test_testable_textarea.py | 182 ++++---------- 22 files changed, 591 insertions(+), 651 deletions(-) rename src/myfasthtml/{docs => examples}/__init__.py (100%) create mode 100644 src/myfasthtml/examples/binding_checkbox.py create mode 100644 src/myfasthtml/examples/binding_input.py create mode 100644 src/myfasthtml/examples/binding_radio.py create mode 100644 src/myfasthtml/examples/binding_select.py create mode 100644 src/myfasthtml/examples/binding_textarea.py rename src/myfasthtml/{docs => examples}/clickme.py (95%) rename src/myfasthtml/{docs => examples}/command_with_htmx_params.py (95%) rename src/myfasthtml/{docs => examples}/helloworld.py (94%) diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py index 38573e5..1fcf120 100644 --- a/src/myfasthtml/controls/helpers.py +++ b/src/myfasthtml/controls/helpers.py @@ -36,16 +36,21 @@ class mk: return ft @staticmethod - def manage_binding(ft, binding: Binding, ft_attr=None): + def manage_binding(ft, binding: Binding, ft_attr=None, init_binding=True): if not binding: return ft binding.bind_ft(ft, ft_attr) - binding.init() + if init_binding: + binding.init() + # as it is the first binding, remove the hx-swap-oob + if "hx-swap-oob" in ft.attrs: + del ft.attrs["hx-swap-oob"] + return ft @staticmethod - def mk(ft, command: Command = None, binding: Binding = None): + def mk(ft, command: Command = None, binding: Binding = None, init_binding=True): ft = mk.manage_command(ft, command) - ft = mk.manage_binding(ft, binding) + ft = mk.manage_binding(ft, binding, init_binding=init_binding) return ft diff --git a/src/myfasthtml/core/bindings.py b/src/myfasthtml/core/bindings.py index 269f2c1..361cfbe 100644 --- a/src/myfasthtml/core/bindings.py +++ b/src/myfasthtml/core/bindings.py @@ -70,7 +70,14 @@ class ValueChangeFtUpdate(FtUpdate): # 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_to_use,) + if ft.tag == "select": + for child in [c for c in ft.children if c.tag == "option"]: + if child.attrs.get("value", None) == new_to_use: + child.attrs["selected"] = "true" + else: + child.attrs.pop("selected", None) + else: + ft.children = (new_to_use,) else: ft.attrs[ft_attr] = new_to_use return ft @@ -169,7 +176,7 @@ class Binding: if self._is_active: self.deactivate() - if ft.tag in ["input"]: + if ft.tag in ["input", "textarea", "select"]: # 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.") diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index 7b791a8..b48fe14 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -3,7 +3,7 @@ import logging from bs4 import Tag from fastcore.xml import FT from fasthtml.fastapp import fast_app -from starlette.routing import Mount, Route +from starlette.routing import Mount from myfasthtml.core.constants import Routes, ROUTE_ROOT from myfasthtml.test.MyFT import MyFT @@ -60,12 +60,15 @@ def merge_classes(*args): def debug_routes(app): + def _debug_routes(_app, _route, prefix=""): + if isinstance(_route, Mount): + for sub_route in _route.app.router.routes: + _debug_routes(_app, sub_route, prefix=_route.path) + else: + print(f"path={prefix}{_route.path}, methods={_route.methods}, endpoint={_route.endpoint}") + for route in app.router.routes: - if isinstance(route, Mount): - for sub_route in route.app.router.routes: - print(f"path={route.path}{sub_route.path}, method={sub_route.methods}, endpoint={sub_route.endpoint}") - elif isinstance(route, Route): - print(f"path={route.path}, methods={route.methods}, endpoint={route.endpoint}") + _debug_routes(app, route) def mount_utils(app): @@ -122,6 +125,21 @@ def is_radio(elt): return False +def quoted_str(s): + if s is None: + return "None" + + if isinstance(s, str): + if "'" in s and '"' in s: + return f'"{s.replace('"', '\\"')}"' + elif '"' in s: + return f"'{s}'" + else: + return f'"{s}"' + + return str(s) + + @utils_rt(Routes.Commands) def post(session, c_id: str): """ diff --git a/src/myfasthtml/docs/__init__.py b/src/myfasthtml/examples/__init__.py similarity index 100% rename from src/myfasthtml/docs/__init__.py rename to src/myfasthtml/examples/__init__.py diff --git a/src/myfasthtml/examples/binding_checkbox.py b/src/myfasthtml/examples/binding_checkbox.py new file mode 100644 index 0000000..608724c --- /dev/null +++ b/src/myfasthtml/examples/binding_checkbox.py @@ -0,0 +1,51 @@ +import logging +from dataclasses import dataclass + +from fasthtml import serve +from fasthtml.components import * + +from myfasthtml.controls.helpers import mk +from myfasthtml.core.bindings import Binding, BooleanConverter +from myfasthtml.core.utils import debug_routes +from myfasthtml.myfastapp import create_app + +logging.basicConfig( + level=logging.DEBUG, # Set logging level to DEBUG + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format + datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format +) + +app, rt = create_app(protect_routes=False) + + +@dataclass +class Data: + value: str = "Hello World" + checked: bool = False + + +data = Data() + + +@rt("/set_checkbox") +def post(check_box_name: str = None): + print(check_box_name) + + +@rt("/") +def index(): + return Div( + mk.mk(Input(name="checked_name", type="checkbox"), binding=Binding(data, attr="checked")), + mk.mk(Label("Text"), binding=Binding(data, attr="checked", converter=BooleanConverter())), + ) + + +@rt("/test_checkbox_htmx") +def get(): + check_box = Input(type="checkbox", name="check_box_name", hx_post="/set_checkbox") + return check_box + + +if __name__ == "__main__": + debug_routes(app) + serve(port=5002) diff --git a/src/myfasthtml/examples/binding_input.py b/src/myfasthtml/examples/binding_input.py new file mode 100644 index 0000000..50f6b87 --- /dev/null +++ b/src/myfasthtml/examples/binding_input.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from typing import Any + +from fasthtml import serve +from fasthtml.components import * + +from myfasthtml.controls.helpers import mk +from myfasthtml.core.bindings import Binding +from myfasthtml.core.utils import debug_routes +from myfasthtml.myfastapp import create_app + +app, rt = create_app(protect_routes=False) + + +@dataclass +class Data: + value: Any = "Hello World" + + +data = Data() + + +@rt("/") +def get(): + return Div( + mk.mk(Input(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")), + mk.mk(Label("Text"), binding=Binding(data, attr="value")) + ) + + +if __name__ == "__main__": + debug_routes(app) + serve(port=5002) diff --git a/src/myfasthtml/examples/binding_radio.py b/src/myfasthtml/examples/binding_radio.py new file mode 100644 index 0000000..0998280 --- /dev/null +++ b/src/myfasthtml/examples/binding_radio.py @@ -0,0 +1,47 @@ +import logging +from dataclasses import dataclass + +from fasthtml import serve +from fasthtml.components import * + +from myfasthtml.controls.helpers import mk +from myfasthtml.core.bindings import Binding +from myfasthtml.core.utils import debug_routes +from myfasthtml.myfastapp import create_app + +logging.basicConfig( + level=logging.DEBUG, # Set logging level to DEBUG + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format + datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format +) + +app, rt = create_app(protect_routes=False) + + +@dataclass +class Data: + value: str = "Hello World" + checked: bool = False + + +data = Data() + + +@rt("/") +def get(): + 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("hi hi hi !") + + 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 + + +if __name__ == "__main__": + debug_routes(app) + serve(port=5002) diff --git a/src/myfasthtml/examples/binding_select.py b/src/myfasthtml/examples/binding_select.py new file mode 100644 index 0000000..a150b63 --- /dev/null +++ b/src/myfasthtml/examples/binding_select.py @@ -0,0 +1,46 @@ +import logging +from dataclasses import dataclass +from typing import Any + +from fasthtml import serve +from fasthtml.components import * + +from myfasthtml.controls.helpers import mk +from myfasthtml.core.bindings import Binding +from myfasthtml.core.utils import debug_routes +from myfasthtml.myfastapp import create_app + +logging.basicConfig( + level=logging.DEBUG, # Set logging level to DEBUG + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format + datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format +) + +app, rt = create_app(protect_routes=False) + + +@dataclass +class Data: + value: Any = "Hello World" + + +data = Data() + + +@rt("/") +def get(): + 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), init_binding=False) + mk.manage_binding(label_elt, Binding(data)) + return select_elt, label_elt + + +if __name__ == "__main__": + debug_routes(app) + serve(port=5002) diff --git a/src/myfasthtml/examples/binding_textarea.py b/src/myfasthtml/examples/binding_textarea.py new file mode 100644 index 0000000..1aa8022 --- /dev/null +++ b/src/myfasthtml/examples/binding_textarea.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from typing import Any + +from fasthtml import serve +from fasthtml.components import * + +from myfasthtml.controls.helpers import mk +from myfasthtml.core.bindings import Binding +from myfasthtml.core.utils import debug_routes +from myfasthtml.myfastapp import create_app + +app, rt = create_app(protect_routes=False) + + +@dataclass +class Data: + value: Any = "Hello World" + + +data = Data() + + +@rt("/") +def get(): + return Div( + mk.mk(Textarea(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")), + mk.mk(Label("Text"), binding=Binding(data, attr="value")) + ) + + +if __name__ == "__main__": + debug_routes(app) + serve(port=5002) diff --git a/src/myfasthtml/docs/clickme.py b/src/myfasthtml/examples/clickme.py similarity index 95% rename from src/myfasthtml/docs/clickme.py rename to src/myfasthtml/examples/clickme.py index 1f7322d..5c4ca88 100644 --- a/src/myfasthtml/docs/clickme.py +++ b/src/myfasthtml/examples/clickme.py @@ -1,26 +1,26 @@ -from fasthtml import serve - -from myfasthtml.controls.helpers import mk -from myfasthtml.core.commands import Command -from myfasthtml.myfastapp import create_app - - -# Define a simple command action -def say_hello(): - return "Hello, FastHtml!" - - -# Create the command -hello_command = Command("say_hello", "Responds with a greeting", say_hello) - -# Create the app -app, rt = create_app(protect_routes=False) - - -@rt("/") -def get_homepage(): - return mk.button("Click Me!", command=hello_command) - - -if __name__ == "__main__": - serve(port=5002) +from fasthtml import serve + +from myfasthtml.controls.helpers import mk +from myfasthtml.core.commands import Command +from myfasthtml.myfastapp import create_app + + +# Define a simple command action +def say_hello(): + return "Hello, FastHtml!" + + +# Create the command +hello_command = Command("say_hello", "Responds with a greeting", say_hello) + +# Create the app +app, rt = create_app(protect_routes=False) + + +@rt("/") +def get_homepage(): + return mk.button("Click Me!", command=hello_command) + + +if __name__ == "__main__": + serve(port=5002) diff --git a/src/myfasthtml/docs/command_with_htmx_params.py b/src/myfasthtml/examples/command_with_htmx_params.py similarity index 95% rename from src/myfasthtml/docs/command_with_htmx_params.py rename to src/myfasthtml/examples/command_with_htmx_params.py index bf20174..3ab2747 100644 --- a/src/myfasthtml/docs/command_with_htmx_params.py +++ b/src/myfasthtml/examples/command_with_htmx_params.py @@ -1,25 +1,25 @@ -from fasthtml import serve -from fasthtml.components import * - -from myfasthtml.controls.helpers import mk -from myfasthtml.core.commands import Command -from myfasthtml.icons.fa import icon_home -from myfasthtml.myfastapp import create_app - -app, rt = create_app(protect_routes=False) - - -def change_text(): - return "New text" - - -command = Command("change_text", "change the text", change_text).htmx(target="#text") - - -@rt("/") -def index(): - return mk.button(Div(mk.icon(icon_home), Div("Hello World", id="text"), cls="flex"), command=command) - - -if __name__ == "__main__": - serve(port=5002) +from fasthtml import serve +from fasthtml.components import * + +from myfasthtml.controls.helpers import mk +from myfasthtml.core.commands import Command +from myfasthtml.icons.fa import icon_home +from myfasthtml.myfastapp import create_app + +app, rt = create_app(protect_routes=False) + + +def change_text(): + return "New text" + + +command = Command("change_text", "change the text", change_text).htmx(target="#text") + + +@rt("/") +def index(): + return mk.button(Div(mk.icon(icon_home), Div("Hello World", id="text"), cls="flex"), command=command) + + +if __name__ == "__main__": + serve(port=5002) diff --git a/src/myfasthtml/docs/helloworld.py b/src/myfasthtml/examples/helloworld.py similarity index 94% rename from src/myfasthtml/docs/helloworld.py rename to src/myfasthtml/examples/helloworld.py index fe63326..c65f0ff 100644 --- a/src/myfasthtml/docs/helloworld.py +++ b/src/myfasthtml/examples/helloworld.py @@ -1,15 +1,15 @@ -from fasthtml import serve -from fasthtml.components import * - -from myfasthtml.myfastapp import create_app - -app, rt = create_app(protect_routes=False) - - -@rt("/") -def get_homepage(): - return Div("Hello, FastHtml!") - - -if __name__ == "__main__": - serve(port=5002) +from fasthtml import serve +from fasthtml.components import * + +from myfasthtml.myfastapp import create_app + +app, rt = create_app(protect_routes=False) + + +@rt("/") +def get_homepage(): + return Div("Hello, FastHtml!") + + +if __name__ == "__main__": + serve(port=5002) diff --git a/src/myfasthtml/test/matcher.py b/src/myfasthtml/test/matcher.py index 9108c3a..1971dd8 100644 --- a/src/myfasthtml/test/matcher.py +++ b/src/myfasthtml/test/matcher.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from fastcore.basics import NotStr +from myfasthtml.core.utils import quoted_str from myfasthtml.test.testclient import MyFT @@ -36,15 +37,6 @@ class AttrPredicate(Predicate): 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) @@ -69,6 +61,27 @@ class DoesNotContain(AttrPredicate): return self.value not in actual +class AnyValue(AttrPredicate): + """ + True is the attribute is present and the value is not None. + """ + + def __init__(self): + super().__init__(None) + + def validate(self, actual): + return actual is not None + + +class ChildrenPredicate(Predicate): + """ + Predicate given as a child of an element. + """ + + def to_debug(self, element): + return element + + class Empty(ChildrenPredicate): def __init__(self): super().__init__(None) @@ -144,7 +157,7 @@ class ErrorOutput: element_index = 0 for expected_child in expected_children: if hasattr(expected_child, "tag"): - if element_index < len(expected_children): + if element_index < len(self.element.children): # display the child element_child = self.element.children[element_index] child_str = self._str_element(element_child, expected_child, keep_open=False) @@ -183,23 +196,27 @@ class ErrorOutput: if expected is None: expected = element - # 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()) - tag_str = f"({element.tag} {elt_attrs_str}" - - # manage the closing tag - if keep_open is False: - tag_str += " ...)" if len(element.children) > 0 else ")" - elif keep_open is True: - tag_str += "..." if elt_attrs_str == "" else " ..." - else: - # close the tag if there are no children - if len([c for c in element.children if not isinstance(c, Predicate)]) == 0: tag_str += ")" + if hasattr(element, "tag"): + # 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]} - return tag_str + 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 + if keep_open is False: + tag_str += " ...)" if len(element.children) > 0 else ")" + elif keep_open is True: + tag_str += "..." if elt_attrs_str == "" else " ..." + else: + # close the tag if there are no children + not_special_children = [c for c in element.children if not isinstance(c, Predicate)] + if len(not_special_children) == 0: tag_str += ")" + return tag_str + + else: + return quoted_str(element) def _detect_error(self, element, expected): if hasattr(expected, "tag") and hasattr(element, "tag"): diff --git a/src/myfasthtml/test/testclient.py b/src/myfasthtml/test/testclient.py index bd26f91..fdded5b 100644 --- a/src/myfasthtml/test/testclient.py +++ b/src/myfasthtml/test/testclient.py @@ -207,7 +207,7 @@ class TestableElement: # Check for explicit association via 'for' attribute label_for = label.get('for') if label_for: - input_field = self.element.find('input', id=label_for) + input_field = self.element.find(id=label_for) if input_field: input_name = self._get_input_identifier(input_field, unnamed_counter) if input_name.startswith('unnamed_'): @@ -348,6 +348,14 @@ class TestableElement: self.fields[name] = selected_value elif options: self.fields[name] = options[0]['value'] + + # Process textarea fields + for textarea_field in self.element.find_all('textarea'): + name = textarea_field.get('name') + if not name: + continue + + self.fields[name] = textarea_field.get_text(strip=True) @staticmethod def _get_input_identifier(input_field, counter): @@ -602,201 +610,12 @@ class TestableForm(TestableElement): headers=headers, data=self.fields ) - - # def _translate(self, field): - # """ - # Translate a given field using a predefined mapping. If the field is not found - # in the mapping, the original field is returned unmodified. - # - # :param field: The field name to be translated. - # :type field: str - # :return: The translated field name if present in the mapping, or the original - # field name if no mapping exists for it. - # :rtype: str - # """ - # return self.fields_mapping.get(field, field) - # - # def _update_fields_mapping(self): - # """ - # Build a mapping between label text and input field names. - # - # This method finds all labels in the form and associates them with their - # corresponding input fields using the following priority order: - # 1. Explicit association via 'for' attribute matching input 'id' - # 2. Implicit association (label contains the input) - # 3. Parent-level association with 'for'/'id' - # 4. Proximity association (siblings in same parent) - # 5. No label (use input name as key) - # - # The mapping is stored in self.fields_mapping as {label_text: input_name}. - # For inputs without a name, the id is used. If neither exists, a generic - # key like "unnamed_0" is generated. - # """ - # self.fields_mapping = {} - # processed_inputs = set() - # unnamed_counter = 0 - # - # # Get all inputs in the form - # all_inputs = self.form.find_all('input') - # - # # Priority 1 & 2: Explicit association (for/id) and implicit (nested) - # for label in self.form.find_all('label'): - # label_text = label.get_text(strip=True) - # - # # Check for explicit association via 'for' attribute - # label_for = label.get('for') - # if label_for: - # input_field = self.form.find('input', id=label_for) - # if input_field: - # input_name = self._get_input_identifier(input_field, unnamed_counter) - # if input_name.startswith('unnamed_'): - # unnamed_counter += 1 - # self.fields_mapping[label_text] = input_name - # processed_inputs.add(id(input_field)) - # continue - # - # # Check for implicit association (label contains input) - # input_field = label.find('input') - # if input_field: - # input_name = self._get_input_identifier(input_field, unnamed_counter) - # if input_name.startswith('unnamed_'): - # unnamed_counter += 1 - # self.fields_mapping[label_text] = input_name - # processed_inputs.add(id(input_field)) - # continue - # - # # Priority 3 & 4: Parent-level associations - # for label in self.form.find_all('label'): - # label_text = label.get_text(strip=True) - # - # # Skip if this label was already processed - # if label_text in self.fields_mapping: - # continue - # - # parent = label.parent - # if parent: - # input_found = False - # - # # Priority 3: Look for sibling input with matching for/id - # label_for = label.get('for') - # if label_for: - # for sibling in parent.find_all('input'): - # if sibling.get('id') == label_for and id(sibling) not in processed_inputs: - # input_name = self._get_input_identifier(sibling, unnamed_counter) - # if input_name.startswith('unnamed_'): - # unnamed_counter += 1 - # self.fields_mapping[label_text] = input_name - # processed_inputs.add(id(sibling)) - # input_found = True - # break - # - # # Priority 4: Fallback to proximity if no input found yet - # if not input_found: - # for sibling in parent.find_all('input'): - # if id(sibling) not in processed_inputs: - # input_name = self._get_input_identifier(sibling, unnamed_counter) - # if input_name.startswith('unnamed_'): - # unnamed_counter += 1 - # self.fields_mapping[label_text] = input_name - # processed_inputs.add(id(sibling)) - # break - # - # # Priority 5: Inputs without labels - # for input_field in all_inputs: - # if id(input_field) not in processed_inputs: - # input_name = self._get_input_identifier(input_field, unnamed_counter) - # if input_name.startswith('unnamed_'): - # unnamed_counter += 1 - # self.fields_mapping[input_name] = input_name - # - # @staticmethod - # def _get_input_identifier(input_field, counter): - # """ - # Get the identifier for an input field. - # - # Args: - # input_field: The BeautifulSoup Tag object representing the input. - # counter: Current counter for unnamed inputs. - # - # Returns: - # The input name, id, or a generated "unnamed_X" identifier. - # """ - # if input_field.get('name'): - # return input_field['name'] - # elif input_field.get('id'): - # return input_field['id'] - # else: - # return f"unnamed_{counter}" - # - # @staticmethod - # def _convert_number(value): - # """ - # Convert a string value to int or float. - # - # Args: - # value: String value to convert. - # - # Returns: - # int, float, or empty string if conversion fails. - # """ - # if not value or value.strip() == '': - # return '' - # - # try: - # # Try float first to detect decimal numbers - # if '.' in value or 'e' in value.lower(): - # return float(value) - # else: - # return int(value) - # except ValueError: - # return value - # - # @staticmethod - # def _convert_value(value): - # """ - # Analyze and convert a value to its appropriate type. - # - # Conversion priority: - # 1. Boolean keywords (true/false) - # 2. Float (contains decimal point) - # 3. Int (numeric) - # 4. Empty string - # 5. String (default) - # - # Args: - # value: String value to convert. - # - # Returns: - # Converted value with appropriate type (bool, float, int, or str). - # """ - # if not value or value.strip() == '': - # return '' - # - # value_lower = value.lower().strip() - # - # # Check for boolean - # if value_lower in ('true', 'false'): - # return value_lower == 'true' - # - # # Check for numeric values - # try: - # # Check for float (has decimal point or scientific notation) - # if '.' in value or 'e' in value_lower: - # return float(value) - # # Try int - # else: - # return int(value) - # except ValueError: - # pass - # - # # Default to string - # return value class TestableControl(TestableElement): def __init__(self, client, source, tag): - super().__init__(client, source, "input") - assert len(self.fields) <= 1 + super().__init__(client, source, tag) + assert len(self.fields) == 1 self._input_name = next(iter(self.fields)) @property diff --git a/tests/auth/test_utils.py b/tests/auth/test_utils.py index 9e4a7f8..1fd0f90 100644 --- a/tests/auth/test_utils.py +++ b/tests/auth/test_utils.py @@ -2,8 +2,10 @@ import pytest from fasthtml.fastapp import fast_app from myfasthtml.auth.utils import create_auth_beforeware +from myfasthtml.core.utils import quoted_str from myfasthtml.test.testclient import MyTestClient + def test_non_protected_route(): app, rt = fast_app() user = MyTestClient(app) @@ -31,3 +33,15 @@ def test_all_routes_are_protected(): user.open("/") user.should_see("Sign In") + + +@pytest.mark.parametrize("actual,expected", [ + ("string", '"string"'), + ("string with 'single quotes'", '''"string with 'single quotes'"'''), + ('string with "double quotes"', """'string with "double quotes"'"""), + ("""string with 'single' and "double" quotes""", '''"string with 'single' and \\"double\\" quotes"'''), + (None, "None"), + (123, "123"), +]) +def test_i_can_quote_str(actual, expected): + assert quoted_str(actual) == expected diff --git a/tests/controls/test_manage_binding.py b/tests/controls/test_manage_binding.py index 181429a..64341f8 100644 --- a/tests/controls/test_manage_binding.py +++ b/tests/controls/test_manage_binding.py @@ -24,7 +24,8 @@ from fasthtml.fastapp import fast_app from myfasthtml.controls.helpers import mk from myfasthtml.core.bindings import Binding, BooleanConverter -from myfasthtml.test.matcher import matches, AttributeForbidden +from myfasthtml.core.constants import Routes, ROUTE_ROOT +from myfasthtml.test.matcher import matches, AttributeForbidden, AnyValue from myfasthtml.test.testclient import MyTestClient @@ -67,7 +68,19 @@ def rt(user): class TestBindingTextarea: """Tests for binding Textarea components.""" - def test_i_can_bind_textarea(self, user, rt): + def test_i_can_bind_textarea(self): + data = Data("") + check_box = Textarea(name="textarea_name") + + binding = Binding(data) + mk.manage_binding(check_box, binding) + + # update the content + res = binding.update({"textarea_name": "Hello world !"}) + expected = [Textarea("Hello world !", name="textarea_name", hx_swap_oob="true")] + assert matches(res, expected) + + def test_i_can_bind_textarea_with_label(self, user, rt): """ Textarea should bind bidirectionally with data. Value changes should update the label. @@ -89,27 +102,6 @@ class TestBindingTextarea: 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. @@ -156,6 +148,80 @@ class TestBindingTextarea: class TestBindingSelect: """Tests for binding Select components (single selection).""" + def test_i_can_bind_select(self): + data = Data("") + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name" + ) + + binding = Binding(data) + updated = mk.manage_binding(select_elt, binding) + + expected = Select( + AttributeForbidden("hx_swap_oob"), + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name", + id=AnyValue(), + hx_post=f"{ROUTE_ROOT}{Routes.Bindings}", + ) + assert matches(updated, expected) + + def test_i_can_update_select(self): + data = Data("") + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name" + ) + + binding = Binding(data) + mk.manage_binding(select_elt, binding) + + res = binding.update({"select_name": "option2"}) + + expected = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2", selected="true"), + Option("Option 3", value="option3"), + name="select_name", + id=AnyValue(), + hx_post=f"{ROUTE_ROOT}{Routes.Bindings}", + hx_swap_oob="true" + ) + assert matches(res, [expected]) + + def test_i_can_change_selection(self): + data = Data("") + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name" + ) + + binding = Binding(data) + mk.manage_binding(select_elt, binding) + + binding.update({"select_name": "option2"}) + res = binding.update({"select_name": "option1"}) + + expected = Select( + Option("Option 1", value="option1", selected="true"), + Option(AttributeForbidden("selected"), "Option 2", value="option2"), + Option(AttributeForbidden("selected"), "Option 3", value="option3"), + name="select_name", + id=AnyValue(), + hx_post=f"{ROUTE_ROOT}{Routes.Bindings}", + hx_swap_oob="true" + ) + assert matches(res, [expected]) + def test_i_can_bind_select_single(self, user, rt): """ Single select should bind with data. diff --git a/tests/testclient/test_matches.py b/tests/testclient/test_matches.py index fc41ce4..0bd63a7 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, AttributeForbidden + ErrorComparisonOutput, AttributeForbidden, AnyValue from myfasthtml.test.testclient import MyFT @@ -17,6 +17,7 @@ from myfasthtml.test.testclient import MyFT (Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))), (Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))), (Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))), + (Div(attr1="value"), Div(attr1=AnyValue())), (None, DoNotCheck()), (123, DoNotCheck()), (Div(), DoNotCheck()), @@ -49,6 +50,8 @@ def test_i_can_match(actual, expected): (Div(attr1="value1"), Div(attr1=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"), (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)'"), + (Div(attr1=None), Div(attr1=AnyValue()), "'attr1' is not found in Actual"), + (Div(), Div(attr1=AnyValue()), "'attr1' is not found in Actual"), (NotStr("456"), NotStr("123"), "Notstr values are different"), (Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"), (Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"), @@ -176,6 +179,7 @@ def test_i_can_output_error_when_predicate(): def test_i_can_output_error_when_predicate_wrong_value(): + """I can display error when the condition predicate is not satisfied.""" elt = "before after" expected = Contains("value") path = "" @@ -186,6 +190,7 @@ def test_i_can_output_error_when_predicate_wrong_value(): def test_i_can_output_error_child_element(): + """I can display error when the element has children""" elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1") expected = elt path = "" @@ -198,6 +203,19 @@ def test_i_can_output_error_child_element(): ')', ] +def test_i_can_output_error_child_element_text(): + """I can display error when the children is not a FT""" + elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1") + expected = elt + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value1"', + ' "Hello world"', + ' (div "id"="child_1")', + ' (div "id"="child_2")', + ')', + ] def test_i_can_output_error_child_element_indicating_sub_children(): elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1") diff --git a/tests/testclient/test_mytestclient.py b/tests/testclient/test_mytestclient.py index 978493f..e1503d9 100644 --- a/tests/testclient/test_mytestclient.py +++ b/tests/testclient/test_mytestclient.py @@ -481,4 +481,3 @@ class TestMyTestClientFindForm: error_message = str(exc_info.value) assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message - diff --git a/tests/testclient/test_teastable_radio.py b/tests/testclient/test_teastable_radio.py index 3c13336..6bb0b55 100644 --- a/tests/testclient/test_teastable_radio.py +++ b/tests/testclient/test_teastable_radio.py @@ -1,18 +1,3 @@ -""" -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 diff --git a/tests/testclient/test_testable_input.py b/tests/testclient/test_testable_input.py index 50c1722..081d217 100644 --- a/tests/testclient/test_testable_input.py +++ b/tests/testclient/test_testable_input.py @@ -54,5 +54,5 @@ def test_i_can_send_values(test_client, rt): def i_can_find_input_by_name(test_client): html = '''''' - test_client.find_input("username") + element = test_client.find_input("Username") assert False \ No newline at end of file diff --git a/tests/testclient/test_testable_select.py b/tests/testclient/test_testable_select.py index 545beeb..f7507b8 100644 --- a/tests/testclient/test_testable_select.py +++ b/tests/testclient/test_testable_select.py @@ -1,191 +1,63 @@ -""" -Comprehensive binding tests for all bindable FastHTML components. +import pytest +from fasthtml.fastapp import fast_app -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 - -from fasthtml.components import ( - Label, Select, Option -) - -from myfasthtml.controls.helpers import mk -from myfasthtml.core.bindings import Binding +from myfasthtml.test.testclient import TestableSelect, MyTestClient -@dataclass -class Data: - value: str = "hello world" +@pytest.fixture +def test_app(): + test_app, rt = fast_app(default_hdrs=False) + return test_app -@dataclass -class NumericData: - value: int = 50 +@pytest.fixture +def rt(test_app): + return test_app.route -@dataclass -class BoolData: - value: bool = True +@pytest.fixture +def test_client(test_app): + return MyTestClient(test_app) -@dataclass -class ListData: - value: list = None - - def __post_init__(self): - if self.value is None: - self.value = [] +def test_i_can_read_select(test_client): + html = ''' + ''' + select_elt = TestableSelect(test_client, html) + assert select_elt.name == "select_name" + assert select_elt.value == "option1" # if no selected found, the first option is selected by default + assert select_elt.options == [{'text': 'Option 1', 'value': 'option1'}, + {'text': 'Option 2', 'value': 'option2'}, + {'text': 'Option 3', 'value': 'option3'}] + assert select_elt.select_fields == {'select_name': [{'text': 'Option 1', 'value': 'option1'}, + {'text': 'Option 2', 'value': 'option2'}, + {'text': 'Option 3', 'value': 'option3'}]} + assert select_elt.is_multiple is False -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") +def test_i_can_select_option(test_client): + html = ''' + ''' + select_elt = TestableSelect(test_client, html) + select_elt.select("option2") + assert select_elt.value == "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']") +def test_i_can_select_by_text(test_client): + html = ''' + ''' + select_elt = TestableSelect(test_client, html) + select_elt.select_by_text("Option 3") + assert select_elt.value == "option3" diff --git a/tests/testclient/test_testable_textarea.py b/tests/testclient/test_testable_textarea.py index 6680065..c7860f5 100644 --- a/tests/testclient/test_testable_textarea.py +++ b/tests/testclient/test_testable_textarea.py @@ -1,136 +1,46 @@ -""" -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 - -from fasthtml.components import ( - Label, Textarea -) - -from myfasthtml.controls.helpers import mk -from myfasthtml.core.bindings import Binding - - -@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 = [] - - -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") +from dataclasses import dataclass + +import pytest +from fasthtml.fastapp import fast_app + +from myfasthtml.test.testclient import MyTestClient, TestableTextarea + + +@dataclass +class Data: + value: str = "hello world" + + +@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 test_client(test_app): + return MyTestClient(test_app) + + +def test_i_can_read_input(test_client): + html = '''''' + + input_elt = TestableTextarea(test_client, html) + + assert input_elt.name == "textarea_name" + assert input_elt.value == "Lorem ipsum" + + +@pytest.mark.skip("To update later") +def test_i_can_read_input_with_label(test_client): + html = '''''' + + input_elt = TestableTextarea(test_client, html) + assert input_elt.fields_mapping == {"Text Area": "textarea_name"} + assert input_elt.name == "textarea_name" + assert input_elt.value == "Lorem ipsum" \ No newline at end of file