First implementation of bindings

This commit is contained in:
2025-11-09 19:23:18 +01:00
parent b5c1c15198
commit 86dfff812b
51 changed files with 5971 additions and 1080 deletions

View File

@@ -7,7 +7,7 @@ from fasthtml.fastapp import fast_app
import myfasthtml.auth.utils
from myfasthtml.auth.routes import setup_auth_routes
from myfasthtml.auth.utils import create_auth_beforeware, register_user
from myfasthtml.core.testclient import MyTestClient
from myfasthtml.test.testclient import MyTestClient
@dataclass

View File

@@ -2,7 +2,9 @@ import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.auth.utils import create_auth_beforeware
from myfasthtml.core.testclient import MyTestClient
from myfasthtml.core.utils import quoted_str
from myfasthtml.test.testclient import MyTestClient
def test_non_protected_route():
app, rt = fast_app()
@@ -31,3 +33,15 @@ def test_all_routes_are_protected():
user.open("/")
user.should_see("Sign In")
@pytest.mark.parametrize("actual,expected", [
("string", '"string"'),
("string with 'single quotes'", '''"string with 'single quotes'"'''),
('string with "double quotes"', """'string with "double quotes"'"""),
("""string with 'single' and "double" quotes""", '''"string with 'single' and \\"double\\" quotes"'''),
(None, "None"),
(123, "123"),
])
def test_i_can_quote_str(actual, expected):
assert quoted_str(actual) == expected

View File

View File

@@ -0,0 +1,91 @@
from dataclasses import dataclass
from typing import Any
import pytest
from fasthtml.components import *
from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.commands import Command
from myfasthtml.test.matcher import matches
from myfasthtml.test.testclient import MyTestClient
@dataclass
class Data:
value: Any
@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
def test_i_can_mk_button():
button = mk.button('button')
expected = Button('button')
assert matches(button, expected)
def test_i_can_mk_button_with_attrs():
button = mk.button('button', id="button_id", class_="button_class")
expected = Button('button', id="button_id", class_="button_class")
assert matches(button, expected)
def test_i_can_mk_button_with_command(user, rt):
def new_value(value): return value
command = Command('test', 'TestingCommand', new_value, "this is my new value")
@rt('/')
def get(): return mk.button('button', command)
user.open("/")
user.should_see("button")
user.find_element("button").click()
user.should_see("this is my new value")
class TestingBindings:
@pytest.fixture()
def data(self):
return Data("value")
def test_i_can_bind_an_input(self, data):
elt = Input(name="input_elt", value="hello")
binding = Binding(data, "value")
elt = mk.manage_binding(elt, binding)
# element is updated
assert "hx-post" in elt.attrs
assert "hx-vals" in elt.attrs
assert "b_id" in elt.attrs["hx-vals"]
# binding is also updated
assert binding.ft == elt
assert binding.ft_name == "input_elt"
def test_i_can_bind_none_input(self, data):
elt = Label("hello", name="input_elt")
binding = Binding(data, "value")
elt = mk.manage_binding(elt, binding)
# element is updated
assert "hx-post" not in elt.attrs
assert "hx-get" not in elt.attrs
# binding is also updated
assert binding.ft == elt
assert binding.ft_name == "input_elt"

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

0
tests/core/__init__.py Normal file
View File

383
tests/core/test_bindings.py Normal file
View File

