deck contribution + game invite: write all FTs red — TDD

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: <room> link appears in My Posts.
Sprint 4 (GameInviteBillPostTest): /billboard/post/<pk>/ 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 <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-27 22:22:08 -04:00
parent fd94a72435
commit e6e2bd10c5
2 changed files with 570 additions and 0 deletions

View File

@@ -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"))

View File

@@ -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: <room>" as a
link in the My Posts applet on the billboard page.
Sprint 4 (GameInviteBillPostTest):
Clicking the invite post navigates to /billboard/post/<pk>/. 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/<pk>/
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/<pk>/ 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"))