2026-03-24 23:29:32 -04:00
|
|
|
from selenium.webdriver.common.action_chains import ActionChains
|
2026-03-15 01:17:09 -04:00
|
|
|
from selenium.webdriver.common.by import By
|
|
|
|
|
from selenium.webdriver.common.keys import Keys
|
|
|
|
|
|
|
|
|
|
from .base import FunctionalTest
|
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>
2026-05-04 01:27:17 -04:00
|
|
|
from apps.applets.models import Applet
|
|
|
|
|
from apps.drama.models import GameEvent, record
|
2026-03-25 01:08:12 -04:00
|
|
|
from apps.epic.models import DeckVariant, Room
|
2026-03-15 01:17:09 -04:00
|
|
|
from apps.lyric.models import Token, User
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GameKitTest(FunctionalTest):
|
|
|
|
|
"""Game Kit <dialog>: opens from footer, shows token cards, dismisses."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
2026-03-25 01:08:12 -04:00
|
|
|
self.earthman, _ = DeckVariant.objects.get_or_create(
|
|
|
|
|
slug="earthman",
|
|
|
|
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
|
|
|
|
)
|
2026-03-15 01:17:09 -04:00
|
|
|
self.create_pre_authenticated_session("gamer@kit.io")
|
|
|
|
|
self.gamer = User.objects.get(email="gamer@kit.io")
|
2026-03-25 01:08:12 -04:00
|
|
|
self.gamer.equipped_deck = self.earthman
|
|
|
|
|
self.gamer.save(update_fields=["equipped_deck"])
|
|
|
|
|
self.gamer.unlocked_decks.add(self.earthman)
|
2026-03-15 01:17:09 -04:00
|
|
|
self.token = self.gamer.tokens.filter(token_type=Token.COIN).first()
|
|
|
|
|
self.room = Room.objects.create(name="Kit Room", owner=self.gamer)
|
|
|
|
|
self.gate_url = self.live_server_url + f"/gameboard/room/{self.room.id}/gate/"
|
|
|
|
|
|
|
|
|
|
def test_kit_btn_in_footer_opens_dialog(self):
|
|
|
|
|
self.browser.get(self.gate_url)
|
|
|
|
|
kit_btn = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(By.ID, "id_kit_btn")
|
|
|
|
|
)
|
|
|
|
|
self.assertTrue(kit_btn.is_displayed())
|
|
|
|
|
kit_btn.click()
|
2026-03-15 01:46:11 -04:00
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.assertTrue(
|
|
|
|
|
self.browser.find_element(By.ID, "id_kit_bag_dialog").is_displayed()
|
|
|
|
|
)
|
2026-03-15 01:17:09 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_kit_dialog_shows_token_cards(self):
|
|
|
|
|
self.browser.get(self.gate_url)
|
|
|
|
|
self.browser.find_element(By.ID, "id_kit_btn").click()
|
|
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR,
|
|
|
|
|
f"#id_kit_bag_dialog [data-token-id='{self.token.id}']",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_kit_dialog_closes_on_escape(self):
|
|
|
|
|
self.browser.get(self.gate_url)
|
|
|
|
|
self.browser.find_element(By.ID, "id_kit_btn").click()
|
2026-03-15 01:46:11 -04:00
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.assertTrue(
|
|
|
|
|
self.browser.find_element(By.ID, "id_kit_bag_dialog").is_displayed()
|
|
|
|
|
)
|
2026-03-15 01:17:09 -04:00
|
|
|
)
|
2026-03-15 01:46:11 -04:00
|
|
|
dialog = self.browser.find_element(By.ID, "id_kit_bag_dialog")
|
2026-03-15 01:17:09 -04:00
|
|
|
dialog.send_keys(Keys.ESCAPE)
|
|
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.assertFalse(
|
|
|
|
|
self.browser.find_element(By.ID, "id_kit_bag_dialog").is_displayed()
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_kit_btn_visible_outside_room(self):
|
|
|
|
|
self.browser.get(self.live_server_url + "/")
|
|
|
|
|
kit_btn = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(By.ID, "id_kit_btn")
|
|
|
|
|
)
|
|
|
|
|
self.assertTrue(kit_btn.is_displayed())
|
2026-03-24 23:18:04 -04:00
|
|
|
|
|
|
|
|
def test_kit_dialog_shows_equipped_deck(self):
|
|
|
|
|
"""New user auto-gets Earthman equipped; kit bar shows its deck card."""
|
|
|
|
|
self.browser.get(self.gate_url)
|
|
|
|
|
self.browser.find_element(By.ID, "id_kit_btn").click()
|
|
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR,
|
|
|
|
|
f"#id_kit_bag_dialog .kit-bag-deck[data-deck-id='{self.gamer.equipped_deck.pk}']",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_kit_dialog_always_shows_dice_placeholder(self):
|
|
|
|
|
self.browser.get(self.gate_url)
|
|
|
|
|
self.browser.find_element(By.ID, "id_kit_btn").click()
|
|
|
|
|
self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR,
|
|
|
|
|
"#id_kit_bag_dialog .kit-bag-placeholder",
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-03-24 23:29:32 -04:00
|
|
|
|
|
|
|
|
def test_kit_dialog_deck_tooltip_shows_name_count_availability_and_stock_version(self):
|
|
|
|
|
self.browser.get(self.gate_url)
|
|
|
|
|
self.browser.find_element(By.ID, "id_kit_btn").click()
|
|
|
|
|
deck_el = self.wait_for(
|
|
|
|
|
lambda: self.browser.find_element(
|
|
|
|
|
By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck"
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-03-25 01:30:18 -04:00
|
|
|
# Dispatch mouseenter via JS — more reliable than ActionChains in headless CI
|
|
|
|
|
self.browser.execute_script(
|
|
|
|
|
"arguments[0].dispatchEvent(new Event('mouseenter'))", deck_el
|
|
|
|
|
)
|
2026-03-24 23:29:32 -04:00
|
|
|
tooltip = self.browser.find_element(
|
2026-04-15 22:16:50 -04:00
|
|
|
By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck .tt"
|
2026-03-24 23:29:32 -04:00
|
|
|
)
|
|
|
|
|
self.wait_for(lambda: self.assertTrue(tooltip.is_displayed()))
|
|
|
|
|
text = tooltip.text
|
|
|
|
|
self.assertIn("Earthman", text)
|
|
|
|
|
self.assertIn("(Default)", text)
|
|
|
|
|
self.assertIn("108", text)
|
|
|
|
|
self.assertIn("Stock version", text)
|
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>
2026-05-04 01:27:17 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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),
|
buddies sprint phase 1: User.buddies M2M(self,symm=False) + my_buddies aperture page + add_buddy JSON endpoint + buddy btn slide-out — TDD; My Contacts applet renamed → My Buddies (slug + name + partial)
- lyric/0004 adds User.buddies = ManyToManyField('self', symmetrical=False, blank=True, related_name='added_as_buddy'). Asymmetric one-way add: A.buddies.add(B) doesn't reciprocate. Reverse via B.added_as_buddy.all() — load-bearing for the future "buddy changed username" snapshot-accept flow noted in design.
- applets/0006 renames slug my-contacts → my-buddies + name 'Contacts' → 'My Buddies'. Existing migrations 0003/0004 untouched (historical artifacts).
- billboard.views.my_buddies + add_buddy:
• my_buddies: GET /billboard/my-buddies/ → renders the aperture page with request.user.buddies.all().
• add_buddy: POST /billboard/buddies/add → JSON {buddy: {id, username, email}|null}. Privacy: returns null when email isn't a registered User OR is the requester's own; never leaks membership. Idempotent on re-add (M2M dedup).
- templates:
• _applet-my-contacts.html → _applet-my-buddies.html (heading + link to /billboard/my-buddies/).
• my_buddies.html — bottom-anchored aperture list of buddies w. {% empty %} fallback "No buddies yet."
• _buddy_add_panel.html — bottom-left handshake btn + slide-out, mirrors _buddy_panel.html (post share) but POSTs to add_buddy and appends to #id_buddies_list. Skips append if data-buddy-id already in DOM (race-safe). Drops the .buddy-entry--empty row on first add.
- SCSS: page-billbuddies joins the body-class aperture trio; .buddies-page extends %billboard-page-base + flex-column + bottom-anchor for #id_buddies_list. id_applet_my_contacts → id_applet_my_buddies (test references + grid placement).
- tests: new test_buddies.py — 14 ITs covering UserBuddiesM2MTest (asymmetric, idempotent), MyBuddiesViewTest (lists own buddies only, anon redirect), AddBuddyViewTest (registered/unregistered/self/idempotent/email-fallback/405). Existing test_views/test_billboard/test_game_kit references swapped to my-buddies. New test_my_buddies.py FT — 4 tests: pre-existing buddies render, empty state, add via panel appends entry w. username, unregistered silent no-op.
- 841 ITs (+14) + 4 my_buddies FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:31:42 -04:00
|
|
|
("my-buddies", "My Buddies", 4, 3),
|
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>
2026-05-04 01:27:17 -04:00
|
|
|
("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())
|