billboard Most Recent Scroll: fix SQLite NULL drop on SIG_READY exclude; pronouns flow FT; Blades middle reversal Nervous → Fickle — TDD

- billboard/views.py _billboard_context: `.exclude(verb=SIG_READY, data__retracted=True)` was silently dropping every SIG_READY event whose data had no `retracted` key — `WHERE NOT (NULL AND verb='sig_ready')` evaluates to NULL via JSON_EXTRACT, which the SQL engine treats as "row not satisfying WHERE", so the row was excluded. Fix: pull a 100-row buffer w. only the SIG_UNREADY exclude at the SQL level, then post-filter retracted SIG_READY in Python before slicing to 36; PostgreSQL handles the lookup correctly so this is a SQLite-only manifestation that explained intermittent "No events yet" in Most Recent Scroll
- CLAUDE.md gotchas: new entry warning that `.exclude(data__key=value)` / `.filter(data__key=value)` on SQLite JSONField bites on missing keys; if the predicate must require key existence, post-filter in Python
- functional_tests/test_game_kit.py PronounsAppletFlowTest: end-to-end profile-wide pronoun flip — start on per-room billscroll seeing "their" cognates, navigate to Game Kit, click bawlmorese card, assert guard portal active w. "yo/yo/yos" preview, click OK, navigate to billboard + see Most Recent Scroll re-rendered w. "yos", navigate back to billscroll + see same flip; covers the whole render-time-pronoun-resolution path on real DOM
- epic/0008_blades_reversal_fickle.py: rename Middle Arcana Blades reversal_qualifier "Nervous" → "Fickle" (RunPython forward+reverse on arcana=MIDDLE, suit=BLADES, number ∈ {11,12,13,14}); SigSelectSpec.js hardcoded "Nervous" updated to "Fickle" + collected static

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-04 01:27:17 -04:00
parent 29493c4f74
commit 5413e63585
6 changed files with 169 additions and 11 deletions

View File

@@ -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).

View File

@@ -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,

View File

@@ -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)]

View File

@@ -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())

View File

@@ -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);
});

View File

@@ -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);
});