10 Commits

Author SHA1 Message Date
4b86194c7e Fixed datalist example 2025-11-09 19:21:47 +01:00
408c8332dc I can attach a command with a binding 2025-11-09 19:15:53 +01:00
dc2f6fd04a Fixed ErrorOutput when special children 2025-11-09 11:23:20 +01:00
42e8566bcf I can bind datalist and range 2025-11-09 10:51:16 +01:00
255f145aca Other unit test fixes 2025-11-08 23:10:27 +01:00
fdc58942eb Fixed Checkbox unit tests 2025-11-08 22:47:04 +01:00
c9f6be105f I can bind multi-select 2025-11-08 22:42:05 +01:00
ad2823042c I can bind select 2025-11-08 19:58:47 +01:00
6a05a84f0c I can bind checkbox 2025-11-07 22:27:32 +01:00
e8ecf72205 I can bind radio 2025-11-07 21:28:19 +01:00
37 changed files with 2216 additions and 1056 deletions

View File

@@ -36,7 +36,7 @@ markdown-it-py==4.0.0
mdurl==0.1.2 mdurl==0.1.2
more-itertools==10.8.0 more-itertools==10.8.0
myauth==0.2.0 myauth==0.2.0
myutils==0.1.0 myutils==0.4.0
nh3==0.3.1 nh3==0.3.1
oauthlib==3.3.1 oauthlib==3.3.1
packaging==25.0 packaging==25.0

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:
@@ -30,39 +30,26 @@ class mk:
@staticmethod @staticmethod
def manage_command(ft, command: Command): def manage_command(ft, command: Command):
if command: if command:
htmx = command.get_htmx_params() ft = command.bind_ft(ft)
ft.attrs |= htmx
return ft return ft
@staticmethod @staticmethod
def manage_binding(ft, binding: Binding): def manage_binding(ft, binding: Binding, ft_attr=None, init_binding=True):
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 if init_binding:
htmx = binding.get_htmx_params() binding.init()
ft.attrs |= htmx # as it is the first binding, remove the hx-swap-oob
if "hx-swap-oob" in ft.attrs:
del ft.attrs["hx-swap-oob"]
# 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
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_command(ft, command)
ft = mk.manage_binding(ft, binding) ft = mk.manage_binding(ft, binding, init_binding=init_binding)
return ft return ft

View File

