I can bind radio

This commit is contained in:
2025-11-07 21:28:19 +01:00
parent cc11e4edaa
commit e8ecf72205
12 changed files with 965 additions and 156 deletions

View File

@@ -1,8 +1,8 @@
from fasthtml.components import * 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.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: class mk:
@@ -36,29 +36,12 @@ class mk:
return ft return ft
@staticmethod @staticmethod
def manage_binding(ft, binding: Binding): def manage_binding(ft, binding: Binding, ft_attr=None):
if not binding: if not binding:
return ft return ft
if ft.tag in ["input"]: binding.bind_ft(ft, ft_attr)
# update the component to post on the correct route input and forms only binding.init()
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
return ft return ft
@staticmethod @staticmethod

View File

@@ -7,7 +7,7 @@ from fasthtml.fastapp import fast_app
from myutils.observable import make_observable, bind, collect_return_values, unbind from myutils.observable import make_observable, bind, collect_return_values, unbind
from myfasthtml.core.constants import Routes, ROUTE_ROOT 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() bindings_app, bindings_rt = fast_app()
logger = logging.getLogger("Bindings") logger = logging.getLogger("Bindings")
@@ -61,27 +61,29 @@ class AttrPresentDetection(AttrChangedDetection):
class FtUpdate: class FtUpdate:
def update(self, ft, ft_name, ft_attr, old, new): def update(self, ft, ft_name, ft_attr, old, new, converter):
pass pass
class ValueChangeFtUpdate(FtUpdate): 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 # simple mode, just update the text or the attribute
new_to_use = converter.convert(new) if converter else new
if ft_attr is None: if ft_attr is None:
ft.children = (new,) ft.children = (new_to_use,)
else: else:
ft.attrs[ft_attr] = new ft.attrs[ft_attr] = new_to_use
return ft return ft
class AttributePresenceFtUpdate(FtUpdate): 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) # 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: if ft_attr is None:
ft.children = (bool(new),) ft.children = (bool(new_to_use),)
else: 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 return ft
@@ -104,6 +106,14 @@ class BooleanConverter(DataConverter):
return False 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: class Binding:
def __init__(self, data: Any, attr: str = None): def __init__(self, data: Any, attr: str = None):
""" """
@@ -136,8 +146,8 @@ class Binding:
def bind_ft(self, def bind_ft(self,
ft, ft,
name,
attr=None, attr=None,
name=None,
data_converter: DataConverter = None, data_converter: DataConverter = None,
detection_mode: DetectionMode = None, detection_mode: DetectionMode = None,
update_mode: UpdateMode = None): update_mode: UpdateMode = None):
@@ -159,18 +169,37 @@ class Binding:
if self._is_active: if self._is_active:
self.deactivate() 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 # Configure UI elements
self.ft = self._safe_ft(ft) self.ft = self._safe_ft(ft)
self.ft_name = name self.ft_name = name or ft.attrs.get("name")
self.ft_attr = attr 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 # Update optional parameters if provided
if data_converter is not None: self.data_converter = data_converter or default_data_converter
self.data_converter = data_converter self.detection_mode = detection_mode or default_detection_mode
if detection_mode is not None: self.update_mode = update_mode or default_update_mode
self.detection_mode = detection_mode
if update_mode is not None:
self.update_mode = update_mode
# Create strategy objects # Create strategy objects
self._detection = self._factory(self.detection_mode) self._detection = self._factory(self.detection_mode)
@@ -187,6 +216,16 @@ class Binding:
"hx-vals": f'{{"b_id": "{self.id}"}}', "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): def notify(self, old, new):
""" """
Callback when the data attribute changes. Callback when the data attribute changes.
@@ -204,16 +243,21 @@ class Binding:
return None return None
logger.debug(f"Binding '{self.id}': Changing from '{old}' to '{new}'") 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" self.ft.attrs["hx-swap-oob"] = "true"
return self.ft return self.ft
def update(self, values: dict): 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=}.") logger.debug(f"Binding '{self.id}': Updating with {values=}.")
matches, value = self._detection.matches(values) matches, value = self._detection.matches(values)
if matches: 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) res = collect_return_values(self.data)
return res return res