@@ -0,0 +1,383 @@
from dataclasses import dataclass
import pytest
from fasthtml.components import Label, Input
from myutils.observable import collect_return_values
from myfasthtml.core.bindings import (
BindingsManager,
Binding,
DetectionMode,
UpdateMode,
BooleanConverter
)
@dataclass
class Data:
value: str = "Hello World"
@pytest.fixture(autouse=True)
def reset_binding_manager():
BindingsManager.reset()
@pytest.fixture()
def data():
return Data()
def test_i_can_register_a_binding(data):
binding = Binding(data, "value")
assert binding.id is not None
assert binding.data is data
assert binding.data_attr == 'value'
def test_i_can_register_a_binding_with_default_attr(data):
binding = Binding(data)
assert binding.id is not None
assert binding.data is data
assert binding.data_attr == 'value'
def test_i_can_retrieve_a_registered_binding(data):
elt = Label("hello", id="label_id")
binding = Binding(data).bind_ft(elt, name="label_name")
assert BindingsManager.get_binding(binding.id) is binding
def test_i_can_reset_bindings(data):
elt = Label("hello", id="label_id")
Binding(data).bind_ft(elt, name="label_name")
assert len(BindingsManager.bindings) != 0
BindingsManager.reset()
assert len(BindingsManager.bindings) == 0
def test_i_can_bind_an_element_to_a_binding(data):
elt = Label("hello", id="label_id")
Binding(data).bind_ft(elt, name="label_name")
data.value = "new value"
assert elt.children[0] == "new value"
assert elt.attrs["hx-swap-oob"] == "true"
assert elt.attrs["id"] == "label_id"
def test_i_can_bind_an_element_attr_to_a_binding(data):
elt = Input(value="some value", id="input_id")
Binding(data).bind_ft(elt, name="input_name", attr="value")
data.value = "new value"
assert elt.attrs["value"] == "new value"
assert elt.attrs["hx-swap-oob"] == "true"
assert elt.attrs["id"] == "input_id"
def test_bound_element_has_an_id():
elt = Label("hello")
assert elt.attrs.get("id", None) is None
Binding(Data()).bind_ft(elt, name="label_name")
assert elt.attrs.get("id", None) is not None
def test_i_can_collect_updates_values(data):
elt = Label("hello")
Binding(data).bind_ft(elt, name="label_name")
data.value = "new value"
collected = collect_return_values(data)
assert collected == [elt]
# a second time to ensure no side effect
data.value = "another value"
collected = collect_return_values(data)
assert collected == [elt]
def test_i_can_react_to_value_change(data):
elt = Input(name="input_elt", value="hello")
binding = Binding(data).bind_ft(elt, name="input_elt", attr="value")
res = binding.update({"input_elt": "new value"})
assert len(res) == 1
def test_i_do_not_react_to_other_value_change(data):
elt = Input(name="input_elt", value="hello")
binding = Binding(data).bind_ft(elt, name="input_elt", attr="value")
res = binding.update({"other_input_elt": "new value"})
assert res is None
def test_i_can_react_to_attr_presence(data):
elt = Input(name="input_elt", type="checkbox")
binding = Binding(data).bind_ft(
elt,
name="input_elt",
attr="checked",
detection_mode=DetectionMode.AttributePresence
)
res = binding.update({"checked": "true"})
assert len(res) == 1
def test_i_can_react_to_attr_non_presence(data):
elt = Input(name="input_elt", type="checkbox")
binding = Binding(data).bind_ft(
elt,
name="input_elt",
attr="checked",
detection_mode=DetectionMode.AttributePresence
)
res = binding.update({})
assert len(res) == 1
def test_i_can_create_a_binding_without_activation(data):
"""
A binding created without calling bind_ft should not be active.
"""
binding = Binding(data, "value")
assert binding._is_active is False
assert binding.ft is None
assert binding.ft_name is None
assert binding.ft_attr is None
assert BindingsManager.get_binding(binding.id) is None
def test_i_can_activate_binding_via_bind_ft(data):
"""
Calling bind_ft should automatically activate the binding.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value")
binding.bind_ft(elt, name="label_name")
assert binding._is_active is True
assert binding.ft is elt
assert binding.ft_name == "label_name"
assert BindingsManager.get_binding(binding.id) is binding
def test_i_cannot_notify_when_not_active(data):
"""
A non-active binding should not update the UI when data changes.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value")
binding.ft = elt
binding.ft_name = "label_name"
# Change data without activating the binding
result = binding.notify("old", "new")
assert result is None
assert elt.children[0] == "hello" # Should not have changed
def test_i_can_deactivate_a_binding(data):
"""
Deactivating a binding should clean up observers and unregister it.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
assert binding._is_active is True
assert BindingsManager.get_binding(binding.id) is binding
binding.deactivate()
assert binding._is_active is False
assert BindingsManager.get_binding(binding.id) is None
def test_i_can_reactivate_a_binding(data):
"""
After deactivation, a binding can be reactivated by calling bind_ft again.
"""
elt1 = Label("hello", id="label_id_1")
binding = Binding(data, "value").bind_ft(elt1, name="label_name_1")
binding.deactivate()
assert binding._is_active is False
elt2 = Label("world", id="label_id_2")
binding.bind_ft(elt2, name="label_name_2")
assert binding._is_active is True
assert binding.ft is elt2
assert binding.ft_name == "label_name_2"
def test_bind_ft_deactivates_before_reconfiguring(data):
"""
Calling bind_ft on an active binding should deactivate it first,
then reconfigure and reactivate.
"""
elt1 = Label("hello", id="label_id_1")
elt2 = Label("world", id="label_id_2")
binding = Binding(data, "value").bind_ft(elt1, name="label_name_1")
# Change data to verify old binding works
data.value = "updated"
assert elt1.children[0] == "updated"
# Reconfigure with new element
binding.bind_ft(elt2, name="label_name_2")
# Change data again
data.value = "final"
# Old element should not update
assert elt1.children[0] == "updated"
# New element should update
assert elt2.children[0] == "final"
def test_deactivate_can_be_called_multiple_times(data):
"""
Calling deactivate multiple times should be safe (idempotent).
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
binding.deactivate()
binding.deactivate() # Should not raise an error
binding.deactivate() # Should not raise an error
assert binding._is_active is False
def test_i_cannot_activate_without_configuration(data):
"""
Calling activate directly without proper configuration should raise ValueError.
"""
binding = Binding(data, "value")
with pytest.raises(ValueError, match="ft element is required"):
binding.activate()
def test_activation_validates_strategies(data):
"""
Activation should fail if detection/update strategies are not initialized.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value")
binding.ft = elt
binding.ft_name = "label_name"
with pytest.raises(ValueError, match="detection strategy not initialized"):
binding.activate()
def test_i_can_chain_bind_ft_calls(data):
"""
bind_ft should return self for method chaining.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
assert isinstance(binding, Binding)
assert binding._is_active is True
def test_bind_ft_updates_optional_parameters(data):
"""
bind_ft should update optional parameters if provided.
"""
elt = Input(name="input_elt", type="checkbox")
binding = Binding(data, "value")
binding.bind_ft(
elt,
name="input_elt",
attr="checked",
data_converter=BooleanConverter(),
detection_mode=DetectionMode.AttributePresence,
update_mode=UpdateMode.AttributePresence
)
assert binding.detection_mode == DetectionMode.AttributePresence
assert binding.update_mode == UpdateMode.AttributePresence
assert isinstance(binding.data_converter, BooleanConverter)
def test_deactivated_binding_does_not_update_on_data_change(data):
"""
After deactivation, changes to data should not update the UI element.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
# Verify it works when active
data.value = "first update"
assert elt.children[0] == "first update"
# Deactivate
binding.deactivate()
# Change data - element should NOT update
data.value = "second update"
assert elt.children[0] == "first update"
def test_multiple_bindings_can_coexist(data):
"""
Multiple bindings can be created and managed independently.
"""
elt1 = Label("hello", id="label_id_1")
elt2 = Input(value="world", id="input_id_2")
binding1 = Binding(data, "value").bind_ft(elt1, name="label_name")
binding2 = Binding(data, "value").bind_ft(elt2, name="input_name", attr="value")
assert len(BindingsManager.bindings) == 2
assert binding1._is_active is True
assert binding2._is_active is True
# Change data - both should update
data.value = "updated"
assert elt1.children[0] == "updated"
assert elt2.attrs["value"] == "updated"
# Deactivate one
binding1.deactivate()
assert len(BindingsManager.bindings) == 1
# Change data - only binding2 should update
data.value = "final"
assert elt1.children[0] == "updated" # Not changed
assert elt2.attrs["value"] == "final" # Changed
def test_i_cannot_bind_when_htmx_post_already_set(data):
elt = Input(name="input_elt", hx_post="/some/url")
binding = Binding(data, "value")
with pytest.raises(ValueError, match="htmx post already set on input"):
binding.bind_ft(elt, name="label_name")

View File

@@ -0,0 +1,95 @@
from dataclasses import dataclass
from typing import Any
import pytest
from fasthtml.components import Button
from myutils.observable import make_observable, bind
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():
return "Hello World"
@pytest.fixture(autouse=True)
def reset_command_manager():
CommandsManager.reset()
def test_i_can_create_a_command_with_no_params():
command = Command('test', 'Command description', callback)
assert command.id is not None
assert command.name == 'test'
assert command.description == 'Command description'
assert command.execute() == "Hello World"
def test_command_are_registered():
command = Command('test', 'Command description', callback)
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

@@ -1,25 +0,0 @@
import pytest
from myfasthtml.core.commands import Command, CommandsManager
def callback():
return "Hello World"
@pytest.fixture(autouse=True)
def test_reset_command_manager():
CommandsManager.reset()
def test_i_can_create_a_command_with_no_params():
command = Command('test', 'Command description', callback)
assert command.id is not None
assert command.name == 'test'
assert command.description == 'Command description'
assert command.execute() == "Hello World"
def test_command_are_registered():
command = Command('test', 'Command description', callback)
assert CommandsManager.commands.get(str(command.id)) is command

80
tests/test_integration.py Normal file
View File

@@ -0,0 +1,80 @@
from dataclasses import dataclass
from typing import Any
import pytest
from fasthtml.components import Input, Label
from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.test.testclient import MyTestClient, TestableElement
def new_value(value):
return value
@dataclass
class Data:
value: Any
@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 TestingCommand:
def test_i_can_trigger_a_command(self, user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
testable = TestableElement(user, mk.button('button', command))
testable.click()
assert user.get_content() == "this is my new value"
def test_error_is_raised_when_command_is_not_found(self, user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
CommandsManager.reset()
testable = TestableElement(user, mk.button('button', command))
with pytest.raises(ValueError) as exc_info:
testable.click()
assert "not found." in str(exc_info.value)
def test_i_can_play_a_complex_scenario(self, user, rt):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
@rt('/')
def get(): return mk.button('button', command)
user.open("/")
user.should_see("button")
user.find_element("button").click()
user.should_see("this is my new value")
class TestingBindings:
def test_i_can_bind_input(self, user, rt):
@rt("/")
def index():
data = Data("hello world")
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("/")
user.should_see("")
testable_input = user.find_element("input")
testable_input.send("new value")
user.should_see("new value") # the one from the label

View File

@@ -1,53 +0,0 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.controls.button import mk_button
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.core.testclient import MyTestClient, TestableElement
def new_value(value):
return 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
def test_i_can_trigger_a_command(user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
testable = TestableElement(user, mk_button('button', command))
testable.click()
assert user.get_content() == "this is my new value"
def test_error_is_raised_when_command_is_not_found(user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
CommandsManager.reset()
testable = TestableElement(user, mk_button('button', command))
with pytest.raises(ValueError) as exc_info:
testable.click()
assert "not found." in str(exc_info.value)
def test_i_can_play_a_complex_scenario(user, rt):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
@rt('/')
def get(): return mk_button('button', command)
user.open("/")
user.should_see("button")
user.find_element("button").click()
user.should_see("this is my new value")

View File

@@ -2,9 +2,9 @@ import pytest
from fastcore.basics import NotStr
from fasthtml.components import *
from myfasthtml.core.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput
from myfasthtml.core.testclient import MyFT
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput, AttributeForbidden, AnyValue
from myfasthtml.test.testclient import MyFT
@pytest.mark.parametrize('actual, expected', [
@@ -17,12 +17,14 @@ from myfasthtml.core.testclient import MyFT
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
(Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))),
(Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))),
(Div(attr1="value"), Div(attr1=AnyValue())),
(None, DoNotCheck()),
(123, DoNotCheck()),
(Div(), DoNotCheck()),
([Div(), Span()], DoNotCheck()),
(NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked
(Div(), Div(Empty())),
(Div(attr1="value1"), Div(AttributeForbidden("attr2"))),
(Div(123), Div(123)),
(Div(Span(123)), Div(Span(123))),
(Div(Span(123)), Div(DoNotCheck())),
@@ -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=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
(Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"),
(Div(attr1=None), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
(Div(), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
(NotStr("456"), NotStr("123"), "Notstr values are different"),
(Div(attr="value"), Div(Empty()), "Actual is not empty"),
(Div(120), Div(Empty()), "Actual is not empty"),
(Div(Span()), Div(Empty()), "Actual is not empty"),
(Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(), Div(Span()), "Actual is lesser than expected"),
(Div(), Div(123), "Actual is lesser than expected"),
(Div(Span()), Div(Div()), "The elements are different"),
@@ -59,6 +63,7 @@ def test_i_can_match(actual, expected):
(Div(123), Div(456), "The values are different"),
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
(Div(Span(Div())), Div(Span(Span())), "The elements are different"),
(Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"),
])
def test_i_can_detect_errors(actual, expected, error_message):
with pytest.raises(AssertionError) as exc_info:
@@ -174,6 +179,7 @@ def test_i_can_output_error_when_predicate():
def test_i_can_output_error_when_predicate_wrong_value():
"""I can display error when the condition predicate is not satisfied."""
elt = "before after"
expected = Contains("value")
path = ""
@@ -184,6 +190,7 @@ def test_i_can_output_error_when_predicate_wrong_value():
def test_i_can_output_error_child_element():
"""I can display error when the element has children"""
elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
expected = elt
path = ""
@@ -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():
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")

View File

@@ -2,7 +2,7 @@ import pytest
from fasthtml.components import Div
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import MyTestClient, TestableElement, TestableForm
from myfasthtml.test.testclient import MyTestClient, TestableElement, TestableForm
class TestMyTestClientOpen:
@@ -481,4 +481,3 @@ class TestMyTestClientFindForm:
error_message = str(exc_info.value)
assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message

View File

@@ -0,0 +1,89 @@
from dataclasses import dataclass
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.test.testclient import MyTestClient, TestableRadio
@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_not_selected_radio(test_client):
html = '''<input type="radio" name="radio_name" value="option1" />'''
input_elt = TestableRadio(test_client, html)
assert input_elt.name == "radio_name"
assert input_elt.value is None
def test_i_can_read_selected_radio(test_client):
html = '''<input type="radio" name="radio_name" value="option1" checked="true"/>'''
input_elt = TestableRadio(test_client, html)
assert input_elt.name == "radio_name"
assert input_elt.value == "option1"
def test_i_cannot_read_radio_with_multiple_values(test_client):
html = '''
<input type="radio" name="radio_name" value="option1" checked="true" />
<input type="radio" name="radio_name" value="option2" />
'''
with pytest.raises(AssertionError) as exc_info:
TestableRadio(test_client, html)
assert "Only one radio button per name is supported" in str(exc_info.value)
def test_i_cannot_read_radio_when_no_radio_button(test_client):
html = '''
<input type="text" name="radio_name" value="option1" checked="true" /> '''
with pytest.raises(AssertionError) as exc_info:
TestableRadio(test_client, html)
assert "No radio buttons found" in str(exc_info.value)
def test_i_can_read_input_with_label(test_client):
html = '''<label for="uid">John Doe</label><input id="uid" type="radio" name="username" value="john_doe" />'''
input_elt = TestableRadio(test_client, html)
assert input_elt.fields_mapping == {"John Doe": "username"}
assert input_elt.name == "username"
assert input_elt.value is None
def test_i_can_send_values(test_client, rt):
html = '''<input type="text" name="username" type="radio" value="john_doe" hx_post="/submit"/>'''
@rt('/submit')
def post(username: str):
return f"Input received {username=}"
input_elt = TestableRadio(test_client, html)
input_elt.select()
assert test_client.get_content() == "Input received username='john_doe'"

View File

@@ -0,0 +1,165 @@
"""
Comprehensive binding tests for all bindable FastHTML components.
This test suite covers:
- Input (text) - already tested
- Checkbox - already tested
- Textarea
- Select (single)
- Select (multiple)
- Range (slider)
- Radio buttons
- Button
- Input with Datalist (combobox)
"""
from dataclasses import dataclass
import pytest
from fasthtml.components import (
Input, Label, Textarea
)
from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.test.testclient import MyTestClient
@dataclass
class Data:
value: str = "hello world"
@dataclass
class NumericData:
value: int = 50
@dataclass
class BoolData:
value: bool = True
@dataclass
class ListData:
value: list = None
def __post_init__(self):
if self.value is None:
self.value = []
@pytest.fixture
def test_app():
test_app, rt = fast_app(default_hdrs=False)
return test_app
@pytest.fixture
def rt(test_app):
return test_app.route
@pytest.fixture
def user(test_app):
return MyTestClient(test_app)
class TestBindingEdgeCases:
"""Tests for edge cases and special scenarios."""
def test_multiple_components_bind_to_same_data(self, user, rt):
"""
Multiple different components can bind to the same data object.
"""
@rt("/")
def index():
data = Data("synchronized")
input_elt = Input(name="input_name")
textarea_elt = Textarea(name="textarea_name")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(textarea_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, textarea_elt, label_elt
user.open("/")
user.should_see("synchronized")
# Change via input
testable_input = user.find_element("input")
testable_input.send("changed via input")
user.should_see("changed via input")
# Change via textarea
testable_textarea = user.find_element("textarea")
testable_textarea.send("changed via textarea")
user.should_see("changed via textarea")
def test_component_without_name_attribute(self, user, rt):
"""
Component without name attribute should handle gracefully.
"""
@rt("/")
def index():
data = Data("test")
# Input without name - should not crash
input_elt = Input() # No name attribute
label_elt = Label()
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
user.should_see("test")
def test_binding_with_initial_empty_string(self, user, rt):
"""
Binding should work correctly with empty string initial values.
"""
@rt("/")
def index():
data = Data("")
input_elt = Input(name="input_name")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
testable_input = user.find_element("input")
testable_input.send("now has value")
user.should_see("now has value")
def test_binding_with_special_characters(self, user, rt):
"""
Binding should handle special characters correctly.
"""
@rt("/")
def index():
data = Data("Hello")
input_elt = Input(name="input_name")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
testable_input = user.find_element("input")
testable_input.send("Special: <>&\"'")
user.should_see("Special: <>&\"'")

View File

@@ -0,0 +1,59 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.test.testclient import MyTestClient, TestableCheckbox
@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)
@pytest.mark.parametrize("html,expected_value", [
('<input type="checkbox" name="male" checked />', True),
('<input type="checkbox" name="male" />', False),
])
def test_i_can_read_input(test_client, html, expected_value):
input_elt = TestableCheckbox(test_client, html)
assert input_elt.name == "male"
assert input_elt.value == expected_value
def test_i_can_read_input_with_label(test_client):
html = '''<label for="uid">Male</label><input id="uid" type="checkbox" name="male" checked />'''
input_elt = TestableCheckbox(test_client, html)
assert input_elt.fields_mapping == {"Male": "male"}
assert input_elt.name == "male"
assert input_elt.value == True
def test_i_can_check_checkbox(test_client, rt):
html = '''<input type="checkbox" name="male" hx_post="/submit"/>'''
@rt('/submit')
def post(male: bool=None):
return f"Checkbox received {male=}"
input_elt = TestableCheckbox(test_client, html)
input_elt.check()
assert test_client.get_content() == "Checkbox received male=True"
input_elt.uncheck()
assert test_client.get_content() == "Checkbox received male=None"
input_elt.toggle()
assert test_client.get_content() == "Checkbox received male=True"

View File

@@ -2,14 +2,14 @@ import pytest
from fasthtml.components import Div
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import MyTestClient, TestableElement, MyFT
from myfasthtml.test.testclient import MyTestClient, TestableElement, MyFT
def test_i_can_create_testable_element_from_ft():
ft = Div("hello world", id="test")
testable_element = TestableElement(None, ft)
assert testable_element.ft == ft
assert testable_element.my_ft == MyFT('div', {'id': 'test'})
assert testable_element.html_fragment == '<div id="test">hello world</div>'
@@ -17,7 +17,7 @@ def test_i_can_create_testable_element_from_str():
ft = '<div id="test">hello world</div>'
testable_element = TestableElement(None, ft)
assert testable_element.ft == MyFT('div', {'id': 'test'})
assert testable_element.my_ft == MyFT('div', {'id': 'test'})
assert testable_element.html_fragment == '<div id="test">hello world</div>'
@@ -27,7 +27,7 @@ def test_i_can_create_testable_element_from_beautifulsoup_element():
tag = BeautifulSoup(ft, 'html.parser').div
testable_element = TestableElement(None, tag)
assert testable_element.ft == MyFT('div', {'id': 'test'})
assert testable_element.my_ft == MyFT('div', {'id': 'test'})
assert testable_element.html_fragment == '<div id="test">hello world</div>'

View File

@@ -1,7 +1,7 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import TestableForm, MyTestClient
from myfasthtml.test.testclient import TestableForm, MyTestClient
@pytest.fixture
@@ -268,8 +268,7 @@ class TestableFormUpdateFieldValues:
'''
form = TestableForm(mock_client, html)
assert "size" not in form.fields, \
f"Expected 'size' not in fields, got {form.fields}"
assert form.fields == {"size": None}, f"Expected 'size' not in fields, got {form.fields}"
def test_i_can_handle_number_input_with_integer(self, mock_client):
"""

