From cc11e4edaa9f4a8e85aeafae0573ef4cbd465757 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 2 Nov 2025 21:54:14 +0100 Subject: [PATCH] Updated README.md. Added other TestableControls --- README.md | 706 ++++++++++++++++++++- src/myfasthtml/test/testclient.py | 478 +++++++++++++- tests/test_integration.py | 1 + tests/testclient/test_teastable_radio.py | 108 ++++ tests/testclient/test_testable.py | 146 +++++ tests/testclient/test_testable_button.py | 104 +++ tests/testclient/test_testable_datalist.py | 124 ++++ tests/testclient/test_testable_select.py | 191 ++++++ tests/testclient/test_testable_textarea.py | 136 ++++ 9 files changed, 1990 insertions(+), 4 deletions(-) create mode 100644 tests/testclient/test_teastable_radio.py create mode 100644 tests/testclient/test_testable.py create mode 100644 tests/testclient/test_testable_button.py create mode 100644 tests/testclient/test_testable_datalist.py create mode 100644 tests/testclient/test_testable_select.py create mode 100644 tests/testclient/test_testable_textarea.py diff --git a/README.md b/README.md index 6478086..13bbf6f 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ A utility library designed to simplify the development of FastHtml applications - **Dynamic HTML with HTMX**: Simplify dynamic interaction using attributes like `hx-post` and custom routes like `/commands`. - **Command management**: Write server-side logic in Python while abstracting the complexities of HTMX. +- **Binding management**: Mechanism to bind two html element together. - **Control helpers**: Easily create reusable components like buttons. -- **Predefined Pages (Roadmap)**: Include common pages like login, user management, and customizable dashboards. +- **Login Pages**: Include common pages for login, user management, and customizable dashboards. > _**Note:** Support for state persistence is currently under construction._ @@ -57,7 +58,7 @@ if __name__ == "__main__": ``` -### Button with a Command +### Use Commands ```python from fasthtml import serve @@ -93,6 +94,23 @@ if __name__ == "__main__": --- +### Bind components + +```python +@dataclass +class Data: + value: str = "Hello World" + checked: bool = False + +# Binds an Input with a label +mk.mk(Input(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")), +mk.mk(Label("Text"), binding=Binding(data, attr="value")), + +# Binds a checkbox with a labl +mk.mk(Input(name="checked_name", type="checkbox"), binding=Binding(data, attr="checked")), +mk.mk(Label("Text"), binding=Binding(data, attr="checked")), +``` + ## Planned Features (Roadmap) ### Predefined Pages @@ -148,6 +166,655 @@ Use the `get_htmx_params()` method to directly integrate commands into HTML comp --- +## Testing + +### TestableElements + +#### TestableTextarea + +**Use case:** Multi-line text input + +**Methods:** + +- `send(value)` - Set the textarea value +- `append(text)` - Append text to current value +- `clear()` - Clear the textarea + +**Example:** + +```python +def test_textarea_binding(user, rt): + @rt("/") + def index(): + data = Data("Initial text") + textarea = Textarea(name="message") + label = Label() + + mk.manage_binding(textarea, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return textarea, label + + user.open("/") + textarea = user.find_element("textarea") + + textarea.send("New message") + user.should_see("New message") + + textarea.append("\nMore text") + user.should_see("New message\nMore text") + + textarea.clear() + user.should_see("") +``` + +#### TestableSelect + +**Use case:** Dropdown selection + +**Properties:** + +- `is_multiple` - Check if multiple selection is enabled +- `options` - List of available options + +**Methods:** + +- `select(value)` - Select option by value +- `select_by_text(text)` - Select option by visible text +- `deselect(value)` - Deselect option (multiple select only) + +**Example (Single Select):** + +```python +def test_select_binding(user, rt): + @rt("/") + def index(): + data = Data("option1") + select = Select( + Option("First", value="option1"), + Option("Second", value="option2"), + Option("Third", value="option3"), + name="choice" + ) + label = Label() + + mk.manage_binding(select, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return select, label + + user.open("/") + select_elt = user.find_element("select") + + select_elt.select("option2") + user.should_see("option2") + + select_elt.select_by_text("Third") + user.should_see("option3") +``` + +**Example (Multiple Select):** + +```python +def test_multiple_select_binding(user, rt): + @rt("/") + def index(): + data = ListData(["option1"]) + select = Select( + Option("First", value="option1"), + Option("Second", value="option2"), + Option("Third", value="option3"), + name="choices", + multiple=True + ) + label = Label() + + mk.manage_binding(select, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return select, label + + user.open("/") + select_elt = user.find_element("select") + + select_elt.select("option2") + user.should_see("['option1', 'option2']") + + select_elt.deselect("option1") + user.should_see("['option2']") +``` + +#### TestableRange + +**Use case:** Slider input + +**Properties:** + +- `min_value` - Minimum value +- `max_value` - Maximum value +- `step` - Step increment + +**Methods:** + +- `set(value)` - Set slider to specific value (auto-clamped) +- `increase()` - Increase by one step +- `decrease()` - Decrease by one step + +**Example:** + +```python +def test_range_binding(user, rt): + @rt("/") + def index(): + data = NumericData(50) + range_input = Input( + type="range", + name="volume", + min="0", + max="100", + step="10", + value="50" + ) + label = Label() + + mk.manage_binding(range_input, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return range_input, label + + user.open("/") + slider = user.find_element("input[type='range']") + + slider.set(75) + user.should_see("75") + + slider.increase() + user.should_see("85") + + slider.decrease() + user.should_see("75") +``` + +#### TestableRadio + +**Use case:** Radio button (mutually exclusive options) + +**Properties:** + +- `radio_value` - The value attribute of this radio +- `is_checked` - Check if this radio is selected + +**Methods:** + +- `select()` - Select this radio button + +**Example:** + +```python +def test_radio_binding(user, rt): + @rt("/") + def index(): + data = Data("option1") + + radio1 = Input(type="radio", name="choice", value="option1", checked=True) + radio2 = Input(type="radio", name="choice", value="option2") + radio3 = Input(type="radio", name="choice", value="option3") + label = Label() + + mk.manage_binding(radio1, Binding(data)) + mk.manage_binding(radio2, Binding(data)) + mk.manage_binding(radio3, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return radio1, radio2, radio3, label + + user.open("/") + + radio2 = user.find_element("input[value='option2']") + radio2.select() + user.should_see("option2") + + radio3 = user.find_element("input[value='option3']") + radio3.select() + user.should_see("option3") +``` + +#### TestableButton + +**Use case:** Clickable button with HTMX + +**Properties:** + +- `text` - Visible text of the button + +**Methods:** + +- `click()` - Click the button (triggers HTMX if configured) + +**Example:** + +```python +def test_button_binding(user, rt): + @rt("/") + def index(): + data = Data("initial") + button = Button( + "Click me", + hx_post="/update", + hx_vals='{"action": "clicked"}' + ) + label = Label() + + mk.manage_binding(button, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return button, label + + @rt("/update") + def update(action: str): + data = Data("updated") + label = Label() + mk.manage_binding(label, Binding(data)) + return label + + user.open("/") + + button = user.find_element("button") + button.click() + user.should_see("updated") +``` + +#### TestableDatalist + +**Use case:** Input with autocomplete suggestions (combobox) + +**Properties:** + +- `suggestions` - List of available suggestions + +**Methods:** + +- `send(value)` - Set input value (any value, not restricted to suggestions) +- `select_suggestion(value)` - Select a value from suggestions + +**Example:** + +```python +def test_datalist_binding(user, rt): + @rt("/") + def index(): + data = Data("") + + datalist = Datalist( + Option(value="apple"), + Option(value="banana"), + Option(value="cherry"), + id="fruits" + ) + input_elt = Input(name="fruit", list="fruits") + label = Label() + + mk.manage_binding(input_elt, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return input_elt, datalist, label + + user.open("/") + + input_with_list = user.find_element("input[list='fruits']") + + # Free text input + input_with_list.send("mango") + user.should_see("mango") + + # Select from suggestions + input_with_list.select_suggestion("banana") + user.should_see("banana") +``` + +## CSS Selectors for Finding Elements + +When using `user.find_element()`, use these selectors: + +| Component | Selector Example | +|----------------|--------------------------------------------------------| +| Input (text) | `"input[name='field_name']"` or `"input[type='text']"` | +| Checkbox | `"input[type='checkbox']"` | +| Radio | `"input[type='radio']"` or `"input[value='option1']"` | +| Range | `"input[type='range']"` | +| Textarea | `"textarea"` or `"textarea[name='field_name']"` | +| Select | `"select"` or `"select[name='field_name']"` | +| Button | `"button"` or `"button.primary"` | +| Datalist Input | `"input[list='datalist_id']"` | + +## Binding + +### Overview + +This package contains everything needed to implement a complete binding system for FastHTML components. + +### Fully Supported Components Summary + +| Component | Testable Class | Binding Support | +|-------------------|------------------|-----------------| +| Input (text) | TestableInput | ✅ | +| Checkbox | TestableCheckbox | ✅ | +| Textarea | TestableTextarea | ✅ | +| Select (single) | TestableSelect | ✅ | +| Select (multiple) | TestableSelect | ✅ | +| Range (slider) | TestableRange | ✅ | +| Radio buttons | TestableRadio | ✅ | +| Button | TestableButton | ✅ | +| Input + Datalist | TestableDatalist | ✅ | + +### Supported Components + +#### 1. Input (Text) + +```python +# Methods +input.send(value) + +# Binding modes +- ValueChange(default) +- Text +updates +trigger +data +changes +``` + +#### 2. Checkbox + +```python +# Methods +checkbox.check() +checkbox.uncheck() +checkbox.toggle() + +# Binding modes +- AttributePresence +- Boolean +data +binding +``` + +#### 3. Textarea + +```python +# Methods +textarea.send(value) +textarea.append(text) +textarea.clear() + +# Binding modes +- ValueChange +- Multi - line +text +support +``` + +#### 4. Select (Single) + +```python +# Methods +select.select(value) +select.select_by_text(text) + +# Properties +select.options # List of available options +select.is_multiple # False for single select + +# Binding modes +- ValueChange +- String +value +binding +``` + +#### 5. Select (Multiple) + +```python +# Methods +select.select(value) +select.deselect(value) +select.select_by_text(text) + +# Properties +select.options +select.is_multiple # True for multiple select + +# Binding modes +- ValueChange +- List +data +binding +``` + +#### 6. Range (Slider) + +```python +# Methods +range.set(value) # Auto-clamps to min/max +range.increase() +range.decrease() + +# Properties +range.min_value +range.max_value +range.step + +# Binding modes +- ValueChange +- Numeric +data +binding +``` + +#### 7. Radio Buttons + +```python +# Methods +radio.select() + +# Properties +radio.radio_value # Value attribute +radio.is_checked + +# Binding modes +- ValueChange +- String +value +binding +- Mutually +exclusive +group +behavior +``` + +#### 8. Button + +```python +# Methods +button.click() + +# Properties +button.text # Visible button text + +# Binding modes +- Triggers +HTMX +requests +- Can +update +bindings +via +server +response +``` + +#### 9. Input + Datalist (Combobox) + +```python +# Methods +datalist.send(value) # Any value +datalist.select_suggestion(value) # From suggestions + +# Properties +datalist.suggestions # Available options + +# Binding modes +- ValueChange +- Hybrid: free +text + suggestions +``` + +### Architecture Overview + +#### Three-Phase Binding Lifecycle + +```python +# Phase 1: Create (inactive) +binding = Binding(data, "value") + +# Phase 2: Configure + Activate +binding.bind_ft(element, name="input", attr="value") + +# Phase 3: Deactivate (cleanup) +binding.deactivate() +``` + +#### Data Flow + +``` +User Input → HTMX Component → HTMX Request → Binding.update() + ↓ + setattr(data, attr, value) + ↓ + Observable triggers + ↓ + Binding.notify() + ↓ + Update all bound UI elements +``` + +### Quick Reference + +#### Creating a Binding + +```python +# Simple binding +binding = Binding(data, "value").bind_ft( + Input(name="input"), + name="input", + attr="value" +) + +# With detection and update modes +binding = Binding(data, "checked").bind_ft( + Input(type="checkbox", name="check"), + name="check", + attr="checked", + detection_mode=DetectionMode.AttributePresence, + update_mode=UpdateMode.AttributePresence +) + +# With data converter +binding = Binding(data, "value").bind_ft( + Input(type="checkbox", name="check"), + name="check", + attr="checked", + data_converter=BooleanConverter() +) +``` + +#### Testing a Component + +```python +def test_component_binding(user, rt): + @rt("/") + def index(): + data = Data("initial") + component = Component(name="field") + label = Label() + + mk.manage_binding(component, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return component, label + + user.open("/") + user.should_see("initial") + + testable = user.find_element("selector") + testable.method("new value") + user.should_see("new value") +``` + +#### Managing Binding Lifecycle + +```python +# Create +binding = Binding(data, "value") + +# Activate (via bind_ft) +binding.bind_ft(element, name="field") + +# Deactivate +binding.deactivate() + +# Reactivate with new element +binding.bind_ft(new_element, name="field") +``` + +### Pattern 1: Bidirectional Binding + +All components support bidirectional binding: + +- UI changes update the data object +- Data object changes update the UI (via Label or other bound components) + +```python +input_elt = Input(name="field") +label_elt = Label() + +mk.manage_binding(input_elt, Binding(data)) +mk.manage_binding(label_elt, Binding(data)) + +# Change via UI +testable_input.send("new value") +# Label automatically updates to show "new value" +``` + +### Pattern 2: Multiple Components, Same Data + +Multiple different components can bind to the same data: + +```python +input_elt = Input(name="input") +textarea_elt = Textarea(name="textarea") +label_elt = Label() + +# All bind to the same data object +mk.manage_binding(input_elt, Binding(data)) +mk.manage_binding(textarea_elt, Binding(data)) +mk.manage_binding(label_elt, Binding(data)) + +# Changing any component updates all others +``` + +### Pattern 3: Component Without Name + +Components without a name attribute won't trigger updates but won't crash: + +```python +input_elt = Input() # No name attribute +label_elt = Label() + +mk.manage_binding(label_elt, Binding(data)) +# Input won't trigger updates, but label will still display data +``` + ## Contributing We welcome contributions! To get started: @@ -226,6 +893,41 @@ Predefined login page that provides a UI template ready for integration. No custom exceptions defined yet. (Placeholder for future use.) +## Troubleshooting + +### Issue: "No element found matching selector" + +**Cause:** Incorrect CSS selector or element not in DOM + +**Solution:** Check the HTML output and adjust selector + +```python +# Debug: Print the HTML +print(user.get_content()) + +# Try different selectors +user.find_element("textarea") +user.find_element("textarea[name='message']") +``` + +### Issue: TestableControl has no attribute 'send' + +**Cause:** Wrong testable class returned by factory + +**Solution:** Verify factory method is updated correctly + +### Issue: AttributeError on TestableTextarea + +**Cause:** Class not properly inheriting from TestableControl + +**Solution:** Check class hierarchy and imports + +### Issue: Select options not found + +**Cause:** `_update_fields()` not parsing select correctly + +**Solution:** Verify TestableElement properly parses select/option tags + ## Relase History * 0.1.0 : First release diff --git a/src/myfasthtml/test/testclient.py b/src/myfasthtml/test/testclient.py index 5893f2f..7cb5d80 100644 --- a/src/myfasthtml/test/testclient.py +++ b/src/myfasthtml/test/testclient.py @@ -836,6 +836,452 @@ class TestableCheckbox(TestableControl): return self._send_value() +class TestableTextarea(TestableControl): + """ + Represents a textarea element that can be interacted with in tests. + + Textareas are similar to text inputs but support multi-line text. + """ + + def __init__(self, client, source): + """ + Initialize a testable textarea. + + Args: + client: The MyTestClient instance. + source: The source HTML or BeautifulSoup Tag. + """ + # Parse as textarea element + super().__init__(client, source, "textarea") + + def send(self, value): + """ + Set the textarea value and trigger HTMX update if configured. + + Args: + value: The text value to set (can be multi-line string). + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + self.fields[self.name] = value + return self._send_value() + + def append(self, text): + """ + Append text to the current textarea value. + + Args: + text: Text to append. + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + current_value = self.fields.get(self.name, '') + self.fields[self.name] = current_value + text + return self._send_value() + + def clear(self): + """ + Clear the textarea content. + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + self.fields[self.name] = '' + return self._send_value() + + +class TestableSelect(TestableControl): + """ + Represents a select dropdown element that can be interacted with in tests. + + Supports both single and multiple selection modes. + """ + + def __init__(self, client, source): + """ + Initialize a testable select element. + + Args: + client: The MyTestClient instance. + source: The source HTML or BeautifulSoup Tag. + """ + # Parse as select element + super().__init__(client, source, "select") + self._is_multiple = self.my_ft.attrs.get('multiple') is not None + + @property + def is_multiple(self): + """Check if this is a multiple selection dropdown.""" + return self._is_multiple + + @property + def options(self): + """ + Get all available options for this select. + + Returns: + List of dicts with 'value' and 'text' keys. + """ + return self.select_fields.get(self.name, []) + + def select(self, value): + """ + Select an option by value. + + Args: + value: The value of the option to select (not the text). + + Returns: + Response from HTMX request if applicable, None otherwise. + + Raises: + ValueError: If the value is not in the available options. + """ + # Validate the value exists in options + available_values = [opt['value'] for opt in self.options] + if value not in available_values: + raise ValueError( + f"Value '{value}' not found in select options. " + f"Available values: {available_values}" + ) + + if self.is_multiple: + # For multiple select, value should be a list + current = self.fields.get(self.name, []) + if not isinstance(current, list): + current = [current] if current else [] + if value not in current: + current.append(value) + self.fields[self.name] = current + else: + # For single select, just set the value + self.fields[self.name] = value + + return self._send_value() + + def select_by_text(self, text): + """ + Select an option by its visible text. + + Args: + text: The visible text of the option to select. + + Returns: + Response from HTMX request if applicable, None otherwise. + + Raises: + ValueError: If the text is not found in options. + """ + # Find the value corresponding to this text + for option in self.options: + if option['text'] == text: + return self.select(option['value']) + + raise ValueError( + f"Option with text '{text}' not found. " + f"Available texts: {[opt['text'] for opt in self.options]}" + ) + + def deselect(self, value): + """ + Deselect an option (only for multiple selects). + + Args: + value: The value of the option to deselect. + + Returns: + Response from HTMX request if applicable, None otherwise. + + Raises: + ValueError: If called on a non-multiple select. + """ + if not self.is_multiple: + raise ValueError("Cannot deselect on a single-select dropdown") + + current = self.fields.get(self.name, []) + if not isinstance(current, list): + current = [current] if current else [] + + if value in current: + current.remove(value) + self.fields[self.name] = current + return self._send_value() + + return None + + +class TestableRange(TestableControl): + """ + Represents a range input (slider) that can be interacted with in tests. + """ + + def __init__(self, client, source): + """ + Initialize a testable range input. + + Args: + client: The MyTestClient instance. + source: The source HTML or BeautifulSoup Tag. + """ + super().__init__(client, source, "input") + + # Extract min, max, step from attributes + self._min = float(self.my_ft.attrs.get('min', 0)) + self._max = float(self.my_ft.attrs.get('max', 100)) + self._step = float(self.my_ft.attrs.get('step', 1)) + + @property + def min_value(self): + """Get the minimum value of the range.""" + return self._min + + @property + def max_value(self): + """Get the maximum value of the range.""" + return self._max + + @property + def step(self): + """Get the step increment of the range.""" + return self._step + + def set(self, value): + """ + Set the range value. + + Args: + value: Numeric value to set (will be clamped to min/max). + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + # Clamp value to valid range + value = max(self._min, min(self._max, float(value))) + + # Round to nearest step + value = round((value - self._min) / self._step) * self._step + self._min + + self.fields[self.name] = value + return self._send_value() + + def increase(self): + """ + Increase the range value by one step. + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + current = float(self.fields.get(self.name, self._min)) + return self.set(current + self._step) + + def decrease(self): + """ + Decrease the range value by one step. + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + current = float(self.fields.get(self.name, self._min)) + return self.set(current - self._step) + + +class TestableRadio(TestableControl): + """ + Represents a radio button input that can be interacted with in tests. + + Note: Radio buttons with the same name form a group where only one + can be selected at a time. + """ + + def __init__(self, client, source): + """ + Initialize a testable radio button. + + Args: + client: The MyTestClient instance. + source: The source HTML or BeautifulSoup Tag. + """ + super().__init__(client, source, "input") + self._radio_value = self.my_ft.attrs.get('value', '') + + @property + def radio_value(self): + """Get the value attribute of this radio button.""" + return self._radio_value + + @property + def is_checked(self): + """Check if this radio button is currently selected.""" + return self.fields.get(self.name) == self._radio_value + + def select(self): + """ + Select this radio button. + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + self.fields[self.name] = self._radio_value + return self._send_value() + + +class TestableButton(TestableElement): + """ + Represents a button element that can be clicked in tests. + + Buttons can trigger HTMX requests or form submissions. + """ + + def __init__(self, client, source): + """ + Initialize a testable button. + + Args: + client: The MyTestClient instance. + source: The source HTML or BeautifulSoup Tag. + """ + super().__init__(client, source, "button") + + @property + def text(self): + """Get the visible text of the button.""" + return self.element.get_text(strip=True) + + def click(self): + """ + Click the button and trigger any associated HTMX request. + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + if self._support_htmx(): + return self._send_htmx_request() + return None + + +class TestableDatalist(TestableControl): + """ + Represents an input with datalist (autocomplete/combobox) that can be + interacted with in tests. + + This is essentially an input that can show suggestions from a datalist. + """ + + def __init__(self, client, source): + """ + Initialize a testable input with datalist. + + Args: + client: The MyTestClient instance. + source: The source HTML or BeautifulSoup Tag. + """ + super().__init__(client, source, "input") + + # Find associated datalist + list_id = self.my_ft.attrs.get('list') + self._datalist_options = [] + + if list_id: + # Parse the full HTML to find the datalist + soup = BeautifulSoup(self.html_fragment, 'html.parser') + datalist = soup.find('datalist', id=list_id) + + if datalist: + for option in datalist.find_all('option'): + option_value = option.get('value', option.get_text(strip=True)) + self._datalist_options.append(option_value) + + @property + def suggestions(self): + """ + Get all available suggestions from the datalist. + + Returns: + List of suggestion values. + """ + return self._datalist_options + + def send(self, value): + """ + Set the input value (can be any value, not restricted to suggestions). + + Args: + value: The value to set. + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + self.fields[self.name] = value + return self._send_value() + + def select_suggestion(self, value): + """ + Select a value from the datalist suggestions. + + Args: + value: The suggestion value to select. + + Returns: + Response from HTMX request if applicable, None otherwise. + + Raises: + ValueError: If the value is not in the suggestions. + """ + if value not in self._datalist_options: + raise ValueError( + f"Value '{value}' not found in datalist suggestions. " + f"Available: {self._datalist_options}" + ) + + return self.send(value) + + +# Update the TestableElement factory method +# This should be added to the MyTestClient._testable_element_factory method + +def _testable_element_factory_extended(client, elt): + """ + Extended factory method for creating appropriate Testable* instances. + + This should replace or extend the existing _testable_element_factory method + in MyTestClient. + + Args: + client: The MyTestClient instance. + elt: BeautifulSoup Tag element. + + Returns: + Appropriate Testable* instance based on element type. + """ + if elt.name == "input": + input_type = elt.get("type", "text").lower() + + if input_type == "checkbox": + return TestableCheckbox(client, elt) + elif input_type == "radio": + return TestableRadio(client, elt) + elif input_type == "range": + return TestableRange(client, elt) + elif elt.get("list"): # Input with datalist + return TestableDatalist(client, elt) + else: + return TestableInput(client, elt) + + elif elt.name == "textarea": + return TestableTextarea(client, elt) + + elif elt.name == "select": + return TestableSelect(client, elt) + + elif elt.name == "button": + return TestableButton(client, elt) + + else: + return TestableElement(client, elt, elt.name) + + # def get_value(tag): # """Return the current user-facing value of an HTML input-like element.""" # if tag.name == 'input': @@ -1148,10 +1594,38 @@ class MyTestClient: return self def _testable_element_factory(self, elt): + """ + Factory method for creating appropriate Testable* instances. + + Args: + elt: BeautifulSoup Tag element. + + Returns: + Appropriate Testable* instance based on element type. + """ if elt.name == "input": - if elt.get("type") == "checkbox": + input_type = elt.get("type", "text").lower() + + if input_type == "checkbox": return TestableCheckbox(self, elt) - return TestableInput(self, elt) + elif input_type == "radio": + return TestableRadio(self, elt) + elif input_type == "range": + return TestableRange(self, elt) + elif elt.get("list"): # Input with datalist + return TestableDatalist(self, elt) + else: + return TestableInput(self, elt) + + elif elt.name == "textarea": + return TestableTextarea(self, elt) + + elif elt.name == "select": + return TestableSelect(self, elt) + + elif elt.name == "button": + return TestableButton(self, elt) + else: return TestableElement(self, elt, elt.name) diff --git a/tests/test_integration.py b/tests/test_integration.py index 683ee6f..8dcefda 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -98,3 +98,4 @@ class TestingBindings: testable_input.uncheck() user.should_see("False") + diff --git a/tests/testclient/test_teastable_radio.py b/tests/testclient/test_teastable_radio.py new file mode 100644 index 0000000..0d5bb72 --- /dev/null +++ b/tests/testclient/test_teastable_radio.py @@ -0,0 +1,108 @@ +""" +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 +) +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" + + +@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 TestBindingRadio: + """Tests for binding Radio button components.""" + + def test_i_can_bind_radio_buttons(self, user, rt): + """ + Radio buttons should bind with data. + Selecting a radio should update the label. + """ + + @rt("/") + def index(): + data = Data("option1") + radio1 = Input(type="radio", name="radio_name", value="option1", checked=True) + radio2 = Input(type="radio", name="radio_name", value="option2") + radio3 = Input(type="radio", name="radio_name", value="option3") + label_elt = Label() + + mk.manage_binding(radio1, Binding(data)) + mk.manage_binding(radio2, Binding(data)) + mk.manage_binding(radio3, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + + return radio1, radio2, radio3, label_elt + + user.open("/") + user.should_see("option1") + + # Select second radio + testable_radio2 = user.find_element("input[value='option2']") + testable_radio2.select() + user.should_see("option2") + + # Select third radio + testable_radio3 = user.find_element("input[value='option3']") + testable_radio3.select() + user.should_see("option3") + + def test_radio_initial_state(self, user, rt): + """ + 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") diff --git a/tests/testclient/test_testable.py b/tests/testclient/test_testable.py new file mode 100644 index 0000000..dfadf45 --- /dev/null +++ b/tests/testclient/test_testable.py @@ -0,0 +1,146 @@ +""" +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, Textarea +) + +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 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: <>&\"'") diff --git a/tests/testclient/test_testable_button.py b/tests/testclient/test_testable_button.py new file mode 100644 index 0000000..7f80456 --- /dev/null +++ b/tests/testclient/test_testable_button.py @@ -0,0 +1,104 @@ +""" +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 diff --git a/tests/testclient/test_testable_datalist.py b/tests/testclient/test_testable_datalist.py new file mode 100644 index 0000000..9228374 --- /dev/null +++ b/tests/testclient/test_testable_datalist.py @@ -0,0 +1,124 @@ +""" +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 diff --git a/tests/testclient/test_testable_select.py b/tests/testclient/test_testable_select.py new file mode 100644 index 0000000..545beeb --- /dev/null +++ b/tests/testclient/test_testable_select.py @@ -0,0 +1,191 @@ +""" +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, Select, Option +) + +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 TestBindingSelect: + """Tests for binding Select components (single selection).""" + + def test_i_can_bind_select_single(self, user, rt): + """ + Single select should bind with data. + Selecting an option should update the label. + """ + + @rt("/") + def index(): + data = Data("option1") + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name" + ) + label_elt = Label() + mk.manage_binding(select_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return select_elt, label_elt + + user.open("/") + user.should_see("option1") + + testable_select = user.find_element("select") + testable_select.select("option2") + user.should_see("option2") + + testable_select.select("option3") + user.should_see("option3") + + def test_i_can_bind_select_by_text(self, user, rt): + """ + Selecting by visible text should work with binding. + """ + + @rt("/") + def index(): + data = Data("opt1") + select_elt = Select( + Option("First Option", value="opt1"), + Option("Second Option", value="opt2"), + Option("Third Option", value="opt3"), + name="select_name" + ) + label_elt = Label() + mk.manage_binding(select_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return select_elt, label_elt + + user.open("/") + user.should_see("opt1") + + testable_select = user.find_element("select") + testable_select.select_by_text("Second Option") + user.should_see("opt2") + + def test_select_with_default_selected_option(self, user, rt): + """ + Select with a pre-selected option should initialize correctly. + """ + + @rt("/") + def index(): + data = Data("option2") + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2", selected=True), + Option("Option 3", value="option3"), + name="select_name" + ) + label_elt = Label() + mk.manage_binding(select_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return select_elt, label_elt + + user.open("/") + user.should_see("option2") + + +class TestBindingSelectMultiple: + """Tests for binding Select components with multiple selection.""" + + def test_i_can_bind_select_multiple(self, user, rt): + """ + Multiple select should bind with list data. + Selecting multiple options should update the label. + """ + + @rt("/") + def index(): + data = ListData(["option1"]) + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name", + multiple=True + ) + label_elt = Label() + mk.manage_binding(select_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return select_elt, label_elt + + user.open("/") + user.should_see("['option1']") + + testable_select = user.find_element("select") + testable_select.select("option2") + user.should_see("['option1', 'option2']") + + testable_select.select("option3") + user.should_see("['option1', 'option2', 'option3']") + + def test_i_can_deselect_from_multiple_select(self, user, rt): + """ + Deselecting options from multiple select should update binding. + """ + + @rt("/") + def index(): + data = ListData(["option1", "option2"]) + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name", + multiple=True + ) + label_elt = Label() + mk.manage_binding(select_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return select_elt, label_elt + + user.open("/") + user.should_see("['option1', 'option2']") + + testable_select = user.find_element("select") + testable_select.deselect("option1") + user.should_see("['option2']") diff --git a/tests/testclient/test_testable_textarea.py b/tests/testclient/test_testable_textarea.py new file mode 100644 index 0000000..6680065 --- /dev/null +++ b/tests/testclient/test_testable_textarea.py @@ -0,0 +1,136 @@ +""" +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, Textarea +) + +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 TestBindingTextarea: + """Tests for binding Textarea components.""" + + def test_i_can_bind_textarea(self, user, rt): + """ + Textarea should bind bidirectionally with data. + Value changes should update the label. + """ + + @rt("/") + def index(): + data = Data("Initial text") + textarea_elt = Textarea(name="textarea_name") + label_elt = Label() + mk.manage_binding(textarea_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return textarea_elt, label_elt + + user.open("/") + user.should_see("Initial text") + + testable_textarea = user.find_element("textarea") + testable_textarea.send("New multiline\ntext content") + user.should_see("New multiline\ntext content") + + def test_i_can_bind_textarea_with_empty_initial_value(self, user, rt): + """ + Textarea with empty initial value should update correctly. + """ + + @rt("/") + def index(): + data = Data("") + textarea_elt = Textarea(name="textarea_name") + label_elt = Label() + mk.manage_binding(textarea_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return textarea_elt, label_elt + + user.open("/") + user.should_see("") # Empty initially + + testable_textarea = user.find_element("textarea") + testable_textarea.send("First content") + user.should_see("First content") + + def test_textarea_append_works_with_binding(self, user, rt): + """ + Appending text to textarea should trigger binding update. + """ + + @rt("/") + def index(): + data = Data("Start") + textarea_elt = Textarea(name="textarea_name") + label_elt = Label() + mk.manage_binding(textarea_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return textarea_elt, label_elt + + user.open("/") + user.should_see("Start") + + testable_textarea = user.find_element("textarea") + testable_textarea.append(" + More") + user.should_see("Start + More") + + def test_textarea_clear_works_with_binding(self, user, rt): + """ + Clearing textarea should update binding to empty string. + """ + + @rt("/") + def index(): + data = Data("Content to clear") + textarea_elt = Textarea(name="textarea_name") + label_elt = Label() + mk.manage_binding(textarea_elt, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + return textarea_elt, label_elt + + user.open("/") + user.should_see("Content to clear") + + testable_textarea = user.find_element("textarea") + testable_textarea.clear() + user.should_not_see("Content to clear")