@@ -3,11 +3,12 @@ import uuid
from enum import Enum from enum import Enum
from typing import Optional, Any from typing import Optional, Any
from fasthtml.components import Option
from fasthtml.fastapp import fast_app 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, is_select, is_datalist
bindings_app, bindings_rt = fast_app() bindings_app, bindings_rt = fast_app()
logger = logging.getLogger("Bindings") logger = logging.getLogger("Bindings")
@@ -16,11 +17,14 @@ logger = logging.getLogger("Bindings")
class UpdateMode(Enum): class UpdateMode(Enum):
ValueChange = "ValueChange" ValueChange = "ValueChange"
AttributePresence = "AttributePresence" AttributePresence = "AttributePresence"
SelectValueChange = "SelectValueChange"
DatalistListChange = "DatalistListChange"
class DetectionMode(Enum): class DetectionMode(Enum):
ValueChange = "ValueChange" ValueChange = "ValueChange"
AttributePresence = "AttributePresence" AttributePresence = "AttributePresence"
SelectValueChange = "SelectValueChange"
class AttrChangedDetection: class AttrChangedDetection:
@@ -51,6 +55,19 @@ class ValueChangedDetection(AttrChangedDetection):
return False, None return False, None
class SelectValueChangedDetection(AttrChangedDetection):
"""
Search for the attribute that is modified.
"""
def matches(self, values):
for key, value in values.items():
if key == self.attr:
return True, value
return True, []
class AttrPresentDetection(AttrChangedDetection): class AttrPresentDetection(AttrChangedDetection):
""" """
Search if the attribute is present in the data object. Search if the attribute is present in the data object.
@@ -61,27 +78,49 @@ 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
class SelectValueChangeFtUpdate(FtUpdate):
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
new_to_use = [new_to_use] if not isinstance(new_to_use, list) else new_to_use
for child in [c for c in ft.children if c.tag == "option"]:
if child.attrs.get("value", None) in new_to_use:
child.attrs["selected"] = "true"
else:
child.attrs.pop("selected", None)
return ft
class DatalistListChangeFtUpdate(FtUpdate):
def update(self, ft, ft_name, ft_attr, old, new, converter):
new_to_use = converter.convert(new) if converter else new
ft.children = tuple([Option(value=v) for v in 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,8 +143,30 @@ class BooleanConverter(DataConverter):
return False return False
class ListConverter(DataConverter):
def convert(self, data):
if data is None:
return []
if isinstance(data, str):
return data.split("\n")
if isinstance(data, (list, set, tuple)):
return data
return [data]
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, converter: DataConverter = None):
""" """
Creates a new binding object between a data object and an HTML element. Creates a new binding object between a data object and an HTML element.
The binding is not active until bind_ft() is called. The binding is not active until bind_ft() is called.
@@ -118,12 +179,12 @@ class Binding:
self.htmx_extra = {} self.htmx_extra = {}
self.data = data self.data = data
self.data_attr = attr or get_default_attr(data) self.data_attr = attr or get_default_attr(data)
self.data_converter = converter
# UI-related attributes (configured later via bind_ft) # UI-related attributes (configured later via bind_ft)
self.ft = None self.ft = None
self.ft_name = None self.ft_name = None
self.ft_attr = None self.ft_attr = None
self.data_converter = None
self.detection_mode = DetectionMode.ValueChange self.detection_mode = DetectionMode.ValueChange
self.update_mode = UpdateMode.ValueChange self.update_mode = UpdateMode.ValueChange
@@ -136,8 +197,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 +220,45 @@ class Binding:
if self._is_active: if self._is_active:
self.deactivate() self.deactivate()
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.")
# 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 = self.data_converter or BooleanConverter()
default_detection_mode = DetectionMode.AttributePresence
default_update_mode = UpdateMode.AttributePresence
elif is_radio(ft):
default_data_converter = self.data_converter or RadioConverter(ft.attrs["value"])
default_detection_mode = DetectionMode.ValueChange
default_update_mode = UpdateMode.AttributePresence
elif is_select(ft):
default_data_converter = self.data_converter
default_detection_mode = DetectionMode.SelectValueChange
default_update_mode = UpdateMode.SelectValueChange
elif is_datalist(ft):
default_data_converter = self.data_converter or ListConverter()
default_detection_mode = DetectionMode.SelectValueChange
default_update_mode = UpdateMode.DatalistListChange
else:
default_data_converter = self.data_converter
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 +275,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 +302,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
@@ -289,12 +392,21 @@ class Binding:
elif mode == DetectionMode.AttributePresence: elif mode == DetectionMode.AttributePresence:
return AttrPresentDetection(self.ft_name) return AttrPresentDetection(self.ft_name)
elif mode == DetectionMode.SelectValueChange:
return SelectValueChangedDetection(self.ft_name)
elif mode == UpdateMode.ValueChange: elif mode == UpdateMode.ValueChange:
return ValueChangeFtUpdate() return ValueChangeFtUpdate()
elif mode == UpdateMode.AttributePresence: elif mode == UpdateMode.AttributePresence:
return AttributePresenceFtUpdate() return AttributePresenceFtUpdate()
elif mode == UpdateMode.SelectValueChange:
return SelectValueChangeFtUpdate()
elif mode == UpdateMode.DatalistListChange:
return DatalistListChangeFtUpdate()
else: else:
raise ValueError(f"Invalid detection mode: {mode}") raise ValueError(f"Invalid detection mode: {mode}")

View File

@@ -1,6 +1,8 @@
import uuid import uuid
from typing import Optional from typing import Optional
from myutils.observable import NotObservableError, ObservableEvent, add_event_listener, remove_event_listener
from myfasthtml.core.constants import Routes, ROUTE_ROOT from myfasthtml.core.constants import Routes, ROUTE_ROOT
@@ -25,13 +27,14 @@ class BaseCommand:
self.id = uuid.uuid4() self.id = uuid.uuid4()
self.name = name self.name = name
self.description = description self.description = description
self.htmx_extra = {} self._htmx_extra = {}
self._bindings = []
# register the command # register the command
CommandsManager.register(self) CommandsManager.register(self)
def get_htmx_params(self): def get_htmx_params(self):
return self.htmx_extra | { return self._htmx_extra | {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}", "hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-vals": f'{{"c_id": "{self.id}"}}', "hx-vals": f'{{"c_id": "{self.id}"}}',
} }
@@ -39,9 +42,46 @@ class BaseCommand:
def execute(self): def execute(self):
raise NotImplementedError raise NotImplementedError
def htmx(self, target=None): def htmx(self, target="this", swap="innerHTML"):
if target: if target is None:
self.htmx_extra["hx-target"] = target self._htmx_extra["hx-swap"] = "none"
elif target != "this":
self._htmx_extra["hx-target"] = target
if swap is None:
self._htmx_extra["hx-swap"] = "none"
elif swap != "innerHTML":
self._htmx_extra["hx-swap"] = swap
return self
def bind_ft(self, ft):
"""
Update the FT with the command's HTMX parameters.
:param ft:
:return:
"""
htmx = self.get_htmx_params()
ft.attrs |= htmx
return ft
def bind(self, data):
"""
Attach a binding to the command.
When done, if a binding is triggered during the execution of the command,
the results of the binding will be passed to the command's execute() method.
:param data:
:return:
"""
if not hasattr(data, '_listeners'):
raise NotObservableError(
f"Object must be made observable with make_observable() before binding"
)
self._bindings.append(data)
# by default, remove the swap on the attached element when binding is used
self._htmx_extra["hx-swap"] = "none"
return self return self
@@ -71,7 +111,26 @@ class Command(BaseCommand):
self.kwargs = kwargs self.kwargs = kwargs
def execute(self): def execute(self):
return self.callback(*self.args, **self.kwargs) ret_from_bindings = []
def binding_result_callback(attr, old, new, results):
ret_from_bindings.extend(results)
for data in self._bindings:
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
ret = self.callback(*self.args, **self.kwargs)
for data in self._bindings:
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
if not ret_from_bindings:
return ret
if isinstance(ret, list):
return ret + ret_from_bindings
else:
return [ret] + ret_from_bindings
def __str__(self): def __str__(self):
return f"Command({self.name})" return f"Command({self.name})"

