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),
|
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
- applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
- billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
- global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
- new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
- my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
- my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
- SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
- 841 ITs + 5 my_buds/my_posts 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 23:08:33 -04:00
|
|
|
("my-buds", "My Buds", 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())
|