"""FTs for the Game Sign (a.k.a. My Significator) picker + billboard applet. Sprint 4a of [[project-my-sea-roadmap]]. The picker lives at `/billboard/my-sign/` — solo lift of the room's sig-select grid (no countdown / polarity / multi-user). Selection persists on User.significator + User.significator_reversed. "Significator" remains the storage-layer term + room sig-select context; this billboard surface is branded "Sign" / "Game Sign". """ from selenium.webdriver.common.by import By from .base import FunctionalTest from .sig_page import _assign_sig, _seed_earthman_sig_pile, _seed_minchiate_image_fixtures from apps.applets.models import Applet from apps.epic.models import TarotCard, personal_sig_cards from apps.lyric.models import User def _seed_my_sign_applet(): Applet.objects.get_or_create( slug="my-sign", defaults={"name": "My Sign", "context": "billboard", "default_visible": True, "grid_cols": 4, "grid_rows": 6}, ) class MySignPickerTest(FunctionalTest): """Happy-path picker: a user with the Earthman deck equipped lands at /billboard/my-sign/, picks a card, clicks SAVE SIGN, and sees the sig propagate to the Game Sign applet on /billboard/.""" def setUp(self): super().setUp() _seed_earthman_sig_pile() _seed_my_sign_applet() # Seed the rest of the billboard applets so /billboard/ renders # without missing-applet errors. for slug, name in [ ("my-scrolls", "My Scrolls"), ("my-buds", "My Buds"), ("most-recent-scroll", "Most Recent Scroll"), ]: Applet.objects.get_or_create( slug=slug, defaults={"name": name, "context": "billboard"}, ) self.email = "sig@test.io" self.gamer = User.objects.create(email=self.email) # post_save signal auto-equips Earthman. Picker uses personal_sig_cards # (= 16 middle arcana + Major 0 & 1, filtered by Note unlocks) so the # target must come from that subset, not the full deck. sig_pile = personal_sig_cards(self.gamer) self.target_card = sig_pile[0] if sig_pile else None self.assertIsNotNone( self.target_card, "personal_sig_cards(user) returned no cards — check Earthman seed" " + DeckVariant fixture availability in the FT DB.", ) # ── Test 1 ─────────────────────────────────────────────────────────────── def test_landing_renders_dry_hex_with_scan_sign_button(self): """GET /billboard/my-sign/ → the DRY table hex (1-chair) renders w. a central .btn-primary reading "SCAN SIGN"; the card grid is hidden until SCAN SIGN is clicked.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") self.wait_for( lambda: self.assertIn( "GAMESIGN", "".join( self.browser.find_element(By.CSS_SELECTOR, "h2").text.upper().split() ), ) ) # Landing phase markers: hex + 1 chair + SCAN SIGN btn self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".my-sign-page .table-hex" ) ) chairs = self.browser.find_elements( By.CSS_SELECTOR, ".my-sign-page .table-seat" ) self.assertEqual(len(chairs), 1, "Landing hex should render exactly 1 chair") scan_btn = self.browser.find_element(By.ID, "id_scan_sign_btn") self.assertIn("SCAN", scan_btn.text.upper()) self.assertIn("SIGN", scan_btn.text.upper()) # Picker grid not yet visible — landing phase only grid = self.browser.find_element(By.CSS_SELECTOR, ".my-sign-deck-grid") self.assertFalse( grid.is_displayed(), "Picker grid should be hidden on landing phase", ) # ── Test 2 ─────────────────────────────────────────────────────────────── def test_scan_sign_click_transitions_to_picker_phase(self): """Click SCAN SIGN → landing hex hides; picker grid + populated stage frame appear. Target card present in the grid.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") scan_btn = self.wait_for( lambda: self.browser.find_element(By.ID, "id_scan_sign_btn") ) self.browser.execute_script("arguments[0].click()", scan_btn) # Phase swap — picker now visible, hex now hidden self.wait_for( lambda: self.assertTrue( self.browser.find_element( By.CSS_SELECTOR, ".my-sign-deck-grid" ).is_displayed(), "Picker grid should be visible after SCAN SIGN click", ) ) # Hex collapses (display:none) after transition self.assertFalse( self.browser.find_element( By.CSS_SELECTOR, ".my-sign-page .table-hex" ).is_displayed(), "Landing hex should hide once picker phase is active", ) # Target card present in the grid self.browser.find_element( By.CSS_SELECTOR, f'.my-sign-deck-grid .sig-card[data-card-id="{self.target_card.id}"]', ) # ── Test 3 ─────────────────────────────────────────────────────────────── def test_click_thumbnail_shows_OK_btn_without_locking(self): """In picker phase: click a thumbnail → .sig-focused class + OK btn visible on it; stat block + FLIP still hidden (no lock yet); SAVE SIGN still disabled.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") self.browser.execute_script( "document.getElementById('id_scan_sign_btn').click()" ) card_el = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, f'.my-sign-deck-grid .sig-card[data-card-id="{self.target_card.id}"]', ) ) self.browser.execute_script("arguments[0].click()", card_el) # .sig-focused class → OK btn visible self.wait_for( lambda: self.assertIn( "sig-focused", self.browser.find_element( By.CSS_SELECTOR, f'.sig-card[data-card-id="{self.target_card.id}"]', ).get_attribute("class"), ) ) ok_btn = self.browser.find_element( By.CSS_SELECTOR, f'.sig-card[data-card-id="{self.target_card.id}"] .sig-ok-btn', ) self.assertTrue(ok_btn.is_displayed(), "OK btn should be visible on focused thumb") # Stat block still hidden (no lock yet) stage = self.browser.find_element(By.CSS_SELECTOR, ".my-sign-stage") self.assertNotIn("sig-stage--frozen", stage.get_attribute("class")) # SAVE SIGN still disabled save_btn = self.browser.find_element(By.ID, "id_save_sign_btn") self.assertTrue(save_btn.get_attribute("disabled")) # ── Test 4 ─────────────────────────────────────────────────────────────── def test_OK_click_locks_thumbnail_and_enables_save_sign(self): """In picker phase: click thumbnail → click its OK btn → stage locks (.sig-stage--frozen), NVM btn visible on the thumbnail, SAVE SIGN enables. Then save persists + applet shows the card.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") self.browser.execute_script( "document.getElementById('id_scan_sign_btn').click()" ) card_el = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, f'.my-sign-deck-grid .sig-card[data-card-id="{self.target_card.id}"]', ) ) self.browser.execute_script("arguments[0].click()", card_el) ok_btn = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, f'.sig-card[data-card-id="{self.target_card.id}"] .sig-ok-btn', ) ) self.browser.execute_script("arguments[0].click()", ok_btn) # Lock applied self.wait_for( lambda: self.assertIn( "sig-stage--frozen", self.browser.find_element( By.CSS_SELECTOR, ".my-sign-stage" ).get_attribute("class"), ) ) # NVM btn on the same thumbnail now visible nvm_btn = self.browser.find_element( By.CSS_SELECTOR, f'.sig-card[data-card-id="{self.target_card.id}"] .sig-nvm-btn', ) self.assertTrue(nvm_btn.is_displayed(), "NVM btn should be visible after OK confirm") # SAVE SIGN enabled save_btn = self.browser.find_element(By.ID, "id_save_sign_btn") self.assertFalse(save_btn.get_attribute("disabled")) # Save persists self.browser.execute_script("arguments[0].click()", save_btn) self.wait_for( lambda: self.gamer.refresh_from_db() or self.assertEqual( self.gamer.significator_id, self.target_card.id, ) ) # Applet on /billboard/ shows the saved card self.browser.get(self.live_server_url + "/billboard/") self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, f'#id_applet_my_sign .my-sign-applet-card[data-card-id="{self.target_card.id}"]', ) ) # ── Test 5 ─────────────────────────────────────────────────────────────── def test_NVM_click_deselects_and_disables_save_sign(self): """After OK-lock, click NVM on the thumbnail → lock clears (no .sig-stage--frozen), .sig-focused removed, SAVE SIGN disables.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") self.browser.execute_script( "document.getElementById('id_scan_sign_btn').click()" ) card_el = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, f'.my-sign-deck-grid .sig-card[data-card-id="{self.target_card.id}"]', ) ) self.browser.execute_script("arguments[0].click()", card_el) ok_btn = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, f'.sig-card[data-card-id="{self.target_card.id}"] .sig-ok-btn', ) ) self.browser.execute_script("arguments[0].click()", ok_btn) # Now NVM nvm_btn = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, f'.sig-card[data-card-id="{self.target_card.id}"] .sig-nvm-btn', ) ) self.browser.execute_script("arguments[0].click()", nvm_btn) # Lock cleared self.wait_for( lambda: self.assertNotIn( "sig-stage--frozen", self.browser.find_element( By.CSS_SELECTOR, ".my-sign-stage" ).get_attribute("class"), ) ) # SAVE SIGN disables again save_btn = self.browser.find_element(By.ID, "id_save_sign_btn") self.assertTrue(save_btn.get_attribute("disabled")) # .sig-focused removed from thumb self.assertNotIn( "sig-focused", self.browser.find_element( By.CSS_SELECTOR, f'.sig-card[data-card-id="{self.target_card.id}"]', ).get_attribute("class"), ) # ── Test 6 ─────────────────────────────────────────────────────────────── def test_landing_previews_saved_sig_on_stage(self): """If user already has a saved significator, the landing collapses to a read-only stage: the saved card preview + its stat block (no SCAN SIGN btn, no hex — user must DEL the saved sig to re-enter picker).""" # Pre-save a sig in the DB self.gamer.significator = self.target_card self.gamer.save(update_fields=["significator"]) self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") # Stage card is visible w. the saved card populated (corner-rank + # name pinned by data-card-id on the stage card wrapper) stage_card = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".my-sign-stage .sig-stage-card" ) ) self.assertTrue( stage_card.is_displayed(), "Stage card should preview the saved sig on landing", ) self.assertEqual( stage_card.get_attribute("data-card-id"), str(self.target_card.id), ) # Stage is frozen → stat block visible next to the card stage = self.browser.find_element(By.CSS_SELECTOR, ".my-sign-stage") self.assertIn("sig-stage--frozen", stage.get_attribute("class")) self.assertTrue( self.browser.find_element( By.CSS_SELECTOR, ".my-sign-stage .sig-stat-block" ).is_displayed(), "Stat block should be visible alongside the saved sig", ) # Hex + SCAN SIGN gone — user must DEL to re-enter picker self.assertEqual( len(self.browser.find_elements(By.ID, "id_scan_sign_btn")), 0, "SCAN SIGN btn should not render when a sig is already saved", ) self.assertEqual( len(self.browser.find_elements( By.CSS_SELECTOR, ".my-sign-page .table-hex" )), 0, "Table hex should not render when a sig is already saved", ) # ── Test 3 ─────────────────────────────────────────────────────────────── def test_applet_renders_blank_state_when_no_sig_chosen(self): """Fresh user with no significator → applet shows the empty-state copy, no card.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/") empty = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, "#id_applet_my_sign .my-sign-applet-empty" ) ) self.assertIn("No sign chosen", empty.text) self.assertEqual( len(self.browser.find_elements( By.CSS_SELECTOR, "#id_applet_my_sign .my-sign-applet-card" )), 0, ) class MySignBackupDeckTest(FunctionalTest): """User with no equipped_deck + no saved sig should still be able to pick. Sprint 4a-follow: no-equipped-deck path. Page renders a Brief banner nudging "Look!—no deck is equipped. Navigate to the game kit to equip one (FYI) or (NVM) proceed with Earthman [Shabby Paperboard] deck." — NVM dismisses + picker stays usable w. backup deck cards; FYI links to the gameboard (Game Kit applet).""" def setUp(self): super().setUp() _seed_earthman_sig_pile() for slug, name in [ ("my-sign", "Game Sign"), ("my-scrolls", "My Scrolls"), ("my-buds", "My Buds"), ("most-recent-scroll", "Most Recent Scroll"), ]: Applet.objects.get_or_create( slug=slug, defaults={"name": name, "context": "billboard"}, ) self.email = "dekless@test.io" self.gamer = User.objects.create(email=self.email) # Simulate "deck-in-use elsewhere" — clear equipped_deck (the # symmetry of the room flow that hands the deck off to a TableSeat). self.gamer.equipped_deck = None self.gamer.save(update_fields=["equipped_deck"]) # ── Test 1 ─────────────────────────────────────────────────────────────── def test_brief_banner_renders_when_no_deck_equipped_and_no_sig(self): """Banner should appear via Brief.showBanner() w. title "Default deck warning" + the Shabby Cardstock copy + FYI + NVM buttons.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") banner = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".my-sign-intro-banner" ) ) self.assertIn("Default deck warning", banner.text) self.assertIn("no deck is equipped", banner.text) self.assertIn("Shabby Cardstock", banner.text) # Brief banner action buttons: .note-banner__nvm + .note-banner__fyi self.assertTrue( banner.find_element(By.CSS_SELECTOR, ".note-banner__fyi").is_displayed() ) self.assertTrue( banner.find_element(By.CSS_SELECTOR, ".note-banner__nvm").is_displayed() ) # ── Test 2 ─────────────────────────────────────────────────────────────── def test_picker_renders_18_card_pile_using_backup_deck(self): """No equipped deck → personal_sig_cards falls back to Earthman, so the picker grid still has the full sig pile (16 cards w. no Note unlocks).""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") # Grid present + populated (find_elements returns N cards) self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sign-deck-grid") ) cards = self.browser.find_elements( By.CSS_SELECTOR, ".my-sign-deck-grid .sig-card" ) self.assertEqual(len(cards), 16) # ── Test 3 ─────────────────────────────────────────────────────────────── def test_nvm_dismisses_banner_and_keeps_picker_usable(self): """NVM click hides the banner; the grid remains interactive (can click a sig-card + SAVE SIGN persists against the backup deck).""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") banner = self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sign-intro-banner") ) banner.find_element(By.CSS_SELECTOR, ".note-banner__nvm").click() self.wait_for( lambda: self.assertEqual( len(self.browser.find_elements( By.CSS_SELECTOR, ".my-sign-intro-banner" )), 0, ) ) # ── Test 4 ─────────────────────────────────────────────────────────────── def test_fyi_links_to_gameboard(self): """FYI button is `.note-banner__fyi` rendered as by Brief.showBanner pointing at the Brief's `post_url` — here /gameboard/ so the user can equip a deck via Game Kit.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") fyi = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".my-sign-intro-banner .note-banner__fyi" ) ) href = fyi.get_attribute("href") or "" self.assertTrue( href.endswith("/gameboard/"), f"FYI should link to /gameboard/, got {href!r}", ) class MySignClearTest(FunctionalTest): """Clear-sign affordance on the SCAN SIGN landing screen. Sprint 4b-adjacent (2026-05-19): the only way to undo a saved sig was DB surgery — which blocked manual verification of [[sprint-my-sea-sign- gate-may19]]'s no-sig branch on dev users w. a sig already set. Design pre-spec'd in the 4b memory: a small CLEAR btn on the SCAN SIGN landing screen, visible only when `user.significator` is set; POST clears the FK + reversed flag + reloads to the no-sig landing.""" def setUp(self): super().setUp() _seed_earthman_sig_pile() _seed_my_sign_applet() self.email = "clear@test.io" self.gamer = User.objects.create(email=self.email) # Pre-save a sig so the DEL affordance is visible on landing. self.target_card = _assign_sig(self.gamer) # ── Test 1 ─────────────────────────────────────────────────────────────── def test_clear_sign_btn_renders_on_landing_when_sig_saved(self): """When `user.significator` is set, the landing screen shows a DEL btn alongside SCAN SIGN. Uses .btn-danger for destructive action treatment (mirrors post.html gear menu DEL).""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") btn = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, "#id_clear_sign_btn" ) ) self.assertTrue(btn.is_displayed()) self.assertIn("DEL", btn.text.upper()) self.assertIn("btn-danger", btn.get_attribute("class")) # ── Test 2 ─────────────────────────────────────────────────────────────── def test_clear_sign_btn_absent_when_no_sig_saved(self): """Fresh user w. no significator → no CLEAR btn on the landing.""" # Wipe the sig set in setUp so this user lands in the no-sig state. self.gamer.significator = None self.gamer.save(update_fields=["significator"]) self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") # Wait for the page to settle on the SCAN SIGN btn (proxy for landing). self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_scan_sign_btn") ) self.assertEqual( len(self.browser.find_elements( By.CSS_SELECTOR, "#id_clear_sign_btn" )), 0, ) # ── Test 3 ─────────────────────────────────────────────────────────────── def test_clear_sign_click_wipes_sig_and_reloads_to_no_sig_landing(self): """Click CLEAR SIGN → POST → page reloads → landing shows the no-sig state (no stage card preview, no CLEAR btn) + the DB has `user.significator = None`.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") btn = self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_clear_sign_btn") ) btn.click() # After the POST → redirect → GET, the CLEAR btn should no longer # exist + the stage card preview should be hidden. self.wait_for( lambda: self.assertEqual( len(self.browser.find_elements( By.CSS_SELECTOR, "#id_clear_sign_btn" )), 0, ) ) self.gamer.refresh_from_db() self.assertIsNone(self.gamer.significator) self.assertFalse(self.gamer.significator_reversed) class MySignImageRenderingTest(FunctionalTest): """Sprint A.3 — when the user's equipped deck has card images (Minchiate Fiorentine 1860-1890 today), the saved-sig stage card renders as an pointing at the deck's image asset, not the text-only fan-card scaffold. First visible-surface FT in the image-rendering rollout per [[project-image-based-deck-face-rendering]]. Other 5 surfaces (my_sea, both billboard applets, room, game_kit) follow in A.5+. """ def setUp(self): super().setUp() # Earthman is auto-equipped by the User post_save signal — seed its # pile first so the signal succeeds, then override the equipped deck # to Minchiate (the image-deck under test). _seed_earthman_sig_pile() self.minchiate = _seed_minchiate_image_fixtures() Applet.objects.get_or_create( slug="my-sign", defaults={"name": "My Sign", "context": "billboard", "default_visible": True, "grid_cols": 4, "grid_rows": 6}, ) for slug, name in [ ("my-scrolls", "My Scrolls"), ("my-buds", "My Buds"), ("most-recent-scroll", "Most Recent Scroll"), ]: Applet.objects.get_or_create( slug=slug, defaults={"name": name, "context": "billboard"}, ) self.email = "img-sig@test.io" # Superuser so post_save grants super-nomad + super-schizo Notes → # `_filter_major_unlocks` lets Il Matto (Major 0) through into the # picker grid. Without the Notes, Minchiate's sig pool is empty for # this user (no MIDDLE arcana cards + the 2 Major-0/1 cards filtered). self.gamer = User.objects.create(email=self.email, is_superuser=True) self.gamer.unlocked_decks.add(self.minchiate) self.gamer.equipped_deck = self.minchiate self.gamer.save(update_fields=["equipped_deck"]) # Save Il Matto as the user's sig (bypass the picker UI — the FT is # about render output, not the pick flow). self.il_matto = TarotCard.objects.get( deck_variant=self.minchiate, slug="il-matto", ) _assign_sig(self.gamer, card=self.il_matto) def test_saved_sig_renders_as_img_for_image_deck(self): """Visit /billboard/my-sign/ with a Minchiate sig saved → the stage card contains an child whose src points at the deck's image asset under the v2 naming convention. The text fan-card scaffold is hidden in image mode.""" self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/billboard/my-sign/") stage_card = self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".my-sign-stage .sig-stage-card" ) ) self.assertTrue( stage_card.is_displayed(), "Stage card should preview the saved sig on landing", ) # child renders w. the v2-convention filename for Il Matto. img = self.wait_for( lambda: stage_card.find_element(By.TAG_NAME, "img") ) src = img.get_attribute("src") or "" self.assertIn( "minchiate-fiorentine-1860-1890-trumps-00-il-matto.png", src, f"Expected Minchiate Il Matto image src, got: {src}", ) # Image mode toggle — the stage card carries a marker class so SCSS # can hide the fan-card text scaffold + show the . self.assertIn( "sig-stage-card--image", stage_card.get_attribute("class"), "Stage card should carry .sig-stage-card--image class in image mode", )