View File

@@ -3,7 +3,7 @@ import logging
from bs4 import Tag from bs4 import Tag
from fastcore.xml import FT from fastcore.xml import FT
from fasthtml.fastapp import fast_app 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.core.constants import Routes, ROUTE_ROOT
from myfasthtml.test.MyFT import MyFT from myfasthtml.test.MyFT import MyFT
@@ -60,12 +60,15 @@ def merge_classes(*args):
def debug_routes(app): 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: for route in app.router.routes:
if isinstance(route, Mount): _debug_routes(app, route)
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}")
def mount_utils(app): def mount_utils(app):
@@ -113,8 +116,50 @@ 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
def is_select(elt):
if isinstance(elt, (FT, MyFT)):
return elt.tag == "select"
elif isinstance(elt, Tag):
return elt.name == "select"
else:
return False
def is_datalist(elt):
if isinstance(elt, (FT, MyFT)):
return elt.tag == "datalist"
elif isinstance(elt, Tag):
return elt.name == "datalist"
else:
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) @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 +176,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

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

View File

@@ -0,0 +1,66 @@
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.commands import Command
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"
def add_suggestion():
nb = len(data.value)
data.value = data.value + [f"suggestion{nb}"]
def remove_suggestion():
if len(data.value) > 0:
data.value = data.value[:-1]
data = Data(["suggestion0", "suggestion1", "suggestion2"])
@rt("/")
def get():
datalist = Datalist(
id="suggestions"
)
input_elt = Input(name="input_name", list="suggestions")
label_elt = Label()
mk.manage_binding(datalist, Binding(data))
mk.manage_binding(label_elt, Binding(data))
add_button = mk.button("Add", command=Command("Add", "Add a suggestion", add_suggestion).bind(data))
remove_button = mk.button("Remove", command=Command("Remove", "Remove a suggestion", remove_suggestion).bind(data))
return Div(
add_button,
remove_button,
input_elt,
datalist,
label_elt
)
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
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(50)
@rt("/")
def get():
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
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

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

View File

@@ -0,0 +1,47 @@
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",
multiple=True
)
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)

View File

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

View File

@@ -3,6 +3,7 @@ from dataclasses import dataclass
from fastcore.basics import NotStr from fastcore.basics import NotStr
from myfasthtml.core.utils import quoted_str
from myfasthtml.test.testclient import MyFT from myfasthtml.test.testclient import MyFT
@@ -14,10 +15,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 +29,15 @@ 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 StartsWith(AttrPredicate):
def __init__(self, value): def __init__(self, value):
super().__init__(value) super().__init__(value)
@@ -33,7 +45,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 +53,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 +61,56 @@ class DoesNotContain(Predicate):
return self.value not in actual 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)
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
@@ -77,7 +129,7 @@ class ErrorOutput:
return item, None, None return item, None, None
def __str__(self): def __str__(self):
self.compute() return f"ErrorOutput({self.output})"
def compute(self): def compute(self):
# first render the path hierarchy # first render the path hierarchy
@@ -99,30 +151,31 @@ 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 element_index >= len(self.element.children):
# When there are fewer children than expected, we display a placeholder
child_str = "! ** MISSING ** !"
self._add_to_output(child_str)
element_index += 1
continue
# display the child
element_child = self.element.children[element_index]
child_str = self._str_element(element_child, expected_child, keep_open=False)
self._add_to_output(child_str)
# manage errors (only when the expected is a FT element
if hasattr(expected_child, "tag"): if hasattr(expected_child, "tag"):
if element_index < len(self.element.children): child_error_str = self._detect_error(element_child, expected_child)
# display the child if child_error_str:
element_child = self.element.children[element_index] self._add_to_output(child_error_str)
child_str = self._str_element(element_child, expected_child, keep_open=False)
self._add_to_output(child_str)
# manage errors in children # continue
child_error_str = self._detect_error(element_child, expected_child) element_index += 1
if child_error_str:
self._add_to_output(child_error_str)
element_index += 1
else:
# When there are fewer children than expected, we display a placeholder
child_str = "! ** MISSING ** !"
self._add_to_output(child_str)
else:
self._add_to_output(expected_child)
self.indent = self.indent[:-2] self.indent = self.indent[:-2]
self._add_to_output(")") self._add_to_output(")")
@@ -142,24 +195,27 @@ class ErrorOutput:
if expected is None: if expected is None:
expected = element expected = element
# the attributes are compared to the expected element if hasattr(element, "tag"):
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in # the attributes are compared to the expected element
[attr_name for attr_name in expected.attrs if attr_name is not None]} elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items()) [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}" 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
# 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: else:
# close the tag if there are no children return quoted_str(element)
if len(element.children) == 0: tag_str += ")"
return tag_str
def _detect_error(self, element, expected): def _detect_error(self, element, expected):
if hasattr(expected, "tag") and hasattr(element, "tag"): if hasattr(expected, "tag") and hasattr(element, "tag"):
@@ -307,16 +363,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 +385,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

