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