Files
python-tdd/src/apps/gameboard/tests/integrated/test_views.py
Disco DeDisco b76d3c5dff My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD
Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring).

Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait.

Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:32 -04:00

1154 lines
52 KiB
Python

import lxml.html
from django.test import TestCase
from django.urls import reverse
from apps.applets.models import Applet, UserApplet
from apps.epic.models import DeckVariant, Room, TableSeat
from apps.lyric.models import Token, User
class GameboardViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
response = self.client.get("/gameboard/")
self.parsed = lxml.html.fromstring(response.content)
def test_gameboard_requires_login(self):
self.client.logout()
response = self.client.get("/gameboard/")
self.assertRedirects(
response, "/?next=/gameboard/", fetch_redirect_response=False
)
def test_gameboard_renders(self):
response = self.client.get("/gameboard/")
self.assertEqual(response.status_code, 200)
def test_gameboard_shows_my_games_applet(self):
[_] = self.parsed.cssselect("#id_applet_my_games")
def test_gameboard_shows_new_game_applet(self):
[_] = self.parsed.cssselect("#id_applet_new_game")
def test_gameboard_shows_my_sea_applet(self):
# Sprint 3 of the My Sea roadmap — applet shell only; sigs/sea/draw
# flow lands in later sprints. Seeded via migration 0008.
[_] = self.parsed.cssselect("#id_applet_my_sea")
def test_my_sea_applet_renders_sign_gate_for_user_without_sig(self):
# Sprint 4b — user with no significator sees the Look!-formatted
# gate (mirror of the standalone page), not the draw UX.
[_gate] = self.parsed.cssselect(
"#id_applet_my_sea .my-sea-sign-gate--applet"
)
# Draw-state nodes are suppressed while the gate is up.
self.assertEqual(
len(self.parsed.cssselect("#id_applet_my_sea .my-sea-empty")), 0,
)
self.assertEqual(
len(self.parsed.cssselect("#id_applet_my_sea .my-sea-card")), 0,
)
def test_my_sea_applet_renders_empty_state_for_user_with_sig_no_draws(self):
# Sig set + no saved draws → the scroll container hosts a single
# placeholder line ("No draws yet."), no card cells, no gate.
from apps.epic.models import personal_sig_cards
sig_pile = personal_sig_cards(self.user)
self.user.significator = sig_pile[0]
self.user.save()
response = self.client.get("/gameboard/")
parsed = lxml.html.fromstring(response.content)
[empty] = parsed.cssselect("#id_applet_my_sea .my-sea-empty")
self.assertIn("No draws yet", empty.text_content())
self.assertEqual(
len(parsed.cssselect("#id_applet_my_sea .my-sea-card")), 0,
)
self.assertEqual(
len(parsed.cssselect("#id_applet_my_sea .my-sea-sign-gate--applet")),
0,
)
def test_my_sea_applet_header_links_to_my_sea_page(self):
[link] = self.parsed.cssselect("#id_applet_my_sea h2 a")
self.assertEqual(link.get("href"), reverse("my_sea"))
def test_gameboard_shows_game_kit(self):
[_] = self.parsed.cssselect("#id_game_kit")
def test_gameboard_shows_game_gear(self):
[_] = self.parsed.cssselect(".gear-btn")
def test_my_games_has_no_game_items_for_new_user(self):
game_items = self.parsed.cssselect("#id_applet_my_games .game-item")
self.assertEqual(len(game_items), 0)
def test_my_games_row_shows_latest_event_prose_and_ts(self):
from apps.drama.models import GameEvent, record
room = Room.objects.create(name="StampedRoom", owner=self.user)
record(
room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/gameboard/")
body = response.content.decode()
self.assertIn("StampedRoom", body)
# Row carries a ts cell from the recorded event
self.assertRegex(
body,
r'#id_applet_my_games|class="[^"]*row-ts'.replace("#", ""),
)
# A .row-body cell carries some event prose
self.assertRegex(body, r'<time[^>]+class="[^"]*row-ts')
def test_my_games_row_body_includes_actor_display_name(self):
"""Mirror of My Scrolls — the My Games row body must include the
event actor's display_name so `<actor> deposits a Coin-on-a-String`
reads as a complete sentence. Scoped to the `.row-body` span (vs.
a loose substring) so the assertion can't pass on actor renders
outside the row (no Most Recent Scroll on gameboard, but the
navbar etc. could shadow a substring match)."""
from apps.drama.models import GameEvent, record
actor = User.objects.create(email="stuart@test.io", username="stuart")
room = Room.objects.create(name="GameRoom", owner=self.user)
record(
room, GameEvent.SLOT_FILLED, actor=actor,
slot_number=2, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/gameboard/")
body = response.content.decode()
self.assertRegex(
body,
r'<span class="row-body">[^<]*stuart[^<]*deposits a Coin-on-a-String',
)
def test_game_kit_has_coin_on_a_string(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_coin_on_a_string")
def test_game_kit_has_free_token(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token")
def test_game_kit_shows_deck_variant_cards(self):
decks = self.parsed.cssselect("#id_game_kit .deck-variant")
self.assertGreater(len(decks), 0)
# Earthman deck (seeded by migration) should have its own card
[_] = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")
def test_game_kit_has_dice_set_placeholder(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")
class GameboardDeckInUseTest(TestCase):
"""Sprint 2: game kit applet renders in-use state for a deck assigned to an active seat."""
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
self.earthman = DeckVariant.objects.get(slug="earthman")
self.room = Room.objects.create(name="Wildfire", owner=self.user)
self.seat = TableSeat.objects.create(
room=self.room, gamer=self.user, slot_number=1,
deck_variant=self.earthman,
)
response = self.client.get("/gameboard/")
self.parsed = lxml.html.fromstring(response.content)
def test_in_use_deck_don_is_disabled(self):
[don] = self.parsed.cssselect("#id_kit_earthman_deck .btn-equip")
self.assertIn("btn-disabled", don.get("class", ""))
def test_in_use_deck_doff_is_absent(self):
active_doff = self.parsed.cssselect(
"#id_kit_earthman_deck .btn-unequip:not(.btn-disabled)"
)
self.assertEqual(len(active_doff), 0)
def test_in_use_deck_carries_room_name_for_mini_portal(self):
[el] = self.parsed.cssselect("#id_kit_earthman_deck")
self.assertEqual("Wildfire", el.get("data-in-use-room-name"))
def test_non_in_use_deck_has_normal_don(self):
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
self.user.unlocked_decks.add(fiorentine)
response = self.client.get("/gameboard/")
parsed = lxml.html.fromstring(response.content)
[don] = parsed.cssselect("#id_kit_fiorentine_deck .btn-equip")
self.assertNotIn("btn-disabled", don.get("class", ""))
class ToggleGameAppletsViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
self.new_game, _ = Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
self.my_games, _ = Applet.objects.get_or_create(
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
)
self.url = reverse("toggle_game_applets")
def test_unauthenticated_user_is_redirected(self):
self.client.logout()
response = self.client.post(self.url)
self.assertRedirects(
response, f"/?next={self.url}", fetch_redirect_response=False
)
def test_unchecked_applet_gets_user_applet_with_visible_false(self):
self.client.post(self.url, {"applets": ["new-game"]})
ua = UserApplet.objects.get(user=self.user, applet=self.my_games)
self.assertFalse(ua.visible)
def test_redirects_on_normal_post(self):
response = self.client.post(self.url, {"applets": ["new-game", "my-games"]})
self.assertRedirects(
response, reverse("gameboard"), fetch_redirect_response=False
)
def test_returns_200_on_htmx_post(self):
response = self.client.post(
self.url,
{"applets": ["new-game", "my-games"]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_does_not_affect_dash_applets(self):
dash_applet, _ = Applet.objects.get_or_create(
slug="username", defaults={"name": "Username", "context": "dashboard"}
)
self.client.post(self.url, {"applets": ["new-game", "my-games"]})
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=dash_applet).exists())
class EquipDeckViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
self.deck = DeckVariant.objects.first()
def test_get_returns_405(self):
response = self.client.get(reverse("equip_deck", kwargs={"deck_id": self.deck.pk}))
self.assertEqual(response.status_code, 405)
def test_post_equips_deck(self):
response = self.client.post(reverse("equip_deck", kwargs={"deck_id": self.deck.pk}))
self.assertEqual(response.status_code, 204)
self.user.refresh_from_db()
self.assertEqual(self.user.equipped_deck, self.deck)
class UnequipDeckViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.deck = DeckVariant.objects.first()
self.user.equipped_deck = self.deck
self.user.save(update_fields=["equipped_deck"])
self.client.force_login(self.user)
def test_get_returns_405(self):
response = self.client.get(reverse("unequip_deck", kwargs={"deck_id": self.deck.pk}))
self.assertEqual(response.status_code, 405)
def test_post_clears_equipped_deck_when_matches(self):
response = self.client.post(reverse("unequip_deck", kwargs={"deck_id": self.deck.pk}))
self.assertEqual(response.status_code, 204)
self.user.refresh_from_db()
self.assertIsNone(self.user.equipped_deck)
def test_post_ignores_non_matching_deck(self):
other_deck = DeckVariant.objects.exclude(pk=self.deck.pk).first()
if other_deck is None:
self.skipTest("Only one deck variant in DB")
self.client.post(reverse("unequip_deck", kwargs={"deck_id": other_deck.pk}))
self.user.refresh_from_db()
self.assertEqual(self.user.equipped_deck, self.deck)
class UnequipTrinketViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
self.token = Token.objects.filter(user=self.user, token_type=Token.COIN).first()
def test_get_returns_405(self):
from apps.lyric.models import Token
token = Token.objects.filter(user=self.user).first()
if token is None:
self.skipTest("No token for user")
response = self.client.get(reverse("unequip_trinket", kwargs={"token_id": token.pk}))
self.assertEqual(response.status_code, 405)
def test_post_clears_equipped_trinket_when_matching(self):
self.user.equipped_trinket = self.token
self.user.save(update_fields=["equipped_trinket"])
response = self.client.post(
reverse("unequip_trinket", kwargs={"token_id": self.token.pk})
)
self.assertEqual(response.status_code, 204)
self.user.refresh_from_db()
self.assertIsNone(self.user.equipped_trinket)
def test_post_ignores_non_matching_trinket(self):
"""POSTing a token that's not the currently-equipped one is a 204 no-op
— equipped_trinket is unchanged. Covers the implicit `else` of the
`if request.user.equipped_trinket_id == token.pk` branch."""
other_token = Token.objects.create(user=self.user, token_type=Token.TITHE)
self.user.equipped_trinket = self.token # COIN is equipped
self.user.save(update_fields=["equipped_trinket"])
response = self.client.post(
reverse("unequip_trinket", kwargs={"token_id": other_token.pk})
)
self.assertEqual(response.status_code, 204)
self.user.refresh_from_db()
self.assertEqual(self.user.equipped_trinket, self.token)
class GameKitViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"})
Applet.objects.get_or_create(slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"})
Applet.objects.get_or_create(slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"})
Applet.objects.get_or_create(slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"})
Applet.objects.get_or_create(slug="pronouns", defaults={"name": "Pronouns", "context": "game-kit"})
response = self.client.get("/gameboard/game-kit/")
self.parsed = lxml.html.fromstring(response.content)
def test_game_kit_requires_login(self):
self.client.logout()
response = self.client.get("/gameboard/game-kit/")
self.assertRedirects(response, "/?next=/gameboard/game-kit/", fetch_redirect_response=False)
def test_game_kit_shows_gear_btn(self):
[_] = self.parsed.cssselect(".gear-btn")
def test_game_kit_shows_applet_menu(self):
[_] = self.parsed.cssselect("#id_game_kit_menu")
def test_game_kit_applet_menu_has_trinkets_checkbox(self):
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-trinkets']")
self.assertEqual(inp.get("type"), "checkbox")
def test_game_kit_applet_menu_has_tokens_checkbox(self):
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-tokens']")
self.assertEqual(inp.get("type"), "checkbox")
def test_game_kit_applet_menu_has_decks_checkbox(self):
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-decks']")
self.assertEqual(inp.get("type"), "checkbox")
def test_game_kit_applet_menu_has_dice_checkbox(self):
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-dice']")
self.assertEqual(inp.get("type"), "checkbox")
def test_game_kit_sections_container_present(self):
[_] = self.parsed.cssselect("#id_gk_sections_container")
def test_all_sections_visible_by_default(self):
sections = self.parsed.cssselect("#id_gk_sections_container section")
# Trinkets, Tokens, Card Decks, Dice Sets, Pronouns
self.assertEqual(len(sections), 5)
def test_pronouns_section_renders_five_cards(self):
[section] = self.parsed.cssselect("#id_gk_pronouns")
cards = section.cssselect(".gk-pronoun-card")
self.assertEqual(len(cards), 5)
slugs = [c.get("data-pronoun") for c in cards]
self.assertEqual(
slugs,
["pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"],
)
def test_pronouns_section_marks_current_choice_active(self):
# Default user pronouns = "pluralism" — that card should carry .active.
[active] = self.parsed.cssselect("#id_gk_pronouns .gk-pronoun-card.active")
self.assertEqual(active.get("data-pronoun"), "pluralism")
def test_game_kit_applet_menu_has_pronouns_checkbox(self):
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='pronouns']")
self.assertEqual(inp.get("type"), "checkbox")
class ToggleGameKitSectionsViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
self.trinkets, _ = Applet.objects.get_or_create(
slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"}
)
self.tokens, _ = Applet.objects.get_or_create(
slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"}
)
self.decks, _ = Applet.objects.get_or_create(
slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"}
)
self.dice, _ = Applet.objects.get_or_create(
slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"}
)
self.url = reverse("toggle_game_kit_sections")
def test_unauthenticated_user_is_redirected(self):
self.client.logout()
response = self.client.post(self.url)
self.assertRedirects(response, f"/?next={self.url}", fetch_redirect_response=False)
def test_unchecked_section_gets_user_applet_with_visible_false(self):
self.client.post(self.url, {"applets": ["gk-trinkets"]})
ua = UserApplet.objects.get(user=self.user, applet=self.tokens)
self.assertFalse(ua.visible)
def test_redirects_on_normal_post(self):
response = self.client.post(self.url, {"applets": ["gk-trinkets", "gk-tokens"]})
self.assertRedirects(response, reverse("game_kit"), fetch_redirect_response=False)
def test_returns_200_on_htmx_post(self):
response = self.client.post(
self.url,
{"applets": ["gk-trinkets", "gk-tokens"]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_does_not_affect_gameboard_applets(self):
gb_applet, _ = Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
self.client.post(self.url, {"applets": ["gk-trinkets"]})
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=gb_applet).exists())
def test_hidden_section_absent_from_htmx_response(self):
response = self.client.post(
self.url,
{"applets": ["gk-trinkets"]},
HTTP_HX_REQUEST="true",
)
parsed = lxml.html.fromstring(response.content)
sections = parsed.cssselect("section")
self.assertEqual(len(sections), 1)
class EquipTrinketViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
self.token = Token.objects.filter(user=self.user, token_type=Token.COIN).first()
def test_get_returns_trinket_button_partial(self):
response = self.client.get(
reverse("equip_trinket", kwargs={"token_id": self.token.pk})
)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/gameboard/_partials/_equip_trinket_btn.html")
def test_post_equips_trinket_and_returns_204(self):
response = self.client.post(
reverse("equip_trinket", kwargs={"token_id": self.token.pk})
)
self.assertEqual(response.status_code, 204)
self.user.refresh_from_db()
self.assertEqual(self.user.equipped_trinket, self.token)
def test_post_requires_token_owner(self):
outsider = User.objects.create(email="outsider@test.io")
self.client.force_login(outsider)
response = self.client.post(
reverse("equip_trinket", kwargs={"token_id": self.token.pk})
)
# get_object_or_404 — the token belongs to self.user, not outsider
self.assertEqual(response.status_code, 404)
class TarotFanViewTest(TestCase):
def setUp(self):
from apps.epic.models import DeckVariant
self.earthman = DeckVariant.objects.get(slug="earthman")
self.fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
self.user = User.objects.create(email="fan@test.io")
self.client.force_login(self.user)
def test_returns_fan_partial_for_unlocked_deck(self):
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/gameboard/_partials/_tarot_fan.html")
def test_returns_403_for_locked_deck(self):
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.fiorentine.pk}))
self.assertEqual(response.status_code, 403)
class MySeaViewTest(TestCase):
"""Sprint 3 of the My Sea roadmap — standalone page is a shell only.
Sigs / sea-select / gatekeeper phase content lands in later sprints."""
def setUp(self):
self.user = User.objects.create(email="sea@test.io")
self.client.force_login(self.user)
def test_my_sea_requires_login(self):
self.client.logout()
response = self.client.get(reverse("my_sea"))
self.assertRedirects(
response, "/?next=/gameboard/my-sea/", fetch_redirect_response=False
)
def test_my_sea_renders_200(self):
response = self.client.get(reverse("my_sea"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/gameboard/my_sea.html")
def test_my_sea_uses_gameboard_page_class(self):
# `page_class` drives the body class for the landscape layout aperture
# — My Sea inherits the gameboard's aperture (same nav/footer rails).
response = self.client.get(reverse("my_sea"))
self.assertIn("page-gameboard", response.content.decode())
self.assertIn("page-my-sea", response.content.decode())
class MySeaDrawSeaLandingViewTest(TestCase):
"""Sprint 5 iter 1 — view context for the DRAW SEA landing UX. Pins
`no_equipped_deck` + `show_backup_intro_banner` context keys + the
presence of the new landing template elements when user passes the
Sprint 4b sign-gate."""
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="draw@test.io")
self.client.force_login(self.user)
# Assign a sig so the view's landing branch (not the gate) renders.
self.user.significator = personal_sig_cards(self.user)[0]
self.user.save(update_fields=["significator"])
def test_context_no_equipped_deck_false_when_user_has_deck(self):
# post_save auto-equips Earthman; `no_equipped_deck` should be False.
response = self.client.get(reverse("my_sea"))
self.assertFalse(response.context["no_equipped_deck"])
def test_context_no_equipped_deck_true_when_user_cleared_deck(self):
self.user.equipped_deck = None
self.user.save(update_fields=["equipped_deck"])
response = self.client.get(reverse("my_sea"))
self.assertTrue(response.context["no_equipped_deck"])
def test_context_show_backup_intro_banner_when_no_deck_and_has_sig(self):
# Brief banner fires when user has a sig AND no deck — they're on the
# landing UX (gate passed) but headed for the backup-deck draw path.
self.user.equipped_deck = None
self.user.save(update_fields=["equipped_deck"])
response = self.client.get(reverse("my_sea"))
self.assertTrue(response.context["show_backup_intro_banner"])
def test_context_show_backup_intro_banner_false_when_deck_equipped(self):
response = self.client.get(reverse("my_sea"))
self.assertFalse(response.context["show_backup_intro_banner"])
def test_landing_renders_free_draw_btn_when_sig_set(self):
# Element ID `id_draw_sea_btn` describes intent (draw entry point);
# visible label is "FREE DRAW" for the daily-free quota draw.
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'id="id_draw_sea_btn"')
self.assertContains(response, "FREE")
self.assertContains(response, "DRAW")
def test_landing_renders_six_chair_seats_with_C_suffix(self):
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
for n in range(1, 7):
with self.subTest(slot=n):
self.assertIn(f'data-slot="{n}"', html)
self.assertIn(f"{n}C", html)
def test_landing_renders_position_status_ban_icon_on_each_seat(self):
# Each chair seat starts empty (red `.fa-ban` status icon). The
# FREE DRAW click handler swaps seat 1C's icon to .fa-circle-check
# client-side; this IT only pins the initial render state. Class
# substrings ("position-status-icon", "fa-ban") ALSO appear in the
# inline JS handler (classList.remove arg, querySelector arg) — so
# counts are asserted on the full class-attribute string only.
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
self.assertEqual(html.count('class="position-status-icon fa-solid fa-ban"'), 6)
self.assertEqual(html.count('class="seat-position-label"'), 6)
def test_landing_not_rendered_when_user_has_no_sig(self):
# Sprint 4b gate still wins precedence — FREE DRAW must not render
# when significator is None.
self.user.significator = None
self.user.save(update_fields=["significator"])
response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, 'id="id_draw_sea_btn"')
class MySeaPickerPhaseTemplateTest(TestCase):
"""Sprint 5 iter 2 — picker-phase template render contract: the
three-card cross (sig in core + cover/leave/loom drop zones) is
server-rendered (hidden until JS swaps data-phase after FREE DRAW).
Crown / lay / cross from the gameroom's 6-position Celtic Cross are
deliberately forsaken in the solo flow."""
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="picker@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
def test_picker_renders_significator_in_core_cell(self):
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
# Sig card carries the user's significator id so iter 3's draw
# flow can target it for SPIN / FLIP / FYI without re-fetching.
self.assertIn('sea-pos-core', html)
self.assertIn('sea-sig-card', html)
self.assertIn(f'data-card-id="{self.target.id}"', html)
def test_picker_renders_cover_leave_loom_positions(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, "sea-pos-cover")
self.assertContains(response, "sea-pos-leave")
self.assertContains(response, "sea-pos-loom")
def test_picker_renders_six_card_only_positions_for_spread_switch(self):
# Crown / lay / cross sit in the DOM unconditionally so iter 3's
# SPREAD dropdown can reveal them via CSS attribute swap (data-
# spread-shape="six-card") without re-rendering. Default 3-card
# spread hides them via `.my-sea-cross[data-spread-shape=
# "three-card"]` rules in _gameboard.scss — FT pins the hidden
# state visually.
response = self.client.get(reverse("my_sea"))
self.assertContains(response, "sea-pos-crown")
self.assertContains(response, "sea-pos-lay")
self.assertContains(response, "sea-pos-cross")
def test_picker_not_rendered_when_user_has_no_sig(self):
# 4b gate wins; picker has no business rendering without a sig.
self.user.significator = None
self.user.save(update_fields=["significator"])
response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, "my-sea-picker")
class MySeaSpreadFormTemplateTest(TestCase):
"""Sprint 5 iter 3 — form col + SPREAD dropdown structure + default-
spread context + cross's `data-spread-shape` attribute. Iter 3 spec
locks `Situation, Action, Outcome` as the default spread (a 3-card
variant); the 6 spreads sit under 2 section dividers (3-card / 6-
card)."""
SPREAD_OPTIONS = [
("past-present-future", "Past, Present, Future"),
("situation-action-outcome", "Situation, Action, Outcome"),
("mind-body-spirit", "Mind, Body, Spirit"),
("desire-obstacle-solution", "Desire, Obstacle, Solution"),
("waite-smith", "Celtic Cross, Waite-Smith"),
("escape-velocity", "Celtic Cross, Escape Velocity"),
]
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="spread@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
def test_context_default_spread_is_situation_action_outcome(self):
response = self.client.get(reverse("my_sea"))
self.assertEqual(
response.context["default_spread"], "situation-action-outcome",
)
def test_context_reversals_pct_defaults_to_25(self):
response = self.client.get(reverse("my_sea"))
self.assertEqual(response.context["reversals_pct"], 25)
def test_template_renders_all_six_spread_options(self):
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
for value, label in self.SPREAD_OPTIONS:
with self.subTest(spread=value):
self.assertIn(f'data-value="{value}"', html)
self.assertIn(label, html)
def test_template_renders_three_card_and_six_card_section_dividers(self):
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
self.assertEqual(html.count("sea-select-divider"), 2)
self.assertIn("3-card spreads", html)
self.assertIn("6-card spreads", html)
def test_template_marks_situation_action_outcome_aria_selected(self):
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
# The default option carries aria-selected="true"; the others false.
self.assertIn(
'data-value="situation-action-outcome" aria-selected="true"', html,
)
def test_cross_carries_initial_data_spread_sao(self):
# `.my-sea-cross[data-spread]` is the per-spread visibility key;
# default-spread context value renders into the attribute. SCSS
# rules in _gameboard.scss hide the inactive positions per spread.
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'data-spread="situation-action-outcome"')
def test_template_renders_sao_position_labels_on_default(self):
# Server-renders the SAO position labels into the empty drop-zone
# `.sea-pos-label` spans so the page is correct before JS boots.
# JS swaps labels on spread change.
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
self.assertIn('data-position="lay">Situation</span>', html)
self.assertIn('data-position="cover">Action</span>', html)
self.assertIn('data-position="crown">Outcome</span>', html)
# Inactive-for-SAO positions render their span but w. empty
# textContent (JS fills them on spread switch).
self.assertIn('data-position="leave"></span>', html)
self.assertIn('data-position="loom"></span>', html)
self.assertIn('data-position="cross"></span>', html)
def test_form_col_renders_decks_lock_hand_del_and_reversal_hint(self):
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
self.assertIn("sea-deck-stack--gravity", html)
self.assertIn("sea-deck-stack--levity", html)
self.assertIn('id="id_sea_lock_hand"', html)
self.assertIn('id="id_sea_del"', html)
self.assertIn("sea-reversal-hint", html)
self.assertIn("25% reversals", html)
class MySeaDeckDataViewTest(TestCase):
"""Sprint 5 iter 4a — view-level deck-data contract. `my_sea` view
embeds a shuffled deck (levity + gravity halves, current user's
significator excluded, reversal pre-rolled at ~25%) as JSON via
the `sea_deck_data` context key + `{{ ...|json_script }}` filter
in the template."""
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="deck@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
def test_context_sea_deck_data_has_two_polarity_halves(self):
response = self.client.get(reverse("my_sea"))
deck = response.context["sea_deck_data"]
self.assertIn("levity", deck)
self.assertIn("gravity", deck)
self.assertIsInstance(deck["levity"], list)
self.assertIsInstance(deck["gravity"], list)
def test_deck_data_excludes_user_significator(self):
response = self.client.get(reverse("my_sea"))
deck = response.context["sea_deck_data"]
all_ids = (
{c["id"] for c in deck["levity"]}
| {c["id"] for c in deck["gravity"]}
)
self.assertNotIn(self.target.id, all_ids)
def test_deck_data_halves_are_disjoint(self):
response = self.client.get(reverse("my_sea"))
deck = response.context["sea_deck_data"]
levity_ids = {c["id"] for c in deck["levity"]}
gravity_ids = {c["id"] for c in deck["gravity"]}
self.assertEqual(levity_ids & gravity_ids, set())
def test_deck_data_cards_carry_corner_rank_suit_icon_and_reversed(self):
# Card dict shape mirrors the gameroom `sea_deck` endpoint so
# iter 4b's persistence/render path can reuse the JSON contract.
response = self.client.get(reverse("my_sea"))
deck = response.context["sea_deck_data"]
any_card = (deck["levity"] + deck["gravity"])[0]
for key in ("id", "corner_rank", "suit_icon", "reversed"):
with self.subTest(key=key):
self.assertIn(key, any_card)
self.assertIsInstance(any_card["reversed"], bool)
def test_template_embeds_deck_as_json_script(self):
# Embed mechanism: `{{ sea_deck_data|json_script:"id_my_sea_deck" }}`
# gives a `<script type="application/json" id="id_my_sea_deck">`.
response = self.client.get(reverse("my_sea"))
self.assertContains(
response,
'<script id="id_my_sea_deck" type="application/json">',
)
def test_deck_data_empty_when_user_has_no_equipped_deck(self):
# Backup-deck branch: per [[sprint-my-sign-picker-may18h]] follow-
# up, no-deck users still proceed via Earthman. So deck_data falls
# back to Earthman, NOT empty. (Earthman seed is migration-loaded
# in this TestCase context.)
self.user.equipped_deck = None
self.user.save(update_fields=["equipped_deck"])
response = self.client.get(reverse("my_sea"))
deck = response.context["sea_deck_data"]
self.assertGreater(len(deck["levity"]) + len(deck["gravity"]), 0)
# ── Sprint 5 iter 4b — server persistence: MySeaDraw + lock + delete ──────────
class MySeaDrawModelTest(TestCase):
"""Sprint 5 iter 4b — `MySeaDraw` model + `active_draw_for` helper."""
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="model@test.io")
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
def _build_hand(self):
from apps.epic.models import TarotCard
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
return [
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
]
def test_create_round_trips_hand_and_sig_snapshot(self):
from apps.gameboard.models import MySeaDraw
hand = self._build_hand()
draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
hand=hand, significator_id=self.target.id, significator_reversed=False,
)
draw.refresh_from_db()
self.assertEqual(draw.hand, hand)
self.assertEqual(draw.significator_id, self.target.id)
def test_next_free_draw_at_is_created_at_plus_24h(self):
from datetime import timedelta
from apps.gameboard.models import MySeaDraw, FREE_DRAW_COOLDOWN_HOURS
draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
hand=self._build_hand(), significator_id=self.target.id,
)
delta = draw.next_free_draw_at - draw.created_at
self.assertEqual(delta, timedelta(hours=FREE_DRAW_COOLDOWN_HOURS))
def test_is_within_quota_window_true_when_fresh(self):
from apps.gameboard.models import MySeaDraw
draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
hand=self._build_hand(), significator_id=self.target.id,
)
self.assertTrue(draw.is_within_quota_window)
def test_is_within_quota_window_false_when_older_than_24h(self):
from datetime import timedelta
from django.utils import timezone
from apps.gameboard.models import MySeaDraw
draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
hand=self._build_hand(), significator_id=self.target.id,
created_at=timezone.now() - timedelta(hours=25),
)
self.assertFalse(draw.is_within_quota_window)
def test_active_draw_for_returns_recent_draw(self):
from apps.gameboard.models import MySeaDraw, active_draw_for
draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
hand=self._build_hand(), significator_id=self.target.id,
)
self.assertEqual(active_draw_for(self.user), draw)
def test_active_draw_for_returns_none_when_no_draws(self):
from apps.gameboard.models import active_draw_for
self.assertIsNone(active_draw_for(self.user))
def test_active_draw_for_returns_none_when_only_stale_draws(self):
from datetime import timedelta
from django.utils import timezone
from apps.gameboard.models import MySeaDraw, active_draw_for
MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
hand=self._build_hand(), significator_id=self.target.id,
created_at=timezone.now() - timedelta(hours=25),
)
self.assertIsNone(active_draw_for(self.user))
def test_active_draw_for_scopes_to_user(self):
from apps.gameboard.models import MySeaDraw, active_draw_for
other = User.objects.create(email="other@test.io")
MySeaDraw.objects.create(
user=other, spread="situation-action-outcome",
hand=self._build_hand(), significator_id=self.target.id,
)
self.assertIsNone(active_draw_for(self.user))
class MySeaLockHandViewTest(TestCase):
"""Sprint 5 iter 4b — POST `/gameboard/my-sea/lock` persists a hand."""
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="lock@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
self.url = reverse("my_sea_lock")
def _build_payload(self, spread="situation-action-outcome", hand=None):
from apps.epic.models import TarotCard
if hand is None:
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
hand = [
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
]
return {"spread": spread, "hand": hand}
def test_lock_requires_login(self):
import json
self.client.logout()
response = self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
self.assertEqual(response.status_code, 302)
def test_lock_get_returns_405(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
def test_lock_post_creates_my_sea_draw_for_user(self):
import json
from apps.gameboard.models import MySeaDraw
response = self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
draw = MySeaDraw.objects.get(user=self.user)
self.assertEqual(draw.spread, "situation-action-outcome")
self.assertEqual(len(draw.hand), 3)
self.assertEqual(draw.significator_id, self.target.id)
def test_lock_post_response_includes_next_free_draw_iso_timestamp(self):
import json
from datetime import datetime
response = self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
body = response.json()
self.assertIn("next_free_draw_at", body)
# Round-trip parse — the server is expected to send an ISO 8601 string.
parsed = datetime.fromisoformat(body["next_free_draw_at"])
self.assertIsNotNone(parsed)
def test_lock_post_within_quota_window_returns_409(self):
# Second lock within 24h: the existing draw already occupies the
# quota; the server rejects rather than overwriting.
import json
from apps.gameboard.models import MySeaDraw
self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
response = self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
self.assertEqual(response.status_code, 409)
self.assertEqual(MySeaDraw.objects.filter(user=self.user).count(), 1)
def test_lock_post_empty_hand_returns_400(self):
import json
response = self.client.post(
self.url, data=json.dumps({"spread": "situation-action-outcome", "hand": []}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
def test_lock_post_missing_spread_returns_400(self):
import json
payload = self._build_payload()
payload.pop("spread")
response = self.client.post(
self.url, data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
def test_lock_post_snapshots_user_significator(self):
import json
from apps.gameboard.models import MySeaDraw
self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
draw = MySeaDraw.objects.get(user=self.user)
# Sig snapshot persists even after user clears their sig.
self.user.significator = None
self.user.save(update_fields=["significator"])
draw.refresh_from_db()
self.assertEqual(draw.significator_id, self.target.id)
class MySeaDeleteDrawViewTest(TestCase):
"""Sprint 5 iter 4b — POST `/gameboard/my-sea/delete` clears the draw."""
def setUp(self):
from apps.epic.models import personal_sig_cards, TarotCard
from apps.gameboard.models import MySeaDraw
self.user = User.objects.create(email="del@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
self.draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
significator_id=self.target.id,
hand=[
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
],
)
self.url = reverse("my_sea_delete")
def test_delete_requires_login(self):
self.client.logout()
response = self.client.post(self.url)
self.assertEqual(response.status_code, 302)
def test_delete_get_returns_405(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
def test_delete_post_clears_active_draw(self):
from apps.gameboard.models import MySeaDraw
response = self.client.post(self.url)
self.assertIn(response.status_code, (200, 204, 302))
self.assertFalse(MySeaDraw.objects.filter(user=self.user).exists())
def test_delete_post_scoped_to_user_does_not_touch_others(self):
from apps.gameboard.models import MySeaDraw
other = User.objects.create(email="other-del@test.io")
other_draw = MySeaDraw.objects.create(
user=other, spread="situation-action-outcome",
hand=self.draw.hand, significator_id=self.target.id,
)
self.client.post(self.url)
self.assertTrue(MySeaDraw.objects.filter(pk=other_draw.pk).exists())
def test_delete_post_idempotent_when_no_active_draw(self):
# User deletes twice in a row — second call is a no-op, not a 500.
self.client.post(self.url)
response = self.client.post(self.url)
self.assertIn(response.status_code, (200, 204, 302))
class MySeaViewWithSavedDrawTest(TestCase):
"""Sprint 5 iter 4b — `my_sea` view branches when an active draw exists.
Active draw bypasses the sign-gate (sig snapshot on the draw is used
even if `user.significator` is None), bypasses the landing phase (the
saved hand IS what the user came to see), and adds a Brief banner +
next-free-draw timestamp to the context."""
def setUp(self):
from apps.epic.models import personal_sig_cards, TarotCard
from apps.gameboard.models import MySeaDraw
self.user = User.objects.create(email="saved@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
self.draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
significator_id=self.target.id,
hand=[
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
],
)
def test_context_carries_active_draw(self):
response = self.client.get(reverse("my_sea"))
self.assertEqual(response.context["active_draw"], self.draw)
def test_context_default_spread_is_saved_spread(self):
response = self.client.get(reverse("my_sea"))
self.assertEqual(response.context["default_spread"], self.draw.spread)
def test_context_carries_next_free_draw_iso(self):
from datetime import datetime
response = self.client.get(reverse("my_sea"))
ts = response.context["next_free_draw_at"]
# Either a datetime instance or an ISO string the template renders.
if isinstance(ts, str):
self.assertIsNotNone(datetime.fromisoformat(ts))
else:
self.assertIsNotNone(ts.isoformat())
def test_saved_draw_bypasses_sign_gate_even_when_user_sig_cleared(self):
# User-spec'd: a cleared sig doesn't invalidate the saved draw.
# The view must still render the picker phase (NOT the sign-gate)
# by falling back to the draw's snapshotted sig.
self.user.significator = None
self.user.save(update_fields=["significator"])
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
self.assertNotIn("my-sea-sign-gate", html)
self.assertIn('data-phase="picker"', html)
def test_view_renders_brief_banner_when_active_draw_exists(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, "my-sea-brief")
self.assertContains(response, "my-sea-brief__timestamp")
self.assertContains(response, "my-sea-brief__nvm")
def test_brief_banner_hidden_without_active_draw(self):
# Markup is rendered unconditionally so JS can un-hide it on LOCK
# HAND POST success without a page reload. When no active_draw,
# the wrapping div carries `[hidden]` so the banner is invisible.
from apps.gameboard.models import MySeaDraw
MySeaDraw.objects.all().delete()
response = self.client.get(reverse("my_sea"))
self.assertContains(response, '<div class="my-sea-brief" hidden>')
def test_view_renders_del_guard_portal_when_active_draw_exists(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'id="id_my_sea_del_portal"')
self.assertContains(response, "my-sea-del-portal__confirm")
self.assertContains(response, "my-sea-del-portal__nvm")
def test_saved_hand_renders_as_filled_slots_in_picker(self):
# Each saved position's slot is server-rendered as `--filled` w.
# the snapshotted card id + polarity. JS-init then layers any
# post-load behaviours (label re-rendering, stage-card lookups).
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
for entry in self.draw.hand:
self.assertIn(f'data-card-id="{entry["card_id"]}"', html)
self.assertIn(f"sea-card-slot--{entry['polarity']}", html)
def test_landing_phase_suppressed_when_active_draw_exists(self):
response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, 'id="id_draw_sea_btn"')
self.assertNotContains(response, 'data-phase="landing"')