@@ -20,6 +20,13 @@ verbs = {
} }
class DoNotSendCls:
pass
DoNotSend = DoNotSendCls()
class TestableElement: class TestableElement:
""" """
Represents an HTML element that can be interacted with in tests. Represents an HTML element that can be interacted with in tests.
@@ -200,7 +207,7 @@ class TestableElement:
# Check for explicit association via 'for' attribute # Check for explicit association via 'for' attribute
label_for = label.get('for') label_for = label.get('for')
if label_for: if label_for:
input_field = self.element.find('input', id=label_for) input_field = self.element.find(id=label_for)
if input_field: if input_field:
input_name = self._get_input_identifier(input_field, unnamed_counter) input_name = self._get_input_identifier(input_field, unnamed_counter)
if input_name.startswith('unnamed_'): if input_name.startswith('unnamed_'):
@@ -300,7 +307,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
@@ -318,7 +325,7 @@ class TestableElement:
# Extract all options # Extract all options
options = [] options = []
selected_value = None selected_value = []
for option in select_field.find_all('option'): for option in select_field.find_all('option'):
option_value = option.get('value', option.get_text(strip=True)) option_value = option.get('value', option.get_text(strip=True))
@@ -331,16 +338,28 @@ class TestableElement:
# Track selected option # Track selected option
if option.has_attr('selected'): if option.has_attr('selected'):
selected_value = option_value selected_value.append(option_value)
# Store options list # Store options list
self.select_fields[name] = options self.select_fields[name] = options
# Store selected value (or first option if none selected) # Store selected value (or first option if none selected)
if selected_value is not None: is_multiple = select_field.has_attr('multiple')
if is_multiple:
self.fields[name] = selected_value self.fields[name] = selected_value
elif options: else:
self.fields[name] = options[0]['value'] if len(selected_value) > 0:
self.fields[name] = selected_value[-1]
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 @staticmethod
def _get_input_identifier(input_field, counter): def _get_input_identifier(input_field, counter):
@@ -596,200 +615,11 @@ class TestableForm(TestableElement):
data=self.fields 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): class TestableControl(TestableElement):
def __init__(self, client, source, tag): def __init__(self, client, source, tag):
super().__init__(client, source, "input") super().__init__(client, source, tag)
assert len(self.fields) <= 1 assert len(self.fields) == 1
self._input_name = next(iter(self.fields)) self._input_name = next(iter(self.fields))
@property @property
@@ -802,7 +632,8 @@ class TestableControl(TestableElement):
def _send_value(self): def _send_value(self):
if self._input_name and self._support_htmx(): if self._input_name and self._support_htmx():
return self._send_htmx_request(data={self._input_name: self.value}) value = {} if self.value is DoNotSend else {self._input_name: self.value}
return self._send_htmx_request(data=value)
return None return None
@@ -824,16 +655,18 @@ class TestableCheckbox(TestableControl):
return self.fields[self._input_name] == True return self.fields[self._input_name] == True
def check(self): def check(self):
self.fields[self._input_name] = True self.fields[self._input_name] = "on"
return self._send_value() return self._send_value()
def uncheck(self): def uncheck(self):
self.fields[self._input_name] = False self.fields[self._input_name] = DoNotSend
return self._send_value() return self._send_value()
def toggle(self): def toggle(self):
self.fields[self._input_name] = not self.fields[self._input_name] if self.fields[self._input_name] == "on":
return self._send_value() return self.uncheck()
else:
return self.check()
class TestableTextarea(TestableControl): class TestableTextarea(TestableControl):
@@ -954,7 +787,7 @@ class TestableSelect(TestableControl):
current = [current] if current else [] current = [current] if current else []
if value not in current: if value not in current:
current.append(value) current.append(value)
self.fields[self.name] = current self.fields[self.name] = current[0] if len(current) == 1 else current # it's not a list when only one is selected
else: else:
# For single select, just set the value # For single select, just set the value
self.fields[self.name] = value self.fields[self.name] = value
@@ -1006,7 +839,7 @@ class TestableSelect(TestableControl):
if value in current: if value in current:
current.remove(value) current.remove(value)
self.fields[self.name] = current self.fields[self.name] = current[0] if len(current) == 1 else current
return self._send_value() return self._send_value()
return None return None
@@ -1104,6 +937,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 +1409,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

