8 Commits

33 changed files with 1332 additions and 1050 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

@@ -30,22 +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, ft_attr=None): def manage_binding(ft, binding: Binding, ft_attr=None, init_binding=True):
if not binding: if not binding:
return ft return ft
binding.bind_ft(ft, ft_attr) binding.bind_ft(ft, ft_attr)
if init_binding:
binding.init() binding.init()
# as it is the first binding, remove the hx-swap-oob
if "hx-swap-oob" in ft.attrs:
del ft.attrs["hx-swap-oob"]
return ft 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, get_default_ft_attr, is_checkbox, is_radio 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.
@@ -76,6 +93,26 @@ class ValueChangeFtUpdate(FtUpdate):
return ft 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
class AttributePresenceFtUpdate(FtUpdate): class AttributePresenceFtUpdate(FtUpdate):
def update(self, ft, ft_name, ft_attr, old, new, converter): 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)
@@ -106,6 +143,20 @@ 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): class RadioConverter(DataConverter):
def __init__(self, radio_value): def __init__(self, radio_value):
self.radio_value = radio_value self.radio_value = radio_value
@@ -169,7 +220,7 @@ class Binding:
if self._is_active: if self._is_active:
self.deactivate() self.deactivate()
if ft.tag in ["input"]: if ft.tag in ["input", "textarea", "select"]:
# I must not force the htmx # I must not force the htmx
if {"hx-post", "hx_post"} & set(ft.attrs.keys()): if {"hx-post", "hx_post"} & set(ft.attrs.keys()):
raise ValueError(f"Binding '{self.id}': htmx post already set on input.") raise ValueError(f"Binding '{self.id}': htmx post already set on input.")
@@ -191,6 +242,14 @@ class Binding:
default_data_converter = self.data_converter or RadioConverter(ft.attrs["value"]) default_data_converter = self.data_converter or RadioConverter(ft.attrs["value"])
default_detection_mode = DetectionMode.ValueChange default_detection_mode = DetectionMode.ValueChange
default_update_mode = UpdateMode.AttributePresence 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: else:
default_data_converter = self.data_converter default_data_converter = self.data_converter
default_detection_mode = DetectionMode.ValueChange default_detection_mode = DetectionMode.ValueChange
@@ -333,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):
@@ -122,6 +125,39 @@ def is_radio(elt):
return False 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, c_id: str): def post(session, c_id: str):
""" """

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
@@ -36,15 +37,6 @@ class AttrPredicate(Predicate):
pass pass
class ChildrenPredicate(Predicate):
"""
Predicate given as a child of an element.
"""
def to_debug(self, element):
return element
class StartsWith(AttrPredicate): class StartsWith(AttrPredicate):
def __init__(self, value): def __init__(self, value):
super().__init__(value) super().__init__(value)
@@ -69,6 +61,27 @@ class DoesNotContain(AttrPredicate):
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): class Empty(ChildrenPredicate):
def __init__(self): def __init__(self):
super().__init__(None) super().__init__(None)
@@ -116,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
@@ -143,28 +156,27 @@ class ErrorOutput:
self.indent += " " self.indent += " "
element_index = 0 element_index = 0
for expected_child in expected_children: for expected_child in expected_children:
if hasattr(expected_child, "tag"): if element_index >= len(self.element.children):
if element_index < len(expected_children): # 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 # display the child
element_child = self.element.children[element_index] element_child = self.element.children[element_index]
child_str = self._str_element(element_child, expected_child, keep_open=False) child_str = self._str_element(element_child, expected_child, keep_open=False)
self._add_to_output(child_str) self._add_to_output(child_str)
# manage errors in children # manage errors (only when the expected is a FT element
if hasattr(expected_child, "tag"):
child_error_str = self._detect_error(element_child, expected_child) child_error_str = self._detect_error(element_child, expected_child)
if child_error_str: if child_error_str:
self._add_to_output(child_error_str) self._add_to_output(child_error_str)
# continue
element_index += 1 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:
if expected_child in self.element.children:
self._add_to_output(expected_child)
self.indent = self.indent[:-2] self.indent = self.indent[:-2]
self._add_to_output(")") self._add_to_output(")")
else: else:
@@ -183,6 +195,7 @@ class ErrorOutput:
if expected is None: if expected is None:
expected = element expected = element
if hasattr(element, "tag"):
# the attributes are compared to the expected element # the attributes are compared to the expected element
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in
[attr_name for attr_name in expected.attrs if attr_name is not None]} [attr_name for attr_name in expected.attrs if attr_name is not None]}
@@ -197,10 +210,13 @@ class ErrorOutput:
tag_str += "..." if elt_attrs_str == "" else " ..." tag_str += "..." if elt_attrs_str == "" else " ..."
else: else:
# close the tag if there are no children # close the tag if there are no children
if len([c for c in element.children if not isinstance(c, Predicate)]) == 0: tag_str += ")" 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 return tag_str
else:
return quoted_str(element)
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"):
tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^") tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^")