View File

@@ -113,8 +113,17 @@ def is_checkbox(elt):
return False 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) @utils_rt(Routes.Commands)
def post(session: str, c_id: str): def post(session, c_id: str):
""" """
Default routes for all commands. Default routes for all commands.
:param session: :param session:
@@ -131,7 +140,7 @@ def post(session: str, c_id: str):
@utils_rt(Routes.Bindings) @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. Default routes for all bindings.
:param session: :param session:

View File

@@ -14,10 +14,13 @@ class Predicate:
raise NotImplementedError raise NotImplementedError
def __str__(self): 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): def __eq__(self, other):
if not isinstance(other, Predicate): if type(self) is not type(other):
return False return False
return self.value == other.value return self.value == other.value
@@ -25,7 +28,24 @@ class Predicate:
return hash(self.value) 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): def __init__(self, value):
super().__init__(value) super().__init__(value)
@@ -33,7 +53,7 @@ class StartsWith(Predicate):
return actual.startswith(self.value) return actual.startswith(self.value)
class Contains(Predicate): class Contains(AttrPredicate):
def __init__(self, value): def __init__(self, value):
super().__init__(value) super().__init__(value)
@@ -41,7 +61,7 @@ class Contains(Predicate):
return self.value in actual return self.value in actual
class DoesNotContain(Predicate): class DoesNotContain(AttrPredicate):
def __init__(self, value): def __init__(self, value):
super().__init__(value) super().__init__(value)
@@ -49,16 +69,35 @@ class DoesNotContain(Predicate):
return self.value not in actual 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 @dataclass
class DoNotCheck: class DoNotCheck:
desc: str = None desc: str = None
@dataclass
class Empty:
desc: str = None
class ErrorOutput: class ErrorOutput:
def __init__(self, path, element, expected): def __init__(self, path, element, expected):
self.path = path self.path = path
@@ -99,12 +138,13 @@ class ErrorOutput:
self._add_to_output(error_str) self._add_to_output(error_str)
# render the children # 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 += " " self.indent += " "
element_index = 0 element_index = 0
for expected_child in self.expected.children: for expected_child in expected_children:
if hasattr(expected_child, "tag"): if hasattr(expected_child, "tag"):
if element_index < len(self.element.children): if element_index < len(expected_children):
# display the child # display the child
element_child = self.element.children[element_index] element_child = self.element.children[element_index]
child_str = self._str_element(element_child, expected_child, keep_open=False) child_str = self._str_element(element_child, expected_child, keep_open=False)
@@ -122,7 +162,8 @@ class ErrorOutput:
self._add_to_output(child_str) self._add_to_output(child_str)
else: 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.indent = self.indent[:-2]
self._add_to_output(")") self._add_to_output(")")
@@ -145,9 +186,8 @@ class ErrorOutput:
# the attributes are compared to the expected element # the attributes are compared to the expected element
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in 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]} [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}" tag_str = f"({element.tag} {elt_attrs_str}"
# manage the closing tag # manage the closing tag
@@ -157,8 +197,8 @@ class ErrorOutput:
tag_str += "..." if elt_attrs_str == "" else " ..." tag_str += "..." if elt_attrs_str == "" else " ..."
else: else:
# close the tag if there are no children # 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 return tag_str
def _detect_error(self, element, expected): def _detect_error(self, element, expected):
@@ -307,16 +347,18 @@ def matches(actual, expected, path=""):
_actual=actual.tag, _actual=actual.tag,
_expected=expected.tag) _expected=expected.tag)
# special case when the expected element is empty # special conditions
if len(expected.children) > 0 and isinstance(expected.children[0], Empty): for predicate in [c for c in expected.children if isinstance(c, ChildrenPredicate)]:
assert len(actual.children) == 0, _error_msg("Actual is not empty:", _actual=actual) assert predicate.validate(actual), \
assert len(actual.attrs) == 0, _error_msg("Actual is not empty:", _actual=actual) _error_msg(f"The condition '{predicate}' is not satisfied.",
return True _actual=actual,
_expected=predicate.to_debug(expected))
# compare the attributes # compare the attributes
for expected_attr, expected_value in expected.attrs.items(): 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.", 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): if isinstance(expected_value, Predicate):
assert expected_value.validate(actual.attrs[expected_attr]), \ assert expected_value.validate(actual.attrs[expected_attr]), \
@@ -327,14 +369,15 @@ def matches(actual, expected, path=""):
else: else:
assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \ assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \
_error_msg(f"The values are different for '{expected_attr}': ", _error_msg(f"The values are different for '{expected_attr}': ",
_actual=actual.attrs[expected_attr], _actual=actual,
_expected=expected.attrs[expected_attr]) _expected=expected)
# compare the children # 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) _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) assert matches(actual_child, expected_child, path=path)
else: else:

