Updated README.md.

Added other TestableControls
This commit is contained in:
2025-11-02 21:54:14 +01:00
parent 9696e67910
commit cc11e4edaa
9 changed files with 1990 additions and 4 deletions

View File

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