diff --git a/.gitignore b/.gitignore index 4d06a41..7fae7a4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ tools.db .idea/sqldialects.xml .idea_bak **/*.prof +**/*.db # Created by .ignore support plugin (hsz.mobi) ### Python template diff --git a/src/Users.db b/src/Users.db deleted file mode 100644 index ecb1569..0000000 Binary files a/src/Users.db and /dev/null differ diff --git a/src/main.py b/src/main.py index 4fd3bd6..6ed01cf 100644 --- a/src/main.py +++ b/src/main.py @@ -20,7 +20,7 @@ daisyui_online_links = [ ] app, rt = fast_app( - # before=beforeware, + before=beforeware, hdrs=tuple(daisyui_offline_links) ) diff --git a/src/myfasthtml/auth/pages/RegisterPage.py b/src/myfasthtml/auth/pages/RegisterPage.py index 436ac6a..5f6bd74 100644 --- a/src/myfasthtml/auth/pages/RegisterPage.py +++ b/src/myfasthtml/auth/pages/RegisterPage.py @@ -106,6 +106,15 @@ class RegisterPage: 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", method="post", cls="mb-6" diff --git a/src/myfasthtml/auth/routes.py b/src/myfasthtml/auth/routes.py index 3d4487f..1e19432 100644 --- a/src/myfasthtml/auth/routes.py +++ b/src/myfasthtml/auth/routes.py @@ -45,7 +45,6 @@ def setup_auth_routes(app, rt, mount_auth_app=True): """ return LoginPage(error_message=error) - @rt("/login-p") def post(email: str, password: str, session, redirect_url: str = "/"): """ @@ -99,6 +98,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True): Args: email: User email from form + username: User name of the password: User password from form confirm_password: Password confirmation from form session: FastHTML session object @@ -107,12 +107,12 @@ def setup_auth_routes(app, rt, mount_auth_app=True): RegisterPage with success/error message via HTMX """ # Validate password confirmation - # if password != confirm_password: - # return RegisterPage(error_message="Passwords do not match. Please try again.") - # - # # Validate password length - # if len(password) < 8: - # return RegisterPage(error_message="Password must be at least 8 characters long.") + if password != confirm_password: + return RegisterPage(error_message="Passwords do not match. Please try again.") + + # Validate password length + if len(password) < 8: + return RegisterPage(error_message="Password must be at least 8 characters long.") # Attempt registration result = register_user(email, username, password) diff --git a/src/myfasthtml/core/testclient.py b/src/myfasthtml/core/testclient.py index 2ab0dc2..bed8113 100644 --- a/src/myfasthtml/core/testclient.py +++ b/src/myfasthtml/core/testclient.py @@ -129,6 +129,307 @@ class TestableElement: # Send the request 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 self.ft.has_attr('hx_get') or self.ft.has_attr('hx_post') + + +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(source, '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_form(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(): + self.fields[name] = value + + def submit(self): + """ + Submit the form. + + Returns: + The response from the form submission. + """ + return self._send_htmx_request(data=self.fields) + + 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: @@ -320,6 +621,33 @@ class MyTestClient: f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1." ) + def find_form(self, fields: list = None): + """ + 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_element()." + ) + + results = self._soup.select("form") + if len(results) == 0: + raise AssertionError( + f"No element form found." + ) + + # result = _filter(results, fields) + + if len(results) == 1: + return TestableForm(self, results[0]) + else: + raise AssertionError( + f"Found {len(results)} forms (with the specified fields). Expected exactly 1." + ) + def get_content(self) -> str: """ Get the raw HTML content of the last opened page. diff --git a/tests/auth/__init__.py b/tests/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/auth/test_login.py b/tests/auth/test_login.py new file mode 100644 index 0000000..24898ea --- /dev/null +++ b/tests/auth/test_login.py @@ -0,0 +1,39 @@ +import pytest +from fasthtml.fastapp import fast_app + +from myfasthtml.auth.routes import setup_auth_routes +from myfasthtml.auth.utils import create_auth_beforeware +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) + return test_app + + +@pytest.fixture() +def rt(app): + return app.route + + +@pytest.fixture() +def user(app): + user = MyTestClient(app) + return user + +def test_i_can_see_login_page(user): + user.open("/login") + user.should_see("Sign In") + user.should_see("Register here") + +def test_i_cannot_login_with_wrong_credentials(user): + user.open("/login") + user.fill_form({ + "username": "wrong", + "password": "" + }) + user.click("button") + user.should_see("Invalid credentials") \ No newline at end of file diff --git a/tests/auth/test_register.py b/tests/auth/test_register.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/auth/test_utils.py b/tests/auth/test_utils.py new file mode 100644 index 0000000..e1d576e --- /dev/null +++ b/tests/auth/test_utils.py @@ -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") diff --git a/tests/testclient/__init__.py b/tests/testclient/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_mytestclient.py b/tests/testclient/test_mytestclient.py similarity index 100% rename from tests/test_mytestclient.py rename to tests/testclient/test_mytestclient.py diff --git a/tests/test_testable_element.py b/tests/testclient/test_testable_element.py similarity index 100% rename from tests/test_testable_element.py rename to tests/testclient/test_testable_element.py diff --git a/tests/testclient/test_testable_form.py b/tests/testclient/test_testable_form.py new file mode 100644 index 0000000..33914e2 --- /dev/null +++ b/tests/testclient/test_testable_form.py @@ -0,0 +1,585 @@ +import pytest + +from myfasthtml.core.testclient import TestableForm + + +@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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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'])}"