@@ -2,8 +2,10 @@ import pytest
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
from myfasthtml.auth.utils import create_auth_beforeware from myfasthtml.auth.utils import create_auth_beforeware
from myfasthtml.core.utils import quoted_str
from myfasthtml.test.testclient import MyTestClient from myfasthtml.test.testclient import MyTestClient
def test_non_protected_route(): def test_non_protected_route():
app, rt = fast_app() app, rt = fast_app()
user = MyTestClient(app) user = MyTestClient(app)
@@ -31,3 +33,15 @@ def test_all_routes_are_protected():
user.open("/") user.open("/")
user.should_see("Sign In") 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

View File

@@ -0,0 +1,891 @@
"""
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 typing import Any
import pytest
from fasthtml.components import (
Input, Label, Textarea, Select, Option, Datalist
)
from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding, BooleanConverter
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.test.matcher import matches, AttributeForbidden, AnyValue
from myfasthtml.test.testclient import MyTestClient
@dataclass
class Data:
value: Any = "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):
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.
"""
@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_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(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.
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):
data = Data("")
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name",
multiple=True
)
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_one_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",
multiple=True
)
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_update_multiple_selections(self):
data = Data("")
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name",
multiple=True
)
binding = Binding(data)
mk.manage_binding(select_elt, binding)
res = binding.update({"select_name": ["option2", "option3"]})
expected = Select(
Option(AttributeForbidden("selected"), "Option 1", value="option1"),
Option("Option 2", value="option2", selected="true"),
Option("Option 3", value="option3", selected="true"),
name="select_name",
id=AnyValue(),
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
hx_swap_oob="true"
)
assert matches(res, [expected])
def test_i_can_update_unselect(self):
data = Data(["option1", "option2", "option3"])
select_elt = Select(
Option("Option 1", value="option1", selected="true"),
Option("Option 2", value="option2", selected="true"),
Option("Option 3", value="option3", selected="true"),
name="select_name",
multiple=True
)
binding = Binding(data)
mk.manage_binding(select_elt, binding)
res = binding.update({})
expected = Select(
Option(AttributeForbidden("selected"), "Option 1", value="option1"),
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_multiple_with_label(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):
data = Data(50)
range_elt = Input(
type="range",
name="range_name",
min="0",
max="100",
value="50"
)
binding = Binding(data)
updated = mk.manage_binding(range_elt, binding)
expected = Input(
AttributeForbidden("hx_swap_oob"),
type="range",
name="range_name",
min="0",
max="100",
value=50,
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
id=AnyValue(),
)
assert matches(updated, expected)
def test_i_can_update_range(self):
data = Data(50)
range_elt = Input(
type="range",
name="range_name",
min="0",
max="100",
value="50"
)
binding = Binding(data)
mk.manage_binding(range_elt, binding)
res = binding.update({"range_name": 25})
expected = [Input(
type="range",
name="range_name",
min="0",
max="100",
value=25,
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
id=AnyValue(),
hx_swap_oob="true"
)]
assert matches(res, expected)
def test_i_can_bind_range_with_label(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", hx_swap_oob="true"),
Input(AttributeForbidden("checked"), type="radio", name="radio_name", value="option2", hx_swap_oob="true"),
Input(AttributeForbidden("checked"), type="radio", name="radio_name", value="option3", hx_swap_oob="true"),
]
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 TestBindingDatalist:
"""Tests for binding Input with Datalist (combobox)."""
def test_i_can_bind_datalist(self):
data = Data(["suggestion2"])
datalist = Datalist(
Option(value="suggestion1"),
id="suggestions"
)
updated = mk.manage_binding(datalist, Binding(data))
expected = Datalist(
Option(value="suggestion2"),
id="suggestions"
)
assert matches(updated, expected)
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: <>&\"'")
class TestCheckBox:
def test_i_can_bind_checkbox(self):
data = Data("")
check_box = Input(name="checkbox_name", type="checkbox")
binding = Binding(data)
mk.manage_binding(check_box, binding)
# checkbox is selected
res = binding.update({"checkbox_name": "on"})
expected = [Input(name="checkbox_name", type="checkbox", checked="true", hx_swap_oob="true")]
assert matches(res, expected)
# check box is not selected
res = binding.update({})
expected = [Input(AttributeForbidden("checked"), name="checkbox_name", type="checkbox", hx_swap_oob="true")]
assert matches(res, expected)
def test_checkbox_initial_state_false(self):
data = Data(False)
check_box = Input(name="checkbox_name", type="checkbox")
binding = Binding(data)
updated = mk.manage_binding(check_box, binding)
expected = Input(AttributeForbidden("checked"), name="checkbox_name", type="checkbox")
assert matches(updated, expected)
def test_checkbox_initial_state_true(self):
data = Data(True)
check_box = Input(name="checkbox_name", type="checkbox")
binding = Binding(data)
updated = mk.manage_binding(check_box, binding)
expected = Input(name="checkbox_name", type="checkbox", checked="true")
assert matches(updated, expected)
def test_i_can_bind_checkbox_and_label_without_converter(self, user, rt):
@rt("/")
def index():
data = Data(True)
input_elt = Input(name="input_name", type="checkbox")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
user.should_see("True")
testable_input = user.find_element("input")
testable_input.check()
user.should_see("on")
testable_input.uncheck()
user.should_not_see("on")
def test_i_can_bind_checkbox_and_label_with_converter(self, user, rt):
@rt("/")
def index():
data = Data(True)
input_elt = Input(name="input_name", type="checkbox")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data, converter=BooleanConverter()))
return input_elt, label_elt
user.open("/")
user.should_see("True")
testable_input = user.find_element("input")
testable_input.check()
user.should_see("True")
testable_input.uncheck()
user.should_see("False")

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

