import json import uuid from typing import Self from bs4 import BeautifulSoup, Tag from fastcore.xml import FT, to_xml from fasthtml.common import FastHTML from starlette.responses import Response from starlette.testclient import TestClient from myfasthtml.core.utils import mount_utils from myfasthtml.test.MyFT import MyFT verbs = { 'hx_get': 'GET', 'hx_post': 'POST', 'hx_put': 'PUT', 'hx_delete': 'DELETE', 'hx_patch': 'PATCH', } class DoNotSendCls: pass DoNotSend = DoNotSendCls() class TestableElement: """ Represents an HTML element that can be interacted with in tests. This class will be used for future interactions like clicking elements or verifying element properties. """ def __init__(self, client, source, tag=None): """ Initialize a testable element. Args: client: The MyTestClient instance. ft: The FastHTML element representation. """ self.client = client if isinstance(source, str): self.html_fragment = source.strip() elif isinstance(source, Tag): self.html_fragment = str(source).strip() elif isinstance(source, FT): self.html_fragment = to_xml(source).strip() else: raise ValueError(f"Invalid source '{source}' for TestableElement.") self.tag, self.element, self.my_ft = self._parse(tag, self.html_fragment) self.fields_mapping = {} # link between the input label and the input name self.fields = {} # Values of the fields {name: value} self.select_fields = {} # list of possible options for 'select' input fields self._update_fields_mapping() self._update_fields() def fill(self, **kwargs): """ Fill the form with the given data. Args: **kwargs: Field names and their values to fill in the form. """ for name, value in kwargs.items(): field_name = self._translate(name) if field_name not in self.fields: raise ValueError(f"Invalid field name '{name}'.") self.fields[self._translate(name)] = value def click(self): """Click the element (to be implemented).""" return self._send_htmx_request() def matches(self, ft): """Check if element matches given FastHTML element (to be implemented).""" pass def _translate(self, field): """ Translate a given field using a predefined mapping. If the field is not found in the mapping, the original field is returned unmodified. :param field: The field name to be translated. :type field: str :return: The translated field name if present in the mapping, or the original field name if no mapping exists for it. :rtype: str """ return self.fields_mapping.get(field, field) def _support_htmx(self): """Check if the element supports HTMX.""" return ('hx_get' in self.my_ft.attrs or 'hx-get' in self.my_ft.attrs or 'hx_post' in self.my_ft.attrs or 'hx-post' in self.my_ft.attrs) def _send_htmx_request(self, json_data: dict | None = None, data: dict | None = None) -> Response: """ Simulates an HTMX request in Python for unit testing. This function reads the 'hx-*' attributes from the FastHTML object to determine the HTTP method, URL, headers, and body of the request, then executes it via the TestClient. Args: data: (Optional) A dict for form data (sends as 'application/x-www-form-urlencoded'). json_data: (Optional) A dict for JSON data (sends as 'application/json'). Takes precedence over 'hx_vals'. Returns: The Response object from the simulated request. """ # The essential header for FastHTML (and HTMX) to identify the request headers = {"HX-Request": "true"} method = "GET" # HTMX defaults to GET if not specified url = None if data is not None: headers['Content-Type'] = 'application/x-www-form-urlencoded' bag_to_use = data elif json_data is not None: headers['Content-Type'] = 'application/json' bag_to_use = json_data else: # default to json_data headers['Content-Type'] = 'application/json' json_data = {} bag_to_use = json_data # .props contains the kwargs passed to the object (e.g., hx_post="/url") element_attrs = self.my_ft.attrs or {} # Build the attributes for key, value in element_attrs.items(): # sanitize the key key = key.lower().strip() if key.startswith('hx-'): key = 'hx_' + key[3:] if key in verbs: # Verb attribute: defines the method and URL method = verbs[key] url = str(value) elif key == 'hx_vals': # hx_vals defines the JSON body, if not already provided by the test if isinstance(value, str): bag_to_use |= json.loads(value) elif isinstance(value, dict): bag_to_use |= value elif key.startswith('hx_'): # Any other hx_* attribute is converted to an HTTP header # e.g.: 'hx_target' -> 'HX-Target' header_name = '-'.join(p.capitalize() for p in key.split('_')) headers[header_name] = str(value) # Sanity check if url is None: raise ValueError( f"The <{self.my_ft.tag}> element has no HTMX verb attribute " "(e.g., hx_get, hx_post) to define a URL." ) # Send the request return self.client.send_request(method, url, headers=headers, data=data, json_data=json_data) def _update_fields_mapping(self): """ Build a mapping between label text and input field names. This method finds all labels in the form and associates them with their corresponding input fields using the following priority order: 1. Explicit association via 'for' attribute matching input 'id' 2. Implicit association (label contains the input) 3. Parent-level association with 'for'/'id' 4. Proximity association (siblings in same parent) 5. No label (use input name as key) The mapping is stored in self.fields_mapping as {label_text: input_name}. For inputs without a name, the id is used. If neither exists, a generic key like "unnamed_0" is generated. """ self.fields_mapping = {} processed_inputs = set() unnamed_counter = 0 # Get all inputs in the form all_inputs = self.element.find_all('input') # Priority 1 & 2: Explicit association (for/id) and implicit (nested) for label in self.element.find_all('label'): label_text = label.get_text(strip=True) # Check for explicit association via 'for' attribute label_for = label.get('for') if label_for: input_field = self.element.find('input', id=label_for) if input_field: input_name = self._get_input_identifier(input_field, unnamed_counter) if input_name.startswith('unnamed_'): unnamed_counter += 1 self.fields_mapping[label_text] = input_name processed_inputs.add(id(input_field)) continue # Check for implicit association (label contains input) input_field = label.find('input') if input_field: input_name = self._get_input_identifier(input_field, unnamed_counter) if input_name.startswith('unnamed_'): unnamed_counter += 1 self.fields_mapping[label_text] = input_name processed_inputs.add(id(input_field)) continue # Priority 3 & 4: Parent-level associations for label in self.element.find_all('label'): label_text = label.get_text(strip=True) # Skip if this label was already processed if label_text in self.fields_mapping: continue parent = label.parent if parent: input_found = False # Priority 3: Look for sibling input with matching for/id label_for = label.get('for') if label_for: for sibling in parent.find_all('input'): if sibling.get('id') == label_for and id(sibling) not in processed_inputs: input_name = self._get_input_identifier(sibling, unnamed_counter) if input_name.startswith('unnamed_'): unnamed_counter += 1 self.fields_mapping[label_text] = input_name processed_inputs.add(id(sibling)) input_found = True break # Priority 4: Fallback to proximity if no input found yet if not input_found: for sibling in parent.find_all('input'): if id(sibling) not in processed_inputs: input_name = self._get_input_identifier(sibling, unnamed_counter) if input_name.startswith('unnamed_'): unnamed_counter += 1 self.fields_mapping[label_text] = input_name processed_inputs.add(id(sibling)) break # Priority 5: Inputs without labels for input_field in all_inputs: if id(input_field) not in processed_inputs: input_name = self._get_input_identifier(input_field, unnamed_counter) if input_name.startswith('unnamed_'): unnamed_counter += 1 self.fields_mapping[input_name] = input_name def _update_fields(self): """ Update the fields dictionary with current form values and their proper types. This method processes all input and select elements in the form: - Determines the appropriate Python type (str, int, float, bool) based on the HTML input type attribute and/or the value itself - For select elements, populates self.select_fields with available options - Stores the final typed values in self.fields Type conversion priority: 1. HTML type attribute (checkbox → bool, number → int/float, etc.) 2. Value analysis fallback for ambiguous types (text/hidden/absent type) """ self.fields = {} self.select_fields = {} # Process input fields for input_field in self.element.find_all('input'): name = input_field.get('name') if not name: continue input_type = input_field.get('type', 'text').lower() raw_value = input_field.get('value', '') # Type conversion based on input type if input_type == 'checkbox': # Checkbox: bool based on 'checked' attribute self.fields[name] = input_field.has_attr('checked') elif input_type == 'radio': # Radio: str value (only if checked) if input_field.has_attr('checked'): self.fields[name] = raw_value elif name not in self.fields: # If no radio is checked yet, don't set a default self.fields[name] = None elif input_type == 'number': # Number: int or float based on value self.fields[name] = self._convert_number(raw_value) else: # Other types (text, hidden, email, password, etc.): analyze value self.fields[name] = self._convert_value(raw_value) # Process select fields for select_field in self.element.find_all('select'): name = select_field.get('name') if not name: continue # Extract all options options = [] selected_value = None for option in select_field.find_all('option'): option_value = option.get('value', option.get_text(strip=True)) option_text = option.get_text(strip=True) options.append({ 'value': option_value, 'text': option_text }) # Track selected option if option.has_attr('selected'): selected_value = option_value # Store options list self.select_fields[name] = options # Store selected value (or first option if none selected) if selected_value is not None: self.fields[name] = selected_value elif options: self.fields[name] = options[0]['value'] @staticmethod def _get_input_identifier(input_field, counter): """ Get the identifier for an input field. Args: input_field: The BeautifulSoup Tag object representing the input. counter: Current counter for unnamed inputs. Returns: The input name, id, or a generated "unnamed_X" identifier. """ if input_field.get('name'): return input_field['name'] elif input_field.get('id'): return input_field['id'] else: return f"unnamed_{counter}" @staticmethod def _convert_number(value): """ Convert a string value to int or float. Args: value: String value to convert. Returns: int, float, or empty string if conversion fails. """ if not value or value.strip() == '': return '' try: # Try float first to detect decimal numbers if '.' in value or 'e' in value.lower(): return float(value) else: return int(value) except ValueError: return value @staticmethod def _convert_value(value): """ Analyze and convert a value to its appropriate type. Conversion priority: 1. Boolean keywords (true/false) 2. Float (contains decimal point) 3. Int (numeric) 4. Empty string 5. String (default) Args: value: String value to convert. Returns: Converted value with appropriate type (bool, float, int, or str). """ if not value or value.strip() == '': return '' value_lower = value.lower().strip() # Check for boolean if value_lower in ('true', 'false'): return value_lower == 'true' # Check for numeric values try: # Check for float (has decimal point or scientific notation) if '.' in value or 'e' in value_lower: return float(value) # Try int else: return int(value) except ValueError: pass # Default to string return value @staticmethod def _parse(tag, html_fragment: str): elt = BeautifulSoup(html_fragment, 'html.parser') if len(elt) == 0: raise ValueError(f"No HTML element found in: {html_fragment}") if len(elt) == 1: elt = elt.find() elt_tag = elt.name if tag is not None and tag != elt_tag: raise ValueError(f"Tag '{tag}' does not match with '{html_fragment}'.") my_ft = MyFT(elt_tag, elt.attrs) if elt_tag != "form": elt = BeautifulSoup(f"
", 'html.parser') return elt_tag, elt, my_ft else: if tag is None: raise ValueError(f"Multiple elements found in {html_fragment}. Please specify a tag.") elt = BeautifulSoup(f"", 'html.parser') _inner = elt.find(tag) my_ft = MyFT(_inner.name, _inner.attrs) return tag, elt.find(), my_ft class TestableForm(TestableElement): """ Represents an HTML form that can be filled and submitted in tests. """ def __init__(self, client, source): """ Initialize a testable form. Args: client: The MyTestClient instance. source: The source HTML string containing a form. """ super().__init__(client, source, "form") # self.form = BeautifulSoup(self.html_fragment, 'html.parser').find('form') # self.fields_mapping = {} # link between the input label and the input name # self.fields = {} # field name; field value # self.select_fields = {} # list of possible options for 'select' input fields # # self._update_fields_mapping() # self.update_fields() # def update_fields(self): # """ # Update the fields dictionary with current form values and their proper types. # # This method processes all input and select elements in the form: # - Determines the appropriate Python type (str, int, float, bool) based on # the HTML input type attribute and/or the value itself # - For select elements, populates self.select_fields with available options # - Stores the final typed values in self.fields # # Type conversion priority: # 1. HTML type attribute (checkbox → bool, number → int/float, etc.) # 2. Value analysis fallback for ambiguous types (text/hidden/absent type) # """ # self.fields = {} # self.select_fields = {} # # # Process input fields # for input_field in self.form.find_all('input'): # name = input_field.get('name') # if not name: # continue # # input_type = input_field.get('type', 'text').lower() # raw_value = input_field.get('value', '') # # # Type conversion based on input type # if input_type == 'checkbox': # # Checkbox: bool based on 'checked' attribute # self.fields[name] = input_field.has_attr('checked') # # elif input_type == 'radio': # # Radio: str value (only if checked) # if input_field.has_attr('checked'): # self.fields[name] = raw_value # elif name not in self.fields: # # If no radio is checked yet, don't set a default # pass # # elif input_type == 'number': # # Number: int or float based on value # self.fields[name] = self._convert_number(raw_value) # # else: # # Other types (text, hidden, email, password, etc.): analyze value # self.fields[name] = self._convert_value(raw_value) # # # Process select fields # for select_field in self.form.find_all('select'): # name = select_field.get('name') # if not name: # continue # # # Extract all options # options = [] # selected_value = None # # for option in select_field.find_all('option'): # option_value = option.get('value', option.get_text(strip=True)) # option_text = option.get_text(strip=True) # # options.append({ # 'value': option_value, # 'text': option_text # }) # # # Track selected option # if option.has_attr('selected'): # selected_value = option_value # # # Store options list # self.select_fields[name] = options # # # Store selected value (or first option if none selected) # if selected_value is not None: # self.fields[name] = selected_value # elif options: # self.fields[name] = options[0]['value'] # def submit(self): """ Submit the form. This method handles both HTMX-enabled forms and classic HTML form submissions: - If the form supports HTMX (has hx_post, hx_get, etc.), uses HTMX request - Otherwise, simulates a classic browser form submission using the form's action and method attributes Returns: The response from the form submission. Raises: ValueError: If the form has no action attribute for classic submission. """ # Check if the form supports HTMX if self._support_htmx(): return self._send_htmx_request(data=self.fields) # Classic form submission action = self.element.get('action') if not action or action.strip() == '': raise ValueError( "The form has no 'action' attribute. " "Cannot submit a classic form without a target URL." ) method = self.element.get('method', 'post').upper() # Prepare headers for classic form submission headers = { "Content-Type": "application/x-www-form-urlencoded" } # Send the request via the client return self.client.send_request( method=method, url=action, headers=headers, data=self.fields ) # def _translate(self, field): # """ # Translate a given field using a predefined mapping. If the field is not found # in the mapping, the original field is returned unmodified. # # :param field: The field name to be translated. # :type field: str # :return: The translated field name if present in the mapping, or the original # field name if no mapping exists for it. # :rtype: str # """ # return self.fields_mapping.get(field, field) # # def _update_fields_mapping(self): # """ # Build a mapping between label text and input field names. # # This method finds all labels in the form and associates them with their # corresponding input fields using the following priority order: # 1. Explicit association via 'for' attribute matching input 'id' # 2. Implicit association (label contains the input) # 3. Parent-level association with 'for'/'id' # 4. Proximity association (siblings in same parent) # 5. No label (use input name as key) # # The mapping is stored in self.fields_mapping as {label_text: input_name}. # For inputs without a name, the id is used. If neither exists, a generic # key like "unnamed_0" is generated. # """ # self.fields_mapping = {} # processed_inputs = set() # unnamed_counter = 0 # # # Get all inputs in the form # all_inputs = self.form.find_all('input') # # # Priority 1 & 2: Explicit association (for/id) and implicit (nested) # for label in self.form.find_all('label'): # label_text = label.get_text(strip=True) # # # Check for explicit association via 'for' attribute # label_for = label.get('for') # if label_for: # input_field = self.form.find('input', id=label_for) # if input_field: # input_name = self._get_input_identifier(input_field, unnamed_counter) # if input_name.startswith('unnamed_'): # unnamed_counter += 1 # self.fields_mapping[label_text] = input_name # processed_inputs.add(id(input_field)) # continue # # # Check for implicit association (label contains input) # input_field = label.find('input') # if input_field: # input_name = self._get_input_identifier(input_field, unnamed_counter) # if input_name.startswith('unnamed_'): # unnamed_counter += 1 # self.fields_mapping[label_text] = input_name # processed_inputs.add(id(input_field)) # continue # # # Priority 3 & 4: Parent-level associations # for label in self.form.find_all('label'): # label_text = label.get_text(strip=True) # # # Skip if this label was already processed # if label_text in self.fields_mapping: # continue # # parent = label.parent # if parent: # input_found = False # # # Priority 3: Look for sibling input with matching for/id # label_for = label.get('for') # if label_for: # for sibling in parent.find_all('input'): # if sibling.get('id') == label_for and id(sibling) not in processed_inputs: # input_name = self._get_input_identifier(sibling, unnamed_counter) # if input_name.startswith('unnamed_'): # unnamed_counter += 1 # self.fields_mapping[label_text] = input_name # processed_inputs.add(id(sibling)) # input_found = True # break # # # Priority 4: Fallback to proximity if no input found yet # if not input_found: # for sibling in parent.find_all('input'): # if id(sibling) not in processed_inputs: # input_name = self._get_input_identifier(sibling, unnamed_counter) # if input_name.startswith('unnamed_'): # unnamed_counter += 1 # self.fields_mapping[label_text] = input_name # processed_inputs.add(id(sibling)) # break # # # Priority 5: Inputs without labels # for input_field in all_inputs: # if id(input_field) not in processed_inputs: # input_name = self._get_input_identifier(input_field, unnamed_counter) # if input_name.startswith('unnamed_'): # unnamed_counter += 1 # self.fields_mapping[input_name] = input_name # # @staticmethod # def _get_input_identifier(input_field, counter): # """ # Get the identifier for an input field. # # Args: # input_field: The BeautifulSoup Tag object representing the input. # counter: Current counter for unnamed inputs. # # Returns: # The input name, id, or a generated "unnamed_X" identifier. # """ # if input_field.get('name'): # return input_field['name'] # elif input_field.get('id'): # return input_field['id'] # else: # return f"unnamed_{counter}" # # @staticmethod # def _convert_number(value): # """ # Convert a string value to int or float. # # Args: # value: String value to convert. # # Returns: # int, float, or empty string if conversion fails. # """ # if not value or value.strip() == '': # return '' # # try: # # Try float first to detect decimal numbers # if '.' in value or 'e' in value.lower(): # return float(value) # else: # return int(value) # except ValueError: # return value # # @staticmethod # def _convert_value(value): # """ # Analyze and convert a value to its appropriate type. # # Conversion priority: # 1. Boolean keywords (true/false) # 2. Float (contains decimal point) # 3. Int (numeric) # 4. Empty string # 5. String (default) # # Args: # value: String value to convert. # # Returns: # Converted value with appropriate type (bool, float, int, or str). # """ # if not value or value.strip() == '': # return '' # # value_lower = value.lower().strip() # # # Check for boolean # if value_lower in ('true', 'false'): # return value_lower == 'true' # # # Check for numeric values # try: # # Check for float (has decimal point or scientific notation) # if '.' in value or 'e' in value_lower: # return float(value) # # Try int # else: # return int(value) # except ValueError: # pass # # # Default to string # return value class TestableControl(TestableElement): def __init__(self, client, source, tag): super().__init__(client, source, "input") assert len(self.fields) <= 1 self._input_name = next(iter(self.fields)) @property def name(self): return self._input_name @property def value(self): return self.fields[self._input_name] def _send_value(self): if self._input_name and self._support_htmx(): value = {} if self.value is DoNotSend else {self._input_name: self.value} return self._send_htmx_request(data=value) return None class TestableInput(TestableControl): def __init__(self, client, source): super().__init__(client, source, "input") def send(self, value): self.fields[self.name] = value return self._send_value() class TestableCheckbox(TestableControl): def __init__(self, client, source): super().__init__(client, source, "input") @property def is_checked(self): return self.fields[self._input_name] == True def check(self): self.fields[self._input_name] = "on" return self._send_value() def uncheck(self): self.fields[self._input_name] = DoNotSend return self._send_value() def toggle(self): if self.fields[self._input_name] == "on": return self.uncheck() else: return self.check() 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") nb_radio_buttons = len(self.element.find_all("input", type="radio")) assert nb_radio_buttons > 0, "No radio buttons found." assert nb_radio_buttons < 2, "Only one radio button per name is supported." self._radio_value = self.my_ft.attrs.get('value', '') @property 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': # t = tag.get('type', 'text').lower() # if t in ('checkbox', 'radio'): # # For checkbox/radio: return True/False if checked, else value if defined # return tag.has_attr('checked') # return tag.get('value', '') # # elif tag.name == 'textarea': # # Textarea content is its text, not an attribute # return tag.text or '' # # elif tag.name == 'select': # # For