diff --git a/common/components/date_range_picker.py b/common/components/date_range_picker.py index 02b611d..860694b 100644 --- a/common/components/date_range_picker.py +++ b/common/components/date_range_picker.py @@ -17,7 +17,6 @@ widget into a ``DateCriterion`` unchanged. All behaviour is wired by ``games/static/js/date_range_picker.js``. """ - from common.components.core import Element, HTMLAttribute, Media, Node, Safe from common.components.primitives import Div, Input, Span from common.time import DatePartSpec, date_parts @@ -101,9 +100,7 @@ def _iso_part_values(iso_value: str, parts: list[DatePartSpec]) -> dict[str, str return values -def _segment_input( - *, part: DatePartSpec, side: str, label: str, value: str -) -> Node: +def _segment_input(*, part: DatePartSpec, side: str, label: str, value: str) -> Node: side_label = "from" if side == "min" else "to" return Input( attributes=[ diff --git a/common/components/filters.py b/common/components/filters.py index 1d158f1..056ec49 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -173,9 +173,7 @@ def _split_modifier(modifier: str, has_m2m: bool = False) -> str: return "" -def _enum_filter( - field_name: str, options, choice: FilterChoice, *, nullable -) -> Node: +def _enum_filter(field_name: str, options, choice: FilterChoice, *, nullable) -> Node: """A FilterSelect over a small, fully pre-rendered option set (enum field). Enum fields are single-valued, so no M2M modifiers (all/only are diff --git a/e2e/conftest.py b/e2e/conftest.py index 6df92e6..3a49536 100644 --- a/e2e/conftest.py +++ b/e2e/conftest.py @@ -7,6 +7,7 @@ import pytest # synchronous operations inside the async context safely. os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true") + @pytest.fixture(scope="session") def browser_type_launch_args(browser_type_launch_args): # Try to find a system-installed Google Chrome or Chromium to bypass Nix/NixOS shared library issues diff --git a/e2e/test_date_filter_e2e.py b/e2e/test_date_filter_e2e.py index a108d69..33f9879 100644 --- a/e2e/test_date_filter_e2e.py +++ b/e2e/test_date_filter_e2e.py @@ -121,9 +121,7 @@ def test_max_only_serializes_as_less_than(live_server, page): ".dispatchEvent(new Event('submit', {cancelable: true}))" ) parsed = _filter_from_url(page.url) - assert parsed == { - "date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"} - } + assert parsed == {"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}} @pytest.mark.django_db diff --git a/e2e/test_range_slider_e2e.py b/e2e/test_range_slider_e2e.py index 8ff5fe9..0ee7043 100644 --- a/e2e/test_range_slider_e2e.py +++ b/e2e/test_range_slider_e2e.py @@ -1,8 +1,4 @@ -"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior. -""" - -import json -import urllib.parse +"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior.""" import pytest from django.http import HttpResponse @@ -41,17 +37,17 @@ urlpatterns = [ @override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e") def test_range_slider_crossover_min_higher_than_max(live_server, page): page.goto(live_server.url + "/test-range-slider/") - + # 1. Start with known state: Min is empty, Max is empty min_input = page.locator('input[name="filter-session-count-min"]') max_input = page.locator('input[name="filter-session-count-max"]') - + # 2. Type "20" into max input max_input.fill("20") - + # 3. Type "50" into min input (which is higher than 20) min_input.fill("50") - + # 4. Max input should have automatically synchronized/snapped to 50 assert max_input.input_value() == "50" @@ -60,16 +56,16 @@ def test_range_slider_crossover_min_higher_than_max(live_server, page): @override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e") def test_range_slider_crossover_max_less_than_min(live_server, page): page.goto(live_server.url + "/test-range-slider/") - + min_input = page.locator('input[name="filter-session-count-min"]') max_input = page.locator('input[name="filter-session-count-max"]') - + # 1. Type "50" into min input min_input.fill("50") - + # 2. Type "30" into max input (which is less than 50) max_input.fill("30") - + # 3. Min input should have automatically synchronized/snapped to 30 assert min_input.input_value() == "30" @@ -78,20 +74,20 @@ def test_range_slider_crossover_max_less_than_min(live_server, page): @override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e") def test_range_slider_strict_bounds_clamping_on_blur(live_server, page): page.goto(live_server.url + "/test-range-slider/") - + min_input = page.locator('input[name="filter-session-count-min"]') max_input = page.locator('input[name="filter-session-count-max"]') - + # 1. Type value higher than dataMax (100 is max, type "150") max_input.fill("150") - max_input.blur() # triggers "change" event - + max_input.blur() # triggers "change" event + assert max_input.input_value() == "100" - + # 2. Type value lower than dataMin (0 is min, type "-20") min_input.fill("-20") - min_input.blur() # triggers "change" event - + min_input.blur() # triggers "change" event + assert min_input.input_value() == "0" @@ -99,18 +95,20 @@ def test_range_slider_strict_bounds_clamping_on_blur(live_server, page): @override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e") def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, page): page.goto(live_server.url + "/test-range-slider/") - + # Locate handles - max_handle = page.locator('.range-handle-max[data-target="filter-session-count-max"]') - + max_handle = page.locator( + '.range-handle-max[data-target="filter-session-count-max"]' + ) + # Initially, max_input is empty, so handle should sit at 100% (far right) style = max_handle.get_attribute("style") assert "left:100%" in style or "left: 100%" in style - + # Set min to 50 min_input = page.locator('input[name="filter-session-count-min"]') min_input.fill("50") - + # Max handle should STILL stay at 100% since max input is still empty (defaults to max_value) style = max_handle.get_attribute("style") assert "left:100%" in style or "left: 100%" in style diff --git a/e2e/test_string_filter_e2e.py b/e2e/test_string_filter_e2e.py index f2de0a3..855b650 100644 --- a/e2e/test_string_filter_e2e.py +++ b/e2e/test_string_filter_e2e.py @@ -38,9 +38,7 @@ def prefilled_bar_view(request): "value": "Switch", "modifier": "INCLUDES", }, - "group": { - "modifier": "IS_NULL" - } + "group": {"modifier": "IS_NULL"}, } ) return HttpResponse(_bar_page(filter_json=filter_json)) @@ -63,19 +61,21 @@ def _filter_from_url(url: str) -> dict: @override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e") def test_string_filter_defaults_and_toggles(live_server, page): page.goto(live_server.url + "/test-string-filter-empty/") - + # 1. Verify text inputs are active by default and modifier "is" (EQUALS) is checked name_input = page.locator('input[name="filter-name"]') assert name_input.is_enabled() - + is_radio = page.locator('input[name="filter-name-modifier"][value="EQUALS"]') assert is_radio.is_checked() # 2. Enter values, click "includes" (INCLUDES), and submit name_input.fill("PlayStation") - includes_radio = page.locator('input[name="filter-name-modifier"][value="INCLUDES"]') + includes_radio = page.locator( + 'input[name="filter-name-modifier"][value="INCLUDES"]' + ) includes_radio.click() - + with page.expect_navigation(): page.evaluate( "document.getElementById('filter-bar-form')" @@ -92,15 +92,15 @@ def test_string_filter_null_states(live_server, page): name_input = page.locator('input[name="filter-name"]') name_input.fill("Xbox") - + # Click "is null" is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]') is_null_radio.click() - + # Verification of interactive disabling assert not name_input.is_enabled() assert name_input.input_value() == "" - + with page.expect_navigation(): page.evaluate( "document.getElementById('filter-bar-form')" @@ -114,19 +114,23 @@ def test_string_filter_null_states(live_server, page): @override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e") def test_string_filter_prefilled_states(live_server, page): page.goto(live_server.url + "/test-string-filter-prefilled/") - + name_input = page.locator('input[name="filter-name"]') group_input = page.locator('input[name="filter-group"]') - + # Verifies name matches "Switch" and "includes" is checked assert name_input.input_value() == "Switch" assert name_input.is_enabled() - assert page.locator('input[name="filter-name-modifier"][value="INCLUDES"]').is_checked() - + assert page.locator( + 'input[name="filter-name-modifier"][value="INCLUDES"]' + ).is_checked() + # Verifies group is empty, disabled, and "is null" is checked assert group_input.input_value() == "" assert not group_input.is_enabled() - assert page.locator('input[name="filter-group-modifier"][value="IS_NULL"]').is_checked() + assert page.locator( + 'input[name="filter-group-modifier"][value="IS_NULL"]' + ).is_checked() @pytest.mark.django_db @@ -136,11 +140,11 @@ def test_string_filter_deselect_re_enables(live_server, page): name_input = page.locator('input[name="filter-name"]') is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]') - + # 1. Click "is null" -> disables input is_null_radio.click() assert not name_input.is_enabled() - + # 2. Click "is null" again to deselect/uncheck -> should re-enable the text input is_null_radio.click() assert name_input.is_enabled() diff --git a/games/filters.py b/games/filters.py index 29721bb..8b6d25e 100644 --- a/games/filters.py +++ b/games/filters.py @@ -412,7 +412,9 @@ class GameFilter(OperatorFilter): from games.models import PlayEvent event_q = criterion.to_q("note") - matching_ids = PlayEvent.objects.filter(event_q).values_list("game_id", flat=True) + matching_ids = PlayEvent.objects.filter(event_q).values_list( + "game_id", flat=True + ) return Q(id__in=matching_ids) diff --git a/games/migrations/0018_alter_session_timestamp_start.py b/games/migrations/0018_alter_session_timestamp_start.py index 9e8bbde..7d29296 100644 --- a/games/migrations/0018_alter_session_timestamp_start.py +++ b/games/migrations/0018_alter_session_timestamp_start.py @@ -4,15 +4,14 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('games', '0017_add_filter_preset'), + ("games", "0017_add_filter_preset"), ] operations = [ migrations.AlterField( - model_name='session', - name='timestamp_start', - field=models.DateTimeField(db_index=True, verbose_name='Start'), + model_name="session", + name="timestamp_start", + field=models.DateTimeField(db_index=True, verbose_name="Start"), ), ] diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py index 098b178..3ce025a 100644 --- a/tests/test_filter_bars.py +++ b/tests/test_filter_bars.py @@ -362,4 +362,3 @@ class FilterBarRenderingTest(TestCase): self.assertIn('name="filter-refunded"', purchase_html) self.assertIn('value="true"', purchase_html) self.assertIn('value="false"', purchase_html) - diff --git a/tests/test_filter_helpers.py b/tests/test_filter_helpers.py index b073f94..ca1dc15 100644 --- a/tests/test_filter_helpers.py +++ b/tests/test_filter_helpers.py @@ -85,4 +85,3 @@ class ParseBoolNullableTest(SimpleTestCase): self.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "field")) self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field")) self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field")) - diff --git a/tests/test_filters.py b/tests/test_filters.py index ec3e49d..15d5da7 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -560,8 +560,14 @@ class TestFilterBarRendering: def test_mastered_not_checked_by_default(self): html = str(FilterBar(filter_json="")) - assert 'name="filter-mastered" value="true" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' not in html - assert 'name="filter-mastered" value="false" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' not in html + assert ( + 'name="filter-mastered" value="true" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' + not in html + ) + assert ( + 'name="filter-mastered" value="false" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' + not in html + ) def test_mastered_checked_when_filtered(self): html = str( @@ -784,7 +790,7 @@ class TestExpandedFiltersAgainstDB: from games.filters import SessionFilter from games.models import Session - data = self._setup_entities() + self._setup_entities() # Test duration_total_hours equals 4 sf_tot = SessionFilter.from_json( @@ -808,7 +814,7 @@ class TestExpandedFiltersAgainstDB: from games.filters import PurchaseFilter from games.models import Purchase - data = self._setup_entities() + self._setup_entities() pf = PurchaseFilter.from_json( {