View File

@@ -300,7 +300,7 @@ class TestableElement:
self.fields[name] = raw_value self.fields[name] = raw_value
elif name not in self.fields: elif name not in self.fields:
# If no radio is checked yet, don't set a default # If no radio is checked yet, don't set a default
pass self.fields[name] = None
elif input_type == 'number': elif input_type == 'number':
# Number: int or float based on value # Number: int or float based on value
@@ -1104,6 +1104,9 @@ class TestableRadio(TestableControl):
source: The source HTML or BeautifulSoup Tag. source: The source HTML or BeautifulSoup Tag.
""" """
super().__init__(client, source, "input") 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', '') self._radio_value = self.my_ft.attrs.get('value', '')
@property @property
@@ -1573,6 +1576,9 @@ class MyTestClient:
f"Found {len(remaining)} forms (with the specified fields). Expected exactly 1." 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: def get_content(self) -> str:
""" """
Get the raw HTML content of the last opened page. Get the raw HTML content of the last opened page.

View File

@@ -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: <>&\"'")

View File

@@ -280,20 +280,6 @@ def test_i_cannot_activate_without_configuration(data):
binding.activate() 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): def test_activation_validates_strategies(data):
""" """
Activation should fail if detection/update strategies are not initialized. Activation should fail if detection/update strategies are not initialized.
@@ -387,3 +373,11 @@ def test_multiple_bindings_can_coexist(data):
data.value = "final" data.value = "final"
assert elt1.children[0] == "updated" # Not changed assert elt1.children[0] == "updated" # Not changed
assert elt2.attrs["value"] == "final" # 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")

View File

@@ -94,8 +94,8 @@ class TestingBindings:
testable_input = user.find_element("input") testable_input = user.find_element("input")
testable_input.check() testable_input.check()
user.should_see("True") user.should_see("true")
testable_input.uncheck() testable_input.uncheck()
user.should_see("False") user.should_see("false")

View File

