Files
python-tdd/src/apps/gameboard/tests/integrated/test_views.py
Disco DeDisco 285597b467 My Sea FREE DRAW + seat-1C seated transition — Sprint 5 iter 1 follow-up — TDD
Iter-1 follow-up after the user re-spec'd the FREE DRAW click behavior:

- **Btn label** DRAW SEA → FREE DRAW (the 1/24h free-quota draw). Element ID `id_draw_sea_btn` retained — describes intent, not label, so a future sprint can conditionally swap the label back to DRAW SEA once the daily free has been used (at which point the btn calls the room gatekeeper partial for token-deposit per [[project-my-sea-roadmap]] Sprint 6).
- **Chair seat markup** — each `.table-seat` now renders w. `.fa-chair` + `.seat-position-label` (1C-6C) + `.position-status-icon.fa-solid.fa-ban` mirroring the room's hex grammar from `_table_positions.html`. **`.seat-position-label`** (not `.seat-role-label`) because my-sea is the solo flow — no roles — and the existing room class carries role-grammar semantics that don't apply here. New class gets its own grid placement in `_gameboard.scss` (col 2 / row 1 default, col 1 for left-side seats 3/4/5 per the room's flip rule).
- **FREE DRAW click flow** — (1) seat 1C immediately gains `.seated` class & its `.fa-ban` icon swaps to `.fa-circle-check`; (2) after 800ms (so the user sees the seat animation against `_room.scss`'s 0.6s `color`/`filter` transition on `.fa-chair`), `data-phase` swaps to `picker` & landing hides. Seat 1C-only because my-sea is single-user-per-page until friend-invite lands — the user always occupies the lowest-numeral seat.
- **`.table-seat.seated` SCSS** in `_gameboard.scss` — `--terUser` chair color + `drop-shadow(--ninUser)` glow. Mirrors `_room.scss:626` `.table-seat.active .fa-chair` styling but uses a stable `.seated` class (semantically distinct: `.active` = current turn in a multi-user room, `.seated` = draw-locked occupant in the solo flow). Status icon green via the existing `_room.scss:616` `.position-status-icon.fa-circle-check` rule — no new color rule needed.
- **FT/IT updates** — renamed `test_landing_renders_hex_with_draw_sea_btn` → `…_free_draw_btn` w. "FREE DRAW" label assertions; T2 extended to assert `.position-status-icon.fa-ban` on each seat at render time; T3 (formerly `…_transitions_to_picker_phase`) rewritten as `test_free_draw_click_seats_user_in_1C_then_swaps_phase` to pin the full click contract: 1C goes `.seated` + `.fa-circle-check`, seats 2-6 unchanged, picker phase swap after the delay. New IT `test_landing_renders_position_status_ban_icon_on_each_seat` asserts initial ban + `.seat-position-label` counts.

**Substring trap caught** (worth a sticky note): bare class-name substrings (`fa-ban`, `position-status-icon`) appear ALSO in the inline JS handler's `classList.remove(…)` / `querySelector(…)` arg strings → `html.count("fa-ban") = 7`, not 6. Tightened IT assertions to match the full class attribute (`class="position-status-icon fa-solid fa-ban"`) — never count bare class names in `assertContains` / `html.count` when the same class is JS-manipulated client-side.

Tests: 25/25 FT green across test_bill_my_sign + test_game_my_sea (165s); 1037/1037 IT/UT green (49s).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:07 -04:00

589 lines
26 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"')