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 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

View File

@@ -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

View File

@@ -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:

View File

@@ -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,7 +197,7 @@ 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
@@ -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:

View File

@@ -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.

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()
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")

View File

@@ -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")

View File

@@ -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:

View File

@@ -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 = '''<input type="radio" name="radio_name" value="option1" />'''
def test_i_can_bind_radio_buttons(self, user, rt):
"""
Radio buttons should bind with data.
Selecting a radio should update the label.
"""
input_elt = TestableRadio(test_client, html)
@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()
assert input_elt.name == "radio_name"
assert input_elt.value is None
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
def test_i_can_read_selected_radio(test_client):
html = '''<input type="radio" name="radio_name" value="option1" checked="true"/>'''
user.open("/")
user.should_see("option1")
input_elt = TestableRadio(test_client, html)
# Select second radio
testable_radio2 = user.find_element("input[value='option2']")
testable_radio2.select()
user.should_see("option2")
assert input_elt.name == "radio_name"
assert input_elt.value == "option1"
# 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.
"""
def test_i_cannot_read_radio_with_multiple_values(test_client):
html = '''
<input type="radio" name="radio_name" value="option1" checked="true" />
<input type="radio" name="radio_name" value="option2" />
'''
@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()
with pytest.raises(AssertionError) as exc_info:
TestableRadio(test_client, html)
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))
assert "Only one radio button per name is supported" in str(exc_info.value)
return radio1, radio2, radio3, label_elt
user.open("/")
user.should_see("option2")
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
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."""

View File

@@ -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 = '''<label for="uid">Username</label><input id="uid" name="username" value="john_doe" />'''
test_client.find_input("username")
assert False