From e6e2bd10c529f6ae41aaee89bf22f04eb486203d Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 27 Apr 2026 22:22:08 -0400 Subject: [PATCH] =?UTF-8?q?deck=20contribution=20+=20game=20invite:=20writ?= =?UTF-8?q?e=20all=20FTs=20red=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5-sprint outside-in FT suite. Each FT class drives one sprint: Sprint 1 (DeckContributionTest): role confirmation sets TableSeat.deck_variant to the gamer's equipped deck; Game Kit tooltip shows game name + In-Use status. Sprint 2 (DeckInUseGameKitTest): DON btn-disabled w.o DOFF toggle for in-use deck; tooltip names the game; non-contributing deck retains normal DON/DOFF. Sprint 3 (GameInviteNotificationTest, @two-browser): invite_gamer() creates BillPost(kind=INVITE) for invitee; INVITE: link appears in My Posts. Sprint 4 (GameInviteBillPostTest): /billboard/post// renders _billpost_invite partial; BYE dismisses; OK shows join-guard when valid deck is equipped. Sprint 5 (GameInviteDeckValidationTest): OK btn-disabled + tooltip when no valid deck; confirming join assigns deck to seat and locks Game Kit DON. New model surface: billboard.BillPost (kind, recipient, room, invite, dismissed) New field: epic.TableSeat.deck_variant FK → DeckVariant Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Sonnet 4.6 --- .../test_deck_contribution.py | 216 +++++++++++ src/functional_tests/test_game_invite.py | 354 ++++++++++++++++++ 2 files changed, 570 insertions(+) create mode 100644 src/functional_tests/test_deck_contribution.py create mode 100644 src/functional_tests/test_game_invite.py diff --git a/src/functional_tests/test_deck_contribution.py b/src/functional_tests/test_deck_contribution.py new file mode 100644 index 0000000..ae2dc81 --- /dev/null +++ b/src/functional_tests/test_deck_contribution.py @@ -0,0 +1,216 @@ +""" +Functional tests: deck-to-seat contribution mechanics. + +Sprint 1 (DeckContributionTest): + Confirming a role in ROLE SELECT assigns the gamer's equipped deck to the + TableSeat. The Game Kit immediately reflects the in-use state: the deck + card gains the game name in its tooltip and the micro-status reads "In-Use". + +Sprint 2 (DeckInUseGameKitTest): + With a deck already assigned to an active seat the Game Kit DON button is + btn-disabled and no DOFF toggle is shown (unlike the normal equipped state + where DOFF is visible). Clicking the card shows a tooltip naming the game. + A second deck that is NOT assigned to any active seat has normal DON/DOFF + behaviour. +""" +import time + +from django.test import tag +from selenium.webdriver.common.by import By + +from apps.applets.models import Applet +from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat +from apps.lyric.models import User +from functional_tests.base import FunctionalTest +from functional_tests.test_room_role_select import _fill_room_via_orm +from functional_tests.test_room_sig_select import _assign_all_roles + +FOUNDER_EMAIL = "founder@test.io" +GAMER_EMAIL = "gamer@test.io" + + +def _equip_earthman(user): + earthman = DeckVariant.objects.get(slug="earthman") + user.equipped_deck = earthman + user.save(update_fields=["equipped_deck"]) + return earthman + + +def _room_at_role_select(name="Wildfire"): + """Create a room filled to 6 and at ROLE_SELECT table status.""" + founder, _ = User.objects.get_or_create(email=FOUNDER_EMAIL) + room = Room.objects.create(name=name, owner=founder) + emails = [FOUNDER_EMAIL, GAMER_EMAIL, + "b@test.io", "c@test.io", "d@test.io", "e@test.io"] + _fill_room_via_orm(room, emails) + for slot in room.gate_slots.all(): + if slot.gamer: + _equip_earthman(slot.gamer) + room.table_status = Room.ROLE_SELECT + room.save() + return room, founder + + +# ── Sprint 1 ───────────────────────────────────────────────────────────────── + +class DeckContributionTest(FunctionalTest): + """Sprint 1: role confirmation → TableSeat.deck_variant set; Game Kit shows In-Use.""" + + def setUp(self): + super().setUp() + self.browser.set_window_size(800, 1200) + for slug, name, ctx in [ + ("game-kit", "Game Kit", "gameboard"), + ("gk-decks", "Card Decks","game-kit"), + ]: + Applet.objects.get_or_create(slug=slug, defaults={"name": name, "context": ctx}) + + def test_confirming_role_assigns_equipped_deck_to_seat(self): + """After the gamer confirms a role, Game Kit shows the Earthman deck as 'In-Use' + with the game name in the deck tooltip, and the micro-status changes from + 'Equipped' to 'In-Use'. + """ + room, founder = _room_at_role_select() + gamer = User.objects.get(email=GAMER_EMAIL) + + # Gamer logs in and navigates to the role-select room + session_key = self.create_pre_authenticated_session(GAMER_EMAIL) + self.browser.get(self.live_server_url) + self.browser.add_cookie({"name": "sessionid", "value": session_key}) + self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/") + + # Gamer confirms a role (NC — slot 2 is theirs) + role_btn = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".role-card[data-role='NC'] .btn-confirm") + ) + role_btn.click() + + # Deck is now assigned to the seat in the DB + self.wait_for(lambda: self.assertTrue( + TableSeat.objects.filter( + gamer=gamer, + room=room, + deck_variant=gamer.equipped_deck, + ).exists(), + "TableSeat.deck_variant was not set after role confirmation", + )) + + # Navigate to Game Kit → Card Decks to verify UI state + self.browser.get(self.live_server_url + "/gameboard/game-kit/") + decks_btn = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck") + ) + decks_btn.click() + + earthman_card = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_earthman_deck") + ) + earthman_card.click() # open tooltip + + # Tooltip shows the game name + tooltip = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".tt-deck-game-name") + ) + self.assertIn(room.name.upper(), tooltip.text.upper()) + + # Micro-status reads "In-Use", not "Equipped" + micro_status = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_kit_earthman_deck .deck-micro-status" + ) + ) + self.assertIn("IN-USE", micro_status.text.upper()) + self.assertNotIn("EQUIPPED", micro_status.text.upper()) + + +# ── Sprint 2 ───────────────────────────────────────────────────────────────── + +class DeckInUseGameKitTest(FunctionalTest): + """Sprint 2: DON disabled + no DOFF toggle for in-use deck; second deck unaffected.""" + + def setUp(self): + super().setUp() + self.browser.set_window_size(800, 1200) + for slug, name, ctx in [ + ("game-kit", "Game Kit", "gameboard"), + ("gk-decks", "Card Decks","game-kit"), + ]: + Applet.objects.get_or_create(slug=slug, defaults={"name": name, "context": ctx}) + + def _setup_in_use_deck(self): + """Create a seated gamer whose Earthman deck is already assigned to a seat.""" + room, founder = _room_at_role_select() + gamer = User.objects.get(email=GAMER_EMAIL) + earthman = _equip_earthman(gamer) + # Assign deck directly (Sprint 1 must be green first) + seat = TableSeat.objects.create(gamer=gamer, room=room, role="NC", deck_variant=earthman) + return gamer, earthman, room, seat + + def test_don_is_disabled_and_doff_absent_for_in_use_deck(self): + """DON button carries btn-disabled; DOFF is not rendered at all (not just disabled).""" + gamer, earthman, room, seat = self._setup_in_use_deck() + session_key = self.create_pre_authenticated_session(GAMER_EMAIL) + self.browser.get(self.live_server_url) + self.browser.add_cookie({"name": "sessionid", "value": session_key}) + self.browser.get(self.live_server_url + "/gameboard/game-kit/") + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_earthman_deck") + ).click() + + # DON is disabled + don_btn = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, f"#id_kit_earthman_deck .btn-equip" + ) + ) + self.assertIn("btn-disabled", don_btn.get_attribute("class")) + + # DOFF button is not present (not just disabled — entirely absent) + doff_btns = self.browser.find_elements( + By.CSS_SELECTOR, f"#id_kit_earthman_deck .btn-unequip:not(.btn-disabled)" + ) + self.assertEqual(len(doff_btns), 0, "DOFF button should not be shown for an in-use deck") + + def test_tooltip_names_the_game_for_in_use_deck(self): + """Opening an in-use deck's tooltip shows the room name it is contributing to.""" + gamer, earthman, room, seat = self._setup_in_use_deck() + session_key = self.create_pre_authenticated_session(GAMER_EMAIL) + self.browser.get(self.live_server_url) + self.browser.add_cookie({"name": "sessionid", "value": session_key}) + self.browser.get(self.live_server_url + "/gameboard/game-kit/") + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_earthman_deck") + ).click() + game_label = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".tt-deck-game-name") + ) + self.assertIn(room.name.upper(), game_label.text.upper()) + + def test_non_contributing_deck_has_normal_don_doff(self): + """A deck not assigned to any active seat shows the normal DON/DOFF apparatus.""" + gamer, earthman, room, seat = self._setup_in_use_deck() + # Unlock Fiorentine for the gamer so it appears in Game Kit + fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate") + gamer.unlocked_decks.add(fiorentine) + session_key = self.create_pre_authenticated_session(GAMER_EMAIL) + self.browser.get(self.live_server_url) + self.browser.add_cookie({"name": "sessionid", "value": session_key}) + self.browser.get(self.live_server_url + "/gameboard/game-kit/") + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_fiorentine_deck") + ).click() + don_btn = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_kit_fiorentine_deck .btn-equip" + ) + ) + self.assertNotIn("btn-disabled", don_btn.get_attribute("class")) diff --git a/src/functional_tests/test_game_invite.py b/src/functional_tests/test_game_invite.py new file mode 100644 index 0000000..7c26a1b --- /dev/null +++ b/src/functional_tests/test_game_invite.py @@ -0,0 +1,354 @@ +""" +Functional tests: game-room invite flow via BillPost. + +BillPost is a new billboard.BillPost model (kind=INVITE for this sprint; +more kinds to follow in later feature sprints). It is the first of a series +of Post-types that will grow through coming sprints. The existing +dashboard.Post model will be renamed (to Message or similar) when the WS +messaging sprint lands; this new system's naming takes precedence. + +Sprint 3 (GameInviteNotificationTest) — @two-browser: + Founder sends an email invite from the room page. invite_gamer() creates a + BillPost(kind=INVITE) for the invitee. Invitee sees "INVITE: " as a + link in the My Posts applet on the billboard page. + +Sprint 4 (GameInviteBillPostTest): + Clicking the invite post navigates to /billboard/post//. The page + renders a _billpost_invite.html partial with room info. OK and BYE + (.btn-confirm / .btn-abandon) appear next to the title. + BYE marks the BillPost dismissed; the post is no longer listed in My Posts. + OK shows a Log-Out-style confirm guard ("Join game? OK / NVM") if the + invitee has a valid (non-contributing) deck equipped. + +Sprint 5 (GameInviteDeckValidationTest): + If the invitee has no deck equipped, or their equipped deck is already + assigned to an active seat, the OK button is immediately btn-disabled + (no guard shown) and a tooltip explains the situation. + When a valid deck is equipped and the invitee confirms, they are redirected + to the room and their deck is assigned to a seat. +""" +import os +import time + +from django.test import tag +from selenium import webdriver +from selenium.webdriver.common.by import By + +from apps.applets.models import Applet +from apps.billboard.models import BillPost +from apps.epic.models import DeckVariant, GateSlot, Room, RoomInvite, TableSeat +from apps.lyric.models import User +from functional_tests.base import FunctionalTest +from functional_tests.test_room_role_select import _fill_room_via_orm + +FOUNDER_EMAIL = "founder@test.io" +INVITEE_EMAIL = "invitee@test.io" + + +def _applets(): + for slug, name, ctx in [ + ("my-posts", "My Posts", "billboard"), + ("billboard-my-scrolls", "My Scrolls","billboard"), + ]: + Applet.objects.get_or_create(slug=slug, defaults={"name": name, "context": ctx}) + + +def _make_open_room(name="Wildfire"): + founder, _ = User.objects.get_or_create(email=FOUNDER_EMAIL) + room = Room.objects.create(name=name, owner=founder) + _fill_room_via_orm(room, [FOUNDER_EMAIL, + "b@test.io", "c@test.io", "d@test.io", "e@test.io", "f@test.io"]) + return room, founder + + +def _make_second_browser(): + opts = webdriver.FirefoxOptions() + if os.environ.get("HEADLESS"): + opts.add_argument("--headless") + b = webdriver.Firefox(options=opts) + b.set_window_size(800, 1200) + return b + + +# ── Sprint 3 ───────────────────────────────────────────────────────────────── + +@tag("two-browser") +class GameInviteNotificationTest(FunctionalTest): + """Sprint 3: founder sends invite → INVITE: BillPost appears in invitee's My Posts.""" + + def setUp(self): + super().setUp() + self.browser.set_window_size(800, 1200) + self.second_browser = _make_second_browser() + _applets() + User.objects.get_or_create(email=INVITEE_EMAIL) + + def tearDown(self): + self.second_browser.quit() + super().tearDown() + + def test_invite_creates_bill_post_in_my_posts(self): + """Founder sends email invite; invitee sees 'INVITE: Wildfire' link in My Posts.""" + room, founder = _make_open_room() + + # Founder: log in, navigate to room, send invite + founder_session = self.create_pre_authenticated_session(FOUNDER_EMAIL) + self.browser.get(self.live_server_url) + self.browser.add_cookie({"name": "sessionid", "value": founder_session}) + self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/") + + invite_input = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "input[name='invitee_email']") + ) + invite_input.send_keys(INVITEE_EMAIL) + self.browser.find_element(By.CSS_SELECTOR, "#id_invite_btn").click() + + # A BillPost(kind=INVITE) is created for the invitee + self.wait_for(lambda: self.assertTrue( + BillPost.objects.filter( + kind=BillPost.INVITE, + recipient__email=INVITEE_EMAIL, + room=room, + dismissed=False, + ).exists(), + "BillPost(kind=INVITE) was not created for the invitee", + )) + + # Invitee: log in to billboard, see "INVITE: Wildfire" in My Posts applet + invitee = User.objects.get(email=INVITEE_EMAIL) + invitee_session = self.create_pre_authenticated_session(INVITEE_EMAIL) + self.second_browser.get(self.live_server_url) + self.second_browser.add_cookie({"name": "sessionid", "value": invitee_session}) + self.second_browser.get(self.live_server_url + "/billboard/") + + my_posts = self.wait_for( + lambda: self.second_browser.find_element(By.CSS_SELECTOR, "#id_applet_my_posts") + ) + invite_link = self.wait_for( + lambda: my_posts.find_element(By.PARTIAL_LINK_TEXT, "INVITE:") + ) + self.assertIn("WILDFIRE", invite_link.text.upper()) + + # Link goes to /billboard/post// + post = BillPost.objects.get(kind=BillPost.INVITE, recipient__email=INVITEE_EMAIL) + self.assertIn(f"/billboard/post/{post.pk}/", invite_link.get_attribute("href")) + + +# ── Sprint 4 ───────────────────────────────────────────────────────────────── + +class GameInviteBillPostTest(FunctionalTest): + """Sprint 4: BillPost invite page — _billpost_invite.html partial, OK/BYE, BYE dismisses.""" + + def setUp(self): + super().setUp() + self.browser.set_window_size(800, 1200) + _applets() + + def _make_invite_post(self, equip_deck=False): + room, founder = _make_open_room() + invitee, _ = User.objects.get_or_create(email=INVITEE_EMAIL) + invite = RoomInvite.objects.create( + room=room, inviter=founder, invitee_email=INVITEE_EMAIL, + status=RoomInvite.PENDING, + ) + post = BillPost.objects.create( + kind=BillPost.INVITE, recipient=invitee, room=room, invite=invite, + ) + if equip_deck: + earthman = DeckVariant.objects.get(slug="earthman") + invitee.equipped_deck = earthman + invitee.unlocked_decks.add(earthman) + invitee.save(update_fields=["equipped_deck"]) + return invitee, post, room + + def test_billpost_page_shows_invite_partial(self): + """The BillPost page at /billboard/post// renders the invite partial with room info.""" + invitee, post, room = self._make_invite_post() + session_key = self.create_pre_authenticated_session(INVITEE_EMAIL) + self.browser.get(self.live_server_url) + self.browser.add_cookie({"name": "sessionid", "value": session_key}) + self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/") + + # Page title / heading shows "INVITE: Wildfire" + heading = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".billpost-title") + ) + self.assertIn("INVITE", heading.text.upper()) + self.assertIn(room.name.upper(), heading.text.upper()) + + # Invite partial is present + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".billpost-invite") + ) + + # OK and BYE buttons are present + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")) + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-abandon")) + + def test_bye_dismisses_invite_and_removes_from_my_posts(self): + """Clicking BYE marks the BillPost dismissed and it no longer appears in My Posts.""" + invitee, post, room = self._make_invite_post() + session_key = self.create_pre_authenticated_session(INVITEE_EMAIL) + self.browser.get(self.live_server_url) + self.browser.add_cookie({"name": "sessionid", "value": session_key}) + self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/") + + bye_btn = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-abandon") + ) + bye_btn.click() + + # BillPost is now dismissed in DB + post.refresh_from_db() + self.assertTrue(post.dismissed) + + # Redirected to billboard; post no longer in My Posts + self.wait_for(lambda: self.assertIn("/billboard/", self.browser.current_url)) + my_posts = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_applet_my_posts") + ) + invite_links = my_posts.find_elements(By.PARTIAL_LINK_TEXT, "INVITE:") + self.assertEqual(len(invite_links), 0, "Dismissed invite should not appear in My Posts") + + def test_ok_with_valid_deck_shows_confirm_guard(self): + """With a valid deck equipped, OK shows a Log-Out-style confirm guard.""" + invitee, post, room = self._make_invite_post(equip_deck=True) + session_key = self.create_pre_authenticated_session(INVITEE_EMAIL) + self.browser.get(self.live_server_url) + self.browser.add_cookie({"name": "sessionid", "value": session_key}) + self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/") + + ok_btn = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm") + ) + self.assertNotIn("btn-disabled", ok_btn.get_attribute("class")) + ok_btn.click() + + # Confirm guard appears ("Join game?" with OK / NVM) + guard = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".join-guard") + ) + self.assertIn("JOIN", guard.text.upper()) + self.wait_for(lambda: guard.find_element(By.CSS_SELECTOR, ".btn-confirm")) + self.wait_for(lambda: guard.find_element(By.CSS_SELECTOR, ".btn-nvm")) + + +# ── Sprint 5 ───────────────────────────────────────────────────────────────── + +class GameInviteDeckValidationTest(FunctionalTest): + """Sprint 5: OK deck validation — disabled without valid deck; on join, deck locked.""" + + def setUp(self): + super().setUp() + self.browser.set_window_size(800, 1200) + _applets() + for slug, name, ctx in [ + ("game-kit", "Game Kit", "gameboard"), + ("gk-decks", "Card Decks","game-kit"), + ]: + Applet.objects.get_or_create(slug=slug, defaults={"name": name, "context": ctx}) + + def _make_invite_post(self, equip_deck=False, deck_in_use=False): + room, founder = _make_open_room() + invitee, _ = User.objects.get_or_create(email=INVITEE_EMAIL) + invite = RoomInvite.objects.create( + room=room, inviter=founder, invitee_email=INVITEE_EMAIL, + status=RoomInvite.PENDING, + ) + post = BillPost.objects.create( + kind=BillPost.INVITE, recipient=invitee, room=room, invite=invite, + ) + earthman = DeckVariant.objects.get(slug="earthman") + if equip_deck or deck_in_use: + invitee.equipped_deck = earthman + invitee.unlocked_decks.add(earthman) + invitee.save(update_fields=["equipped_deck"]) + if deck_in_use: + # Simulate the deck already being assigned to another active seat + other_room = Room.objects.create(name="Other Game", owner=invitee) + TableSeat.objects.create( + gamer=invitee, room=other_room, role="PC", deck_variant=earthman, + ) + return invitee, post, room, earthman + + def test_ok_immediately_disabled_without_equipped_deck(self): + """OK is btn-disabled on load when invitee has no deck equipped.""" + invitee, post, room, _ = self._make_invite_post(equip_deck=False) + session_key = self.create_pre_authenticated_session(INVITEE_EMAIL) + self.browser.get(self.live_server_url) + self.browser.add_cookie({"name": "sessionid", "value": session_key}) + self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/") + + ok_btn = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm") + ) + self.assertIn("btn-disabled", ok_btn.get_attribute("class"), + "OK should be disabled when invitee has no deck equipped") + + # Tooltip explains the situation + ok_btn.click() + tooltip = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".invite-no-deck-tooltip") + ) + self.assertTrue(tooltip.is_displayed()) + + def test_ok_immediately_disabled_when_deck_in_use(self): + """OK is btn-disabled when invitee's equipped deck is already assigned to another game.""" + invitee, post, room, _ = self._make_invite_post(deck_in_use=True) + session_key = self.create_pre_authenticated_session(INVITEE_EMAIL) + self.browser.get(self.live_server_url) + self.browser.add_cookie({"name": "sessionid", "value": session_key}) + self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/") + + ok_btn = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm") + ) + self.assertIn("btn-disabled", ok_btn.get_attribute("class"), + "OK should be disabled when invitee's deck is already in use") + + def test_confirming_join_assigns_deck_to_seat_and_locks_game_kit(self): + """After confirming join, invitee's deck is assigned to their new seat and + the Game Kit DON button for that deck becomes btn-disabled.""" + invitee, post, room, earthman = self._make_invite_post(equip_deck=True) + session_key = self.create_pre_authenticated_session(INVITEE_EMAIL) + self.browser.get(self.live_server_url) + self.browser.add_cookie({"name": "sessionid", "value": session_key}) + self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/") + + # OK → guard → confirm + ok_btn = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm") + ) + ok_btn.click() + guard_ok = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".join-guard .btn-confirm") + ) + guard_ok.click() + + # Redirected into the room + self.wait_for( + lambda: self.assertIn(f"/gameboard/room/{room.pk}/", self.browser.current_url) + ) + + # Invitee's seat has their deck assigned + self.wait_for(lambda: self.assertTrue( + TableSeat.objects.filter( + gamer=invitee, room=room, deck_variant=earthman, + ).exists(), + "TableSeat.deck_variant was not set on join", + )) + + # Game Kit reflects in-use state + self.browser.get(self.live_server_url + "/gameboard/game-kit/") + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_earthman_deck") + ).click() + don_btn = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_kit_earthman_deck .btn-equip" + ) + ) + self.assertIn("btn-disabled", don_btn.get_attribute("class"))