Add filters
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
"""Tests for the filtering system."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.db.models import Q
|
||||
|
||||
from common.criteria import (
|
||||
BoolCriterion,
|
||||
ChoiceCriterion,
|
||||
IntCriterion,
|
||||
Modifier,
|
||||
MultiCriterion,
|
||||
StringCriterion,
|
||||
)
|
||||
from common.components import FilterBar, SelectableFilter
|
||||
from games.filters import GameFilter, parse_game_filter
|
||||
|
||||
|
||||
class TestStringCriterion:
|
||||
def test_equals(self):
|
||||
c = StringCriterion(value="zelda", modifier=Modifier.EQUALS)
|
||||
assert c.to_q("name") == Q(name="zelda")
|
||||
|
||||
def test_is_null(self):
|
||||
c = StringCriterion(value="", modifier=Modifier.IS_NULL)
|
||||
assert c.to_q("name") == Q(name__isnull=True)
|
||||
|
||||
|
||||
class TestIntCriterion:
|
||||
def test_between(self):
|
||||
c = IntCriterion(value=2020, value2=2024, modifier=Modifier.BETWEEN)
|
||||
assert c.to_q("year_released") == Q(year_released__gte=2020, year_released__lte=2024)
|
||||
|
||||
|
||||
class TestBoolCriterion:
|
||||
def test_equals_true(self):
|
||||
c = BoolCriterion(value=True, modifier=Modifier.EQUALS)
|
||||
assert c.to_q("mastered") == Q(mastered=True)
|
||||
|
||||
|
||||
class TestChoiceCriterion:
|
||||
def test_includes(self):
|
||||
c = ChoiceCriterion(value=["f", "p"], modifier=Modifier.INCLUDES)
|
||||
assert c.to_q("status") == Q(status__in=["f", "p"])
|
||||
|
||||
def test_excludes(self):
|
||||
c = ChoiceCriterion(value=["a"], modifier=Modifier.EXCLUDES)
|
||||
assert c.to_q("status") == ~Q(status__in=["a"])
|
||||
|
||||
def test_excludes_only_empty_value(self):
|
||||
"""Excluding a single status with no includes — value=[], excludes=["f"]."""
|
||||
c = ChoiceCriterion(value=[], excludes=["f"], modifier=Modifier.INCLUDES)
|
||||
q = c.to_q("status")
|
||||
assert q == ~Q(status__in=["f"])
|
||||
|
||||
def test_excludes_two(self):
|
||||
"""Excluding two statuses with no includes."""
|
||||
c = ChoiceCriterion(value=[], excludes=["f", "a"], modifier=Modifier.INCLUDES)
|
||||
q = c.to_q("status")
|
||||
assert q == ~Q(status__in=["f", "a"])
|
||||
|
||||
def test_include_and_exclude(self):
|
||||
"""Include f, exclude a — both lists set."""
|
||||
c = ChoiceCriterion(value=["f"], excludes=["a"], modifier=Modifier.INCLUDES)
|
||||
q = c.to_q("status")
|
||||
assert q == Q(status__in=["f"]) & ~Q(status__in=["a"])
|
||||
|
||||
def test_include_two_and_exclude_one(self):
|
||||
c = ChoiceCriterion(value=["f", "p"], excludes=["a"], modifier=Modifier.INCLUDES)
|
||||
q = c.to_q("status")
|
||||
assert q == Q(status__in=["f", "p"]) & ~Q(status__in=["a"])
|
||||
|
||||
def test_is_null(self):
|
||||
c = ChoiceCriterion(value=[], modifier=Modifier.IS_NULL)
|
||||
assert c.to_q("status") == Q(status__isnull=True)
|
||||
|
||||
def test_not_null(self):
|
||||
c = ChoiceCriterion(value=[], modifier=Modifier.NOT_NULL)
|
||||
assert c.to_q("status") == Q(status__isnull=False)
|
||||
|
||||
def test_excludes_modifier(self):
|
||||
"""EXCLUDES modifier with value set."""
|
||||
c = ChoiceCriterion(value=["f"], modifier=Modifier.EXCLUDES)
|
||||
assert c.to_q("status") == ~Q(status__in=["f"])
|
||||
|
||||
def test_excludes_modifier_empty_value(self):
|
||||
"""EXCLUDES modifier with empty value — should produce empty Q."""
|
||||
c = ChoiceCriterion(value=[], modifier=Modifier.EXCLUDES)
|
||||
q = c.to_q("status")
|
||||
assert q == Q()
|
||||
|
||||
def test_not_equals(self):
|
||||
c = ChoiceCriterion(value=["f"], modifier=Modifier.NOT_EQUALS)
|
||||
assert c.to_q("status") == ~Q(status__in=["f"])
|
||||
|
||||
|
||||
class TestChoiceCriterionAgainstDB:
|
||||
"""Verify ChoiceCriterion produces correct DB results."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, django_db_blocker):
|
||||
pass
|
||||
|
||||
def _seed_games(self):
|
||||
"""Create test games with different statuses."""
|
||||
from games.models import Game, Platform
|
||||
platform, _ = Platform.objects.get_or_create(name="Test", icon="test")
|
||||
statuses = ["u", "p", "f", "r", "a"]
|
||||
for i, s in enumerate(statuses):
|
||||
Game.objects.get_or_create(
|
||||
name=f"Test Game {i}",
|
||||
defaults={"platform": platform, "status": s},
|
||||
)
|
||||
|
||||
def _count(self, c: ChoiceCriterion) -> int:
|
||||
from games.models import Game
|
||||
return Game.objects.filter(c.to_q("status")).count()
|
||||
|
||||
def _statuses(self, c: ChoiceCriterion) -> set[str]:
|
||||
from games.models import Game
|
||||
return set(Game.objects.filter(c.to_q("status")).values_list("status", flat=True))
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_include_finished_includes_only_finished(self):
|
||||
self._seed_games()
|
||||
c = ChoiceCriterion(value=["f"], modifier=Modifier.INCLUDES)
|
||||
assert self._statuses(c) == {"f"}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_exclude_finished_excludes_finished(self):
|
||||
self._seed_games()
|
||||
c = ChoiceCriterion(value=[], excludes=["f"], modifier=Modifier.INCLUDES)
|
||||
assert "f" not in self._statuses(c)
|
||||
assert len(self._statuses(c)) == 4 # u, p, r, a
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_include_and_exclude(self):
|
||||
"""Include Finished but exclude Abandoned."""
|
||||
self._seed_games()
|
||||
c = ChoiceCriterion(value=["f", "a"], excludes=["a"], modifier=Modifier.INCLUDES)
|
||||
# Include f and a, but exclude a → only f
|
||||
assert self._statuses(c) == {"f"}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_include_two(self):
|
||||
"""Include Finished AND Played."""
|
||||
self._seed_games()
|
||||
c = ChoiceCriterion(value=["f", "p"], modifier=Modifier.INCLUDES)
|
||||
assert self._statuses(c) == {"f", "p"}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_exclude_two(self):
|
||||
"""Exclude Finished AND Abandoned."""
|
||||
self._seed_games()
|
||||
c = ChoiceCriterion(value=[], excludes=["f", "a"], modifier=Modifier.INCLUDES)
|
||||
statuses = self._statuses(c)
|
||||
assert "f" not in statuses
|
||||
assert "a" not in statuses
|
||||
assert statuses == {"u", "p", "r"}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_not_null_has_results(self):
|
||||
self._seed_games()
|
||||
c = ChoiceCriterion(value=[], modifier=Modifier.NOT_NULL)
|
||||
assert self._count(c) == 5
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_is_null_no_results(self):
|
||||
"""IS_NULL on a non-null field returns zero."""
|
||||
self._seed_games()
|
||||
c = ChoiceCriterion(value=[], modifier=Modifier.IS_NULL)
|
||||
assert self._count(c) == 0
|
||||
|
||||
|
||||
class TestGameFilterFromJson:
|
||||
def test_status_choice_criterion(self):
|
||||
gf = GameFilter.from_json(
|
||||
{"status": {"value": ["f", "p"], "modifier": "INCLUDES"}}
|
||||
)
|
||||
assert gf is not None
|
||||
assert gf.status is not None
|
||||
assert gf.status.value == ["f", "p"]
|
||||
assert gf.status.modifier == Modifier.INCLUDES
|
||||
|
||||
def test_status_not_null(self):
|
||||
gf = GameFilter.from_json({"status": {"modifier": "NOT_NULL"}})
|
||||
assert gf is not None
|
||||
assert gf.status is not None
|
||||
assert gf.status.modifier == Modifier.NOT_NULL
|
||||
|
||||
def test_platform_choice_criterion(self):
|
||||
gf = GameFilter.from_json(
|
||||
{"platform": {"value": ["1", "3"], "modifier": "INCLUDES"}}
|
||||
)
|
||||
assert gf is not None
|
||||
assert gf.platform is not None
|
||||
assert gf.platform.value == ["1", "3"]
|
||||
|
||||
def test_round_trip(self):
|
||||
data = {"status": {"value": ["f"], "modifier": "INCLUDES"}, "mastered": {"value": True, "modifier": "EQUALS"}}
|
||||
gf = GameFilter.from_json(data)
|
||||
json_out = gf.to_json()
|
||||
gf2 = GameFilter.from_json(json_out)
|
||||
assert gf2 is not None
|
||||
assert gf2.status is not None
|
||||
assert gf2.mastered is not None
|
||||
|
||||
|
||||
class TestGameFilterToQ:
|
||||
def test_status_choice_includes(self):
|
||||
gf = GameFilter.from_json(
|
||||
{"status": {"value": ["f", "p"], "modifier": "INCLUDES"}}
|
||||
)
|
||||
q = gf.to_q()
|
||||
assert q == Q(status__in=["f", "p"])
|
||||
|
||||
def test_status_not_null(self):
|
||||
gf = GameFilter.from_json({"status": {"modifier": "NOT_NULL"}})
|
||||
q = gf.to_q()
|
||||
assert q == Q(status__isnull=False)
|
||||
|
||||
|
||||
class TestFilterBarRendering:
|
||||
"""Tests for FilterBar with SelectableFilter widgets."""
|
||||
|
||||
def test_status_uses_selectable_filter(self):
|
||||
html = str(FilterBar(platform_options=[]))
|
||||
assert "data-selectable-filter" in html
|
||||
|
||||
def test_mastered_not_checked_by_default(self):
|
||||
html = str(FilterBar(filter_json="", platform_options=[]))
|
||||
assert 'checked="true"' not in html
|
||||
|
||||
def test_mastered_checked_when_filtered(self):
|
||||
html = str(
|
||||
FilterBar(
|
||||
platform_options=[],
|
||||
filter_json=json.dumps({"mastered": {"value": True, "modifier": "EQUALS"}}),
|
||||
)
|
||||
)
|
||||
assert 'checked="true"' in html
|
||||
|
||||
def test_status_prefilled(self):
|
||||
html = str(
|
||||
FilterBar(
|
||||
platform_options=[],
|
||||
filter_json=json.dumps({"status": {"value": ["f"], "modifier": "INCLUDES"}}),
|
||||
)
|
||||
)
|
||||
assert 'data-value="f"' in html
|
||||
assert "Finished" in html
|
||||
|
||||
def test_no_hx_get(self):
|
||||
html = str(FilterBar(platform_options=[]))
|
||||
assert "hx-get" not in html
|
||||
|
||||
def test_platform_options_rendered(self):
|
||||
html = str(FilterBar(platform_options=[(1, "Steam"), (2, "Switch")]))
|
||||
assert "Steam" in html
|
||||
assert "Switch" in html
|
||||
|
||||
def test_status_has_no_modifiers(self):
|
||||
"""Non-nullable fields should not show (None) but MUST show (Any)."""
|
||||
html = str(FilterBar(platform_options=[]))
|
||||
status_start = html.find('data-selectable-filter="status"')
|
||||
platform_start = html.find('data-selectable-filter="platform"')
|
||||
status_section = html[status_start:platform_start]
|
||||
# Must have (Any) — always available
|
||||
assert "(Any)" in status_section
|
||||
# Must NOT have (None) — field is non-nullable
|
||||
assert "(None)" not in status_section
|
||||
|
||||
def test_platform_has_modifiers(self):
|
||||
"""Nullable ForeignKey fields should show (Any)/(None)."""
|
||||
html = str(FilterBar(platform_options=[(1, "Steam")]))
|
||||
platform_start = html.find('data-selectable-filter="platform"')
|
||||
platform_section = html[platform_start:]
|
||||
# Should have at least one modifier option
|
||||
assert "(Any)" in platform_section or "(None)" in platform_section
|
||||
Reference in New Issue
Block a user