Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX

This commit is contained in:
pacnpal
2025-12-22 16:56:27 -05:00
parent 2e35f8c5d9
commit ae31e889d7
144 changed files with 25792 additions and 4440 deletions

View File

@@ -0,0 +1,6 @@
"""
Form tests.
This module contains tests for Django forms to verify
validation, widgets, and custom logic.
"""

View File

@@ -0,0 +1,315 @@
"""
Tests for Park forms.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from decimal import Decimal
from unittest.mock import Mock, patch, MagicMock
from django.test import TestCase
from apps.parks.forms import (
ParkForm,
ParkSearchForm,
ParkAutocomplete,
)
from tests.factories import (
ParkFactory,
OperatorCompanyFactory,
LocationFactory,
)
@pytest.mark.django_db
class TestParkForm(TestCase):
"""Tests for ParkForm."""
def test__init__new_park__no_location_prefilled(self):
"""Test initializing form for new park has no location prefilled."""
form = ParkForm()
assert form.fields["latitude"].initial is None
assert form.fields["longitude"].initial is None
assert form.fields["city"].initial is None
def test__init__existing_park_with_location__prefills_location_fields(self):
"""Test initializing form for existing park prefills location fields."""
park = ParkFactory()
# Create location via factory's post_generation hook
form = ParkForm(instance=park)
# Location should be prefilled if it exists
if park.location.exists():
location = park.location.first()
assert form.fields["latitude"].initial == location.latitude
assert form.fields["longitude"].initial == location.longitude
assert form.fields["city"].initial == location.city
def test__clean_latitude__valid_value__returns_normalized_value(self):
"""Test clean_latitude normalizes valid latitude."""
operator = OperatorCompanyFactory()
data = {
"name": "Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "37.123456789", # Too many decimal places
"longitude": "-122.123456",
}
form = ParkForm(data=data)
form.is_valid()
if "latitude" in form.cleaned_data:
# Should be rounded to 6 decimal places
assert len(form.cleaned_data["latitude"].split(".")[-1]) <= 6
def test__clean_latitude__out_of_range__returns_error(self):
"""Test clean_latitude rejects out-of-range latitude."""
operator = OperatorCompanyFactory()
data = {
"name": "Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "95.0", # Invalid: > 90
"longitude": "-122.0",
}
form = ParkForm(data=data)
is_valid = form.is_valid()
assert not is_valid
assert "latitude" in form.errors
def test__clean_latitude__negative_ninety__is_valid(self):
"""Test clean_latitude accepts -90 (edge case)."""
operator = OperatorCompanyFactory()
data = {
"name": "Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "-90.0",
"longitude": "0.0",
}
form = ParkForm(data=data)
is_valid = form.is_valid()
# Should be valid (form may have other errors but not latitude)
if not is_valid:
assert "latitude" not in form.errors
def test__clean_longitude__valid_value__returns_normalized_value(self):
"""Test clean_longitude normalizes valid longitude."""
operator = OperatorCompanyFactory()
data = {
"name": "Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "37.0",
"longitude": "-122.123456789", # Too many decimal places
}
form = ParkForm(data=data)
form.is_valid()
if "longitude" in form.cleaned_data:
# Should be rounded to 6 decimal places
assert len(form.cleaned_data["longitude"].split(".")[-1]) <= 6
def test__clean_longitude__out_of_range__returns_error(self):
"""Test clean_longitude rejects out-of-range longitude."""
operator = OperatorCompanyFactory()
data = {
"name": "Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "37.0",
"longitude": "-200.0", # Invalid: < -180
}
form = ParkForm(data=data)
is_valid = form.is_valid()
assert not is_valid
assert "longitude" in form.errors
def test__clean_longitude__positive_180__is_valid(self):
"""Test clean_longitude accepts 180 (edge case)."""
operator = OperatorCompanyFactory()
data = {
"name": "Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "0.0",
"longitude": "180.0",
}
form = ParkForm(data=data)
is_valid = form.is_valid()
# Should be valid (form may have other errors but not longitude)
if not is_valid:
assert "longitude" not in form.errors
def test__save__new_park_with_location__creates_park_and_location(self):
"""Test saving new park creates both park and location."""
operator = OperatorCompanyFactory()
data = {
"name": "New Test Park",
"operator": operator.pk,
"status": "OPERATING",
"latitude": "37.123456",
"longitude": "-122.123456",
"city": "San Francisco",
"state": "CA",
"country": "USA",
}
form = ParkForm(data=data)
if form.is_valid():
park = form.save()
assert park.name == "New Test Park"
# Location should be created
assert park.location.exists() or hasattr(park, "location")
def test__save__existing_park__updates_location(self):
"""Test saving existing park updates location."""
park = ParkFactory()
# Update data
data = {
"name": park.name,
"operator": park.operator.pk,
"status": park.status,
"latitude": "40.0",
"longitude": "-74.0",
"city": "New York",
"state": "NY",
"country": "USA",
}
form = ParkForm(instance=park, data=data)
if form.is_valid():
updated_park = form.save()
# Location should be updated
assert updated_park.pk == park.pk
def test__meta__fields__includes_all_expected_fields(self):
"""Test Meta.fields includes all expected park and location fields."""
expected_fields = [
"name",
"description",
"operator",
"property_owner",
"status",
"opening_date",
"closing_date",
"operating_season",
"size_acres",
"website",
"latitude",
"longitude",
"street_address",
"city",
"state",
"country",
"postal_code",
]
for field in expected_fields:
assert field in ParkForm.Meta.fields
def test__widgets__latitude_longitude_hidden__are_hidden_inputs(self):
"""Test latitude and longitude use HiddenInput widgets."""
form = ParkForm()
assert form.fields["latitude"].widget.input_type == "hidden"
assert form.fields["longitude"].widget.input_type == "hidden"
def test__widgets__text_fields__have_styling_classes(self):
"""Test text fields have appropriate CSS classes."""
form = ParkForm()
# Check city field has expected styling
city_widget = form.fields["city"].widget
assert "class" in city_widget.attrs
assert "rounded-lg" in city_widget.attrs["class"]
@pytest.mark.django_db
class TestParkSearchForm(TestCase):
"""Tests for ParkSearchForm."""
def test__init__creates_park_field(self):
"""Test initializing form creates park field."""
form = ParkSearchForm()
assert "park" in form.fields
def test__park_field__uses_autocomplete_widget(self):
"""Test park field uses AutocompleteWidget."""
form = ParkSearchForm()
# Check the widget type
widget = form.fields["park"].widget
widget_class_name = widget.__class__.__name__
assert "Autocomplete" in widget_class_name or "Select" in widget_class_name
def test__park_field__not_required(self):
"""Test park field is not required."""
form = ParkSearchForm()
assert form.fields["park"].required is False
def test__validate__empty_form__is_valid(self):
"""Test empty form is valid."""
form = ParkSearchForm(data={})
assert form.is_valid()
def test__validate__with_park__is_valid(self):
"""Test form with valid park is valid."""
park = ParkFactory()
form = ParkSearchForm(data={"park": park.pk})
assert form.is_valid()
@pytest.mark.django_db
class TestParkAutocomplete(TestCase):
"""Tests for ParkAutocomplete."""
def test__model__is_park(self):
"""Test autocomplete model is Park."""
from apps.parks.models import Park
assert ParkAutocomplete.model == Park
def test__search_attrs__includes_name(self):
"""Test search_attrs includes name field."""
assert "name" in ParkAutocomplete.search_attrs
def test__search__matching_name__returns_results(self):
"""Test searching by name returns matching parks."""
park1 = ParkFactory(name="Cedar Point")
park2 = ParkFactory(name="Kings Island")
# The autocomplete should return Cedar Point when searching for "Cedar"
queryset = ParkAutocomplete.model.objects.filter(name__icontains="Cedar")
assert park1 in queryset
assert park2 not in queryset
def test__search__no_match__returns_empty(self):
"""Test searching with no match returns empty queryset."""
ParkFactory(name="Cedar Point")
queryset = ParkAutocomplete.model.objects.filter(name__icontains="NoMatchHere")
assert queryset.count() == 0

View File

@@ -0,0 +1,371 @@
"""
Tests for Ride forms.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from django.test import TestCase
from apps.rides.forms import (
RideForm,
RideSearchForm,
)
from tests.factories import (
ParkFactory,
RideFactory,
ParkAreaFactory,
ManufacturerCompanyFactory,
DesignerCompanyFactory,
RideModelFactory,
)
@pytest.mark.django_db
class TestRideForm(TestCase):
"""Tests for RideForm."""
def test__init__no_park__shows_park_search_field(self):
"""Test initializing without park shows park search field."""
form = RideForm()
assert "park_search" in form.fields
assert "park" in form.fields
def test__init__with_park__hides_park_search_field(self):
"""Test initializing with park hides park search field."""
park = ParkFactory()
form = RideForm(park=park)
assert "park_search" not in form.fields
assert "park" in form.fields
assert form.fields["park"].initial == park
def test__init__with_park__populates_park_area_queryset(self):
"""Test initializing with park populates park_area choices."""
park = ParkFactory()
area1 = ParkAreaFactory(park=park, name="Area 1")
area2 = ParkAreaFactory(park=park, name="Area 2")
form = RideForm(park=park)
# Park area queryset should contain park's areas
queryset = form.fields["park_area"].queryset
assert area1 in queryset
assert area2 in queryset
def test__init__without_park__park_area_disabled(self):
"""Test initializing without park disables park_area."""
form = RideForm()
assert form.fields["park_area"].widget.attrs.get("disabled") is True
def test__init__existing_ride__prefills_manufacturer(self):
"""Test initializing with existing ride prefills manufacturer."""
manufacturer = ManufacturerCompanyFactory(name="Test Manufacturer")
ride = RideFactory(manufacturer=manufacturer)
form = RideForm(instance=ride)
assert form.fields["manufacturer_search"].initial == "Test Manufacturer"
assert form.fields["manufacturer"].initial == manufacturer
def test__init__existing_ride__prefills_designer(self):
"""Test initializing with existing ride prefills designer."""
designer = DesignerCompanyFactory(name="Test Designer")
ride = RideFactory(designer=designer)
form = RideForm(instance=ride)
assert form.fields["designer_search"].initial == "Test Designer"
assert form.fields["designer"].initial == designer
def test__init__existing_ride__prefills_ride_model(self):
"""Test initializing with existing ride prefills ride model."""
ride_model = RideModelFactory(name="Test Model")
ride = RideFactory(ride_model=ride_model)
form = RideForm(instance=ride)
assert form.fields["ride_model_search"].initial == "Test Model"
assert form.fields["ride_model"].initial == ride_model
def test__init__existing_ride_without_park_arg__prefills_park_search(self):
"""Test initializing with existing ride prefills park search."""
park = ParkFactory(name="Test Park")
ride = RideFactory(park=park)
form = RideForm(instance=ride)
assert form.fields["park_search"].initial == "Test Park"
assert form.fields["park"].initial == park
def test__init__category_is_required(self):
"""Test category field is required."""
form = RideForm()
assert form.fields["category"].required is True
def test__init__date_fields_have_no_initial_value(self):
"""Test date fields have no initial value."""
form = RideForm()
assert form.fields["opening_date"].initial is None
assert form.fields["closing_date"].initial is None
assert form.fields["status_since"].initial is None
def test__field_order__matches_expected(self):
"""Test fields are ordered correctly."""
form = RideForm()
expected_order = [
"park_search",
"park",
"park_area",
"name",
"manufacturer_search",
"manufacturer",
"designer_search",
"designer",
"ride_model_search",
"ride_model",
"category",
]
# Get first 11 fields from form
actual_order = list(form.fields.keys())[:11]
assert actual_order == expected_order
def test__validate__valid_data__is_valid(self):
"""Test form is valid with all required data."""
park = ParkFactory()
manufacturer = ManufacturerCompanyFactory()
data = {
"name": "Test Ride",
"park": park.pk,
"category": "RC", # Roller coaster
"status": "OPERATING",
"manufacturer": manufacturer.pk,
}
form = RideForm(data=data)
# Remove park_search validation error by skipping it
if "park_search" in form.errors:
del form.errors["park_search"]
# Check if form would be valid otherwise
assert "name" not in form.errors
assert "category" not in form.errors
def test__validate__missing_name__returns_error(self):
"""Test form is invalid without name."""
park = ParkFactory()
data = {
"park": park.pk,
"category": "RC",
"status": "OPERATING",
}
form = RideForm(data=data)
is_valid = form.is_valid()
assert not is_valid
assert "name" in form.errors
def test__validate__missing_category__returns_error(self):
"""Test form is invalid without category."""
park = ParkFactory()
data = {
"name": "Test Ride",
"park": park.pk,
"status": "OPERATING",
}
form = RideForm(data=data)
is_valid = form.is_valid()
assert not is_valid
assert "category" in form.errors
def test__widgets__name_field__has_styling(self):
"""Test name field has appropriate CSS classes."""
form = RideForm()
name_widget = form.fields["name"].widget
assert "class" in name_widget.attrs
assert "rounded-lg" in name_widget.attrs["class"]
def test__widgets__category_field__has_htmx_attributes(self):
"""Test category field has HTMX attributes."""
form = RideForm()
category_widget = form.fields["category"].widget
assert "hx-get" in category_widget.attrs
assert "hx-target" in category_widget.attrs
assert "hx-trigger" in category_widget.attrs
def test__widgets__status_field__has_alpine_attributes(self):
"""Test status field has Alpine.js attributes."""
form = RideForm()
status_widget = form.fields["status"].widget
assert "x-model" in status_widget.attrs
assert "@change" in status_widget.attrs
def test__widgets__closing_date__has_conditional_display(self):
"""Test closing_date has conditional display logic."""
form = RideForm()
closing_date_widget = form.fields["closing_date"].widget
assert "x-show" in closing_date_widget.attrs
def test__meta__model__is_ride(self):
"""Test Meta.model is Ride."""
from apps.rides.models import Ride
assert RideForm.Meta.model == Ride
def test__meta__fields__includes_expected_fields(self):
"""Test Meta.fields includes expected ride fields."""
expected_fields = [
"name",
"category",
"status",
"opening_date",
"closing_date",
"min_height_in",
"max_height_in",
"description",
]
for field in expected_fields:
assert field in RideForm.Meta.fields
@pytest.mark.django_db
class TestRideSearchForm(TestCase):
"""Tests for RideSearchForm."""
def test__init__creates_ride_field(self):
"""Test initializing form creates ride field."""
form = RideSearchForm()
assert "ride" in form.fields
def test__ride_field__not_required(self):
"""Test ride field is not required."""
form = RideSearchForm()
assert form.fields["ride"].required is False
def test__ride_field__uses_select_widget(self):
"""Test ride field uses Select widget."""
form = RideSearchForm()
widget = form.fields["ride"].widget
assert "Select" in widget.__class__.__name__
def test__ride_field__has_htmx_attributes(self):
"""Test ride field has HTMX attributes."""
form = RideSearchForm()
ride_widget = form.fields["ride"].widget
assert "hx-get" in ride_widget.attrs
assert "hx-trigger" in ride_widget.attrs
assert "hx-target" in ride_widget.attrs
def test__validate__empty_form__is_valid(self):
"""Test empty form is valid."""
form = RideSearchForm(data={})
assert form.is_valid()
def test__validate__with_ride__is_valid(self):
"""Test form with valid ride is valid."""
ride = RideFactory()
form = RideSearchForm(data={"ride": ride.pk})
assert form.is_valid()
def test__validate__with_invalid_ride__is_invalid(self):
"""Test form with invalid ride is invalid."""
form = RideSearchForm(data={"ride": 99999})
assert not form.is_valid()
assert "ride" in form.errors
@pytest.mark.django_db
class TestRideFormWithParkAreas(TestCase):
"""Tests for RideForm park area functionality."""
def test__park_area__queryset_empty_without_park(self):
"""Test park_area queryset is empty when no park provided."""
form = RideForm()
# When no park, the queryset should be empty (none())
queryset = form.fields["park_area"].queryset
assert queryset.count() == 0
def test__park_area__queryset_filtered_to_park(self):
"""Test park_area queryset only contains areas from given park."""
park1 = ParkFactory()
park2 = ParkFactory()
area1 = ParkAreaFactory(park=park1)
area2 = ParkAreaFactory(park=park2)
form = RideForm(park=park1)
queryset = form.fields["park_area"].queryset
assert area1 in queryset
assert area2 not in queryset
def test__park_area__is_optional(self):
"""Test park_area field is optional."""
form = RideForm()
assert form.fields["park_area"].required is False
@pytest.mark.django_db
class TestRideFormFieldOrder(TestCase):
"""Tests for RideForm field ordering."""
def test__field_order__park_fields_first(self):
"""Test park-related fields come first."""
form = RideForm()
field_names = list(form.fields.keys())
# park_search should be first
assert field_names[0] == "park_search"
assert field_names[1] == "park"
assert field_names[2] == "park_area"
def test__field_order__name_after_park(self):
"""Test name field comes after park fields."""
form = RideForm()
field_names = list(form.fields.keys())
name_index = field_names.index("name")
park_index = field_names.index("park")
assert name_index > park_index
def test__field_order__description_last(self):
"""Test description is near the end."""
form = RideForm()
field_names = list(form.fields.keys())
# Description should be one of the last fields
description_index = field_names.index("description")
assert description_index > len(field_names) // 2