@@ -3,7 +3,7 @@ from fastcore.basics import NotStr
from fasthtml.components import * from fasthtml.components import *
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \ from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput ErrorComparisonOutput, AttributeForbidden
from myfasthtml.test.testclient import MyFT from myfasthtml.test.testclient import MyFT
@@ -23,6 +23,7 @@ from myfasthtml.test.testclient import MyFT
([Div(), Span()], DoNotCheck()), ([Div(), Span()], DoNotCheck()),
(NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked (NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked
(Div(), Div(Empty())), (Div(), Div(Empty())),
(Div(attr1="value1"), Div(AttributeForbidden("attr2"))),
(Div(123), Div(123)), (Div(123), Div(123)),
(Div(Span(123)), Div(Span(123))), (Div(Span(123)), Div(Span(123))),
(Div(Span(123)), Div(DoNotCheck())), (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"), 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="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"),
(NotStr("456"), NotStr("123"), "Notstr values are different"), (NotStr("456"), NotStr("123"), "Notstr values are different"),
(Div(attr="value"), Div(Empty()), "Actual is not empty"), (Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(120), Div(Empty()), "Actual is not empty"), (Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(Span()), Div(Empty()), "Actual is not empty"), (Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(), Div(Span()), "Actual is lesser than expected"), (Div(), Div(Span()), "Actual is lesser than expected"),
(Div(), Div(123), "Actual is lesser than expected"), (Div(), Div(123), "Actual is lesser than expected"),
(Div(Span()), Div(Div()), "The elements are different"), (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(123), Div(456), "The values are different"),
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different"), (Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
(Div(Span(Div())), Div(Span(Span())), "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): def test_i_can_detect_errors(actual, expected, error_message):
with pytest.raises(AssertionError) as exc_info: with pytest.raises(AssertionError) as exc_info:

View File

@@ -16,14 +16,9 @@ This test suite covers:
from dataclasses import dataclass from dataclasses import dataclass
import pytest import pytest
from fasthtml.components import (
Input, Label
)
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk from myfasthtml.test.testclient import MyTestClient, TestableRadio
from myfasthtml.core.bindings import Binding
from myfasthtml.test.testclient import MyTestClient
@dataclass @dataclass
@@ -43,66 +38,67 @@ def rt(test_app):
@pytest.fixture @pytest.fixture
def user(test_app): def test_client(test_app):
return MyTestClient(test_app) return MyTestClient(test_app)
class TestBindingRadio: def test_i_can_read_not_selected_radio(test_client):
"""Tests for binding Radio button components.""" html = '''<input type="radio" name="radio_name" value="option1" />'''
def test_i_can_bind_radio_buttons(self, user, rt): input_elt = TestableRadio(test_client, html)
"""
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")
def test_radio_initial_state(self, user, rt): assert input_elt.name == "radio_name"
""" assert input_elt.value is None
Radio buttons should initialize with correct checked state.
"""
def test_i_can_read_selected_radio(test_client):
@rt("/") html = '''<input type="radio" name="radio_name" value="option1" checked="true"/>'''
def index():
data = Data("option2") input_elt = TestableRadio(test_client, html)
radio1 = Input(type="radio", name="radio_name", value="option1")
radio2 = Input(type="radio", name="radio_name", value="option2", checked=True) assert input_elt.name == "radio_name"
radio3 = Input(type="radio", name="radio_name", value="option3") assert input_elt.value == "option1"
label_elt = Label()
mk.manage_binding(radio1, Binding(data)) def test_i_cannot_read_radio_with_multiple_values(test_client):
mk.manage_binding(radio2, Binding(data)) html = '''
mk.manage_binding(radio3, Binding(data)) <input type="radio" name="radio_name" value="option1" checked="true" />
mk.manage_binding(label_elt, Binding(data)) <input type="radio" name="radio_name" value="option2" />
'''
return radio1, radio2, radio3, label_elt
with pytest.raises(AssertionError) as exc_info:
user.open("/") TestableRadio(test_client, html)
user.should_see("option2")
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 = '''
<input type="text" name="radio_name" value="option1" checked="true" /> '''
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 = '''<label for="uid">John Doe</label><input id="uid" type="radio" name="username" value="john_doe" />'''
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 = '''<input type="text" name="username" type="radio" value="john_doe" hx_post="/submit"/>'''
@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'"

View File

@@ -15,12 +15,15 @@ This test suite covers:
from dataclasses import dataclass from dataclasses import dataclass
import pytest
from fasthtml.components import ( from fasthtml.components import (
Input, Label, Textarea Input, Label, Textarea
) )
from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding from myfasthtml.core.bindings import Binding
from myfasthtml.test.testclient import MyTestClient
@dataclass @dataclass
@@ -47,6 +50,22 @@ class ListData:
self.value = [] 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: class TestBindingEdgeCases:
"""Tests for edge cases and special scenarios.""" """Tests for edge cases and special scenarios."""

View File

@@ -49,3 +49,10 @@ def test_i_can_send_values(test_client, rt):
input_elt.send("another name") input_elt.send("another name")
assert test_client.get_content() == "Input received username='another name'" assert test_client.get_content() == "Input received username='another name'"
def i_can_find_input_by_name(test_client):
html = '''<label for="uid">Username</label><input id="uid" name="username" value="john_doe" />'''
test_client.find_input("username")
assert False