@@ -1,6 +1,18 @@
from dataclasses import dataclass
from typing import Any
import pytest import pytest
from fasthtml.components import Button
from myutils.observable import make_observable, bind
from myfasthtml.core.commands import Command, CommandsManager from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.core.constants import ROUTE_ROOT, Routes
from myfasthtml.test.matcher import matches
@dataclass
class Data:
value: Any
def callback(): def callback():
@@ -23,3 +35,61 @@ def test_i_can_create_a_command_with_no_params():
def test_command_are_registered(): def test_command_are_registered():
command = Command('test', 'Command description', callback) command = Command('test', 'Command description', callback)
assert CommandsManager.commands.get(str(command.id)) is command assert CommandsManager.commands.get(str(command.id)) is command
def test_i_can_bind_a_command_to_an_element():
command = Command('test', 'Command description', callback)
elt = Button()
updated = command.bind_ft(elt)
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}")
assert matches(updated, expected)
def test_i_can_suppress_swapping_with_target_attr():
command = Command('test', 'Command description', callback).htmx(target=None)
elt = Button()
updated = command.bind_ft(elt)
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="none")
assert matches(updated, expected)
def test_i_can_bind_a_command_to_an_observable():
data = Data("hello")
def on_data_change(old, new):
return old, new
def another_callback():
data.value = "new value"
return "another callback result"
make_observable(data)
bind(data, "value", on_data_change)
command = Command('test', 'Command description', another_callback).bind(data)
res = command.execute()
assert res == ["another callback result", ("hello", "new value")]
def test_i_can_bind_a_command_to_an_observable_2():
data = Data("hello")
def on_data_change(old, new):
return old, new
def another_callback():
data.value = "new value"
return ["another 1", "another 2"]
make_observable(data)
bind(data, "value", on_data_change)
command = Command('test', 'Command description', another_callback).bind(data)
res = command.execute()
assert res == ["another 1", "another 2", ("hello", "new value")]

View File

@@ -6,7 +6,7 @@ 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.controls.helpers import mk
from myfasthtml.core.bindings import Binding, DetectionMode, UpdateMode, BooleanConverter from myfasthtml.core.bindings import Binding
from myfasthtml.core.commands import Command, CommandsManager from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.test.testclient import MyTestClient, TestableElement from myfasthtml.test.testclient import MyTestClient, TestableElement
@@ -78,24 +78,3 @@ class TestingBindings:
testable_input = user.find_element("input") testable_input = user.find_element("input")
testable_input.send("new value") testable_input.send("new value")
user.should_see("new value") # the one from the label user.should_see("new value") # the one from the label
def test_i_can_bind_checkbox(self, user, rt):
@rt("/")
def index():
data = Data(True)
input_elt = Input(name="input_name", type="checkbox")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
user.should_see("")
testable_input = user.find_element("input")
testable_input.check()
user.should_see("True")
testable_input.uncheck()
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, AnyValue
from myfasthtml.test.testclient import MyFT from myfasthtml.test.testclient import MyFT
@@ -17,12 +17,14 @@ from myfasthtml.test.testclient import MyFT
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))), (Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
(Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))), (Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))),
(Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))), (Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))),
(Div(attr1="value"), Div(attr1=AnyValue())),
(None, DoNotCheck()), (None, DoNotCheck()),
(123, DoNotCheck()), (123, DoNotCheck()),
(Div(), DoNotCheck()), (Div(), DoNotCheck()),
([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())),
@@ -48,10 +50,12 @@ 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=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"), 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)'"),
(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"), (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 +63,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:
@@ -174,6 +179,7 @@ def test_i_can_output_error_when_predicate():
def test_i_can_output_error_when_predicate_wrong_value(): def test_i_can_output_error_when_predicate_wrong_value():
"""I can display error when the condition predicate is not satisfied."""
elt = "before after" elt = "before after"
expected = Contains("value") expected = Contains("value")
path = "" path = ""
@@ -184,6 +190,7 @@ def test_i_can_output_error_when_predicate_wrong_value():
def test_i_can_output_error_child_element(): 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") elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
expected = elt expected = elt
path = "" path = ""
@@ -196,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(): 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") elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")

