2 Commits

Author SHA1 Message Date
09d012d065 Working on LoginPage tests 2025-10-26 22:45:34 +01:00
b98e52378e Adding test for LoginPage 2025-10-26 20:26:32 +01:00
17 changed files with 1920 additions and 345 deletions

1
.gitignore vendored
View File

@@ -22,6 +22,7 @@ tools.db
.idea/sqldialects.xml .idea/sqldialects.xml
.idea_bak .idea_bak
**/*.prof **/*.prof
**/*.db
# Created by .ignore support plugin (hsz.mobi) # Created by .ignore support plugin (hsz.mobi)
### Python template ### Python template

View File

@@ -14,5 +14,9 @@ clean-build: clean-package
find . -name "*.pyc" -exec rm -f {} + find . -name "*.pyc" -exec rm -f {} +
find . -name "*.pyo" -exec rm -f {} + find . -name "*.pyo" -exec rm -f {} +
clean-tests:
rm -rf tests/.sesskey
rm -rf tests/Users.db
# Alias to clean everything # Alias to clean everything
clean: clean-build clean: clean-build clean-tests

Binary file not shown.

View File

@@ -20,7 +20,7 @@ daisyui_online_links = [
] ]
app, rt = fast_app( app, rt = fast_app(
# before=beforeware, before=beforeware,
hdrs=tuple(daisyui_offline_links) hdrs=tuple(daisyui_offline_links)
) )

View File

@@ -106,6 +106,15 @@ class RegisterPage:
cls="btn w-full font-bold py-2 px-4 rounded" cls="btn w-full font-bold py-2 px-4 rounded"
), ),
# Registration link
Div(
P(
"Already have an account? ",
A("Sign in here", href="/login", cls="text-blue-600 hover:underline"),
cls="text-sm text-gray-600 text-center"
)
),
action="register-p", action="register-p",
method="post", method="post",
cls="mb-6" cls="mb-6"

View File

@@ -18,7 +18,7 @@ from ..auth.utils import (
) )
def setup_auth_routes(app, rt, mount_auth_app=True): def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db"):
""" """
Setup all authentication and protected routes. Setup all authentication and protected routes.
@@ -26,6 +26,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True):
app: FastHTML application instance app: FastHTML application instance
rt: Route decorator from FastHTML rt: Route decorator from FastHTML
mount_auth_app: Whether to mount the auth FastApi API routes mount_auth_app: Whether to mount the auth FastApi API routes
sqlite_db_path: by default, create a new SQLite database at this path
""" """
# ============================================================================ # ============================================================================
@@ -45,7 +46,6 @@ def setup_auth_routes(app, rt, mount_auth_app=True):
""" """
return LoginPage(error_message=error) return LoginPage(error_message=error)
@rt("/login-p") @rt("/login-p")
def post(email: str, password: str, session, redirect_url: str = "/"): def post(email: str, password: str, session, redirect_url: str = "/"):
""" """
@@ -99,6 +99,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True):
Args: Args:
email: User email from form email: User email from form
username: User name of the
password: User password from form password: User password from form
confirm_password: Password confirmation from form confirm_password: Password confirmation from form
session: FastHTML session object session: FastHTML session object
@@ -107,12 +108,12 @@ def setup_auth_routes(app, rt, mount_auth_app=True):
RegisterPage with success/error message via HTMX RegisterPage with success/error message via HTMX
""" """
# Validate password confirmation # Validate password confirmation
# if password != confirm_password: if password != confirm_password:
# return RegisterPage(error_message="Passwords do not match. Please try again.") return RegisterPage(error_message="Passwords do not match. Please try again.")
#
# # Validate password length # Validate password length
# if len(password) < 8: if len(password) < 8:
# return RegisterPage(error_message="Password must be at least 8 characters long.") return RegisterPage(error_message="Password must be at least 8 characters long.")
# Attempt registration # Attempt registration
result = register_user(email, username, password) result = register_user(email, username, password)
@@ -187,7 +188,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True):
def mount_auth_fastapi_api(): def mount_auth_fastapi_api():
# Mount FastAPI auth backend # Mount FastAPI auth backend
auth_api = create_auth_app_for_sqlite("Users.db", "jwt-secret-to-change") auth_api = create_auth_app_for_sqlite(sqlite_db_path, "jwt-secret-to-change")
app.mount("/auth", auth_api) app.mount("/auth", auth_api)
if mount_auth_app: if mount_auth_app:

View File

@@ -27,8 +27,10 @@ DEFAULT_SKIP_PATTERNS = [
r'.*\.css', r'.*\.css',
r'.*\.js', r'.*\.js',
'/login', '/login',
'/login2', '/login-p',
'/register', '/register',
'/register-p',
'/logout',
] ]

View File

@@ -2,6 +2,7 @@ import dataclasses
import json import json
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from typing import Self
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
from fastcore.xml import FT, to_xml from fastcore.xml import FT, to_xml
@@ -130,6 +131,349 @@ class TestableElement:
# Send the request # Send the request
return self.client.send_request(method, url, headers=headers, data=data, json_data=json_data) return self.client.send_request(method, url, headers=headers, data=data, json_data=json_data)
def _support_htmx(self):
"""Check if the element supports HTMX."""
return ('hx_get' in self.ft.attrs or
'hx-get' in self.ft.attrs or
'hx_post' in self.ft.attrs or
'hx-post' in self.ft.attrs)
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)
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 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 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.form.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.form.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):
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 MyTestClient: class MyTestClient:
""" """
@@ -158,7 +502,7 @@ class MyTestClient:
# make sure that the commands are mounted # make sure that the commands are mounted
mount_commands(self.app) mount_commands(self.app)
def open(self, path: str): def open(self, path: str) -> Self:
""" """
Open a page and store its content for subsequent assertions. Open a page and store its content for subsequent assertions.
@@ -182,7 +526,9 @@ class MyTestClient:
return self return self
def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None): def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None):
if json_data is not None:
json_data['session'] = self._session json_data['session'] = self._session
res = self.client.request( res = self.client.request(
method, method,
url, url,
@@ -199,7 +545,7 @@ class MyTestClient:
self.set_content(res.text) self.set_content(res.text)
return self return self
def should_see(self, text: str): def should_see(self, text: str) -> Self:
""" """
Assert that the given text is present in the visible page content. Assert that the given text is present in the visible page content.
@@ -216,6 +562,11 @@ class MyTestClient:
AssertionError: If the text is not found in the page content. AssertionError: If the text is not found in the page content.
ValueError: If no page has been opened yet. ValueError: If no page has been opened yet.
""" """
def clean_text(txt):
return "\n".join(line for line in txt.splitlines() if line.strip())
if self._content is None: if self._content is None:
raise ValueError( raise ValueError(
"No page content available. Call open() before should_see()." "No page content available. Call open() before should_see()."
@@ -226,7 +577,7 @@ class MyTestClient:
if text not in visible_text: if text not in visible_text:
# Provide a snippet of the actual content for debugging # Provide a snippet of the actual content for debugging
snippet_length = 200 snippet_length = 200
content_snippet = ( content_snippet = clean_text(
visible_text[:snippet_length] + "..." visible_text[:snippet_length] + "..."
if len(visible_text) > snippet_length if len(visible_text) > snippet_length
else visible_text else visible_text
@@ -238,7 +589,7 @@ class MyTestClient:
return self return self
def should_not_see(self, text: str): def should_not_see(self, text: str) -> Self:
""" """
Assert that the given text is NOT present in the visible page content. Assert that the given text is NOT present in the visible page content.
@@ -281,7 +632,7 @@ class MyTestClient:
return self return self
def find_element(self, selector: str): def find_element(self, selector: str) -> TestableElement:
""" """
Find a single HTML element using a CSS selector. Find a single HTML element using a CSS selector.
@@ -320,6 +671,40 @@ class MyTestClient:
f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1." f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1."
) )
def find_form(self, fields: list = None) -> TestableForm:
"""
Find a form element in the page content.
Can provide title of the fields to ease the search
:param fields:
:return:
"""
if self._content is None:
raise ValueError(
"No page content available. Call open() before find_form()."
)
results = self._soup.select("form")
if len(results) == 0:
raise AssertionError(
f"No form found."
)
if fields is None:
remaining = [TestableForm(self, form) for form in results]
else:
remaining = []
for form in results:
testable_form = TestableForm(self, form)
if all(testable_form.translate(field) in testable_form.fields for field in fields):
remaining.append(testable_form)
if len(remaining) == 1:
return remaining[0]
else:
raise AssertionError(
f"Found {len(remaining)} forms (with the specified fields). Expected exactly 1."
)
def get_content(self) -> str: def get_content(self) -> str:
""" """
Get the raw HTML content of the last opened page. Get the raw HTML content of the last opened page.
@@ -329,7 +714,7 @@ class MyTestClient:
""" """
return self._content return self._content
def set_content(self, content: str): def set_content(self, content: str) -> Self:
""" """
Set the HTML content and parse it with BeautifulSoup. Set the HTML content and parse it with BeautifulSoup.
@@ -338,6 +723,7 @@ class MyTestClient:
""" """
self._content = content self._content = content
self._soup = BeautifulSoup(content, 'html.parser') self._soup = BeautifulSoup(content, 'html.parser')
return self
@staticmethod @staticmethod
def _find_visible_text_element(soup, text: str): def _find_visible_text_element(soup, text: str):

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

59
tests/auth/test_login.py Normal file
View File

@@ -0,0 +1,59 @@
import os
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.auth.routes import setup_auth_routes
from myfasthtml.auth.utils import create_auth_beforeware, register_user
from myfasthtml.core.testclient import MyTestClient
@pytest.fixture()
def app():
beforeware = create_auth_beforeware()
test_app, test_rt = fast_app(before=beforeware)
setup_auth_routes(test_app, test_rt, mount_auth_app=True, sqlite_db_path="TestUsers.db")
return test_app
@pytest.fixture()
def rt(app):
return app.route
@pytest.fixture()
def user(app):
user = MyTestClient(app)
return user
@pytest.fixture(autouse=True)
def cleanup():
if os.path.exists("TestUsers.db"):
os.remove("TestUsers.db")
def test_i_can_see_login_page(user):
user.open("/login")
user.should_see("Sign In")
user.should_see("Register here")
user.find_form(fields=["Email", "Password"])
def test_i_cannot_login_with_wrong_credentials(user):
user.open("/login")
form = user.find_form(fields=["Email", "Password"])
form.fill(Email="user@email.com", Password="#Passw0rd")
form.submit()
user.should_see("Invalid email or password. Please try again.")
def test_i_can_login_with_correct_credentials(user):
# create user
register_user("user@email.com", "user", "#Passw0rd")
user.open("/login")
form = user.find_form(fields=["Email", "Password"])
form.fill(Email="user@email.com", Password="#Passw0rd")
form.submit()
user.should_see("You are now logged in")

View File

33
tests/auth/test_utils.py Normal file
View File

@@ -0,0 +1,33 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.auth.utils import create_auth_beforeware
from myfasthtml.core.testclient import MyTestClient
def test_non_protected_route():
app, rt = fast_app()
user = MyTestClient(app)
@rt('/')
def index(): return "Welcome"
@rt('/login')
def index(): return "Sign In"
user.open("/")
user.should_see("Welcome")
def test_all_routes_are_protected():
beforeware = create_auth_beforeware()
app, rt = fast_app(before=beforeware)
user = MyTestClient(app)
@rt('/')
def index(): return "Welcome"
@rt('/login')
def index(): return "Sign In"
user.open("/")
user.should_see("Sign In")

View File

@@ -1,326 +0,0 @@
import pytest
from fasthtml.components import Div
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import MyTestClient, TestableElement
def test_i_can_open_a_page():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get(): return "hello world"
client.open("/")
assert client.get_content() == "hello world"
def test_i_can_open_a_page_when_html():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get(): return Div("hello world")
client.open("/")
assert client.get_content() == ' <!doctype html>\n <html>\n <head>\n <title>FastHTML page</title>\n <link rel="canonical" href="http://testserver/">\n </head>\n <body>\n <div>hello world</div>\n </body>\n </html>\n'
def test_i_cannot_open_a_page_not_defined():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(AssertionError) as exc_info:
client.open("/not_found")
assert str(exc_info.value) == "Failed to open '/not_found'. status code=404 : reason='404 Not Found'"
def test_i_can_see_text_in_plain_response():
"""Test that should_see() works with plain text responses."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
client.open("/").should_see("hello world")
def test_i_can_see_text_in_html_response():
"""Test that should_see() extracts visible text from HTML responses."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "<html><body><h1>Welcome</h1><p>This is a test</p></body></html>"
client.open("/").should_see("Welcome").should_see("This is a test")
def test_i_can_see_text_ignoring_html_tags():
"""Test that should_see() searches in visible text only, not in HTML tags."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container">Content</div>'
# Should find the visible text
client.open("/").should_see("Content")
# Should NOT find text that's only in attributes/tags
with pytest.raises(AssertionError) as exc_info:
client.should_see("container")
assert "Expected to see 'container' in page content but it was not found" in str(exc_info.value)
def test_i_cannot_see_text_that_is_not_present():
"""Test that should_see() raises AssertionError when text is not found."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_see("goodbye")
assert "Expected to see 'goodbye' in page content but it was not found" in str(exc_info.value)
assert "hello world" in str(exc_info.value)
def test_i_cannot_call_should_see_without_opening_page():
"""Test that should_see() raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.should_see("anything")
assert str(exc_info.value) == "No page content available. Call open() before should_see()."
def test_i_can_verify_text_is_not_present():
"""Test that should_not_see() works when text is absent."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
client.open("/").should_not_see("goodbye")
def test_i_cannot_use_should_not_see_when_text_is_present():
"""Test that should_not_see() raises AssertionError when text is found."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("hello")
error_message = str(exc_info.value)
assert "Expected NOT to see 'hello' in page content but it was found" in error_message
def test_i_cannot_call_should_not_see_without_opening_page():
"""Test that should_not_see() raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.should_not_see("anything")
assert str(exc_info.value) == "No page content available. Call open() before should_not_see()."
def test_i_can_chain_multiple_assertions():
"""Test that assertions can be chained together."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "<html><body><h1>Welcome</h1><p>Content here</p></body></html>"
# Chain multiple assertions
client.open("/").should_see("Welcome").should_see("Content").should_not_see("Error")
def test_i_can_see_element_context_when_text_should_not_be_seen():
"""Test that the HTML element containing the text is displayed with parent context."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container"><p class="content">forbidden text</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("forbidden text")
error_message = str(exc_info.value)
assert "Found in:" in error_message
assert '<p class="content">forbidden text</p>' in error_message
assert '<div class="container">' in error_message
def test_i_can_configure_parent_levels_in_constructor():
"""Test that parent_levels parameter controls the number of parent levels shown."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app, parent_levels=2)
@rt('/')
def get():
return '<body><div class="wrapper"><div class="container"><p>error</p></div></div></body>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("error")
error_message = str(exc_info.value)
assert '<p>error</p>' in error_message
assert '<div class="container">' in error_message
assert '<div class="wrapper">' in error_message
def test_i_can_find_text_in_nested_elements():
"""Test that the smallest element containing the text is found in nested structures."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div><section><article><p class="target">nested text</p></article></section></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("nested text")
error_message = str(exc_info.value)
# Should find the <p> element, not the outer <div>
assert '<p class="target">nested text</p>' in error_message
def test_i_can_find_fragmented_text_across_tags():
"""Test that text fragmented across multiple tags is correctly found."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<p class="message">hel<span>lo</span> world</p>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("hello world")
error_message = str(exc_info.value)
# Should find the parent <p> element that contains the full text
assert '<p class="message">' in error_message
def test_i_do_not_find_text_in_html_attributes():
"""Test that text in HTML attributes is not considered as visible text."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="error message" title="error info">Success</div>'
# "error" is in attributes but not in visible text
client.open("/").should_not_see("error")
# "Success" is in visible text
with pytest.raises(AssertionError):
client.should_not_see("Success")
@pytest.mark.parametrize("selector,expected_tag", [
("#unique-id", '<div class="main wrapper"'),
(".home-link", '<a class="link home-link"'),
("div > div", '<div class="content"'),
("div span", "<span"),
("[data-type]", '<a class="link home-link"'),
('[data-type="navigation"]', '<a class="link home-link"'),
('[class~="link"]', '<a class="link home-link"'),
('[href^="/home"]', '<a class="link home-link"'),
('[href$="about"]', '<a href="/about">'),
('[data-author*="john"]', '<a class="link home-link"'),
])
def test_i_can_find_element(selector, expected_tag):
"""Test that find_element works with various CSS selectors."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<div id="unique-id" class="main wrapper">
<a href="/home" class="link home-link" data-type="navigation" data-author="john-doe">Home</a>
<a href="/about">About</a>
<div class="content">
<span class="text">Content</span>
</div>
</div>
'''
element = client.open("/").find_element(selector)
assert element is not None
assert isinstance(element, TestableElement)
assert expected_tag in element.html_fragment
def test_i_cannot_find_element_when_none_exists():
"""Test that find_element raises AssertionError when no element matches."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container"><p>Content</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_element("#non-existent")
assert "No element found matching selector '#non-existent'" in str(exc_info.value)
def test_i_cannot_find_element_when_multiple_exist():
"""Test that find_element raises AssertionError when multiple elements match."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div><p class="text">First</p><p class="text">Second</p><p class="text">Third</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_element(".text")
error_message = str(exc_info.value)
assert "Found 3 elements matching selector '.text'" in error_message
assert "Expected exactly 1" in error_message
def test_i_cannot_call_find_element_without_opening_page():
"""Test that find_element raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.find_element("#any-selector")
assert str(exc_info.value) == "No page content available. Call open() before find_element()."

View File

View File

@@ -0,0 +1,484 @@
import pytest
from fasthtml.components import Div
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import MyTestClient, TestableElement, TestableForm
class TestMyTestClientOpen:
def test_i_can_open_a_page(self):
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get(): return "hello world"
client.open("/")
assert client.get_content() == "hello world"
def test_i_can_open_a_page_when_html(self):
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get(): return Div("hello world")
client.open("/")
assert client.get_content() == ' <!doctype html>\n <html>\n <head>\n <title>FastHTML page</title>\n <link rel="canonical" href="http://testserver/">\n </head>\n <body>\n <div>hello world</div>\n </body>\n </html>\n'
def test_i_cannot_open_a_page_not_defined(self):
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(AssertionError) as exc_info:
client.open("/not_found")
assert str(exc_info.value) == "Failed to open '/not_found'. status code=404 : reason='404 Not Found'"
class TestMyTestClientShouldSee:
def test_i_can_see_text_in_plain_response(self):
"""Test that should_see() works with plain text responses."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
client.open("/").should_see("hello world")
def test_i_can_see_text_in_html_response(self):
"""Test that should_see() extracts visible text from HTML responses."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "<html><body><h1>Welcome</h1><p>This is a test</p></body></html>"
client.open("/").should_see("Welcome").should_see("This is a test")
def test_i_can_see_text_ignoring_html_tags(self):
"""Test that should_see() searches in visible text only, not in HTML tags."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container">Content</div>'
# Should find the visible text
client.open("/").should_see("Content")
# Should NOT find text that's only in attributes/tags
with pytest.raises(AssertionError) as exc_info:
client.should_see("container")
assert "Expected to see 'container' in page content but it was not found" in str(exc_info.value)
def test_i_cannot_see_text_that_is_not_present(self):
"""Test that should_see() raises AssertionError when text is not found."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_see("goodbye")
assert "Expected to see 'goodbye' in page content but it was not found" in str(exc_info.value)
assert "hello world" in str(exc_info.value)
def test_i_cannot_call_should_see_without_opening_page(self):
"""Test that should_see() raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.should_see("anything")
assert str(exc_info.value) == "No page content available. Call open() before should_see()."
def test_i_can_verify_text_is_not_present(self):
"""Test that should_not_see() works when text is absent."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
client.open("/").should_not_see("goodbye")
def test_i_cannot_use_should_not_see_when_text_is_present(self):
"""Test that should_not_see() raises AssertionError when text is found."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("hello")
error_message = str(exc_info.value)
assert "Expected NOT to see 'hello' in page content but it was found" in error_message
def test_i_cannot_call_should_not_see_without_opening_page(self):
"""Test that should_not_see() raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.should_not_see("anything")
assert str(exc_info.value) == "No page content available. Call open() before should_not_see()."
def test_i_can_chain_multiple_assertions(self):
"""Test that assertions can be chained together."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "<html><body><h1>Welcome</h1><p>Content here</p></body></html>"
# Chain multiple assertions
client.open("/").should_see("Welcome").should_see("Content").should_not_see("Error")
def test_i_can_see_element_context_when_text_should_not_be_seen(self):
"""Test that the HTML element containing the text is displayed with parent context."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container"><p class="content">forbidden text</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("forbidden text")
error_message = str(exc_info.value)
assert "Found in:" in error_message
assert '<p class="content">forbidden text</p>' in error_message
assert '<div class="container">' in error_message
def test_i_can_configure_parent_levels_in_constructor(self):
"""Test that parent_levels parameter controls the number of parent levels shown."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app, parent_levels=2)
@rt('/')
def get():
return '<body><div class="wrapper"><div class="container"><p>error</p></div></div></body>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("error")
error_message = str(exc_info.value)
assert '<p>error</p>' in error_message
assert '<div class="container">' in error_message
assert '<div class="wrapper">' in error_message
def test_i_can_find_text_in_nested_elements(self):
"""Test that the smallest element containing the text is found in nested structures."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div><section><article><p class="target">nested text</p></article></section></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("nested text")
error_message = str(exc_info.value)
# Should find the <p> element, not the outer <div>
assert '<p class="target">nested text</p>' in error_message
def test_i_can_find_fragmented_text_across_tags(self):
"""Test that text fragmented across multiple tags is correctly found."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<p class="message">hel<span>lo</span> world</p>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("hello world")
error_message = str(exc_info.value)
# Should find the parent <p> element that contains the full text
assert '<p class="message">' in error_message
def test_i_do_not_find_text_in_html_attributes(self):
"""Test that text in HTML attributes is not considered as visible text."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="error message" title="error info">Success</div>'
# "error" is in attributes but not in visible text
client.open("/").should_not_see("error")
# "Success" is in visible text
with pytest.raises(AssertionError):
client.should_not_see("Success")
class TestMyTestClientFindElement:
@pytest.mark.parametrize("selector,expected_tag", [
("#unique-id", '<div class="main wrapper"'),
(".home-link", '<a class="link home-link"'),
("div > div", '<div class="content"'),
("div span", "<span"),
("[data-type]", '<a class="link home-link"'),
('[data-type="navigation"]', '<a class="link home-link"'),
('[class~="link"]', '<a class="link home-link"'),
('[href^="/home"]', '<a class="link home-link"'),
('[href$="about"]', '<a href="/about">'),
('[data-author*="john"]', '<a class="link home-link"'),
])
def test_i_can_find_element(self, selector, expected_tag):
"""Test that find_element works with various CSS selectors."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<div id="unique-id" class="main wrapper">
<a href="/home" class="link home-link" data-type="navigation" data-author="john-doe">Home</a>
<a href="/about">About</a>
<div class="content">
<span class="text">Content</span>
</div>
</div>
'''
element = client.open("/").find_element(selector)
assert element is not None
assert isinstance(element, TestableElement)
assert expected_tag in element.html_fragment
def test_i_cannot_find_element_when_none_exists(self):
"""Test that find_element raises AssertionError when no element matches."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container"><p>Content</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_element("#non-existent")
assert "No element found matching selector '#non-existent'" in str(exc_info.value)
def test_i_cannot_find_element_when_multiple_exist(self):
"""Test that find_element raises AssertionError when multiple elements match."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div><p class="text">First</p><p class="text">Second</p><p class="text">Third</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_element(".text")
error_message = str(exc_info.value)
assert "Found 3 elements matching selector '.text'" in error_message
assert "Expected exactly 1" in error_message
def test_i_cannot_call_find_element_without_opening_page(self):
"""Test that find_element raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.find_element("#any-selector")
assert str(exc_info.value) == "No page content available. Call open() before find_element()."
class TestMyTestClientFindForm:
def test_i_can_find_form(self):
"""Test that find_form works fo simple form."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''<form></form>'''
form = client.open("/").find_form()
assert form is not None
assert isinstance(form, TestableForm)
def test_i_can_find_form_in_nested_elements(self):
"""Test that find_form works when form is nested in other elements."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<div class="container">
<div class="wrapper">
<form></form>
</div>
</div>
'''
form = client.open("/").find_form()
assert form is not None
assert isinstance(form, TestableForm)
def test_i_can_find_form_with_all_fields(self):
"""Test that find_form works when form has fields."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<form>
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign In</button>
</form>
'''
form = client.open("/").find_form(fields=["username", "password"])
assert form is not None
assert isinstance(form, TestableForm)
def test_i_can_find_form_with_label(self):
"""Test that find_form works when form has fields."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<form>
<label for="username">Username</label>
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign In</button>
</form>
'''
form = client.open("/").find_form(fields=["Username", "password"])
assert form is not None
assert isinstance(form, TestableForm)
def test_i_can_find_form_with_one_field(self):
"""Test that find_form works when form has at least one field."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<form>
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign In</button>
</form>
'''
form = client.open("/").find_form(fields=["username"])
assert form is not None
assert isinstance(form, TestableForm)
def test_i_cannot_find_element_when_none_exists(self):
"""Test that find_form raises AssertionError when no form found."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container"><p>Content</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_form()
assert "No form found" in str(exc_info.value)
def test_i_cannot_call_find_form_without_opening_page(self):
"""Test that find_form raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.find_form()
assert str(exc_info.value) == "No page content available. Call open() before find_form()."
def test_cannot_find_form_when_multiple_exist(self):
"""Test that find_form raises AssertionError when multiple forms match."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<div class="container">
<form></form>
<form></form>
</div>
'''
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_form()
error_message = str(exc_info.value)
assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message
def test_cannot_find_form_with_fields_when_multiple_exist(self):
"""Test that find_form raises AssertionError when multiple forms match."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<div class="container">
<form>
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign In</button>
</form>
<form>
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign In</button>
</form>
</div>
'''
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_form(fields=["username", "password"])
error_message = str(exc_info.value)
assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message

View File

@@ -0,0 +1,922 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import TestableForm, MyTestClient
@pytest.fixture
def mock_client():
"""Mock client for testing purposes."""
return None
class TestableFormUpdateFieldMapping:
def test_i_can_map_label_with_explicit_for_attribute(self, mock_client):
"""
Test that labels with explicit 'for' attribute are correctly mapped.
This is the most reliable association method (priority 1).
"""
html = '<form><label for="uid">Username</label><input id="uid" name="username" /></form>'
form = TestableForm(mock_client, html)
assert form.fields_mapping == {"Username": "username"}
def test_i_can_map_label_containing_input(self, mock_client):
"""
Test that labels containing inputs are correctly mapped.
This tests implicit association by nesting (priority 2).
"""
html = '<form><label>Username<input name="username" /></label></form>'
form = TestableForm(mock_client, html)
assert form.fields_mapping == {"Username": "username"}
def test_i_can_map_label_and_input_as_siblings_with_for_id(self, mock_client):
"""
Test that sibling labels and inputs with for/id are correctly mapped.
This tests parent-level association with explicit for/id (priority 3).
"""
html = '<form><div><label for="uid">Username</label><input id="uid" name="username" /></div></form>'
form = TestableForm(mock_client, html)
assert form.fields_mapping == {"Username": "username"}
def test_i_can_map_label_and_input_as_siblings_by_proximity(self, mock_client):
"""
Test that sibling labels and inputs are mapped by proximity.
This tests association by proximity without for/id (priority 4).
"""
html = '<form><div><label>Username</label><input name="username" /></div></form>'
form = TestableForm(mock_client, html)
assert form.fields_mapping == {"Username": "username"}
def test_i_can_map_input_without_label_using_name(self, mock_client):
"""
Test that inputs without labels use their name attribute as key.
This tests the fallback mechanism (priority 5).
"""
html = '<form><input name="csrf_token" /></form>'
form = TestableForm(mock_client, html)
assert form.fields_mapping == {"csrf_token": "csrf_token"}
def test_i_can_map_input_without_name_using_id(self, mock_client):
"""
Test that inputs without name attribute fallback to id attribute.
This ensures inputs without name can still be identified.
"""
html = '<form><input id="submit_btn" /></form>'
form = TestableForm(mock_client, html)
assert form.fields_mapping == {"submit_btn": "submit_btn"}
def test_i_can_map_input_without_name_and_id_using_unnamed(self, mock_client):
"""
Test that inputs without name or id get a generated unnamed key.
This ensures all inputs are tracked even without identifiers.
"""
html = '<form><input type="submit" /></form>'
form = TestableForm(mock_client, html)
assert form.fields_mapping == {"unnamed_0": "unnamed_0"}
def test_i_can_handle_multiple_unnamed_inputs(self, mock_client):
"""
Test that multiple unnamed inputs get incrementing counters.
This ensures each unnamed input has a unique identifier.
"""
html = '<form><input type="submit" /><input type="button" /></form>'
form = TestableForm(mock_client, html)
assert form.fields_mapping == {"unnamed_0": "unnamed_0", "unnamed_1": "unnamed_1"}
def test_i_can_strip_whitespace_from_label_text(self, mock_client):
"""
Test that whitespace and newlines are stripped from label text.
This ensures clean, consistent label keys in the mapping.
"""
html = '<form><label for="uid"> Username \n</label><input id="uid" name="username" /></form>'
form = TestableForm(mock_client, html)
assert form.fields_mapping == {"Username": "username"}
def test_i_can_extract_text_from_complex_labels(self, mock_client):
"""
Test that text from nested elements in labels is extracted.
This ensures labels with spans, emphasis, etc. are handled correctly.
"""
html = '<form><label for="uid">Username <span class="required">*</span></label><input id="uid" name="username" /></form>'
form = TestableForm(mock_client, html)
assert form.fields_mapping == {"Username*": "username"}
def test_i_can_handle_mixed_scenarios_in_same_form(self, mock_client):
"""
Test that all association priorities work together in one form.
This is a comprehensive test ensuring the priority system works correctly.
"""
html = '''
<form>
<label for="email">Email</label>
<input id="email" name="email" />
<label>Password<input name="password" /></label>
<div>
<label for="phone">Phone</label>
<input id="phone" name="phone" />
</div>
<div>
<label>Address</label>
<input name="address" />
</div>
<input name="csrf_token" />
<input id="submit_btn" />
<input type="hidden" />
</form>
'''
form = TestableForm(mock_client, html)
expected = {
"Email": "email",
"Password": "password",
"Phone": "phone",
"Address": "address",
"csrf_token": "csrf_token",
"submit_btn": "submit_btn",
"unnamed_0": "unnamed_0"
}
assert form.fields_mapping == expected
def test_i_can_handle_empty_form(self, mock_client):
"""
Test that an empty form doesn't cause errors.
This ensures robustness when dealing with minimal forms.
"""
html = '<form></form>'
form = TestableForm(mock_client, html)
assert form.fields_mapping == {}
def test_i_can_handle_form_with_only_labels(self, mock_client):
"""
Test that labels without associated inputs don't cause errors.
This ensures the code handles malformed or incomplete forms gracefully.
"""
html = '<form><label>Test</label></form>'
form = TestableForm(mock_client, html)
assert form.fields_mapping == {}
def test_i_can_handle_label_with_invalid_for_attribute(self, mock_client):
"""
Test that labels with invalid 'for' attributes fallback correctly.
This ensures the priority system cascades properly when higher
priorities fail to find a match.
"""
html = '<form><div><label for="nonexistent">Test</label><input name="field" /></div></form>'
form = TestableForm(mock_client, html)
assert form.fields_mapping == {"Test": "field"}
class TestableFormUpdateFieldValues:
def test_i_can_handle_checkbox_checked(self, mock_client):
"""
Test that a checked checkbox is converted to True.
This ensures proper boolean handling for checked checkboxes.
"""
html = '<form><input type="checkbox" name="agree" checked /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {"agree": True}, \
f"Expected {{'agree': True}}, got {form.fields}"
def test_i_can_handle_checkbox_unchecked(self, mock_client):
"""
Test that an unchecked checkbox is converted to False.
This ensures proper boolean handling for unchecked checkboxes.
"""
html = '<form><input type="checkbox" name="agree" /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {"agree": False}, \
f"Expected {{'agree': False}}, got {form.fields}"
def test_i_can_handle_radio_button_checked(self, mock_client):
"""
Test that a checked radio button returns its value as string.
This ensures radio buttons store their value attribute.
"""
html = '<form><input type="radio" name="size" value="large" checked /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {"size": "large"}, \
f"Expected {{'size': 'large'}}, got {form.fields}"
def test_i_can_handle_multiple_radio_buttons_with_one_checked(self, mock_client):
"""
Test that only the checked radio button value is returned.
This ensures correct handling of radio button groups.
"""
html = '''
<form>
<input type="radio" name="size" value="small" />
<input type="radio" name="size" value="medium" checked />
<input type="radio" name="size" value="large" />
</form>
'''
form = TestableForm(mock_client, html)
assert form.fields == {"size": "medium"}, \
f"Expected {{'size': 'medium'}}, got {form.fields}"
def test_i_can_handle_radio_buttons_with_none_checked(self, mock_client):
"""
Test that no value is set when no radio button is checked.
This ensures proper handling of unchecked radio button groups.
"""
html = '''
<form>
<input type="radio" name="size" value="small" />
<input type="radio" name="size" value="medium" />
<input type="radio" name="size" value="large" />
</form>
'''
form = TestableForm(mock_client, html)
assert "size" not in form.fields, \
f"Expected 'size' not in fields, got {form.fields}"
def test_i_can_handle_number_input_with_integer(self, mock_client):
"""
Test that a number input with integer value becomes int.
This ensures proper type conversion for integer numbers.
"""
html = '<form><input type="number" name="age" value="25" /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {"age": 25}, \
f"Expected {{'age': 25}}, got {form.fields}"
assert isinstance(form.fields["age"], int), \
f"Expected int type, got {type(form.fields['age'])}"
def test_i_can_handle_number_input_with_float(self, mock_client):
"""
Test that a number input with decimal value becomes float.
This ensures proper type conversion for floating point numbers.
"""
html = '<form><input type="number" name="price" value="19.99" /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {"price": 19.99}, \
f"Expected {{'price': 19.99}}, got {form.fields}"
assert isinstance(form.fields["price"], float), \
f"Expected float type, got {type(form.fields['price'])}"
def test_i_can_handle_text_input_with_string_value(self, mock_client):
"""
Test that a text input with string value remains str.
This ensures text values are not converted unnecessarily.
"""
html = '<form><input type="text" name="username" value="john_doe" /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {"username": "john_doe"}, \
f"Expected {{'username': 'john_doe'}}, got {form.fields}"
assert isinstance(form.fields["username"], str), \
f"Expected str type, got {type(form.fields['username'])}"
def test_i_can_handle_text_input_with_integer_value(self, mock_client):
"""
Test that a text input with numeric value is converted to int.
This ensures automatic type detection for text inputs.
"""
html = '<form><input type="text" name="code" value="123" /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {"code": 123}, \
f"Expected {{'code': 123}}, got {form.fields}"
assert isinstance(form.fields["code"], int), \
f"Expected int type, got {type(form.fields['code'])}"
def test_i_can_handle_text_input_with_float_value(self, mock_client):
"""
Test that a text input with decimal value is converted to float.
This ensures automatic float detection for text inputs.
"""
html = '<form><input type="text" name="rate" value="3.14" /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {"rate": 3.14}, \
f"Expected {{'rate': 3.14}}, got {form.fields}"
assert isinstance(form.fields["rate"], float), \
f"Expected float type, got {type(form.fields['rate'])}"
def test_i_can_handle_text_input_with_boolean_true(self, mock_client):
"""
Test that a text input with 'true' is converted to bool.
This ensures boolean keyword detection for text inputs.
"""
html = '<form><input type="text" name="flag" value="true" /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {"flag": True}, \
f"Expected {{'flag': True}}, got {form.fields}"
assert isinstance(form.fields["flag"], bool), \
f"Expected bool type, got {type(form.fields['flag'])}"
def test_i_can_handle_text_input_with_boolean_false(self, mock_client):
"""
Test that a text input with 'false' is converted to bool.
This ensures boolean keyword detection for false values.
"""
html = '<form><input type="text" name="flag" value="false" /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {"flag": False}, \
f"Expected {{'flag': False}}, got {form.fields}"
assert isinstance(form.fields["flag"], bool), \
f"Expected bool type, got {type(form.fields['flag'])}"
def test_i_can_handle_hidden_input_with_auto_conversion(self, mock_client):
"""
Test that hidden inputs benefit from automatic type conversion.
This ensures hidden fields are processed like text fields.
"""
html = '<form><input type="hidden" name="id" value="42" /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {"id": 42}, \
f"Expected {{'id': 42}}, got {form.fields}"
assert isinstance(form.fields["id"], int), \
f"Expected int type, got {type(form.fields['id'])}"
def test_i_can_handle_empty_input_value(self, mock_client):
"""
Test that an empty input value remains an empty string.
This ensures empty values are not converted to None or other types.
"""
html = '<form><input type="text" name="optional" value="" /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {"optional": ""}, \
f"Expected {{'optional': ''}}, got {form.fields}"
assert isinstance(form.fields["optional"], str), \
f"Expected str type, got {type(form.fields['optional'])}"
def test_i_can_extract_select_options(self, mock_client):
"""
Test that select options are correctly extracted.
This ensures proper population of select_fields dictionary.
"""
html = '''
<form>
<select name="country">
<option value="FR">France</option>
<option value="US">USA</option>
</select>
</form>
'''
form = TestableForm(mock_client, html)
expected_options = [
{"value": "FR", "text": "France"},
{"value": "US", "text": "USA"}
]
assert form.select_fields == {"country": expected_options}, \
f"Expected {{'country': {expected_options}}}, got {form.select_fields}"
def test_i_can_handle_select_with_selected_option(self, mock_client):
"""
Test that the selected option is stored in fields.
This ensures proper detection of selected options.
"""
html = '''
<form>
<select name="country">
<option value="FR">France</option>
<option value="US" selected>USA</option>
</select>
</form>
'''
form = TestableForm(mock_client, html)
assert form.fields == {"country": "US"}, \
f"Expected {{'country': 'US'}}, got {form.fields}"
def test_i_can_handle_select_without_selected_option(self, mock_client):
"""
Test that the first option is used by default.
This ensures proper default value handling for select fields.
"""
html = '''
<form>
<select name="country">
<option value="FR">France</option>
<option value="US">USA</option>
</select>
</form>
'''
form = TestableForm(mock_client, html)
assert form.fields == {"country": "FR"}, \
f"Expected {{'country': 'FR'}}, got {form.fields}"
def test_i_can_handle_select_option_without_value_attribute(self, mock_client):
"""
Test that option text is used when value attribute is missing.
This ensures fallback to text content for options without value.
"""
html = '''
<form>
<select name="country">
<option>France</option>
<option>USA</option>
</select>
</form>
'''
form = TestableForm(mock_client, html)
expected_options = [
{"value": "France", "text": "France"},
{"value": "USA", "text": "USA"}
]
assert form.select_fields == {"country": expected_options}, \
f"Expected {{'country': {expected_options}}}, got {form.select_fields}"
def test_i_can_handle_mixed_input_types_in_same_form(self, mock_client):
"""
Test that all input types work together correctly.
This is a comprehensive test ensuring type handling is consistent.
"""
html = '''
<form>
<input type="text" name="username" value="john" />
<input type="number" name="age" value="30" />
<input type="checkbox" name="subscribe" checked />
<input type="radio" name="gender" value="male" checked />
<input type="hidden" name="token" value="123" />
<select name="country">
<option value="FR" selected>France</option>
<option value="US">USA</option>
</select>
</form>
'''
form = TestableForm(mock_client, html)
expected_fields = {
"username": "john",
"age": 30,
"subscribe": True,
"gender": "male",
"token": 123,
"country": "FR"
}
assert form.fields == expected_fields, \
f"Expected {expected_fields}, got {form.fields}"
assert "country" in form.select_fields, \
f"Expected 'country' in select_fields, got {form.select_fields}"
def test_i_can_handle_input_without_name_attribute(self, mock_client):
"""
Test that inputs without name attribute are ignored.
This ensures proper handling of unnamed inputs.
"""
html = '<form><input type="text" value="test" /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {}, \
f"Expected empty dict, got {form.fields}"
def test_i_can_handle_select_without_name_attribute(self, mock_client):
"""
Test that select elements without name attribute are ignored.
This ensures proper handling of unnamed select fields.
"""
html = '<form><select><option>Test</option></select></form>'
form = TestableForm(mock_client, html)
assert form.select_fields == {}, \
f"Expected empty dict, got {form.select_fields}"
assert form.fields == {}, \
f"Expected empty dict, got {form.fields}"
def test_i_can_handle_number_input_with_empty_value(self, mock_client):
"""
Test that a number input with empty value remains empty string.
This ensures empty values are not converted to 0 or None.
"""
html = '<form><input type="number" name="count" value="" /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {"count": ""}, \
f"Expected {{'count': ''}}, got {form.fields}"
def test_i_can_handle_select_with_empty_options(self, mock_client):
"""
Test behavior with a select element without options.
This ensures robustness when dealing with empty selects.
"""
html = '<form><select name="empty"></select></form>'
form = TestableForm(mock_client, html)
assert form.select_fields == {"empty": []}, \
f"Expected {{'empty': []}}, got {form.select_fields}"
assert "empty" not in form.fields, \
f"Expected 'empty' not in fields, got {form.fields}"
def test_i_can_handle_case_insensitive_boolean_values(self, mock_client):
"""
Test that boolean values are case-insensitive.
This ensures 'TRUE', 'True', 'FALSE', 'False' are all converted properly.
"""
html = '<form><input type="text" name="flag" value="TRUE" /></form>'
form = TestableForm(mock_client, html)
assert form.fields == {"flag": True}, \
f"Expected {{'flag': True}}, got {form.fields}"
assert isinstance(form.fields["flag"], bool), \
f"Expected bool type, got {type(form.fields['flag'])}"
class TestMyTestClientFill:
def test_i_can_fill_form_using_input_name(self, mock_client):
"""
I can fill using the input name
"""
html = '<form><label for="uid">Username</label><input id="uid" name="username" /></form>'
form = TestableForm(mock_client, html)
form.fill(username="john_doe")
assert form.fields == {"username": "john_doe"}
def test_i_can_fill_form_using_label(self, mock_client):
"""
I can fill using the label associated with the input
"""
html = '<form><label for="uid">Username</label><input id="uid" name="username" /></form>'
form = TestableForm(mock_client, html)
form.fill(Username="john_doe")
assert form.fields == {"username": "john_doe"}
def test_i_cannot_fill_form_with_invalid_field_name(self, mock_client):
"""
I cannot fill form with invalid field name
"""
html = '<form><label for="uid">Username</label><input id="uid" name="username" /></form>'
form = TestableForm(mock_client, html)
with pytest.raises(ValueError) as excinfo:
form.fill(invalid_field="john_doe")
assert str(excinfo.value) == "Invalid field name 'invalid_field'."
class TestableFormSubmit:
"""
Test suite for the submit() method of TestableForm class.
This module tests form submission for both HTMX and classic forms.
"""
def test_i_can_submit_classic_form_with_post_method(self):
"""
Test that a classic form with POST method is submitted correctly.
This ensures the form uses the action and method attributes properly.
"""
# HTML form with classic submission
html = '''
<form action="/submit" method="post">
<input type="text" name="username" value="john_doe" />
<input type="password" name="password" value="secret123" />
</form>
'''
# Create the form
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/submit')
def post(username: str, password: str):
return f"Form received {username=}, {password=}"
form.submit()
assert client.get_content() == "Form received username='john_doe', password='secret123'"
def test_i_can_submit_classic_form_with_get_method(self):
"""
Test that a classic form with GET method is submitted correctly.
This ensures GET requests are properly handled.
"""
html = '''
<form action="/search" method="get">
<input type="text" name="q" value="python" />
<input type="text" name="page" value="1" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/search')
def get(q: str, page: int):
return f"Search results for {q=}, {page=}"
form.submit()
assert client.get_content() == "Search results for q='python', page=1"
def test_i_can_submit_classic_form_without_method_defaults_to_post(self):
"""
Test that POST is used by default when method attribute is absent.
This ensures proper default behavior matching HTML standards.
"""
html = '''
<form action="/submit">
<input type="text" name="data" value="test" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/submit')
def post(data: str):
return f"Received {data=}"
form.submit()
assert client.get_content() == "Received data='test'"
def test_i_can_submit_form_with_htmx_post(self):
"""
Test that a form with hx_post uses HTMX submission.
This ensures HTMX-enabled forms are handled correctly.
"""
html = '''
<form hx-post="/htmx-submit">
<input type="text" name="username" value="alice" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/htmx-submit')
def post(username: str):
return f"HTMX received {username=}"
form.submit()
assert client.get_content() == "HTMX received username='alice'"
def test_i_can_submit_form_with_htmx_get(self):
"""
Test that a form with hx_get uses HTMX submission.
This ensures HTMX GET requests work properly.
"""
html = '''
<form hx-get="/htmx-search">
<input type="text" name="query" value="fasthtml" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/htmx-search')
def get(query: str):
return f"HTMX search for {query=}"
form.submit()
assert client.get_content() == "HTMX search for query='fasthtml'"
def test_i_can_submit_form_with_filled_fields(self):
"""
Test that fields filled via fill_form() are submitted correctly.
This ensures dynamic form filling works as expected.
"""
html = '''
<form action="/login" method="post">
<input type="text" name="username" value="" />
<input type="password" name="password" value="" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
# Fill the form dynamically
form.fill(username="bob", password="secure456")
@rt('/login')
def post(username: str, password: str):
return f"Login {username=}, {password=}"
form.submit()
assert client.get_content() == "Login username='bob', password='secure456'"
def test_i_can_submit_form_with_mixed_field_types(self):
"""
Test that all field types are submitted with correct values.
This ensures type conversion and submission work together.
"""
html = '''
<form action="/register" method="post">
<input type="text" name="username" value="charlie" />
<input type="number" name="age" value="30" />
<input type="checkbox" name="newsletter" checked />
<select name="country">
<option value="US" selected>USA</option>
<option value="FR">France</option>
</select>
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/register')
def post(username: str, age: int, newsletter: bool, country: str):
return f"Registration {username=}, {age=}, {newsletter=}, {country=}"
result = form.submit()
# Note: the types are converted in self.fields
expected = "Registration username='charlie', age=30, newsletter=True, country='US'"
assert client.get_content() == expected
def test_i_cannot_submit_classic_form_without_action(self):
"""
Test that an exception is raised when action attribute is missing.
This ensures proper error handling for malformed forms.
"""
html = '''
<form method="post">
<input type="text" name="data" value="test" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
# Should raise ValueError
try:
form.submit()
assert False, "Expected ValueError to be raised"
except ValueError as e:
assert "no 'action' attribute" in str(e).lower()
def test_i_cannot_submit_classic_form_with_empty_action(self):
"""
Test that an exception is raised when action attribute is empty.
This ensures validation of the action attribute.
"""
html = '''
<form action="" method="post">
<input type="text" name="data" value="test" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
# Should raise ValueError
try:
form.submit()
assert False, "Expected ValueError to be raised"
except ValueError as e:
assert "no 'action' attribute" in str(e).lower()
def test_i_can_submit_form_with_case_insensitive_method(self):
"""
Test that HTTP method is properly normalized to uppercase.
This ensures method attribute is case-insensitive.
"""
html = '''
<form action="/submit" method="PoSt">
<input type="text" name="data" value="test" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/submit')
def post(data: str):
return f"Received {data=}"
form.submit()
assert client.get_content() == "Received data='test'"
def test_i_can_prioritize_htmx_over_classic_submission(self):
"""
Test that HTMX is prioritized even when action/method are present.
This ensures correct priority between HTMX and classic submission.
"""
html = '''
<form action="/classic" method="post" hx-post="/htmx">
<input type="text" name="data" value="test" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/classic')
def post_classic(data: str):
return "Classic submission"
@rt('/htmx')
def post_htmx(data: str):
return "HTMX submission"
form.submit()
assert client.get_content() == "HTMX submission"
def test_i_can_submit_empty_form(self):
"""
Test that a form without fields can be submitted.
This ensures robustness for minimal forms.
"""
html = '<form action="/empty" method="post"></form>'
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/empty')
def post():
return "Empty form received"
form.submit()
assert client.get_content() == "Empty form received"