Align set-criterion modifiers with Stash (any/all/none) and harmonize EXCLUDES
Closes #10. Backend (common/criteria.py): - Treat `excludes` as an always-orthogonal AND'd negative across both MultiCriterion and ChoiceCriterion; the modifier now governs only the `value` (include) set. This removes the prior divergence where MultiCriterion.EXCLUDES dropped the excludes list and ChoiceCriterion.EXCLUDES swapped include/exclude into a positive. - Fold INCLUDES / INCLUDES_ALL / EXCLUDES (+ EQUALS/NOT_EQUALS aliases) into the shared _SetCriterion base so the two subclasses cannot drift; remove _extra_q. M2M "has all" (games/filters.py): - PurchaseFilter._games_to_q builds a pk__in subquery with one join per value so INCLUDES_ALL on the many-to-many games field works in a single .filter() (a naive Q(games=a) & Q(games=b) collapses to one join and matches nothing). UI (FilterSelect + filter_bar.js): - Add an optional any/all/none match-mode <select> (INCLUDES/INCLUDES_ALL/ EXCLUDES) rendered before the pills via a new `leading` slot on the shared combobox shell. A native control so its value is its state. readSearchSelect serialises it to data-match; filter_bar folds it into the criterion modifier. Orthogonal to the (Any)/(None) presence pseudo-options and the exclude channel. - Enable it for the M2M Purchase.games field (INCLUDES_ALL is only meaningful for multi-valued relations). Styled with already-compiled utilities. Tests: harmonized EXCLUDES + INCLUDES_ALL for both criterion types, a DB-backed INCLUDES_ALL vs INCLUDES contrast on Purchase.games, and FilterSelect / PurchaseFilterBar rendering + round-trip of the match mode. https://claude.ai/code/session_01KwVrGFbq13mZdhDL9G6zhg
This commit is contained in:
@@ -92,16 +92,64 @@ class FilterBarRenderingTest(TestCase):
|
||||
self._assert_shell(html, "/presets/purchases/list", "/presets/purchases/save")
|
||||
self._assert_range_slider(html)
|
||||
|
||||
def test_purchase_filter_bar_games_has_match_modes(self):
|
||||
"""The many-to-many games field surfaces the any/all/none match select;
|
||||
single-valued fields (platform) do not."""
|
||||
html = str(
|
||||
PurchaseFilterBar(
|
||||
filter_json="", preset_list_url="/l", preset_save_url="/s"
|
||||
)
|
||||
)
|
||||
self.assertIn("data-search-select-match", html)
|
||||
self.assertIn('value="INCLUDES_ALL"', html)
|
||||
# Platform is single-valued: no match select before its widget.
|
||||
games_start = html.find('data-name="games"')
|
||||
platform_start = html.find('data-name="platform"')
|
||||
platform_section = html[platform_start:]
|
||||
self.assertNotIn("data-search-select-match", platform_section)
|
||||
self.assertGreater(games_start, 0)
|
||||
|
||||
def test_purchase_filter_bar_roundtrips_includes_all(self):
|
||||
"""A stored INCLUDES_ALL modifier pre-selects the match <option> and the
|
||||
included game still renders as a pill."""
|
||||
filter_json = json.dumps(
|
||||
{
|
||||
"games": {
|
||||
"value": [{"id": "5", "label": "Hollow Knight"}],
|
||||
"modifier": "INCLUDES_ALL",
|
||||
}
|
||||
}
|
||||
)
|
||||
html = str(
|
||||
PurchaseFilterBar(
|
||||
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
|
||||
)
|
||||
)
|
||||
self.assertIn('data-match="INCLUDES_ALL"', html)
|
||||
self.assertIn('value="INCLUDES_ALL" selected=""', html)
|
||||
self.assertIn("Hollow Knight", html)
|
||||
self.assertIn('data-search-select-type="include"', html)
|
||||
self.assertNoEscapedTags(html)
|
||||
|
||||
def test_game_filter_bar_roundtrips_selected_status(self):
|
||||
"""A status in filter_json renders as an include pill in the widget."""
|
||||
filter_json = json.dumps({"status": {"value": [{"id": "f", "label": "Finished"}], "modifier": "INCLUDES"}})
|
||||
filter_json = json.dumps(
|
||||
{
|
||||
"status": {
|
||||
"value": [{"id": "f", "label": "Finished"}],
|
||||
"modifier": "INCLUDES",
|
||||
}
|
||||
}
|
||||
)
|
||||
html = str(
|
||||
FilterBar(
|
||||
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
|
||||
)
|
||||
)
|
||||
self.assertIn('data-search-select-mode="filter"', html)
|
||||
self.assertIn('data-search-select-type="include"', html) # rendered as an include pill
|
||||
self.assertIn(
|
||||
'data-search-select-type="include"', html
|
||||
) # rendered as an include pill
|
||||
self.assertIn('data-value="f"', html) # selected status reflected in widget
|
||||
self.assertIn("Finished", html) # ...with its label
|
||||
self.assertNoEscapedTags(html)
|
||||
|
||||
+124
-2
@@ -94,6 +94,18 @@ class TestChoiceCriterion:
|
||||
q = c.to_q("status")
|
||||
assert q == Q()
|
||||
|
||||
def test_excludes_modifier_keeps_excludes_orthogonal(self):
|
||||
"""Harmonized (Stash model): under EXCLUDES the ``excludes`` channel stays
|
||||
an orthogonal AND'd negative — it is *not* swapped into a positive
|
||||
include (the old divergent ChoiceCriterion behaviour)."""
|
||||
c = ChoiceCriterion(value=["f"], excludes=["a"], modifier=Modifier.EXCLUDES)
|
||||
assert c.to_q("status") == ~Q(status__in=["f"]) & ~Q(status__in=["a"])
|
||||
|
||||
def test_includes_all(self):
|
||||
"""INCLUDES_ALL ANDs an equality per value (shared with MultiCriterion)."""
|
||||
c = ChoiceCriterion(value=["f", "p"], modifier=Modifier.INCLUDES_ALL)
|
||||
assert c.to_q("status") == Q(status="f") & Q(status="p")
|
||||
|
||||
def test_not_equals(self):
|
||||
c = ChoiceCriterion(value=["f"], modifier=Modifier.NOT_EQUALS)
|
||||
assert c.to_q("status") == ~Q(status__in=["f"])
|
||||
@@ -117,6 +129,18 @@ class TestMultiCriterion:
|
||||
c = MultiCriterion(value=[1], excludes=[2], modifier=Modifier.INCLUDES)
|
||||
assert c.to_q("game_id") == Q(game_id__in=[1]) & ~Q(game_id__in=[2])
|
||||
|
||||
def test_excludes_modifier_applies_excludes_channel(self):
|
||||
"""Harmonized (Stash model): EXCLUDES negates ``value`` AND still applies
|
||||
the orthogonal ``excludes`` channel. Previously MultiCriterion.EXCLUDES
|
||||
dropped the excludes list entirely."""
|
||||
c = MultiCriterion(value=[1], excludes=[2], modifier=Modifier.EXCLUDES)
|
||||
assert c.to_q("game_id") == ~Q(game_id__in=[1]) & ~Q(game_id__in=[2])
|
||||
|
||||
def test_includes_all(self):
|
||||
"""INCLUDES_ALL requires the row to relate to every value (M2M)."""
|
||||
c = MultiCriterion(value=[1, 2], modifier=Modifier.INCLUDES_ALL)
|
||||
assert c.to_q("games") == Q(games=1) & Q(games=2)
|
||||
|
||||
def test_is_null(self):
|
||||
c = MultiCriterion(value=[], modifier=Modifier.IS_NULL)
|
||||
assert c.to_q("device_id") == Q(device_id__isnull=True)
|
||||
@@ -124,7 +148,10 @@ class TestMultiCriterion:
|
||||
def test_from_json_strips_embedded_labels(self):
|
||||
"""from_json normalises {id, label} dicts to bare ids."""
|
||||
c = MultiCriterion.from_json(
|
||||
{"value": [{"id": 797, "label": "Hollow Knight"}], "excludes": [{"id": 11, "label": "Steam Deck"}]}
|
||||
{
|
||||
"value": [{"id": 797, "label": "Hollow Knight"}],
|
||||
"excludes": [{"id": 11, "label": "Steam Deck"}],
|
||||
}
|
||||
)
|
||||
assert c.value == [797]
|
||||
assert c.excludes == [11]
|
||||
@@ -216,6 +243,96 @@ class TestChoiceCriterionAgainstDB:
|
||||
assert self._count(c) == 0
|
||||
|
||||
|
||||
class TestPurchaseGamesIncludesAllAgainstDB:
|
||||
"""INCLUDES_ALL on the many-to-many ``Purchase.games`` should match only
|
||||
purchases linked to *all* of the given games — Stash's ``includes all``."""
|
||||
|
||||
def _seed(self):
|
||||
import datetime
|
||||
|
||||
from games.models import Game, Platform, Purchase
|
||||
|
||||
platform, _ = Platform.objects.get_or_create(name="Test", icon="test")
|
||||
a, _ = Game.objects.get_or_create(name="A", defaults={"platform": platform})
|
||||
b, _ = Game.objects.get_or_create(name="B", defaults={"platform": platform})
|
||||
c, _ = Game.objects.get_or_create(name="C", defaults={"platform": platform})
|
||||
|
||||
def make(linked):
|
||||
purchase = Purchase.objects.create(
|
||||
platform=platform, date_purchased=datetime.date(2024, 1, 1)
|
||||
)
|
||||
purchase.games.set(linked)
|
||||
return purchase
|
||||
|
||||
return {
|
||||
"a": a,
|
||||
"b": b,
|
||||
"both": make([a, b]),
|
||||
"only_a": make([a]),
|
||||
"all_three": make([a, b, c]),
|
||||
}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_includes_all_matches_only_supersets(self):
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
seeded = self._seed()
|
||||
pf = PurchaseFilter.from_json(
|
||||
{
|
||||
"games": {
|
||||
"value": [seeded["a"].id, seeded["b"].id],
|
||||
"modifier": "INCLUDES_ALL",
|
||||
}
|
||||
}
|
||||
)
|
||||
result = set(Purchase.objects.filter(pf.to_q()))
|
||||
assert result == {seeded["both"], seeded["all_three"]}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_includes_any_is_broader(self):
|
||||
"""Contrast: plain INCLUDES (any) also matches the A-only purchase."""
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
seeded = self._seed()
|
||||
pf = PurchaseFilter.from_json(
|
||||
{
|
||||
"games": {
|
||||
"value": [seeded["a"].id, seeded["b"].id],
|
||||
"modifier": "INCLUDES",
|
||||
}
|
||||
}
|
||||
)
|
||||
result = set(Purchase.objects.filter(pf.to_q()))
|
||||
assert result == {seeded["both"], seeded["only_a"], seeded["all_three"]}
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_includes_all_strips_embedded_labels(self):
|
||||
"""Stash-style {id, label} value items are normalised to bare ids."""
|
||||
from common.criteria import Modifier
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
seeded = self._seed()
|
||||
pf = PurchaseFilter.from_json(
|
||||
{
|
||||
"games": {
|
||||
"value": [
|
||||
{"id": seeded["a"].id, "label": "A"},
|
||||
{"id": seeded["b"].id, "label": "B"},
|
||||
],
|
||||
"modifier": "INCLUDES_ALL",
|
||||
}
|
||||
}
|
||||
)
|
||||
assert pf.games is not None
|
||||
assert pf.games.modifier == Modifier.INCLUDES_ALL
|
||||
assert pf.games.value == [seeded["a"].id, seeded["b"].id]
|
||||
result = set(Purchase.objects.filter(pf.to_q()))
|
||||
assert result == {seeded["both"], seeded["all_three"]}
|
||||
|
||||
|
||||
class TestGameFilterFromJson:
|
||||
def test_status_choice_criterion(self):
|
||||
gf = GameFilter.from_json(
|
||||
@@ -293,7 +410,12 @@ class TestFilterBarRendering:
|
||||
html = str(
|
||||
FilterBar(
|
||||
filter_json=json.dumps(
|
||||
{"status": {"value": [{"id": "f", "label": "Finished"}], "modifier": "INCLUDES"}}
|
||||
{
|
||||
"status": {
|
||||
"value": [{"id": "f", "label": "Finished"}],
|
||||
"modifier": "INCLUDES",
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -208,7 +208,9 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
panel = html.split("data-search-select-template")[0]
|
||||
self.assertNotIn('data-search-select-option=""', panel)
|
||||
self.assertIn('data-search-select-template="row"', html)
|
||||
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html) # still pinned
|
||||
self.assertIn(
|
||||
'data-search-select-modifier-option="NOT_NULL"', html
|
||||
) # still pinned
|
||||
self.assertIn('data-prefetch="20"', html)
|
||||
|
||||
def test_search_url_pills_use_resolved_labels(self):
|
||||
@@ -221,6 +223,29 @@ class FilterSelectComponentTest(unittest.TestCase):
|
||||
self.assertIn(">Obscure Game</span>", html)
|
||||
self.assertIn('data-value="4172"', html)
|
||||
|
||||
MATCH_MODES = [("INCLUDES", "any"), ("INCLUDES_ALL", "all"), ("EXCLUDES", "none")]
|
||||
|
||||
def test_match_modes_render_native_select(self):
|
||||
html = FilterSelect(field_name="games", match_modes=self.MATCH_MODES)
|
||||
# A native <select> carries the include-set match mode; options are labels.
|
||||
self.assertIn("data-search-select-match", html)
|
||||
self.assertIn('value="INCLUDES_ALL"', html)
|
||||
self.assertIn(">all</option>", html)
|
||||
# The container exposes the active mode (defaults to the first) for the JS.
|
||||
self.assertIn('data-match="INCLUDES"', html)
|
||||
|
||||
def test_active_match_marks_selected_option(self):
|
||||
html = FilterSelect(
|
||||
field_name="games", match="INCLUDES_ALL", match_modes=self.MATCH_MODES
|
||||
)
|
||||
self.assertIn('data-match="INCLUDES_ALL"', html)
|
||||
self.assertIn('value="INCLUDES_ALL" selected=""', html)
|
||||
|
||||
def test_no_match_modes_omits_select(self):
|
||||
html = FilterSelect(field_name="status", options=[("f", "Finished")])
|
||||
self.assertNotIn("data-search-select-match", html)
|
||||
self.assertNotIn("data-match=", html)
|
||||
|
||||
|
||||
class SearchLabelTest(django.test.TestCase):
|
||||
@classmethod
|
||||
|
||||
Reference in New Issue
Block a user