View File

@@ -481,4 +481,3 @@ class TestMyTestClientFindForm:
error_message = str(exc_info.value) error_message = str(exc_info.value)
assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message

View File

@@ -1,29 +1,9 @@
"""
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 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 +23,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("/") assert input_elt.name == "radio_name"
def index(): assert input_elt.value is None
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 def test_i_can_read_selected_radio(test_client):
html = '''<input type="radio" name="radio_name" value="option1" checked="true"/>'''
user.open("/") input_elt = TestableRadio(test_client, html)
user.should_see("option1")
# Select second radio assert input_elt.name == "radio_name"
testable_radio2 = user.find_element("input[value='option2']") assert input_elt.value == "option1"
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): def test_i_cannot_read_radio_with_multiple_values(test_client):
""" html = '''
Radio buttons should initialize with correct checked state. <input type="radio" name="radio_name" value="option1" checked="true" />
""" <input type="radio" name="radio_name" value="option2" />
'''
@rt("/") with pytest.raises(AssertionError) as exc_info:
def index(): TestableRadio(test_client, html)
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)) assert "Only one radio button per name is supported" in str(exc_info.value)
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("/") def test_i_cannot_read_radio_when_no_radio_button(test_client):
user.should_see("option2") 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

@@ -1,104 +0,0 @@
"""
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, Button
)
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 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

View File

@@ -44,7 +44,7 @@ def test_i_can_check_checkbox(test_client, rt):
html = '''<input type="checkbox" name="male" hx_post="/submit"/>''' html = '''<input type="checkbox" name="male" hx_post="/submit"/>'''
@rt('/submit') @rt('/submit')
def post(male: bool): def post(male: bool=None):
return f"Checkbox received {male=}" return f"Checkbox received {male=}"
input_elt = TestableCheckbox(test_client, html) input_elt = TestableCheckbox(test_client, html)
@@ -53,7 +53,7 @@ def test_i_can_check_checkbox(test_client, rt):
assert test_client.get_content() == "Checkbox received male=True" assert test_client.get_content() == "Checkbox received male=True"
input_elt.uncheck() input_elt.uncheck()
assert test_client.get_content() == "Checkbox received male=False" assert test_client.get_content() == "Checkbox received male=None"
input_elt.toggle() input_elt.toggle()
assert test_client.get_content() == "Checkbox received male=True" assert test_client.get_content() == "Checkbox received male=True"

View File

@@ -1,124 +0,0 @@
"""
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 (
Input, Label, Option, Datalist
)
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 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

View File

@@ -268,8 +268,7 @@ class TestableFormUpdateFieldValues:
''' '''
form = TestableForm(mock_client, html) form = TestableForm(mock_client, html)
assert "size" not in form.fields, \ assert form.fields == {"size": None}, f"Expected 'size' not in fields, got {form.fields}"
f"Expected 'size' not in fields, got {form.fields}"
def test_i_can_handle_number_input_with_integer(self, mock_client): def test_i_can_handle_number_input_with_integer(self, mock_client):
""" """

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" />'''
element = test_client.find_input("Username")
assert False

View File

@@ -0,0 +1,72 @@
from dataclasses import dataclass
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.test.testclient import MyTestClient, TestableRange
@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_range(test_client):
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
input_elt = TestableRange(test_client, html)
assert input_elt.name == "range_name"
assert input_elt.value == 50
assert input_elt.min_value == 0
assert input_elt.max_value == 100
assert input_elt.step == 10
@pytest.mark.parametrize("value, expected", [
(30, 30),
(24, 20), # step 10
(-10, 0), # min 0
(110, 100), # max 100
])
def test_i_can_set_value(test_client, value, expected):
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
input_elt = TestableRange(test_client, html)
input_elt.set(value)
assert input_elt.value == expected
def test_i_can_increase_value(test_client):
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
input_elt = TestableRange(test_client, html)
input_elt.increase()
assert input_elt.value == 60
def test_i_can_decrease_value(test_client):
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
input_elt = TestableRange(test_client, html)
input_elt.decrease()
assert input_elt.value == 40

View File