View File

@@ -207,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_'):
@@ -325,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))
@@ -338,17 +338,29 @@ 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
else:
if len(selected_value) > 0:
self.fields[name] = selected_value[-1]
elif options: elif options:
self.fields[name] = options[0]['value'] 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):
""" """
@@ -603,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
@@ -964,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
@@ -1016,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

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

@@ -18,13 +18,14 @@ from typing import Any
import pytest import pytest
from fasthtml.components import ( from fasthtml.components import (
Input, Label, Textarea, Select, Option, Button, Datalist Input, Label, Textarea, Select, Option, Datalist
) )
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, BooleanConverter from myfasthtml.core.bindings import Binding, BooleanConverter
from myfasthtml.test.matcher import matches, AttributeForbidden from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.test.matcher import matches, AttributeForbidden, AnyValue
from myfasthtml.test.testclient import MyTestClient from myfasthtml.test.testclient import MyTestClient
@@ -67,7 +68,19 @@ def rt(user):
class TestBindingTextarea: class TestBindingTextarea:
"""Tests for binding Textarea components.""" """Tests for binding Textarea components."""
def test_i_can_bind_textarea(self, user, rt): def test_i_can_bind_textarea(self):
data = Data("")
check_box = Textarea(name="textarea_name")
binding = Binding(data)
mk.manage_binding(check_box, binding)
# update the content
res = binding.update({"textarea_name": "Hello world !"})
expected = [Textarea("Hello world !", name="textarea_name", hx_swap_oob="true")]
assert matches(res, expected)
def test_i_can_bind_textarea_with_label(self, user, rt):
""" """
Textarea should bind bidirectionally with data. Textarea should bind bidirectionally with data.
Value changes should update the label. Value changes should update the label.
@@ -89,27 +102,6 @@ class TestBindingTextarea:
testable_textarea.send("New multiline\ntext content") testable_textarea.send("New multiline\ntext content")
user.should_see("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): def test_textarea_append_works_with_binding(self, user, rt):
""" """
Appending text to textarea should trigger binding update. Appending text to textarea should trigger binding update.
@@ -156,6 +148,80 @@ class TestBindingTextarea:
class TestBindingSelect: class TestBindingSelect:
"""Tests for binding Select components (single selection).""" """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): def test_i_can_bind_select_single(self, user, rt):
""" """
Single select should bind with data. Single select should bind with data.
@@ -238,7 +304,109 @@ class TestBindingSelect:
class TestBindingSelectMultiple: class TestBindingSelectMultiple:
"""Tests for binding Select components with multiple selection.""" """Tests for binding Select components with multiple selection."""
def test_i_can_bind_select_multiple(self, user, rt): 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. Multiple select should bind with list data.
Selecting multiple options should update the label. Selecting multiple options should update the label.
@@ -294,13 +462,65 @@ class TestBindingSelectMultiple:
testable_select = user.find_element("select") testable_select = user.find_element("select")
testable_select.deselect("option1") testable_select.deselect("option1")
user.should_see("['option2']") user.should_see("option2")
class TestBindingRange: class TestBindingRange:
"""Tests for binding Range (slider) components.""" """Tests for binding Range (slider) components."""
def test_i_can_bind_range(self, user, rt): 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. Range input should bind with numeric data.
Changing the slider should update the label. Changing the slider should update the label.
@@ -474,138 +694,23 @@ class TestBindingRadio:
user.should_see("option2") user.should_see("option2")
class TestBindingButton:
"""Tests for binding Button components."""
def test_i_can_click_button_with_binding(self, user, rt):
"""
Clicking a button with HTMX should trigger binding updates.
"""
@rt("/")
def index():
data = Data("initial")
button_elt = Button("Click me", hx_post="/update", hx_vals='{"action": "clicked"}')
label_elt = Label()
mk.manage_binding(button_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return button_elt, label_elt
@rt("/update")
def update(action: str):
data = Data("button clicked")
label_elt = Label()
mk.manage_binding(label_elt, Binding(data))
return label_elt
user.open("/")
user.should_see("initial")
testable_button = user.find_element("button")
testable_button.click()
user.should_see("button clicked")
def test_button_without_htmx_does_nothing(self, user, rt):
"""
Button without HTMX should not trigger updates.
"""
@rt("/")
def index():
data = Data("initial")
button_elt = Button("Plain button") # No HTMX
label_elt = Label()
mk.manage_binding(button_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return button_elt, label_elt
user.open("/")
user.should_see("initial")
testable_button = user.find_element("button")
result = testable_button.click()
assert result is None # No HTMX, no response
class TestBindingDatalist: class TestBindingDatalist:
"""Tests for binding Input with Datalist (combobox).""" """Tests for binding Input with Datalist (combobox)."""
def test_i_can_bind_input_with_datalist(self, user, rt): def test_i_can_bind_datalist(self):
""" data = Data(["suggestion2"])
Input with datalist should allow both free text and suggestions.
"""
@rt("/")
def index():
data = Data("")
datalist = Datalist( datalist = Datalist(
Option(value="suggestion1"), Option(value="suggestion1"),
Option(value="suggestion2"),
Option(value="suggestion3"),
id="suggestions" id="suggestions"
) )
input_elt = Input(
name="input_name", updated = mk.manage_binding(datalist, Binding(data))
list="suggestions" expected = Datalist(
Option(value="suggestion2"),
id="suggestions"
) )
label_elt = Label()
mk.manage_binding(input_elt, Binding(data)) assert matches(updated, expected)
mk.manage_binding(label_elt, Binding(data))
return input_elt, datalist, label_elt
user.open("/")
user.should_see("")
testable_input = user.find_element("input[list='suggestions']")
# Can type free text
testable_input.send("custom value")
user.should_see("custom value")
# Can select from suggestions
testable_input.select_suggestion("suggestion2")
user.should_see("suggestion2")
def test_datalist_suggestions_are_available(self, user, rt):
"""
Datalist suggestions should be accessible for validation.
"""
@rt("/")
def index():
data = Data("")
datalist = Datalist(
Option(value="apple"),
Option(value="banana"),
Option(value="cherry"),
id="fruits"
)
input_elt = Input(
name="input_name",
list="fruits"
)
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, datalist, label_elt
user.open("/")
testable_input = user.find_element("input[list='fruits']")
# Check that suggestions are available
suggestions = testable_input.suggestions
assert "apple" in suggestions
assert "banana" in suggestions
assert "cherry" in suggestions
class TestBindingEdgeCases: class TestBindingEdgeCases:
@@ -732,7 +837,7 @@ class TestCheckBox:
binding = Binding(data) binding = Binding(data)
updated = mk.manage_binding(check_box, binding) updated = mk.manage_binding(check_box, binding)
expected = Input(AttributeForbidden("checked"), name="checkbox_name", type="checkbox", hx_swap_oob="true") expected = Input(AttributeForbidden("checked"), name="checkbox_name", type="checkbox")
assert matches(updated, expected) assert matches(updated, expected)
def test_checkbox_initial_state_true(self): def test_checkbox_initial_state_true(self):
@@ -742,7 +847,7 @@ class TestCheckBox:
binding = Binding(data) binding = Binding(data)
updated = mk.manage_binding(check_box, binding) updated = mk.manage_binding(check_box, binding)
expected = Input(name="checkbox_name", type="checkbox", hx_swap_oob="true", checked="true") expected = Input(name="checkbox_name", type="checkbox", checked="true")
assert matches(updated, expected) assert matches(updated, expected)
def test_i_can_bind_checkbox_and_label_without_converter(self, user, rt): def test_i_can_bind_checkbox_and_label_without_converter(self, user, rt):

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

@@ -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, AttributeForbidden ErrorComparisonOutput, AttributeForbidden, AnyValue
from myfasthtml.test.testclient import MyFT from myfasthtml.test.testclient import MyFT
@@ -17,6 +17,7 @@ from myfasthtml.test.testclient import MyFT
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))), (Div(attr1="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()),
@@ -49,6 +50,8 @@ def test_i_can_match(actual, expected):
(Div(attr1="value1"), Div(attr1=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"), (Div(attr1="value1"), Div(attr1=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()), "The condition 'Empty()' is not satisfied"), (Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"), (Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"),
@@ -176,6 +179,7 @@ def test_i_can_output_error_when_predicate():
def test_i_can_output_error_when_predicate_wrong_value(): 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 = ""
@@ -186,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 = ""
@@ -198,6 +203,19 @@ def test_i_can_output_error_child_element():
')', ')',
] ]
def test_i_can_output_error_child_element_text():
"""I can display error when the children is not a FT"""
elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' "Hello world"',
' (div "id"="child_1")',
' (div "id"="child_2")',
')',
]
def test_i_can_output_error_child_element_indicating_sub_children(): 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,18 +1,3 @@
"""
Comprehensive binding tests for all bindable FastHTML components.
This test suite covers:
- Input (text) - already tested
- Checkbox - already tested
- Textarea
- Select (single)
- Select (multiple)
- Range (slider)
- Radio buttons
- Button
- Input with Datalist (combobox)
"""
from dataclasses import dataclass from dataclasses import dataclass
import pytest import pytest

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

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

@@ -54,5 +54,5 @@ def test_i_can_send_values(test_client, rt):
def i_can_find_input_by_name(test_client): def i_can_find_input_by_name(test_client):
html = '''<label for="uid">Username</label><input id="uid" name="username" value="john_doe" />''' html = '''<label for="uid">Username</label><input id="uid" name="username" value="john_doe" />'''
test_client.find_input("username") element = test_client.find_input("Username")
assert False 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")