View File

@@ -0,0 +1,58 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.test.testclient import TestableInput, 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_input(test_client):
html = '''<input type="text" name="username" value="john_doe" />'''
input_elt = TestableInput(test_client, html)
assert input_elt.name == "username"
assert input_elt.value == "john_doe"
def test_i_can_read_input_with_label(test_client):
html = '''<label for="uid">Username</label><input id="uid" name="username" value="john_doe" />'''
input_elt = TestableInput(test_client, html)
assert input_elt.fields_mapping == {"Username": "username"}
assert input_elt.name == "username"
assert input_elt.value == "john_doe"
def test_i_can_send_values(test_client, rt):
html = '''<input type="text" name="username" value="john_doe" hx_post="/submit"/>'''
@rt('/submit')
def post(username: str):
return f"Input received {username=}"
input_elt = TestableInput(test_client, html)
input_elt.send("another name")
assert test_client.get_content() == "Input received username='another name'"
def i_can_find_input_by_name(test_client):
html = '''<label for="uid">Username</label><input id="uid" name="username" value="john_doe" />'''
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

@@ -0,0 +1,63 @@
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">
<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 == "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
def test_i_can_select_option(test_client):
html = '''<select name="select_name">
<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_by_text(test_client):
html = '''<select name="select_name">
<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"

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

@@ -0,0 +1,36 @@
from dataclasses import dataclass
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.test.testclient import MyTestClient, TestableTextarea
@dataclass
class Data:
value: str = "hello world"
@pytest.fixture
def test_app():
test_app, rt = fast_app(default_hdrs=False)
return test_app
@pytest.fixture
def rt(test_app):
return test_app.route
@pytest.fixture
def test_client(test_app):
return MyTestClient(test_app)
def test_i_can_read_input(test_client):
html = '''<textarea name="textarea_name">Lorem ipsum</textarea>'''
input_elt = TestableTextarea(test_client, html)
assert input_elt.name == "textarea_name"
assert input_elt.value == "Lorem ipsum"