diff --git a/CLAUDE.md b/CLAUDE.md index a9dc561..54007e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,4 +140,7 @@ Use `.parent:has(.child-class)` to style a parent based on its contents without ### Plausible FT noise Plausible analytics script in `base.html` fires a beacon during Selenium tests → harmless console error. Fix: `{% if not debug %}` guard around the script tag. +### JSONField `.exclude(data__key=value)` on SQLite +`.exclude(data__retracted=True)` on a row whose `data` has no `retracted` key resolves to `WHERE NOT (NULL = TRUE)` → NULL → SQL filters that row out. The exclude becomes "exclude rows where the key is True OR missing" instead of "exclude rows where the key is True". PostgreSQL evaluates this correctly, so the bug only manifests in local dev / SQLite ITs. If you mean *exclude only when the key exists and equals X*, do the predicate in Python after fetching a buffered queryset (see `_billboard_context` for the pattern). The same trap applies to `.filter(data__key=value)` — you'll silently miss rows where the key is missing. + See `.claude/skills/TDD/SKILL.md` for test-specific gotchas (TransactionTestCase flush, static files in tests, Selenium text-transform, multi-browser CI, msgpack integer keys). diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index c7ff736..882ad6c 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -41,16 +41,26 @@ def _billboard_context(user): .distinct() .first() ) - recent_events = ( - list( + # SIG_READY+retracted exclusion is done in Python because SQLite's NULL + # semantics drop ALL SIG_READY events whose data has no `retracted` key: + # `data__retracted=True` resolves to NULL via JSON_EXTRACT for missing keys, + # and `WHERE NOT (NULL AND verb='sig_ready')` evaluates to NULL → row + # filtered out. We pull a buffer (100) to absorb any retracted prefix and + # then slice to 36 after Python filtering. + if recent_room: + candidates = list( recent_room.events .select_related("actor") .exclude(verb=GameEvent.SIG_UNREADY) - .exclude(verb=GameEvent.SIG_READY, data__retracted=True) - .order_by("-timestamp")[:36] - )[::-1] - if recent_room else [] - ) + .order_by("-timestamp")[:100] + ) + visible = [ + e for e in candidates + if not (e.verb == GameEvent.SIG_READY and e.data.get("retracted")) + ] + recent_events = visible[:36][::-1] + else: + recent_events = [] return { "my_rooms": my_rooms, diff --git a/src/apps/epic/migrations/0008_blades_reversal_fickle.py b/src/apps/epic/migrations/0008_blades_reversal_fickle.py new file mode 100644 index 0000000..660f1b3 --- /dev/null +++ b/src/apps/epic/migrations/0008_blades_reversal_fickle.py @@ -0,0 +1,38 @@ +"""Rename the Blades Middle Arcana reversal qualifier "Nervous" → "Fickle".""" +from django.db import migrations + + +SUIT = "BLADES" +COURT_NUMBERS = [11, 12, 13, 14] +OLD = "Nervous" +NEW = "Fickle" + + +def _update(apps, value): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + try: + earthman = DeckVariant.objects.get(slug="earthman") + except DeckVariant.DoesNotExist: + return + TarotCard.objects.filter( + deck_variant=earthman, + arcana="MIDDLE", + suit=SUIT, + number__in=COURT_NUMBERS, + ).update(reversal_qualifier=value) + + +def forward(apps, schema_editor): + _update(apps, NEW) + + +def backward(apps, schema_editor): + _update(apps, OLD) + + +class Migration(migrations.Migration): + dependencies = [ + ("epic", "0007_finalize_earthman_deck"), + ] + operations = [migrations.RunPython(forward, backward)] diff --git a/src/functional_tests/test_game_kit.py b/src/functional_tests/test_game_kit.py index b4ff090..45bd841 100644 --- a/src/functional_tests/test_game_kit.py +++ b/src/functional_tests/test_game_kit.py @@ -3,6 +3,8 @@ from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from .base import FunctionalTest +from apps.applets.models import Applet +from apps.drama.models import GameEvent, record from apps.epic.models import DeckVariant, Room from apps.lyric.models import Token, User @@ -113,3 +115,108 @@ class GameKitTest(FunctionalTest): self.assertIn("(Default)", text) self.assertIn("108", text) self.assertIn("Stock version", text) + + +class PronounsAppletFlowTest(FunctionalTest): + """End-to-end profile-wide pronoun flip: + + The same provenance prose ("X embodies as {poss} Significator …") rendered + on both the per-room scroll AND the billboard's Most Recent Scroll applet + should re-render when the user flips their pronouns ideology via the + Pronouns applet on the Game Kit page. The test starts on a billscroll seeing + "their" cognates (default pluralism), navigates to Game Kit, clicks the + bawlmorese card → guard portal → OK, then verifies the Most Recent Scroll + on the billboard AND the room's billscroll both render "yos" cognates. + """ + + def setUp(self): + super().setUp() + # Billboard applets — page renders blank without these + for slug, name, cols, rows in [ + ("my-scrolls", "My Scrolls", 4, 3), + ("my-contacts", "Contacts", 4, 3), + ("most-recent-scroll", "Most Recent Scroll", 8, 6), + ]: + Applet.objects.get_or_create( + slug=slug, + defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"}, + ) + # Game Kit applets — including the new Pronouns applet + for slug, name in [ + ("gk-trinkets", "Trinkets"), + ("gk-tokens", "Tokens"), + ("gk-decks", "Card Decks"), + ("gk-dice", "Dice Sets"), + ("pronouns", "Pronouns"), + ]: + Applet.objects.get_or_create( + slug=slug, + defaults={"name": name, "grid_cols": 3, "grid_rows": 3, "context": "game-kit"}, + ) + self.create_pre_authenticated_session("disco@pronouns.io") + self.user = User.objects.get(email="disco@pronouns.io") + self.room = Room.objects.create(name="Pronoun Chamber", owner=self.user) + # Seed a SIG_READY event so both scrolls have prose to render + record( + self.room, GameEvent.SIG_READY, actor=self.user, + card_name="Maid of Brands", corner_rank="M", + suit_icon="fa-wand-sparkles", + ) + self.scroll_url = self.live_server_url + f"/billboard/room/{self.room.id}/scroll/" + self.billboard_url = self.live_server_url + "/billboard/" + self.game_kit_url = self.live_server_url + "/gameboard/game-kit/" + + def _scroll_text(self): + return self.browser.find_element(By.ID, "id_drama_scroll").text + + def _most_recent_text(self): + return self.browser.find_element(By.ID, "id_applet_most_recent_scroll").text + + def test_pronoun_flip_propagates_to_billscroll_and_most_recent(self): + # 1. Start on the room's billscroll — default pluralism reads "their". + self.browser.get(self.scroll_url) + self.wait_for(lambda: self.browser.find_element(By.ID, "id_drama_scroll")) + self.assertIn("embodies as their Significator", self._scroll_text()) + + # 2. Navigate to the Game Kit page and find the bawlmorese card. + self.browser.get(self.game_kit_url) + bawlmorese_card = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".gk-pronoun-card[data-pronoun='bawlmorese']" + ) + ) + + # 3. Click the card → guard portal becomes active w. the slash-trio + # preview ("yo/yo/yos") above OK|NVM. + self.browser.execute_script("arguments[0].click()", bawlmorese_card) + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_guard_portal.active" + ) + ) + portal = self.browser.find_element(By.ID, "id_guard_portal") + self.assertIn("Set pronoun preference?", portal.text) + self.assertIn("yo/yo/yos", portal.text) + + # 4. Click OK — the page reloads, .active class moves to bawlmorese. + portal.find_element(By.CSS_SELECTOR, ".guard-yes").click() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".gk-pronoun-card.active[data-pronoun='bawlmorese']" + ) + ) + + # 5. Navigate to the Billboard — Most Recent Scroll applet must now + # read "yos" cognates, not "their". + self.browser.get(self.billboard_url) + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_applet_most_recent_scroll") + ) + self.assertIn("embodies as yos Significator", self._most_recent_text()) + self.assertNotIn("embodies as their Significator", self._most_recent_text()) + + # 6. And back on the per-room billscroll — same prose, same flip. + self.browser.get(self.scroll_url) + self.wait_for(lambda: self.browser.find_element(By.ID, "id_drama_scroll")) + self.assertIn("embodies as yos Significator", self._scroll_text()) + self.assertNotIn("embodies as their Significator", self._scroll_text()) diff --git a/src/static/tests/SigSelectSpec.js b/src/static/tests/SigSelectSpec.js index a5ae3ff..5a15109 100644 --- a/src/static/tests/SigSelectSpec.js +++ b/src/static/tests/SigSelectSpec.js @@ -518,9 +518,9 @@ describe("SigSelect", () => { it("non-major with data-reversal-qualifier: reversal-qualifier = suit word, reversal-name = card name", () => { makeFixture(); - card.dataset.reversalQualifier = "Nervous"; + card.dataset.reversalQualifier = "Fickle"; hover(); - expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Nervous"); + expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Fickle"); expect(stageCard.querySelector(".fan-card-reversal-name").textContent) .toBe(card.dataset.nameTitle); }); diff --git a/src/static_src/tests/SigSelectSpec.js b/src/static_src/tests/SigSelectSpec.js index a5ae3ff..5a15109 100644 --- a/src/static_src/tests/SigSelectSpec.js +++ b/src/static_src/tests/SigSelectSpec.js @@ -518,9 +518,9 @@ describe("SigSelect", () => { it("non-major with data-reversal-qualifier: reversal-qualifier = suit word, reversal-name = card name", () => { makeFixture(); - card.dataset.reversalQualifier = "Nervous"; + card.dataset.reversalQualifier = "Fickle"; hover(); - expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Nervous"); + expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Fickle"); expect(stageCard.querySelector(".fan-card-reversal-name").textContent) .toBe(card.dataset.nameTitle); });