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:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
38
src/apps/epic/migrations/0008_blades_reversal_fickle.py
Normal file
38
src/apps/epic/migrations/0008_blades_reversal_fickle.py
Normal 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)]
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user