@@ -1,191 +1,63 @@
""" import pytest
Comprehensive binding tests for all bindable FastHTML components. from fasthtml.fastapp import fast_app
This test suite covers: from myfasthtml.test.testclient import TestableSelect, MyTestClient
- 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
@dataclass @pytest.fixture
class Data: def test_app():
value: str = "hello world" test_app, rt = fast_app(default_hdrs=False)
return test_app
@dataclass @pytest.fixture
class NumericData: def rt(test_app):
value: int = 50 return test_app.route
@dataclass @pytest.fixture
class BoolData: def test_client(test_app):
value: bool = True return MyTestClient(test_app)
@dataclass def test_i_can_read_select(test_client):
class ListData: html = '''<select name="select_name">
value: list = None <option value="option1">Option 1</option>
<option value="option2">Option 2</option>
def __post_init__(self): <option value="option3">Option 3</option>
if self.value is None: </select>
self.value = [] '''
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: def test_i_can_select_option(test_client):
"""Tests for binding Select components (single selection).""" html = '''<select name="select_name">
<option value="option1">Option 1</option>
def test_i_can_bind_select_single(self, user, rt): <option value="option2">Option 2</option>
""" <option value="option3">Option 3</option>
Single select should bind with data. </select>
Selecting an option should update the label. '''
""" select_elt = TestableSelect(test_client, html)
select_elt.select("option2")
@rt("/") assert select_elt.value == "option2"
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: def test_i_can_select_by_text(test_client):
"""Tests for binding Select components with multiple selection.""" html = '''<select name="select_name">
<option value="option1">Option 1</option>
def test_i_can_bind_select_multiple(self, user, rt): <option value="option2">Option 2</option>
""" <option value="option3">Option 3</option>
Multiple select should bind with list data. </select>
Selecting multiple options should update the label. '''
""" select_elt = TestableSelect(test_client, html)
select_elt.select_by_text("Option 3")
@rt("/") assert select_elt.value == "option3"
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']")

View File

@@ -0,0 +1,107 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.test.testclient import TestableSelect, MyTestClient
@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_select(test_client):
html = '''<select name="select_name" multiple>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
assert select_elt.name == "select_name"
assert select_elt.value == []
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 True
def test_i_can_read_select_with_multiple_selected_values(test_client):
html = '''<select name="select_name" multiple>
<option value="option1" selected>Option 1</option>
<option value="option2">Option 2</option>
<option value="option3" selected>Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
assert select_elt.name == "select_name"
assert select_elt.value == ["option1", "option3"]
assert select_elt.is_multiple is True
def test_i_can_select_option(test_client):
html = '''<select name="select_name" multiple>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
select_elt.select("option2")
assert select_elt.value == "option2"
def test_i_can_select_multiple_options(test_client):
html = '''<select name="select_name" multiple>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
select_elt.select("option2")
select_elt.select("option3")
assert select_elt.value == ["option2", "option3"]
def test_i_can_select_by_text(test_client):
html = '''<select name="select_name" multiple>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
select_elt.select_by_text("Option 3")
assert select_elt.value == "option3"
def test_i_can_deselect(test_client):
html = '''<select name="select_name" multiple>
<option value="option1" selected>Option 1</option>
<option value="option2" selected>Option 2</option>
<option value="option3" selected>Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
select_elt.deselect("option3")
assert select_elt.value == ["option1", "option2"]
select_elt.deselect("option2")
assert select_elt.value == "option1"
select_elt.deselect("option1")
assert select_elt.value == []

View File

@@ -1,26 +1,9 @@
"""
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 dataclasses import dataclass
from fasthtml.components import ( import pytest
Label, Textarea from fasthtml.fastapp import fast_app
)
from myfasthtml.controls.helpers import mk from myfasthtml.test.testclient import MyTestClient, TestableTextarea
from myfasthtml.core.bindings import Binding
@dataclass @dataclass
@@ -28,109 +11,26 @@ class Data:
value: str = "hello world" value: str = "hello world"
@dataclass @pytest.fixture
class NumericData: def test_app():
value: int = 50 test_app, rt = fast_app(default_hdrs=False)
return test_app
@dataclass @pytest.fixture
class BoolData: def rt(test_app):
value: bool = True return test_app.route
@dataclass @pytest.fixture
class ListData: def test_client(test_app):
value: list = None return MyTestClient(test_app)
def __post_init__(self):
if self.value is None:
self.value = []
class TestBindingTextarea: def test_i_can_read_input(test_client):
"""Tests for binding Textarea components.""" html = '''<textarea name="textarea_name">Lorem ipsum</textarea>'''
def test_i_can_bind_textarea(self, user, rt): input_elt = TestableTextarea(test_client, html)
"""
Textarea should bind bidirectionally with data.
Value changes should update the label.
"""
@rt("/") assert input_elt.name == "textarea_name"
def index(): assert input_elt.value == "Lorem ipsum"
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")