Phase 0 of the room GATE VIEW + seat-renewal sprint. Mirrors the my-sea treatment: on any room page the self-referential CONT GAME is replaced by a GATE VIEW button that opens the room's renewal gate-view. - `room_view` page_class → "page-gameboard page-room"; the bare gameboard listing stays "page-gameboard" (no page-room) so CONT GAME persists there for returning to a recent room. - `_navbar.html` GATE VIEW branch fires on `page-my-sea` OR `page-room`; onclick routes, in precedence: page-room → epic:room_gate (room in context); my-sea-visit → visitor gate; else owner's sea gate. One consolidated branch (DRY) instead of two near-identical button blocks. Tests: RoomNavbarGateViewTest (4) — room page shows GATE VIEW not CONT GAME, links to room_gate, gate-view page also shows it, page-room marker present. 826 epic+gameboard ITs green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2765 lines
119 KiB
Python
2765 lines
119 KiB
Python
from datetime import timedelta
|
||
from unittest.mock import ANY, patch
|
||
|
||
from django.test import TestCase
|
||
from django.urls import reverse
|
||
from django.utils import timezone
|
||
|
||
from apps.drama.models import GameEvent, Note
|
||
from apps.lyric.models import Token, User
|
||
from apps.epic.models import (
|
||
Character, DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||
)
|
||
|
||
|
||
class RoomCreationViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="founder@test.io")
|
||
self.client.force_login(self.user)
|
||
|
||
def test_post_creates_room_and_redirects_to_gatekeeper(self):
|
||
response = self.client.post(
|
||
reverse("epic:create_room"),
|
||
data={"name": "Test Room"},
|
||
)
|
||
room = Room.objects.get(owner=self.user)
|
||
self.assertRedirects(
|
||
response, reverse(
|
||
"epic:gatekeeper",
|
||
args=[room.id],
|
||
)
|
||
)
|
||
|
||
def test_post_requires_login(self):
|
||
self.client.logout()
|
||
response = self.client.post(
|
||
reverse("epic:create_room"),
|
||
data={"name": "Test Room"},
|
||
)
|
||
|
||
def test_create_room_get_redirects_to_gameboard(self):
|
||
response = self.client.get(reverse("epic:create_room"))
|
||
self.assertRedirects(response, "/gameboard/")
|
||
|
||
def test_create_room_records_welcome_event_with_no_actor(self):
|
||
"""First scroll log on a fresh room is a system-authored welcome —
|
||
not a user action, so actor=None. The visible greeting is the
|
||
ROOM_CREATED event's `to_prose` ("Welcome to <name>!")."""
|
||
self.client.post(
|
||
reverse("epic:create_room"),
|
||
data={"name": "Welcoming Room"},
|
||
)
|
||
room = Room.objects.get(owner=self.user)
|
||
event = room.events.first()
|
||
self.assertIsNotNone(event, "no ROOM_CREATED event recorded")
|
||
self.assertEqual(event.verb, GameEvent.ROOM_CREATED)
|
||
self.assertIsNone(event.actor, "welcome line must be system-authored")
|
||
|
||
def test_create_room_welcome_event_renders_welcome_prose(self):
|
||
self.client.post(
|
||
reverse("epic:create_room"),
|
||
data={"name": "Greenroom"},
|
||
)
|
||
room = Room.objects.get(owner=self.user)
|
||
event = room.events.first()
|
||
self.assertEqual(event.to_prose(), "Welcome to Greenroom!")
|
||
|
||
|
||
class MyGamesContextTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="gamer@example.com")
|
||
self.client.force_login(self.user)
|
||
|
||
def test_gameboard_context_includes_owned_rooms(self):
|
||
room = Room.objects.create(name="Durango", owner=self.user)
|
||
response = self.client.get("/gameboard/")
|
||
self.assertIn(room, response.context["my_games"])
|
||
|
||
def test_gameboard_context_includes_rooms_with_filled_slot(self):
|
||
other = User.objects.create(email="friend@example.com")
|
||
room = Room.objects.create(name="Their Room", owner=other)
|
||
slot = room.gate_slots.get(slot_number=2)
|
||
slot.gamer = self.user
|
||
slot.status = "FILLED"
|
||
slot.save()
|
||
response = self.client.get("/gameboard/")
|
||
self.assertIn(room, response.context["my_games"])
|
||
|
||
|
||
class GateStatusViewTest(TestCase):
|
||
def setUp(self):
|
||
self.owner = User.objects.create(email="founder@test.io")
|
||
self.client.force_login(self.owner)
|
||
self.room = Room.objects.create(name="Test Room", owner=self.owner)
|
||
|
||
def test_gate_status_returns_launch_btn_when_open(self):
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.save()
|
||
response = self.client.get(reverse("epic:gate_status", kwargs={"room_id": self.room.id}))
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, "launch-game-btn")
|
||
|
||
def test_gate_status_returns_partial_when_gathering(self):
|
||
response = self.client.get(
|
||
reverse("epic:gate_status", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, "gate-modal")
|
||
|
||
|
||
class DropTokenViewTest(TestCase):
|
||
def setUp(self):
|
||
self.gamer = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.gamer)
|
||
owner = User.objects.create(email="owner@test.io")
|
||
self.room = Room.objects.create(name="Test Room", owner=owner)
|
||
|
||
def test_drop_token_reserves_lowest_empty_slot(self):
|
||
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
|
||
slot = self.room.gate_slots.get(slot_number=1)
|
||
self.assertEqual(slot.status, GateSlot.RESERVED)
|
||
self.assertEqual(slot.gamer, self.gamer)
|
||
|
||
def test_drop_token_skips_already_filled_slots(self):
|
||
other = User.objects.create(email="other@test.io")
|
||
slot1 = self.room.gate_slots.get(slot_number=1)
|
||
slot1.gamer = other
|
||
slot1.status = GateSlot.FILLED
|
||
slot1.save()
|
||
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
|
||
slot2 = self.room.gate_slots.get(slot_number=2)
|
||
self.assertEqual(slot2.status, GateSlot.RESERVED)
|
||
self.assertEqual(slot2.gamer, self.gamer)
|
||
|
||
def test_drop_token_blocked_when_another_slot_reserved(self):
|
||
other = User.objects.create(email="other@test.io")
|
||
slot1 = self.room.gate_slots.get(slot_number=1)
|
||
slot1.gamer = other
|
||
slot1.status = GateSlot.RESERVED
|
||
slot1.reserved_at = timezone.now()
|
||
slot1.save()
|
||
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
|
||
# Slot 2 should remain EMPTY — lock held by other user
|
||
slot2 = self.room.gate_slots.get(slot_number=2)
|
||
self.assertEqual(slot2.status, GateSlot.EMPTY)
|
||
|
||
def test_drop_token_blocked_when_user_already_has_filled_slot(self):
|
||
slot1 = self.room.gate_slots.get(slot_number=1)
|
||
slot1.gamer = self.gamer
|
||
slot1.status = GateSlot.FILLED
|
||
slot1.save()
|
||
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
|
||
slot2 = self.room.gate_slots.get(slot_number=2)
|
||
self.assertEqual(slot2.status, GateSlot.EMPTY)
|
||
|
||
def test_drop_token_sets_reserved_at(self):
|
||
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
|
||
slot = self.room.gate_slots.get(slot_number=1)
|
||
self.assertIsNotNone(slot.reserved_at)
|
||
|
||
def test_drop_token_redirects_to_gatekeeper(self):
|
||
response = self.client.post(
|
||
reverse("epic:drop_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertRedirects(
|
||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||
)
|
||
|
||
def test_carte_drop_sets_current_room(self):
|
||
carte = Token.objects.create(user=self.gamer, token_type=Token.CARTE)
|
||
self.client.post(
|
||
reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
|
||
data={"token_id": carte.pk},
|
||
)
|
||
carte.refresh_from_db()
|
||
self.assertEqual(carte.current_room, self.room)
|
||
|
||
def test_carte_drop_unequips_trinket(self):
|
||
carte = Token.objects.create(user=self.gamer, token_type=Token.CARTE)
|
||
self.gamer.equipped_trinket = carte
|
||
self.gamer.save(update_fields=["equipped_trinket"])
|
||
self.client.post(
|
||
reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
|
||
data={"token_id": carte.pk},
|
||
)
|
||
self.gamer.refresh_from_db()
|
||
self.assertIsNone(self.gamer.equipped_trinket)
|
||
|
||
def test_carte_drop_rejected_when_already_in_different_room(self):
|
||
other_room = Room.objects.create(name="Other Room", owner=self.gamer)
|
||
carte = Token.objects.create(
|
||
user=self.gamer, token_type=Token.CARTE, current_room=other_room,
|
||
)
|
||
response = self.client.post(
|
||
reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
|
||
data={"token_id": carte.pk},
|
||
)
|
||
self.assertEqual(response.status_code, 409)
|
||
carte.refresh_from_db()
|
||
self.assertEqual(carte.current_room, other_room) # unchanged
|
||
|
||
|
||
class ConfirmTokenViewTest(TestCase):
|
||
def setUp(self):
|
||
self.gamer = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.gamer)
|
||
owner = User.objects.create(email="owner@test.io")
|
||
self.room = Room.objects.create(name="Test Room", owner=owner)
|
||
self.slot = self.room.gate_slots.get(slot_number=1)
|
||
self.slot.gamer = self.gamer
|
||
self.slot.status = GateSlot.RESERVED
|
||
self.slot.reserved_at = timezone.now()
|
||
self.slot.save()
|
||
Token.objects.create(user=self.gamer, token_type=Token.FREE)
|
||
|
||
def test_confirm_marks_slot_filled(self):
|
||
self.client.post(
|
||
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.status, GateSlot.FILLED)
|
||
|
||
def test_confirm_sets_gate_open_when_all_slots_filled(self):
|
||
# Fill slots 2–6 via ORM
|
||
for i in range(2, 7):
|
||
other = User.objects.create(email=f"g{i}@test.io")
|
||
s = self.room.gate_slots.get(slot_number=i)
|
||
s.gamer = other
|
||
s.status = GateSlot.FILLED
|
||
s.save()
|
||
self.client.post(
|
||
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.room.refresh_from_db()
|
||
self.assertEqual(self.room.gate_status, Room.OPEN)
|
||
|
||
def test_confirm_redirects_to_gatekeeper(self):
|
||
response = self.client.post(
|
||
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertRedirects(
|
||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||
)
|
||
|
||
def test_confirm_does_nothing_without_reserved_slot(self):
|
||
self.slot.status = GateSlot.EMPTY
|
||
self.slot.gamer = None
|
||
self.slot.save()
|
||
self.client.post(
|
||
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
||
|
||
|
||
class ReturnTokenViewTest(TestCase):
|
||
def setUp(self):
|
||
self.gamer = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.gamer)
|
||
owner = User.objects.create(email="owner@test.io")
|
||
self.room = Room.objects.create(name="Test Room", owner=owner)
|
||
self.slot = self.room.gate_slots.get(slot_number=1)
|
||
self.slot.gamer = self.gamer
|
||
self.slot.status = GateSlot.RESERVED
|
||
self.slot.reserved_at = timezone.now()
|
||
self.slot.save()
|
||
|
||
def test_return_clears_reserved_slot(self):
|
||
self.client.post(
|
||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
||
self.assertIsNone(self.slot.gamer)
|
||
self.assertIsNone(self.slot.reserved_at)
|
||
|
||
def test_return_after_confirm_clears_filled_slot(self):
|
||
self.slot.status = GateSlot.FILLED
|
||
self.slot.save()
|
||
self.client.post(
|
||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
||
self.assertIsNone(self.slot.gamer)
|
||
|
||
def test_return_redirects_to_gatekeeper(self):
|
||
response = self.client.post(
|
||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertRedirects(
|
||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||
)
|
||
|
||
def test_return_restores_coin_token(self):
|
||
coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
|
||
coin.current_room = self.room
|
||
coin.next_ready_at = timezone.now() + timedelta(days=7)
|
||
coin.save()
|
||
self.slot.status = GateSlot.FILLED
|
||
self.slot.debited_token_type = Token.COIN
|
||
self.slot.save()
|
||
self.client.post(
|
||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
coin.refresh_from_db()
|
||
self.assertIsNone(coin.current_room)
|
||
self.assertIsNone(coin.next_ready_at)
|
||
|
||
def test_return_restores_free_token(self):
|
||
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
|
||
expires = timezone.now() + timedelta(days=3)
|
||
self.slot.status = GateSlot.FILLED
|
||
self.slot.debited_token_type = Token.FREE
|
||
self.slot.debited_token_expires_at = expires
|
||
self.slot.save()
|
||
self.client.post(
|
||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
restored = Token.objects.filter(user=self.gamer, token_type=Token.FREE).first()
|
||
self.assertIsNotNone(restored)
|
||
self.assertEqual(restored.expires_at, expires)
|
||
|
||
def test_return_restores_tithe_token(self):
|
||
self.slot.status = GateSlot.FILLED
|
||
self.slot.debited_token_type = Token.TITHE
|
||
self.slot.save()
|
||
self.client.post(
|
||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertTrue(
|
||
Token.objects.filter(user=self.gamer, token_type=Token.TITHE).exists()
|
||
)
|
||
|
||
|
||
class DropTokenAvailabilityViewTest(TestCase):
|
||
def setUp(self):
|
||
self.gamer = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.gamer)
|
||
owner = User.objects.create(email="owner@test.io")
|
||
self.room = Room.objects.create(name="Test Room", owner=owner)
|
||
self.other_room = Room.objects.create(name="Other Room", owner=owner)
|
||
self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
|
||
|
||
def test_drop_reserves_slot_when_tokens_available(self):
|
||
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
|
||
slot = self.room.gate_slots.get(slot_number=1)
|
||
self.assertEqual(slot.status, GateSlot.RESERVED)
|
||
# token not debited yet — that happens at confirm
|
||
self.coin.refresh_from_db()
|
||
self.assertIsNone(self.coin.current_room)
|
||
|
||
def test_drop_returns_402_when_all_tokens_depleted(self):
|
||
self.coin.current_room = self.other_room
|
||
self.coin.save()
|
||
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
|
||
response = self.client.post(
|
||
reverse("epic:drop_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertEqual(response.status_code, 402)
|
||
|
||
|
||
class ConfirmTokenPriorityViewTest(TestCase):
|
||
def setUp(self):
|
||
self.gamer = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.gamer)
|
||
owner = User.objects.create(email="owner@test.io")
|
||
self.room = Room.objects.create(name="Test Room", owner=owner)
|
||
self.other_room = Room.objects.create(name="Other Room", owner=owner)
|
||
self.slot = self.room.gate_slots.get(slot_number=1)
|
||
self.slot.gamer = self.gamer
|
||
self.slot.status = GateSlot.RESERVED
|
||
self.slot.reserved_at = timezone.now()
|
||
self.slot.save()
|
||
self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
|
||
|
||
def test_confirm_leases_coin_to_room(self):
|
||
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
|
||
self.coin.refresh_from_db()
|
||
self.assertEqual(self.coin.current_room, self.room)
|
||
self.assertTrue(Token.objects.filter(pk=self.coin.pk).exists())
|
||
|
||
def test_confirm_uses_free_token_when_coin_in_use(self):
|
||
self.coin.current_room = self.other_room
|
||
self.coin.save()
|
||
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
|
||
self.assertEqual(
|
||
Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(), 0
|
||
)
|
||
self.coin.refresh_from_db()
|
||
self.assertEqual(self.coin.current_room, self.other_room)
|
||
|
||
def test_confirm_uses_tithe_when_free_tokens_exhausted(self):
|
||
self.coin.current_room = self.other_room
|
||
self.coin.save()
|
||
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
|
||
tithe = Token.objects.create(user=self.gamer, token_type=Token.TITHE)
|
||
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
|
||
self.assertFalse(Token.objects.filter(pk=tithe.pk).exists())
|
||
|
||
def test_pass_not_consumed_and_coin_not_leased(self):
|
||
"""Equipped PASS picked over the still-equipped-by-default COIN —
|
||
confirm leaves PASS untouched + doesn't lease the COIN (PASS is
|
||
never-consumed; COIN stays free for a future room). The equip
|
||
slot is the precondition; DON-ing PASS swaps it in for the auto-
|
||
equipped COIN."""
|
||
self.gamer.is_staff = True
|
||
self.gamer.save()
|
||
pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
|
||
self.gamer.equipped_trinket = pass_token
|
||
self.gamer.save(update_fields=["equipped_trinket"])
|
||
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
|
||
self.assertTrue(Token.objects.filter(pk=pass_token.pk).exists())
|
||
self.coin.refresh_from_db()
|
||
self.assertIsNone(self.coin.current_room)
|
||
|
||
|
||
class RoleSelectRenderingTest(TestCase):
|
||
def setUp(self):
|
||
self.founder = User.objects.create(email="founder@test.io")
|
||
self.room = Room.objects.create(name="Test Room", owner=self.founder)
|
||
self.gamers = [self.founder]
|
||
for i in range(2, 7):
|
||
self.gamers.append(User.objects.create(email=f"g{i}@test.io"))
|
||
for i, gamer in enumerate(self.gamers, start=1):
|
||
slot = self.room.gate_slots.get(slot_number=i)
|
||
slot.gamer = gamer
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
for i, gamer in enumerate(self.gamers, start=1):
|
||
TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_room_view_includes_card_stack_when_role_select(self):
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(
|
||
self.url
|
||
)
|
||
self.assertContains(response, "card-stack")
|
||
|
||
def test_card_stack_eligible_for_slot1_gamer(self):
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(
|
||
self.url
|
||
)
|
||
self.assertContains(response, 'data-state="eligible"')
|
||
|
||
def test_card_stack_ineligible_for_slot2_gamer(self):
|
||
self.client.force_login(self.gamers[1])
|
||
response = self.client.get(
|
||
self.url
|
||
)
|
||
self.assertContains(response, 'data-state="ineligible"')
|
||
|
||
def test_card_stack_ineligible_shows_fa_ban(self):
|
||
self.client.force_login(self.gamers[1])
|
||
response = self.client.get(
|
||
self.url
|
||
)
|
||
self.assertContains(response, "fa-ban")
|
||
|
||
def test_card_stack_eligible_omits_fa_ban(self):
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(
|
||
self.url
|
||
)
|
||
# Seat ban icons carry "position-status-icon"; card-stack ban does not.
|
||
# Assert the bare "fa-solid fa-ban" (card-stack form) is absent.
|
||
self.assertNotContains(response, 'class="fa-solid fa-ban"')
|
||
|
||
def test_gatekeeper_overlay_absent_when_role_select(self):
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(
|
||
self.url
|
||
)
|
||
self.assertNotContains(response, "gate-overlay")
|
||
|
||
def test_tray_wrap_has_role_select_phase_class(self):
|
||
# Tray handle hidden until gamer confirms a role pick
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, 'id="id_tray_wrap" class="role-select-phase"')
|
||
|
||
def test_tray_absent_during_gatekeeper_phase(self):
|
||
# Tray must not render before the gamer occupies a seat
|
||
room = Room.objects.create(name="Gate Room", owner=self.founder)
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(
|
||
reverse("epic:gatekeeper", kwargs={"room_id": room.id})
|
||
)
|
||
self.assertNotContains(response, 'id="id_tray_wrap"')
|
||
|
||
def test_six_table_seats_rendered(self):
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(
|
||
self.url
|
||
)
|
||
self.assertContains(response, "table-seat", count=6)
|
||
|
||
def test_table_seats_never_active_on_load(self):
|
||
# Seat glow is JS-only (during tray animation); never server-rendered
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(self.url)
|
||
self.assertNotContains(response, 'class="table-seat active"')
|
||
|
||
def test_assigned_seat_renders_role_confirmed_class(self):
|
||
# A seat with a role already picked must load as role-confirmed (opaque chair)
|
||
self.gamers[0].refresh_from_db()
|
||
seat = self.room.table_seats.get(slot_number=1)
|
||
seat.role = "PC"
|
||
seat.save()
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, 'table-seat role-confirmed')
|
||
|
||
def test_unassigned_seat_lacks_role_confirmed_class(self):
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(self.url)
|
||
self.assertNotContains(response, 'table-seat role-confirmed')
|
||
|
||
def test_assigned_slot_circle_renders_role_assigned_class(self):
|
||
# Slot 1 circle hidden because 1 role was assigned (count-based, not role-label-based)
|
||
seat = self.room.table_seats.get(slot_number=1)
|
||
seat.role = "PC"
|
||
seat.save()
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, 'gate-slot filled role-assigned')
|
||
|
||
def test_slot_circle_hides_by_count_not_role_label(self):
|
||
# Gamer in slot 1 picks NC (not PC) — slot 1 circle must still hide, not slot 2's
|
||
seat = self.room.table_seats.get(slot_number=1)
|
||
seat.role = "NC"
|
||
seat.save()
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(self.url)
|
||
content = response.content.decode()
|
||
import re
|
||
# Template renders class before data-slot; capture both orderings
|
||
circles = re.findall(r'class="([^"]*gate-slot[^"]*)"[^>]*data-slot="(\d)"', content)
|
||
slot1_classes = next((cls for cls, slot in circles if slot == "1"), "")
|
||
slot2_classes = next((cls for cls, slot in circles if slot == "2"), "")
|
||
self.assertIn("role-assigned", slot1_classes)
|
||
self.assertNotIn("role-assigned", slot2_classes)
|
||
|
||
def test_unassigned_slot_circle_lacks_role_assigned_class(self):
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(self.url)
|
||
self.assertNotContains(response, 'role-assigned')
|
||
|
||
def test_position_strip_rendered_during_role_select(self):
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "position-strip")
|
||
|
||
def test_position_strip_has_six_gate_slots(self):
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "gate-slot", count=6)
|
||
|
||
def test_card_stack_has_data_user_slots_for_eligible_gamer(self):
|
||
self.client.force_login(self.founder) # founder is slot 1 only
|
||
response = self.client.get(
|
||
self.url
|
||
)
|
||
self.assertContains(response, 'data-user-slots="1"')
|
||
|
||
def test_card_stack_has_data_user_slots_for_ineligible_gamer(self):
|
||
self.client.force_login(self.gamers[1]) # slot 2 gamer
|
||
response = self.client.get(
|
||
self.url
|
||
)
|
||
self.assertContains(response, 'data-user-slots="2"')
|
||
|
||
def test_assigned_seat_renders_check_icon(self):
|
||
seat = self.room.table_seats.get(slot_number=1)
|
||
seat.role = "PC"
|
||
seat.save()
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(self.url)
|
||
content = response.content.decode()
|
||
# The PC seat should have fa-circle-check, not fa-ban
|
||
pc_seat_start = content.index('data-role="PC"')
|
||
pc_seat_chunk = content[pc_seat_start:pc_seat_start + 300]
|
||
self.assertIn("fa-circle-check", pc_seat_chunk)
|
||
self.assertNotIn("fa-ban", pc_seat_chunk)
|
||
|
||
def test_unassigned_seat_renders_ban_icon(self):
|
||
# slot 2's role is still null
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(self.url)
|
||
content = response.content.decode()
|
||
nc_seat_start = content.index('data-role="NC"')
|
||
nc_seat_chunk = content[nc_seat_start:nc_seat_start + 300]
|
||
self.assertIn("fa-ban", nc_seat_chunk)
|
||
self.assertNotIn("fa-circle-check", nc_seat_chunk)
|
||
|
||
|
||
class PickRolesViewTest(TestCase):
|
||
def setUp(self):
|
||
self.founder = User.objects.create(email="founder@test.io")
|
||
self.client.force_login(self.founder)
|
||
self.room = Room.objects.create(name="Test Room", owner=self.founder)
|
||
for i in range(1, 7):
|
||
gamer = self.founder if i == 1 else User.objects.create(email=f"g{i}@test.io")
|
||
slot = self.room.gate_slots.get(slot_number=i)
|
||
slot.gamer = gamer
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.save()
|
||
|
||
def test_pick_roles_transitions_room_to_role_select(self):
|
||
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||
self.room.refresh_from_db()
|
||
self.assertEqual(self.room.table_status, Room.ROLE_SELECT)
|
||
|
||
def test_pick_roles_creates_one_table_seat_per_filled_slot(self):
|
||
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||
self.assertEqual(TableSeat.objects.filter(room=self.room).count(), 6)
|
||
|
||
def test_pick_roles_table_seats_carry_gamer_and_slot_number(self):
|
||
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||
seat = TableSeat.objects.get(room=self.room, slot_number=1)
|
||
self.assertEqual(seat.gamer, self.founder)
|
||
|
||
def test_only_open_room_can_start_role_select(self):
|
||
self.room.gate_status = Room.GATHERING
|
||
self.room.save()
|
||
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||
self.room.refresh_from_db()
|
||
self.assertIsNone(self.room.table_status)
|
||
|
||
def test_pick_roles_requires_login(self):
|
||
self.client.logout()
|
||
response = self.client.post(
|
||
reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertIn("/accounts/login/", response.url)
|
||
|
||
def test_pick_roles_redirects_to_room(self):
|
||
response = self.client.post(
|
||
reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertRedirects(
|
||
response, reverse("epic:room", args=[self.room.id])
|
||
)
|
||
|
||
def test_pick_roles_notifies_channel_layer(self):
|
||
with patch("apps.epic.views._notify_role_select_start") as mock_notify:
|
||
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||
mock_notify.assert_called_once_with(self.room.id)
|
||
|
||
def test_pick_roles_idempotent_no_duplicate_seats(self):
|
||
url = reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
|
||
self.client.post(url)
|
||
self.client.post(url) # second call must be a no-op
|
||
self.assertEqual(TableSeat.objects.filter(room=self.room).count(), 6)
|
||
|
||
|
||
class SelectRoleViewTest(TestCase):
|
||
def setUp(self):
|
||
self.founder = User.objects.create(email="founder@test.io")
|
||
self.room = Room.objects.create(name="Test Room", owner=self.founder)
|
||
self.gamers = [self.founder]
|
||
for i in range(2, 7):
|
||
self.gamers.append(User.objects.create(email=f"g{i}@test.io"))
|
||
for i, gamer in enumerate(self.gamers, start=1):
|
||
slot = self.room.gate_slots.get(slot_number=i)
|
||
slot.gamer = gamer
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
for i, gamer in enumerate(self.gamers, start=1):
|
||
TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
|
||
self.client.force_login(self.founder)
|
||
|
||
def test_select_role_records_choice(self):
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
seat = TableSeat.objects.get(room=self.room, slot_number=1)
|
||
self.assertEqual(seat.role, "PC")
|
||
|
||
def test_select_role_wrong_turn_makes_no_change(self):
|
||
self.client.force_login(self.gamers[1]) # slot 2 — not their turn
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "BC"},
|
||
)
|
||
seat = TableSeat.objects.get(room=self.room, slot_number=2)
|
||
self.assertIsNone(seat.role)
|
||
|
||
def test_turn_advances_after_selection(self):
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
next_active = TableSeat.objects.filter(
|
||
room=self.room, role__isnull=True
|
||
).order_by("slot_number").first()
|
||
self.assertEqual(next_active.slot_number, 2)
|
||
|
||
def test_all_selected_stays_role_select_status(self):
|
||
roles = ["PC", "BC", "SC", "AC", "NC"]
|
||
for i, role in enumerate(roles):
|
||
seat = TableSeat.objects.get(room=self.room, slot_number=i + 1)
|
||
seat.role = role
|
||
seat.save()
|
||
self.client.force_login(self.gamers[5]) # slot 6 — last
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "EC"},
|
||
)
|
||
self.room.refresh_from_db()
|
||
self.assertEqual(self.room.table_status, Room.ROLE_SELECT)
|
||
|
||
def test_select_role_notifies_turn_changed(self):
|
||
with patch("apps.epic.views._notify_turn_changed") as mock_notify:
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
mock_notify.assert_called_once_with(self.room.id)
|
||
|
||
def test_select_role_notifies_all_roles_filled_when_last(self):
|
||
roles = ["PC", "BC", "SC", "AC", "NC"]
|
||
for i, role in enumerate(roles):
|
||
seat = TableSeat.objects.get(room=self.room, slot_number=i + 1)
|
||
seat.role = role
|
||
seat.save()
|
||
self.client.force_login(self.gamers[5])
|
||
with patch("apps.epic.views._notify_all_roles_filled") as mock_notify:
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "EC"},
|
||
)
|
||
mock_notify.assert_called_once_with(self.room.id)
|
||
|
||
def test_select_role_assigns_equipped_deck_to_seat(self):
|
||
earthman = DeckVariant.objects.get(slug="earthman")
|
||
self.founder.equipped_deck = earthman
|
||
self.founder.save(update_fields=["equipped_deck"])
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
seat = TableSeat.objects.get(room=self.room, slot_number=1)
|
||
self.assertEqual(seat.deck_variant, earthman)
|
||
|
||
def test_select_role_no_deck_leaves_deck_variant_null(self):
|
||
self.founder.equipped_deck = None
|
||
self.founder.save(update_fields=["equipped_deck"])
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
seat = TableSeat.objects.get(room=self.room, slot_number=1)
|
||
self.assertIsNone(seat.deck_variant)
|
||
|
||
def test_select_role_unequips_deck_from_user(self):
|
||
earthman = DeckVariant.objects.get(slug="earthman")
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
self.founder.refresh_from_db()
|
||
self.assertIsNone(self.founder.equipped_deck)
|
||
|
||
def test_select_role_requires_login(self):
|
||
self.client.logout()
|
||
response = self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertIn("/accounts/login/", response.url)
|
||
|
||
def test_select_role_returns_ok(self):
|
||
response = self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_select_role_returns_409_for_duplicate_role(self):
|
||
TableSeat.objects.filter(room=self.room, slot_number=2).update(role="BC")
|
||
response = self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "BC"},
|
||
)
|
||
self.assertEqual(response.status_code, 409)
|
||
|
||
def test_select_role_redirects_when_not_role_select_phase(self):
|
||
self.room.table_status = None
|
||
self.room.save()
|
||
response = self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
self.assertRedirects(
|
||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||
)
|
||
|
||
def test_select_role_redirects_for_invalid_role_code(self):
|
||
response = self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "BOGUS"},
|
||
)
|
||
self.assertRedirects(
|
||
response, reverse("epic:room", args=[self.room.id])
|
||
)
|
||
|
||
def test_same_gamer_cannot_double_pick_sequentially(self):
|
||
"""A second POST from the active gamer — after their role has been
|
||
saved — must redirect rather than assign a second role."""
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
response = self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "BC"},
|
||
)
|
||
self.assertRedirects(
|
||
response, reverse("epic:room", args=[self.room.id])
|
||
)
|
||
self.assertEqual(
|
||
TableSeat.objects.filter(room=self.room, role__isnull=False).count(), 1
|
||
)
|
||
|
||
|
||
class SelectRoleMultiSeatTest(TestCase):
|
||
"""Carte Blanche multi-seat: second role reuses the deck from the first seat."""
|
||
|
||
def setUp(self):
|
||
self.founder = User.objects.create(email="founder@test.io")
|
||
self.client.force_login(self.founder)
|
||
self.room = Room.objects.create(name="Test Room", owner=self.founder)
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||
|
||
def test_second_role_inherits_deck_from_first_seat_in_room(self):
|
||
# Founder's first seat: PC already taken with deck assigned
|
||
TableSeat.objects.create(
|
||
room=self.room, gamer=self.founder, slot_number=1,
|
||
role="PC", deck_variant=self.earthman,
|
||
)
|
||
# Deck unequipped after first role
|
||
self.founder.equipped_deck = None
|
||
self.founder.save(update_fields=["equipped_deck"])
|
||
# Founder's second seat (Carte Blanche): no role yet
|
||
second_seat = TableSeat.objects.create(
|
||
room=self.room, gamer=self.founder, slot_number=2,
|
||
)
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "BC"},
|
||
)
|
||
second_seat.refresh_from_db()
|
||
self.assertEqual(second_seat.deck_variant, self.earthman)
|
||
|
||
def test_second_role_does_not_unequip_again(self):
|
||
"""No-op unequip when deck was already cleared by the first role."""
|
||
TableSeat.objects.create(
|
||
room=self.room, gamer=self.founder, slot_number=1,
|
||
role="PC", deck_variant=self.earthman,
|
||
)
|
||
self.founder.equipped_deck = None
|
||
self.founder.save(update_fields=["equipped_deck"])
|
||
TableSeat.objects.create(room=self.room, gamer=self.founder, slot_number=2)
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "BC"},
|
||
)
|
||
self.founder.refresh_from_db()
|
||
self.assertIsNone(self.founder.equipped_deck) # still None, not broken
|
||
|
||
# ── Return-trip Role-Select bug — CARTE user navigates away + back ───── #
|
||
#
|
||
# After the FIRST role pick, select_role() clears user.equipped_deck
|
||
# ("deck committed to room"). The room view's role-select context then
|
||
# passes `equipped_deck_id = user.equipped_deck_id` to the template,
|
||
# which sets `data-equipped-deck=""` and JS guards against role-select
|
||
# with "Equip card deck before Role select." → blocks a CARTE user from
|
||
# continuing to pick roles for their remaining seats. Fix: the context's
|
||
# `equipped_deck_id` should also accept the deck_variant of any seat the
|
||
# user already holds in this room (the deck IS in play — it's just
|
||
# committed to existing seats, not to user.equipped_deck).
|
||
|
||
def test_role_select_context_recovers_deck_id_from_existing_seat(self):
|
||
"""User cleared their equipped_deck after first role pick, but they
|
||
still have a seat in this room w. deck_variant set → context should
|
||
report that deck's id so the guard doesn't fire on return."""
|
||
TableSeat.objects.create(
|
||
room=self.room, gamer=self.founder, slot_number=1,
|
||
role="PC", deck_variant=self.earthman,
|
||
)
|
||
# Slot 2 still needs role (CARTE user's next seat)
|
||
TableSeat.objects.create(room=self.room, gamer=self.founder, slot_number=2)
|
||
self.founder.equipped_deck = None
|
||
self.founder.save(update_fields=["equipped_deck"])
|
||
# Simulate filled gate slots so the room renders in role-select state
|
||
for i in (1, 2):
|
||
slot = self.room.gate_slots.get(slot_number=i)
|
||
slot.gamer = self.founder
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
response = self.client.get(
|
||
reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertEqual(
|
||
response.context["equipped_deck_id"], self.earthman.id,
|
||
"Returning CARTE user should see their in-play deck reflected in "
|
||
"the role-select context so the JS guard doesn't fire.",
|
||
)
|
||
|
||
def test_role_select_context_renders_data_equipped_deck_non_empty(self):
|
||
"""Template-level check — the rendered `data-equipped-deck` attribute
|
||
should be non-empty so the JS guard at role-select.js:165 lets the
|
||
fan open."""
|
||
TableSeat.objects.create(
|
||
room=self.room, gamer=self.founder, slot_number=1,
|
||
role="PC", deck_variant=self.earthman,
|
||
)
|
||
TableSeat.objects.create(room=self.room, gamer=self.founder, slot_number=2)
|
||
self.founder.equipped_deck = None
|
||
self.founder.save(update_fields=["equipped_deck"])
|
||
for i in (1, 2):
|
||
slot = self.room.gate_slots.get(slot_number=i)
|
||
slot.gamer = self.founder
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
response = self.client.get(
|
||
reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
)
|
||
# Should NOT contain the empty-string version that triggers the guard.
|
||
self.assertNotContains(response, 'data-equipped-deck=""')
|
||
self.assertContains(response, f'data-equipped-deck="{self.earthman.id}"')
|
||
|
||
|
||
class RoomViewAllRolesFilledTest(TestCase):
|
||
"""Room view in ROLE_SELECT with all seats assigned shows SCAN SIGS button."""
|
||
def setUp(self):
|
||
import lxml.html
|
||
self.lxml = lxml.html
|
||
self.owner = User.objects.create(email="owner@test.io")
|
||
self.room = Room.objects.create(name="Test Room", owner=self.owner)
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
all_roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
|
||
for i, role in enumerate(all_roles, start=1):
|
||
user = User.objects.create(email=f"p{i}@test.io")
|
||
TableSeat.objects.create(room=self.room, gamer=user, slot_number=i, role=role)
|
||
self.client.force_login(self.owner)
|
||
|
||
def test_pick_sigs_btn_present_when_all_roles_filled(self):
|
||
response = self.client.get(reverse("epic:room", kwargs={"room_id": self.room.id}))
|
||
parsed = self.lxml.fromstring(response.content)
|
||
[_] = parsed.cssselect("#id_pick_sigs_btn")
|
||
self.assertEqual(parsed.cssselect(".card-stack"), [])
|
||
|
||
def test_pick_sigs_btn_hidden_during_role_select(self):
|
||
# Clear one role — still mid-pick, wrap must be hidden
|
||
TableSeat.objects.filter(room=self.room, slot_number=6).update(role=None)
|
||
response = self.client.get(reverse("epic:room", kwargs={"room_id": self.room.id}))
|
||
parsed = self.lxml.fromstring(response.content)
|
||
[wrap] = parsed.cssselect("#id_pick_sigs_wrap")
|
||
self.assertIn("display:none", wrap.get("style", "").replace(" ", ""))
|
||
|
||
|
||
class PickSigsViewTest(TestCase):
|
||
def setUp(self):
|
||
self.owner = User.objects.create(email="owner@test.io")
|
||
self.room = Room.objects.create(name="Test Room", owner=self.owner)
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
all_roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
|
||
for i, role in enumerate(all_roles, start=1):
|
||
user = User.objects.create(email=f"p{i}@test.io")
|
||
TableSeat.objects.create(room=self.room, gamer=user, slot_number=i, role=role)
|
||
self.client.force_login(self.owner)
|
||
self.url = reverse("epic:pick_sigs", kwargs={"room_id": self.room.id})
|
||
|
||
def test_pick_sigs_requires_login(self):
|
||
self.client.logout()
|
||
response = self.client.post(self.url)
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertIn("/accounts/login/", response.url)
|
||
|
||
def test_pick_sigs_transitions_to_sig_select(self):
|
||
self.client.post(self.url)
|
||
self.room.refresh_from_db()
|
||
self.assertEqual(self.room.table_status, Room.SIG_SELECT)
|
||
|
||
def test_pick_sigs_redirects_to_room(self):
|
||
response = self.client.post(self.url)
|
||
self.assertRedirects(response, reverse("epic:room", args=[self.room.id]))
|
||
|
||
def test_pick_sigs_is_noop_if_not_role_select(self):
|
||
self.room.table_status = Room.SIG_SELECT
|
||
self.room.save()
|
||
self.client.post(self.url)
|
||
self.room.refresh_from_db()
|
||
self.assertEqual(self.room.table_status, Room.SIG_SELECT)
|
||
|
||
def test_pick_sigs_notifies_sig_select_started(self):
|
||
with patch("apps.epic.views._notify_sig_select_started") as mock_notify:
|
||
self.client.post(self.url)
|
||
mock_notify.assert_called_once_with(self.room.id)
|
||
|
||
|
||
class RoomActionsViewTest(TestCase):
|
||
def setUp(self):
|
||
self.owner = User.objects.create(email="owner@test.io")
|
||
self.gamer = User.objects.create(email="gamer@test.io")
|
||
self.room = Room.objects.create(name="Test Room", owner=self.owner)
|
||
self.slot = self.room.gate_slots.get(slot_number=2)
|
||
self.slot.gamer = self.gamer
|
||
self.slot.status = "FILLED"
|
||
self.slot.save()
|
||
RoomInvite.objects.create(
|
||
room=self.room, inviter=self.owner,
|
||
invitee_email=self.gamer.email
|
||
)
|
||
|
||
def test_owner_delete_removes_room(self):
|
||
self.client.force_login(self.owner)
|
||
self.client.post(reverse("epic:delete_room", kwargs={"room_id": self.room.id}))
|
||
self.assertFalse(Room.objects.filter(pk=self.room.pk).exists())
|
||
|
||
def test_non_owner_delete_does_not_remove_room(self):
|
||
self.client.force_login(self.gamer)
|
||
self.client.post(reverse("epic:delete_room", kwargs={"room_id": self.room.id}))
|
||
self.assertTrue(Room.objects.filter(pk=self.room.pk).exists())
|
||
|
||
def test_delete_redirects_to_gameboard(self):
|
||
self.client.force_login(self.owner)
|
||
response = self.client.post(
|
||
reverse("epic:delete_room", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertRedirects(response, "/gameboard/")
|
||
|
||
def test_abandon_clears_slot(self):
|
||
self.client.force_login(self.gamer)
|
||
self.client.post(reverse("epic:abandon_room", kwargs={"room_id": self.room.id}))
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.status, "EMPTY")
|
||
self.assertIsNone(self.slot.gamer)
|
||
|
||
def test_abandon_deletes_pending_invite(self):
|
||
self.client.force_login(self.gamer)
|
||
self.client.post(reverse("epic:abandon_room", kwargs={"room_id": self.room.id}))
|
||
self.assertFalse(
|
||
RoomInvite.objects.filter(
|
||
room=self.room, invitee_email=self.gamer.email
|
||
).exists()
|
||
)
|
||
|
||
def test_abandon_redirects_to_gameboard(self):
|
||
self.client.force_login(self.gamer)
|
||
response = self.client.post(
|
||
reverse("epic:abandon_room", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertRedirects(response, "/gameboard/")
|
||
|
||
|
||
class ReleaseSlotViewTest(TestCase):
|
||
def setUp(self):
|
||
self.gamer = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.gamer)
|
||
owner = User.objects.create(email="owner@test.io")
|
||
self.room = Room.objects.create(name="Test Room", owner=owner)
|
||
self.slot = self.room.gate_slots.get(slot_number=1)
|
||
self.slot.gamer = self.gamer
|
||
self.slot.status = GateSlot.FILLED
|
||
self.slot.debited_token_type = Token.CARTE
|
||
self.slot.save()
|
||
|
||
def test_release_slot_downgrades_open_room_to_gathering(self):
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.save()
|
||
self.client.post(
|
||
reverse("epic:release_slot", kwargs={"room_id": self.room.id}),
|
||
data={"slot_number": self.slot.slot_number},
|
||
)
|
||
self.room.refresh_from_db()
|
||
self.assertEqual(self.room.gate_status, Room.GATHERING)
|
||
|
||
|
||
# ── Significator Selection ────────────────────────────────────────────────────
|
||
|
||
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||
|
||
|
||
def _full_sig_setUp(test_case, role_order=None):
|
||
"""Populate test_case with a SIG_SELECT room; return (room, gamers, earthman, card_in_deck).
|
||
Uses get_or_create for DeckVariant — migration data persists in TestCase."""
|
||
if role_order is None:
|
||
role_order = SIG_SEAT_ORDER[:]
|
||
earthman, _ = DeckVariant.objects.get_or_create(
|
||
slug="earthman",
|
||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||
)
|
||
founder = User.objects.create(email="founder@test.io")
|
||
gamers = [founder]
|
||
for i in range(2, 7):
|
||
gamers.append(User.objects.create(email=f"g{i}@test.io"))
|
||
for gamer in gamers:
|
||
gamer.equipped_deck = earthman
|
||
gamer.save(update_fields=["equipped_deck"])
|
||
room = Room.objects.create(name="Sig Room", owner=founder)
|
||
for i, (gamer, role) in enumerate(zip(gamers, role_order), start=1):
|
||
slot = room.gate_slots.get(slot_number=i)
|
||
slot.gamer = gamer
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
TableSeat.objects.create(
|
||
room=room, gamer=gamer, slot_number=i, role=role,
|
||
role_revealed=True, deck_variant=earthman,
|
||
)
|
||
room.gate_status = Room.OPEN
|
||
room.table_status = Room.SIG_SELECT
|
||
room.save()
|
||
card_in_deck = TarotCard.objects.get(
|
||
deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||
)
|
||
test_case.client.force_login(founder)
|
||
return room, gamers, earthman, card_in_deck
|
||
|
||
|
||
class SigSelectRenderingTest(TestCase):
|
||
"""Gate view at SIG_SELECT renders the Significator deck."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_sig_deck_element_present(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_sig_deck")
|
||
|
||
def test_sig_deck_contains_16_sig_cards_by_default(self):
|
||
"""Without Note unlocks the deck shows only 16 court cards (no Nomad/Schizo)."""
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.content.decode().count('data-card-id='), 16)
|
||
|
||
def test_nomad_note_adds_nomad_to_sig_deck(self):
|
||
Note.objects.create(user=self.gamers[0], slug="nomad", earned_at=timezone.now())
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.content.decode().count('data-card-id='), 17)
|
||
|
||
def test_schizo_note_adds_schizo_to_sig_deck(self):
|
||
Note.objects.create(user=self.gamers[0], slug="schizo", earned_at=timezone.now())
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.content.decode().count('data-card-id='), 17)
|
||
|
||
def test_super_nomad_note_also_unlocks_nomad(self):
|
||
Note.objects.create(user=self.gamers[0], slug="super-nomad", earned_at=timezone.now())
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.content.decode().count('data-card-id='), 17)
|
||
|
||
def test_super_schizo_note_also_unlocks_schizo(self):
|
||
Note.objects.create(user=self.gamers[0], slug="super-schizo", earned_at=timezone.now())
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.content.decode().count('data-card-id='), 17)
|
||
|
||
def test_both_notes_gives_18_sig_cards(self):
|
||
Note.objects.create(user=self.gamers[0], slug="nomad", earned_at=timezone.now())
|
||
Note.objects.create(user=self.gamers[0], slug="schizo", earned_at=timezone.now())
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.content.decode().count('data-card-id='), 18)
|
||
|
||
def test_seats_rendered_in_pc_nc_ec_sc_ac_bc_order(self):
|
||
response = self.client.get(self.url)
|
||
content = response.content.decode()
|
||
positions = {role: content.find(f'data-role="{role}"') for role in SIG_SEAT_ORDER}
|
||
# Every role must appear
|
||
self.assertTrue(all(pos != -1 for pos in positions.values()))
|
||
# Rendered in canonical sequence
|
||
ordered = sorted(SIG_SEAT_ORDER, key=lambda r: positions[r])
|
||
self.assertEqual(ordered, SIG_SEAT_ORDER)
|
||
|
||
def test_sig_deck_not_present_during_role_select(self):
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
response = self.client.get(self.url)
|
||
self.assertNotContains(response, "id_sig_deck")
|
||
|
||
def test_sig_cards_render_keyword_data_attributes(self):
|
||
response = self.client.get(self.url)
|
||
content = response.content.decode()
|
||
self.assertIn("data-keywords-upright=", content)
|
||
self.assertIn("data-keywords-reversed=", content)
|
||
|
||
def test_sig_stat_block_structure_rendered(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "sig-stat-block")
|
||
self.assertContains(response, "spin-btn")
|
||
self.assertContains(response, "stat-face--upright")
|
||
self.assertContains(response, "stat-face--reversed")
|
||
|
||
def test_sig_cards_render_energies_operations_data_attributes(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "data-energies=")
|
||
self.assertContains(response, "data-operations=")
|
||
|
||
def test_sig_info_panel_structure_rendered(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "sig-info")
|
||
self.assertContains(response, "fyi-btn")
|
||
self.assertContains(response, "sig-info-effect")
|
||
self.assertContains(response, "sig-info-index")
|
||
self.assertContains(response, "fyi-prev")
|
||
self.assertContains(response, "fyi-next")
|
||
|
||
|
||
class SelectSigCardViewTest(TestCase):
|
||
"""select_sig view — records choice, enforces turn order, rejects bad input."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, self.card = _full_sig_setUp(self)
|
||
# Founder is slot 1, role=PC — active first in canonical order
|
||
self.url = reverse("epic:select_sig", kwargs={"room_id": self.room.id})
|
||
|
||
def _post(self, card_id=None, client=None):
|
||
c = client or self.client
|
||
return c.post(self.url, data={"card_id": card_id or self.card.id})
|
||
|
||
def test_select_sig_records_choice_on_active_seat(self):
|
||
self._post()
|
||
seat = TableSeat.objects.get(room=self.room, role="PC")
|
||
self.assertEqual(seat.significator, self.card)
|
||
|
||
def test_select_sig_returns_200(self):
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_select_sig_wrong_turn_makes_no_change(self):
|
||
# Gamer 2 is NC — not their turn yet
|
||
self.client.force_login(self.gamers[1])
|
||
self._post()
|
||
seat = TableSeat.objects.get(room=self.room, role="NC")
|
||
self.assertIsNone(seat.significator)
|
||
|
||
def test_select_sig_wrong_turn_returns_403(self):
|
||
self.client.force_login(self.gamers[1])
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 403)
|
||
|
||
def test_select_sig_card_not_in_deck_returns_400(self):
|
||
# Create a pip card (number=5) — not in the sig deck (only court 11–14 + major 0–1)
|
||
other = TarotCard.objects.create(
|
||
deck_variant=self.earthman, arcana="MINOR", suit="BRANDS", number=5,
|
||
name="Five of Brands Test", slug="five-of-brands-test",
|
||
keywords_upright=[], keywords_reversed=[],
|
||
)
|
||
response = self._post(card_id=other.id)
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_select_sig_card_already_taken_returns_409(self):
|
||
# Another seat already holds this card as their significator
|
||
nc_seat = TableSeat.objects.get(room=self.room, role="NC")
|
||
nc_seat.significator = self.card
|
||
nc_seat.save()
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 409)
|
||
|
||
def test_select_sig_advances_active_seat_to_nc(self):
|
||
self._post()
|
||
from apps.epic.models import active_sig_seat
|
||
seat = active_sig_seat(self.room)
|
||
self.assertEqual(seat.role, "NC")
|
||
|
||
def test_select_sig_notifies_ws(self):
|
||
with patch("apps.epic.views._notify_sig_selected") as mock_notify:
|
||
self._post()
|
||
mock_notify.assert_called_once()
|
||
|
||
def test_select_sig_requires_login(self):
|
||
self.client.logout()
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertIn("/accounts/login/", response.url)
|
||
|
||
def test_select_sig_wrong_phase_redirects(self):
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
response = self._post()
|
||
self.assertRedirects(
|
||
response, reverse("epic:room", args=[self.room.id])
|
||
)
|
||
|
||
def test_select_sig_last_choice_does_not_advance_to_none(self):
|
||
"""After all 6 significators chosen, active_sig_seat() is None —
|
||
no unhandled AttributeError in the view."""
|
||
cards = list(TarotCard.objects.filter(deck_variant=self.earthman, arcana="MINOR"))
|
||
seats_in_order = list(
|
||
TableSeat.objects.filter(room=self.room).order_by("slot_number")
|
||
)
|
||
# Assign all but the last (BC) manually
|
||
for seat, card in zip(seats_in_order[:-1], cards):
|
||
seat.significator = card
|
||
seat.save()
|
||
# BC gamer POSTs the final choice
|
||
bc_seat = TableSeat.objects.get(room=self.room, role="BC")
|
||
self.client.force_login(bc_seat.gamer)
|
||
last_card = TarotCard.objects.filter(
|
||
deck_variant=self.earthman, arcana="MAJOR", number=0
|
||
).first()
|
||
response = self.client.post(self.url, data={"card_id": last_card.id})
|
||
self.assertIn(response.status_code, (200, 302))
|
||
|
||
|
||
class ConfirmTokenRecordsSlotFilledTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="gamer@test.io")
|
||
self.client.force_login(self.user)
|
||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||
self.token = Token.objects.create(user=self.user, token_type=Token.TITHE)
|
||
self.slot = self.room.gate_slots.get(slot_number=1)
|
||
self.slot.gamer = self.user
|
||
self.slot.status = GateSlot.RESERVED
|
||
self.slot.reserved_at = timezone.now()
|
||
self.slot.save()
|
||
|
||
def test_confirm_token_records_slot_filled_event(self):
|
||
session = self.client.session
|
||
session["kit_token_id"] = str(self.token.id)
|
||
session.save()
|
||
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
|
||
event = GameEvent.objects.get(room=self.room, verb=GameEvent.SLOT_FILLED)
|
||
self.assertEqual(event.actor, self.user)
|
||
self.assertEqual(event.data["slot_number"], 1)
|
||
self.assertEqual(event.data["token_type"], Token.TITHE)
|
||
|
||
def test_no_event_recorded_if_no_reserved_slot(self):
|
||
self.slot.gamer = None
|
||
self.slot.status = GateSlot.EMPTY
|
||
self.slot.save()
|
||
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
|
||
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.SLOT_FILLED).count(), 0)
|
||
|
||
|
||
class SelectRoleRecordsRoleSelectedTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="player@test.io")
|
||
self.client.force_login(self.user)
|
||
self.room = Room.objects.create(
|
||
name="Role Room", owner=self.user, table_status=Room.ROLE_SELECT
|
||
)
|
||
self.seat = TableSeat.objects.create(
|
||
room=self.room, gamer=self.user, slot_number=1
|
||
)
|
||
|
||
def test_select_role_records_role_selected_event(self):
|
||
self.client.post(
|
||
reverse("epic:select_role", args=[self.room.id]),
|
||
data={"role": "PC"},
|
||
)
|
||
event = GameEvent.objects.get(room=self.room, verb=GameEvent.ROLE_SELECTED)
|
||
self.assertEqual(event.actor, self.user)
|
||
self.assertEqual(event.data["role"], "PC")
|
||
self.assertEqual(event.data["slot_number"], 1)
|
||
|
||
def test_no_event_if_role_already_taken(self):
|
||
TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=2, role="PC")
|
||
self.client.post(
|
||
reverse("epic:select_role", args=[self.room.id]),
|
||
data={"role": "PC"},
|
||
)
|
||
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.ROLE_SELECTED).count(), 0)
|
||
|
||
|
||
# ── sig_reserve view ──────────────────────────────────────────────────────────
|
||
|
||
class SigReserveViewTest(TestCase):
|
||
"""sig_reserve — provisional card hold; OK/NVM flow."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, self.card = _full_sig_setUp(self)
|
||
# founder (gamers[0]) is PC — levity polarity
|
||
self.client.force_login(self.gamers[0])
|
||
self.url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})
|
||
|
||
def _reserve(self, card_id=None, action="reserve", client=None):
|
||
c = client or self.client
|
||
return c.post(self.url, data={
|
||
"card_id": card_id or self.card.id,
|
||
"action": action,
|
||
})
|
||
|
||
# ── happy-path reserve ────────────────────────────────────────────────
|
||
|
||
def test_reserve_creates_sig_reservation(self):
|
||
self._reserve()
|
||
self.assertTrue(SigReservation.objects.filter(
|
||
room=self.room, gamer=self.gamers[0], card=self.card
|
||
).exists())
|
||
|
||
def test_reserve_returns_200(self):
|
||
response = self._reserve()
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_reservation_has_correct_polarity(self):
|
||
self._reserve()
|
||
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
||
self.assertEqual(res.polarity, "levity")
|
||
|
||
def test_gravity_gamer_reservation_has_gravity_polarity(self):
|
||
# gamers[3] is SC (index 3 → role SC → but _full_sig_setUp uses SIG_SEAT_ORDER
|
||
# which assigns PC→NC→EC→SC→AC→BC, so slot 4 = SC, slot 5 = AC, slot 6 = BC)
|
||
# gamers[5] is BC → gravity
|
||
bc_client = self.client.__class__()
|
||
bc_client.force_login(self.gamers[5])
|
||
self._reserve(client=bc_client)
|
||
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[5])
|
||
self.assertEqual(res.polarity, "gravity")
|
||
|
||
# ── conflict handling ─────────────────────────────────────────────────
|
||
|
||
def test_reserve_taken_card_same_polarity_returns_409(self):
|
||
# NC (gamers[1]) reserves the same card first — both are levity
|
||
nc_client = self.client.__class__()
|
||
nc_client.force_login(self.gamers[1])
|
||
self._reserve(client=nc_client)
|
||
# Now PC tries to grab the same card — should be blocked
|
||
response = self._reserve()
|
||
self.assertEqual(response.status_code, 409)
|
||
|
||
def test_reserve_taken_card_cross_polarity_succeeds(self):
|
||
# BC (gamers[5], gravity) reserves the same card — different polarity, allowed
|
||
bc_client = self.client.__class__()
|
||
bc_client.force_login(self.gamers[5])
|
||
self._reserve(client=bc_client)
|
||
response = self._reserve() # PC (levity) grabs same card
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_reserve_different_card_while_holding_returns_409(self):
|
||
"""Cannot OK a different card while holding one — must NVM first."""
|
||
card_b = TarotCard.objects.filter(
|
||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12
|
||
).first()
|
||
self._reserve() # PC grabs card A → 200
|
||
response = self._reserve(card_id=card_b.id) # tries card B → 409
|
||
self.assertEqual(response.status_code, 409)
|
||
# Original reservation still intact
|
||
reservations = SigReservation.objects.filter(room=self.room, gamer=self.gamers[0])
|
||
self.assertEqual(reservations.count(), 1)
|
||
self.assertEqual(reservations.first().card, self.card)
|
||
|
||
def test_reserve_same_card_again_is_idempotent(self):
|
||
"""Re-POSTing the same card while already holding it returns 200 (no-op)."""
|
||
self._reserve()
|
||
response = self._reserve() # same card again
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertEqual(
|
||
SigReservation.objects.filter(room=self.room, gamer=self.gamers[0]).count(), 1
|
||
)
|
||
|
||
def test_reserve_blocked_then_unblocked_after_release(self):
|
||
"""After NVM, a new card can be OK'd."""
|
||
card_b = TarotCard.objects.filter(
|
||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12
|
||
).first()
|
||
self._reserve() # hold card A
|
||
self._reserve(action="release") # NVM
|
||
response = self._reserve(card_id=card_b.id) # now card B → 200
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertTrue(SigReservation.objects.filter(
|
||
room=self.room, gamer=self.gamers[0], card=card_b
|
||
).exists())
|
||
|
||
# ── release ───────────────────────────────────────────────────────────
|
||
|
||
def test_release_deletes_reservation(self):
|
||
self._reserve()
|
||
self._reserve(action="release")
|
||
self.assertFalse(SigReservation.objects.filter(
|
||
room=self.room, gamer=self.gamers[0]
|
||
).exists())
|
||
|
||
def test_release_returns_200(self):
|
||
self._reserve()
|
||
response = self._reserve(action="release")
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_release_with_no_reservation_still_200(self):
|
||
"""NVM when nothing held is harmless."""
|
||
response = self._reserve(action="release")
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_release_while_ready_records_sig_unready(self):
|
||
"""Releasing a ready reservation implicitly acts as WAIT NVM and records SIG_UNREADY."""
|
||
self._reserve()
|
||
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
||
res.ready = True
|
||
res.save()
|
||
self._reserve(action="release")
|
||
self.assertTrue(self.room.events.filter(
|
||
actor=self.gamers[0], verb=GameEvent.SIG_UNREADY
|
||
).exists())
|
||
|
||
# ── guards ────────────────────────────────────────────────────────────
|
||
|
||
def test_reserve_non_post_returns_405(self):
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 405)
|
||
|
||
def test_reserve_requires_login(self):
|
||
self.client.logout()
|
||
response = self._reserve()
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertIn("/accounts/login/", response.url)
|
||
|
||
def test_reserve_requires_seated_gamer(self):
|
||
outsider = User.objects.create(email="outsider@test.io")
|
||
outsider_client = self.client.__class__()
|
||
outsider_client.force_login(outsider)
|
||
response = self._reserve(client=outsider_client)
|
||
self.assertEqual(response.status_code, 403)
|
||
|
||
def test_reserve_wrong_phase_returns_400(self):
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
response = self._reserve()
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_reserve_broadcasts_ws(self):
|
||
with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
|
||
self._reserve()
|
||
mock_notify.assert_called_once()
|
||
|
||
def test_release_broadcasts_ws(self):
|
||
self._reserve()
|
||
with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
|
||
self._reserve(action="release")
|
||
mock_notify.assert_called_once()
|
||
|
||
def test_release_broadcasts_card_id_so_second_browser_can_clear_it(self):
|
||
"""WS release event must include the card_id; otherwise the receiving
|
||
browser can't find the card element to remove .sig-reserved--own."""
|
||
self._reserve()
|
||
with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
|
||
self._reserve(action="release")
|
||
args, kwargs = mock_notify.call_args
|
||
self.assertEqual(args[1], self.card.pk) # card_id must not be None
|
||
self.assertFalse(kwargs['reserved']) # reserved=False
|
||
|
||
|
||
# ── sig_ready view ────────────────────────────────────────────────────────────
|
||
|
||
def _make_levity_reservations(room, gamers, earthman, ready=False):
|
||
"""Create SigReservations for the three levity gamers (PC, NC, SC).
|
||
Returns the three reservations in PC→NC→SC order."""
|
||
cards = [
|
||
TarotCard.objects.get(deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=n)
|
||
for n in (11, 12, 13)
|
||
]
|
||
roles = ["PC", "NC", "SC"]
|
||
# gamers[0]=PC, gamers[1]=NC, gamers[3]=SC
|
||
gamer_indices = [0, 1, 3]
|
||
reservations = []
|
||
for gamer_idx, role, card in zip(gamer_indices, roles, cards):
|
||
seat = TableSeat.objects.get(room=room, role=role)
|
||
res = SigReservation.objects.create(
|
||
room=room, gamer=gamers[gamer_idx], card=card,
|
||
role=role, polarity="levity", seat=seat, ready=ready,
|
||
)
|
||
reservations.append(res)
|
||
return reservations
|
||
|
||
|
||
class SigReadyViewTest(TestCase):
|
||
"""sig_ready — toggle ready/unready for the polarity-room countdown."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||
self.reservations = _make_levity_reservations(self.room, self.gamers, self.earthman)
|
||
self.url = reverse("epic:sig_ready", kwargs={"room_id": self.room.id})
|
||
|
||
def _post(self, action="ready", seconds_remaining=None, client=None):
|
||
c = client or self.client
|
||
data = {"action": action}
|
||
if seconds_remaining is not None:
|
||
data["seconds_remaining"] = seconds_remaining
|
||
return c.post(self.url, data=data)
|
||
|
||
# ── guards ────────────────────────────────────────────────────────────
|
||
|
||
def test_sig_ready_non_post_returns_405(self):
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 405)
|
||
|
||
def test_sig_ready_requires_login(self):
|
||
self.client.logout()
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertIn("/accounts/login/", response.url)
|
||
|
||
def test_sig_ready_requires_seated_gamer(self):
|
||
outsider = User.objects.create(email="outsider@test.io")
|
||
outsider_client = self.client.__class__()
|
||
outsider_client.force_login(outsider)
|
||
response = self._post(client=outsider_client)
|
||
self.assertEqual(response.status_code, 403)
|
||
|
||
def test_sig_ready_wrong_phase_returns_400(self):
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_sig_ready_without_reservation_returns_400(self):
|
||
"""Can't go ready without an OK'd card."""
|
||
SigReservation.objects.filter(room=self.room, gamer=self.gamers[0]).delete()
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
# ── happy-path ready ──────────────────────────────────────────────────
|
||
|
||
def test_sig_ready_sets_ready_true_on_reservation(self):
|
||
self._post(action="ready")
|
||
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
||
self.assertTrue(res.ready)
|
||
|
||
def test_sig_ready_returns_200(self):
|
||
response = self._post(action="ready")
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_sig_ready_already_ready_is_idempotent(self):
|
||
"""Re-posting ready when already ready returns 200 without re-triggering countdown."""
|
||
self.reservations[0].ready = True
|
||
self.reservations[0].save()
|
||
response = self._post(action="ready")
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
# ── unready ──────────────────────────────────────────────────────────
|
||
|
||
def test_sig_unready_sets_ready_false(self):
|
||
self.reservations[0].ready = True
|
||
self.reservations[0].save()
|
||
self._post(action="unready")
|
||
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
||
self.assertFalse(res.ready)
|
||
|
||
def test_sig_unready_when_not_ready_is_harmless(self):
|
||
response = self._post(action="unready")
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
# ── countdown mechanics ───────────────────────────────────────────────
|
||
|
||
def test_sig_ready_broadcasts_countdown_start_when_all_three_polarity_ready(self):
|
||
"""When all three levity gamers are ready, countdown_start broadcasts."""
|
||
# Make NC and SC ready first
|
||
for res in self.reservations[1:]:
|
||
res.ready = True
|
||
res.save()
|
||
# PC (founder) goes ready — triggers all-three condition
|
||
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
||
self._post(action="ready")
|
||
mock_notify.assert_called_once()
|
||
args = mock_notify.call_args[0]
|
||
self.assertIn("levity", args) # polarity in call
|
||
|
||
def test_sig_ready_does_not_broadcast_countdown_when_only_two_ready(self):
|
||
self.reservations[1].ready = True
|
||
self.reservations[1].save()
|
||
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
||
self._post(action="ready")
|
||
mock_notify.assert_not_called()
|
||
|
||
def test_sig_unready_invalid_seconds_defaults_to_12(self):
|
||
"""Non-numeric seconds_remaining falls back to 12."""
|
||
self.reservations[0].ready = True
|
||
self.reservations[0].save()
|
||
self._post(action="unready", seconds_remaining="abc")
|
||
self.reservations[0].refresh_from_db()
|
||
self.assertEqual(self.reservations[0].countdown_remaining, 12)
|
||
|
||
def test_sig_unready_saves_seconds_remaining_on_all_polarity_reservations(self):
|
||
for res in self.reservations:
|
||
res.ready = True
|
||
res.save()
|
||
self._post(action="unready", seconds_remaining=7)
|
||
for res in self.reservations:
|
||
res.refresh_from_db()
|
||
self.assertEqual(res.countdown_remaining, 7)
|
||
|
||
def test_sig_unready_broadcasts_countdown_cancel(self):
|
||
for res in self.reservations:
|
||
res.ready = True
|
||
res.save()
|
||
with patch("apps.epic.views._notify_countdown_cancel") as mock_notify:
|
||
self._post(action="unready", seconds_remaining=7)
|
||
mock_notify.assert_called_once()
|
||
|
||
def test_sig_ready_uses_saved_seconds_for_countdown_restart(self):
|
||
"""If countdown_remaining is saved (e.g. 7), countdown_start sends 7 not 12."""
|
||
for res in self.reservations:
|
||
res.ready = True
|
||
res.countdown_remaining = 7
|
||
res.save()
|
||
# One unreadied; now goes ready again — all 3 ready → start from 7
|
||
self.reservations[0].ready = False
|
||
self.reservations[0].save()
|
||
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
||
self._post(action="ready")
|
||
mock_notify.assert_called_once()
|
||
args, kwargs = mock_notify.call_args
|
||
seconds_sent = kwargs.get("seconds") or args[1]
|
||
self.assertEqual(seconds_sent, 7)
|
||
|
||
|
||
# ── sig_confirm view ──────────────────────────────────────────────────────────
|
||
|
||
def _make_gravity_reservations(room, gamers, earthman, ready=False):
|
||
"""Create SigReservations for the three gravity gamers (EC, AC, BC)."""
|
||
cards = [
|
||
TarotCard.objects.get(deck_variant=earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
||
for n in (11, 12, 13)
|
||
]
|
||
roles = ["EC", "AC", "BC"]
|
||
# gamers[2]=EC, gamers[4]=AC, gamers[5]=BC
|
||
gamer_indices = [2, 4, 5]
|
||
reservations = []
|
||
for gamer_idx, role, card in zip(gamer_indices, roles, cards):
|
||
seat = TableSeat.objects.get(room=room, role=role)
|
||
res = SigReservation.objects.create(
|
||
room=room, gamer=gamers[gamer_idx], card=card,
|
||
role=role, polarity="gravity", seat=seat, ready=ready,
|
||
)
|
||
reservations.append(res)
|
||
return reservations
|
||
|
||
|
||
class SigConfirmViewTest(TestCase):
|
||
"""sig_confirm — finalize polarity group once countdown reaches zero."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||
# All three levity gamers are ready
|
||
self.lev_res = _make_levity_reservations(
|
||
self.room, self.gamers, self.earthman, ready=True
|
||
)
|
||
# founder (PC) is already logged in from _full_sig_setUp
|
||
self.url = reverse("epic:sig_confirm", kwargs={"room_id": self.room.id})
|
||
|
||
def _post(self, polarity="levity", client=None):
|
||
c = client or self.client
|
||
return c.post(self.url, data={"polarity": polarity})
|
||
|
||
# ── guards ────────────────────────────────────────────────────────────
|
||
|
||
def test_sig_confirm_non_post_returns_405(self):
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 405)
|
||
|
||
def test_sig_confirm_requires_login(self):
|
||
self.client.logout()
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertIn("/accounts/login/", response.url)
|
||
|
||
def test_sig_confirm_requires_seated_gamer(self):
|
||
outsider = User.objects.create(email="outsider@test.io")
|
||
outsider_client = self.client.__class__()
|
||
outsider_client.force_login(outsider)
|
||
response = self._post(client=outsider_client)
|
||
self.assertEqual(response.status_code, 403)
|
||
|
||
def test_sig_confirm_wrong_phase_returns_400(self):
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_sig_confirm_not_all_polarity_ready_returns_400(self):
|
||
"""If any of the three in the polarity group isn't ready, reject."""
|
||
self.lev_res[1].ready = False
|
||
self.lev_res[1].save()
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
# ── happy-path ────────────────────────────────────────────────────────
|
||
|
||
def test_sig_confirm_sets_significator_on_seats_from_reservations(self):
|
||
self._post()
|
||
for res in self.lev_res:
|
||
seat = TableSeat.objects.get(room=self.room, role=res.role)
|
||
self.assertEqual(seat.significator, res.card)
|
||
|
||
def test_sig_confirm_returns_200(self):
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_sig_confirm_broadcasts_polarity_room_done(self):
|
||
with patch("apps.epic.views._notify_polarity_room_done") as mock_notify:
|
||
self._post()
|
||
mock_notify.assert_called_once()
|
||
args = mock_notify.call_args[0]
|
||
self.assertIn("levity", args)
|
||
|
||
def test_sig_confirm_is_idempotent_if_significators_already_set(self):
|
||
"""Second call from another browser returns 200 without re-running logic."""
|
||
self._post()
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
# ── both polarities done ──────────────────────────────────────────────
|
||
|
||
def test_sig_confirm_broadcasts_pick_sky_available_when_both_polarities_done(self):
|
||
"""After both levity and gravity confirm, pick_sky_available fires."""
|
||
# Pre-set gravity seats to already have significators (simulating earlier confirm)
|
||
grav_cards = [
|
||
TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
||
for n in (11, 12, 13)
|
||
]
|
||
for role, card in zip(["EC", "AC", "BC"], grav_cards):
|
||
seat = TableSeat.objects.get(room=self.room, role=role)
|
||
seat.significator = card
|
||
seat.save()
|
||
with patch("apps.epic.views._notify_pick_sky_available") as mock_notify:
|
||
self._post(polarity="levity")
|
||
mock_notify.assert_called_once()
|
||
|
||
def test_sig_confirm_sets_room_to_sky_select_when_both_polarities_done(self):
|
||
grav_cards = [
|
||
TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
||
for n in (11, 12, 13)
|
||
]
|
||
for role, card in zip(["EC", "AC", "BC"], grav_cards):
|
||
seat = TableSeat.objects.get(room=self.room, role=role)
|
||
seat.significator = card
|
||
seat.save()
|
||
self._post(polarity="levity")
|
||
self.room.refresh_from_db()
|
||
self.assertEqual(self.room.table_status, Room.SKY_SELECT)
|
||
|
||
def test_sig_confirm_does_not_broadcast_pick_sky_available_when_only_one_polarity_done(self):
|
||
with patch("apps.epic.views._notify_pick_sky_available") as mock_notify:
|
||
self._post(polarity="levity")
|
||
mock_notify.assert_not_called()
|
||
|
||
|
||
# ── SKY_SELECT rendering ──────────────────────────────────────────────────────
|
||
|
||
class PickSkyRenderingTest(TestCase):
|
||
"""Room page at SKY_SELECT renders CAST SKY btn and sig card in tray cell 2."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
self.sig_card = TarotCard.objects.get(
|
||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||
)
|
||
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||
pc_seat.significator = self.sig_card
|
||
pc_seat.save()
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_pick_sky_btn_present_in_sky_select_phase(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_pick_sky_btn")
|
||
|
||
def test_tray_cell_2_contains_sig_card_icon_in_sky_select(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "tray-sig-card")
|
||
|
||
def test_pick_sky_btn_hidden_during_sig_select(self):
|
||
# Rendered hidden (display:none) so JS can reveal it on pick_sky_available WS event
|
||
self.room.table_status = Room.SIG_SELECT
|
||
self.room.save()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, 'id="id_pick_sky_btn"')
|
||
self.assertContains(response, 'style="display:none"')
|
||
|
||
def test_sky_delete_clears_seat_character_and_returns_json(self):
|
||
"""POST epic:sky_delete clears any Character on the requesting gamer's
|
||
seat — both unconfirmed drafts AND confirmed ones (the latter case is
|
||
why un-saved-via-DEL data was rehydrating on refresh: a SAVE SKY click
|
||
confirms a Character, and only that seat's Character row is the durable
|
||
target the in-room DEL has to purge)."""
|
||
# Seed both a draft & a confirmed Character — DEL must clear them both
|
||
from apps.epic.models import Character
|
||
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||
# Confirmed (the SAVE SKY case)
|
||
confirmed = Character.objects.create(
|
||
seat=pc_seat,
|
||
chart_data={"planets": {"Sun": {"sign": "Gemini"}}},
|
||
confirmed_at=timezone.now(),
|
||
)
|
||
url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id})
|
||
response = self.client.post(url)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertJSONEqual(response.content, {"deleted": True})
|
||
self.assertFalse(
|
||
Character.objects.filter(seat=pc_seat, retired_at__isnull=True).exists(),
|
||
"Both draft and confirmed Characters on the seat should be gone",
|
||
)
|
||
|
||
def test_sky_delete_405_on_get(self):
|
||
url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id})
|
||
self.assertEqual(self.client.get(url).status_code, 405)
|
||
|
||
def test_sky_delete_requires_seat_owner(self):
|
||
"""A gamer who isn't seated at this room can't purge another seat."""
|
||
outsider = User.objects.create(email="outsider@test.io")
|
||
self.client.force_login(outsider)
|
||
url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id})
|
||
self.assertEqual(self.client.post(url).status_code, 403)
|
||
|
||
def test_sky_delete_does_not_touch_user_model(self):
|
||
"""In-room DEL targets the seat's Character, never the User-level
|
||
sky_chart_data. (The Dashsky / My Sky applet DEL is the one that
|
||
clears the user's saved sky.)"""
|
||
founder = self.gamers[0]
|
||
founder.sky_chart_data = {"planets": {"Sun": {"sign": "Gemini"}}}
|
||
founder.sky_birth_tz = "America/New_York"
|
||
founder.save()
|
||
url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id})
|
||
self.client.post(url)
|
||
founder.refresh_from_db()
|
||
self.assertEqual(founder.sky_chart_data, {"planets": {"Sun": {"sign": "Gemini"}}})
|
||
self.assertEqual(founder.sky_birth_tz, "America/New_York")
|
||
|
||
def test_no_sky_delete_btn_in_blank_sky_select_modal(self):
|
||
"""A fresh CAST SKY modal (no preview wheel rendered yet) must not
|
||
carry the DEL btn — it would otherwise float in the empty wheel area
|
||
suggesting there's something to delete when the user has only seen
|
||
the form. The JS schedulePreview success handler is the contract that
|
||
injects the btn after the wheel paints — so the rendered HTML should
|
||
carry no <button id="id_sky_delete_btn"> markup. (The literal string
|
||
does still appear inside the inline <script> that does the injection,
|
||
so the assertion targets the rendered attribute syntax, not the bare
|
||
identifier.)"""
|
||
response = self.client.get(self.url)
|
||
self.assertNotContains(response, 'id="id_sky_delete_btn"')
|
||
|
||
|
||
# ── SEA_SELECT rendering ──────────────────────────────────────────────────────
|
||
|
||
class PickSeaRenderingTest(TestCase):
|
||
"""At SKY_SELECT, a confirmed Character swaps CAST SKY → DRAW SEA + sea overlay."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
self.sig_card = TarotCard.objects.get(
|
||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||
)
|
||
self.pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||
self.pc_seat.significator = self.sig_card
|
||
self.pc_seat.save()
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def _confirm_sky(self, seat=None):
|
||
target = seat or self.pc_seat
|
||
return Character.objects.create(seat=target, confirmed_at=timezone.now())
|
||
|
||
def test_sky_confirmed_false_when_no_character(self):
|
||
response = self.client.get(self.url)
|
||
self.assertFalse(response.context["sky_confirmed"])
|
||
|
||
def test_sky_confirmed_true_when_character_confirmed(self):
|
||
self._confirm_sky()
|
||
response = self.client.get(self.url)
|
||
self.assertTrue(response.context["sky_confirmed"])
|
||
|
||
def test_pick_sea_btn_shown_when_sky_confirmed(self):
|
||
self._confirm_sky()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_pick_sea_btn")
|
||
|
||
def test_pick_sky_btn_shown_when_sky_not_confirmed(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_pick_sky_btn")
|
||
|
||
def test_sea_overlay_included_when_sky_confirmed(self):
|
||
self._confirm_sky()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_sea_overlay")
|
||
|
||
def test_sea_overlay_select_defaults_to_waite_smith_for_levity(self):
|
||
self._confirm_sky()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "Celtic Cross, Waite-Smith")
|
||
|
||
def test_sea_overlay_select_defaults_to_escape_velocity_for_gravity(self):
|
||
ec_gamer = self.gamers[2] # EC — gravity
|
||
self.client.force_login(ec_gamer)
|
||
ec_seat = TableSeat.objects.get(room=self.room, role="EC")
|
||
self._confirm_sky(seat=ec_seat)
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "Celtic Cross, Escape Velocity")
|
||
|
||
def test_user_polarity_in_context_at_sky_select(self):
|
||
response = self.client.get(self.url)
|
||
self.assertIn("user_polarity", response.context)
|
||
self.assertEqual(response.context["user_polarity"], "levity") # PC is levity
|
||
|
||
def test_my_tray_sig_falls_back_to_seat_when_char_sig_is_none(self):
|
||
"""Characters created before the sig-sync fix have significator=None; fall back to seat."""
|
||
Character.objects.create(
|
||
seat=self.pc_seat,
|
||
significator=None,
|
||
confirmed_at=timezone.now(),
|
||
)
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.context["my_tray_sig"], self.sig_card)
|
||
|
||
def test_my_tray_sig_comes_from_character_significator_when_confirmed(self):
|
||
"""When sky_confirmed, my_tray_sig reads from Character.significator (not TableSeat)."""
|
||
char = Character.objects.create(
|
||
seat=self.pc_seat,
|
||
significator=self.sig_card,
|
||
confirmed_at=timezone.now(),
|
||
)
|
||
# Give the seat a *different* sig card so we can distinguish the sources
|
||
other_card = TarotCard.objects.get(
|
||
deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=11
|
||
)
|
||
self.pc_seat.significator = other_card
|
||
self.pc_seat.save()
|
||
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.context["my_tray_sig"], char.significator)
|
||
self.assertNotEqual(response.context["my_tray_sig"], other_card)
|
||
|
||
|
||
# ── select_role GET redirect ──────────────────────────────────────────────────
|
||
|
||
class SelectRoleGetRedirectTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="gamer@sr.io")
|
||
self.client.force_login(self.user)
|
||
self.room = Room.objects.create(name="R", owner=self.user)
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
|
||
def test_get_redirects_to_room(self):
|
||
response = self.client.get(reverse("epic:select_role", kwargs={"room_id": self.room.id}))
|
||
self.assertRedirects(response, reverse("epic:room", kwargs={"room_id": self.room.id}),
|
||
fetch_redirect_response=False)
|
||
|
||
|
||
# ── sig_reserve / sig_ready / sig_confirm / select_sig helpers ────────────────
|
||
|
||
def _make_sig_room(owner, *extra_gamers):
|
||
room = Room.objects.create(name="SR", owner=owner)
|
||
seat_map = {}
|
||
gamers = [owner] + list(extra_gamers)
|
||
roles = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||
for i, (gamer, role) in enumerate(zip(gamers, roles), start=1):
|
||
seat = TableSeat.objects.create(room=room, gamer=gamer, slot_number=i, role=role)
|
||
seat_map[role] = seat
|
||
room.table_status = Room.SIG_SELECT
|
||
room.save()
|
||
return room, seat_map
|
||
|
||
|
||
class SelectSigViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="pc@selsig.io")
|
||
self.client.force_login(self.user)
|
||
self.room, self.seats = _make_sig_room(self.user)
|
||
|
||
def test_non_post_redirects(self):
|
||
response = self.client.get(reverse("epic:select_sig", kwargs={"room_id": self.room.id}))
|
||
self.assertEqual(response.status_code, 302)
|
||
|
||
def test_nonexistent_card_returns_400(self):
|
||
response = self.client.post(
|
||
reverse("epic:select_sig", kwargs={"room_id": self.room.id}),
|
||
{"card_id": "99999999"},
|
||
)
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
|
||
# ── sky_preview (epic) ──────────────────────────────────────────────────────
|
||
|
||
class SkyPreviewViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="pc@sky.io")
|
||
self.client.force_login(self.user)
|
||
self.room, _ = _make_sig_room(self.user)
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
self.url = reverse("epic:sky_preview", kwargs={"room_id": self.room.id})
|
||
|
||
def test_missing_params_returns_400(self):
|
||
response = self.client.get(self.url, {"date": "1990-06-15"})
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_non_numeric_lat_returns_400(self):
|
||
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "abc", "lon": "0"})
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_out_of_range_lat_returns_400(self):
|
||
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "999", "lon": "0"})
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_invalid_tz_string_returns_400(self):
|
||
response = self.client.get(
|
||
self.url,
|
||
{"date": "1990-06-15", "lat": "51.5", "lon": "-0.1", "tz": "Not/Real"},
|
||
)
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_bad_date_format_returns_400(self):
|
||
response = self.client.get(
|
||
self.url,
|
||
{"date": "baddate", "time": "09:00", "lat": "51.5", "lon": "-0.1", "tz": "UTC"},
|
||
)
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
@patch("apps.epic.views.http_requests")
|
||
def test_pyswiss_failure_returns_502(self, mock_requests):
|
||
from unittest.mock import MagicMock
|
||
tz_r = MagicMock()
|
||
tz_r.json.return_value = {"timezone": "UTC"}
|
||
tz_r.raise_for_status = MagicMock()
|
||
chart_r = MagicMock()
|
||
chart_r.raise_for_status.side_effect = Exception("timeout")
|
||
mock_requests.get.side_effect = [tz_r, chart_r]
|
||
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
|
||
self.assertEqual(response.status_code, 502)
|
||
|
||
@patch("apps.epic.views.http_requests")
|
||
def test_success_returns_chart_distinctions_timezone(self, mock_requests):
|
||
from unittest.mock import MagicMock
|
||
payload = {
|
||
"planets": {"Sun": {"degree": 84.5}},
|
||
"houses": {"cusps": [0] * 12},
|
||
"elements": {"Earth": 1},
|
||
"house_system": "O",
|
||
}
|
||
tz_r = MagicMock()
|
||
tz_r.json.return_value = {"timezone": "Europe/London"}
|
||
tz_r.raise_for_status = MagicMock()
|
||
ch_r = MagicMock()
|
||
ch_r.json.return_value = payload
|
||
ch_r.raise_for_status = MagicMock()
|
||
mock_requests.get.side_effect = [tz_r, ch_r]
|
||
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
|
||
self.assertEqual(response.status_code, 200)
|
||
data = response.json()
|
||
self.assertIn("distinctions", data)
|
||
self.assertIn("Stone", data["elements"])
|
||
self.assertNotIn("Earth", data["elements"])
|
||
|
||
|
||
# ── tarot_deal ────────────────────────────────────────────────────────────────
|
||
|
||
class TarotDealViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="dealer@test.io")
|
||
self.client.force_login(self.user)
|
||
self.room, _ = _make_sig_room(self.user)
|
||
|
||
def test_non_post_redirects_to_tarot_deck(self):
|
||
response = self.client.get(
|
||
reverse("epic:tarot_deal", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertRedirects(
|
||
response,
|
||
reverse("epic:tarot_deck", kwargs={"room_id": self.room.id}),
|
||
fetch_redirect_response=False,
|
||
)
|
||
|
||
|
||
# ── sky_save (epic) ─────────────────────────────────────────────────────────
|
||
|
||
class SkySaveViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="pc@skysave.io")
|
||
self.client.force_login(self.user)
|
||
self.room, _ = _make_sig_room(self.user)
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
self.url = reverse("epic:sky_save", kwargs={"room_id": self.room.id})
|
||
|
||
def _post(self, payload):
|
||
import json as _json
|
||
return self.client.post(self.url, data=_json.dumps(payload), content_type="application/json")
|
||
|
||
def test_get_returns_405(self):
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 405)
|
||
|
||
def test_invalid_json_returns_400(self):
|
||
response = self.client.post(self.url, data="not json", content_type="application/json")
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_save_draft_returns_id_and_not_confirmed(self):
|
||
response = self._post({
|
||
"birth_dt": "1990-06-15T09:00:00Z",
|
||
"birth_lat": 51.5,
|
||
"birth_lon": -0.1,
|
||
"birth_place": "London",
|
||
"house_system": "O",
|
||
"chart_data": {},
|
||
})
|
||
self.assertEqual(response.status_code, 200)
|
||
data = response.json()
|
||
self.assertIn("id", data)
|
||
self.assertFalse(data["confirmed"])
|
||
|
||
def test_confirm_action_locks_character(self):
|
||
response = self._post({
|
||
"birth_dt": "1990-06-15T09:00:00Z",
|
||
"birth_lat": 51.5,
|
||
"birth_lon": -0.1,
|
||
"birth_place": "",
|
||
"house_system": "O",
|
||
"chart_data": {},
|
||
"action": "confirm",
|
||
})
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertTrue(response.json()["confirmed"])
|
||
|
||
def test_confirm_records_sky_saved_event_with_top_capacitors(self):
|
||
"""When action=confirm, log a SKY_SAVED GameEvent w. the highest-count
|
||
capacitor name(s) so the billscroll can render the new prose."""
|
||
from apps.drama.models import GameEvent
|
||
chart = {
|
||
"elements": {
|
||
# Earthman uses 6 elements; canonical names map to capacitors:
|
||
# Fire→Ardor Stone→Ossum Air→Pneuma Water→Humor Time→Tempo Space→Nexus.
|
||
"Fire": 3,
|
||
"Stone": 1,
|
||
"Air": 2,
|
||
"Water": 0,
|
||
"Time": 1,
|
||
"Space": 1,
|
||
}
|
||
}
|
||
self._post({
|
||
"birth_dt": "1990-06-15T09:00:00Z",
|
||
"birth_lat": 51.5, "birth_lon": -0.1,
|
||
"birth_place": "", "house_system": "O",
|
||
"chart_data": chart, "action": "confirm",
|
||
})
|
||
event = GameEvent.objects.get(room=self.room, verb=GameEvent.SKY_SAVED)
|
||
self.assertEqual(event.actor, self.user)
|
||
self.assertEqual(event.data.get("top_capacitors"), ["Ardor"])
|
||
|
||
def test_confirm_records_sky_saved_event_with_two_way_tie(self):
|
||
from apps.drama.models import GameEvent
|
||
chart = {
|
||
"elements": {
|
||
"Fire": 3, "Stone": 3, # tied at top
|
||
"Air": 2, "Water": 0, "Time": 1, "Space": 1,
|
||
}
|
||
}
|
||
self._post({
|
||
"birth_dt": "1990-06-15T09:00:00Z",
|
||
"birth_lat": 51.5, "birth_lon": -0.1,
|
||
"birth_place": "", "house_system": "O",
|
||
"chart_data": chart, "action": "confirm",
|
||
})
|
||
event = GameEvent.objects.get(room=self.room, verb=GameEvent.SKY_SAVED)
|
||
# Order follows the canonical ELEMENT_ORDER (Fire, Stone, Time, Space, Air, Water)
|
||
self.assertEqual(event.data.get("top_capacitors"), ["Ardor", "Ossum"])
|
||
|
||
def test_save_without_confirm_does_not_record_sky_saved_event(self):
|
||
from apps.drama.models import GameEvent
|
||
self._post({
|
||
"birth_dt": "1990-06-15T09:00:00Z",
|
||
"birth_lat": 51.5, "birth_lon": -0.1,
|
||
"birth_place": "", "house_system": "O",
|
||
"chart_data": {"elements": {"Fire": 3}},
|
||
# no action=confirm — just a draft save
|
||
})
|
||
self.assertFalse(
|
||
GameEvent.objects.filter(room=self.room, verb=GameEvent.SKY_SAVED).exists()
|
||
)
|
||
|
||
def test_confirm_with_dict_shaped_elements_extracts_count(self):
|
||
"""Some chart payloads enrich each element to {count, contributors};
|
||
sky_save should read .count rather than treating the dict as a value."""
|
||
from apps.drama.models import GameEvent
|
||
chart = {
|
||
"elements": {
|
||
"Fire": {"count": 4, "contributors": ["Sun", "Mars", "Jupiter", "Pluto"]},
|
||
"Stone": {"count": 1, "contributors": ["Venus"]},
|
||
"Air": {"count": 2, "contributors": ["Mercury", "Uranus"]},
|
||
"Water": {"count": 0, "contributors": []},
|
||
"Time": {"count": 1, "stellia": ["Saturn"]},
|
||
"Space": {"count": 1, "parades": ["Neptune"]},
|
||
}
|
||
}
|
||
self._post({
|
||
"birth_dt": "1990-06-15T09:00:00Z",
|
||
"birth_lat": 51.5, "birth_lon": -0.1,
|
||
"birth_place": "", "house_system": "O",
|
||
"chart_data": chart, "action": "confirm",
|
||
})
|
||
event = GameEvent.objects.get(room=self.room, verb=GameEvent.SKY_SAVED)
|
||
self.assertEqual(event.data.get("top_capacitors"), ["Ardor"])
|
||
|
||
def test_confirm_copies_seat_significator_to_character(self):
|
||
"""sky_save with action=confirm copies seat.significator onto Character."""
|
||
earthman, _ = DeckVariant.objects.get_or_create(
|
||
slug="earthman", defaults={"name": "Earthman Deck", "card_count": 108}
|
||
)
|
||
sig_card = TarotCard.objects.filter(deck_variant=earthman).first()
|
||
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||
pc_seat.significator = sig_card
|
||
pc_seat.save()
|
||
|
||
self._post({
|
||
"birth_dt": "1990-06-15T09:00:00Z",
|
||
"birth_lat": 51.5, "birth_lon": -0.1,
|
||
"birth_place": "", "house_system": "O",
|
||
"chart_data": {}, "action": "confirm",
|
||
})
|
||
|
||
char = Character.objects.get(seat=pc_seat)
|
||
self.assertEqual(char.significator, sig_card)
|
||
|
||
|
||
# ── SIG event-retraction branches ────────────────────────────────────────────
|
||
# The provenance scrolls use a `data["retracted"] = True` flag to soft-cancel
|
||
# prior events when a gamer reverses themselves (WAIT NVM after SAVE SIG, etc).
|
||
# These three branches in sig_reserve / sig_ready are the load-bearing ones —
|
||
# without them a recanted action stays visible in the billboard scrollback.
|
||
|
||
class SigEventRetractionTest(TestCase):
|
||
"""`data["retracted"] = True` writes on the three reverse-direction paths."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, self.card = _full_sig_setUp(self)
|
||
# PC (founder) already logged in; reserve + go ready so subsequent
|
||
# actions have prior SIG_READY events to retract.
|
||
self.reserve_url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})
|
||
self.ready_url = reverse("epic:sig_ready", kwargs={"room_id": self.room.id})
|
||
|
||
def _reserve(self):
|
||
return self.client.post(self.reserve_url, data={
|
||
"card_id": self.card.id, "action": "reserve",
|
||
})
|
||
|
||
def _ready(self, action="ready"):
|
||
return self.client.post(self.ready_url, data={"action": action})
|
||
|
||
def test_sig_unready_retracts_prior_sig_ready_event(self):
|
||
"""sig_ready action=unready flips `data["retracted"]=True` on the most
|
||
recent un-retracted SIG_READY event for this actor (views.py L937)."""
|
||
self._reserve()
|
||
self._ready(action="ready")
|
||
prior = self.room.events.filter(
|
||
actor=self.gamers[0], verb=GameEvent.SIG_READY
|
||
).last()
|
||
self.assertFalse(prior.data.get("retracted"), "precondition: not yet retracted")
|
||
|
||
self._ready(action="unready")
|
||
|
||
prior.refresh_from_db()
|
||
self.assertTrue(prior.data.get("retracted"))
|
||
|
||
def test_sig_ready_retracts_prior_sig_unready_event(self):
|
||
"""sig_ready action=ready retracts the most recent un-retracted
|
||
SIG_UNREADY event (views.py L907) — the cancellation is now moot."""
|
||
self._reserve()
|
||
self._ready(action="ready")
|
||
self._ready(action="unready")
|
||
prior_unready = self.room.events.filter(
|
||
actor=self.gamers[0], verb=GameEvent.SIG_UNREADY
|
||
).last()
|
||
self.assertFalse(prior_unready.data.get("retracted"))
|
||
|
||
self._ready(action="ready")
|
||
|
||
prior_unready.refresh_from_db()
|
||
self.assertTrue(prior_unready.data.get("retracted"))
|
||
|
||
def test_sig_release_while_ready_retracts_prior_sig_ready_event(self):
|
||
"""sig_reserve action=release on a ready reservation acts as implicit
|
||
WAIT NVM — retracts the most recent SIG_READY (views.py L823)."""
|
||
self._reserve()
|
||
self._ready(action="ready")
|
||
prior = self.room.events.filter(
|
||
actor=self.gamers[0], verb=GameEvent.SIG_READY
|
||
).last()
|
||
self.assertFalse(prior.data.get("retracted"))
|
||
|
||
self.client.post(self.reserve_url, data={
|
||
"card_id": self.card.id, "action": "release",
|
||
})
|
||
|
||
prior.refresh_from_db()
|
||
self.assertTrue(prior.data.get("retracted"))
|
||
|
||
def test_sig_release_while_ready_records_sig_unready_event(self):
|
||
"""Same release-while-ready path also records a fresh SIG_UNREADY
|
||
(the implicit cancellation event)."""
|
||
self._reserve()
|
||
self._ready(action="ready")
|
||
unready_count_before = self.room.events.filter(
|
||
actor=self.gamers[0], verb=GameEvent.SIG_UNREADY
|
||
).count()
|
||
self.client.post(self.reserve_url, data={
|
||
"card_id": self.card.id, "action": "release",
|
||
})
|
||
self.assertEqual(
|
||
self.room.events.filter(
|
||
actor=self.gamers[0], verb=GameEvent.SIG_UNREADY
|
||
).count(),
|
||
unready_count_before + 1,
|
||
)
|
||
|
||
|
||
# ── SIG_RESERVE invalid card-id branch ───────────────────────────────────────
|
||
|
||
class SigReserveInvalidCardIdTest(TestCase):
|
||
"""sig_reserve POSTed with a card_id that doesn't exist returns 400."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||
self.url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})
|
||
|
||
def test_unknown_card_id_returns_400(self):
|
||
"""TarotCard.DoesNotExist branch (views.py L840-841)."""
|
||
response = self.client.post(self.url, data={
|
||
"card_id": 999999, "action": "reserve",
|
||
})
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
|
||
# ── SIG_SELECT gravity-polarity rendering ────────────────────────────────────
|
||
|
||
class SigSelectGravityContextTest(TestCase):
|
||
"""SIG_SELECT room context for a gravity-polarity gamer.
|
||
|
||
Covers the `user_polarity = 'gravity'` branch (views.py L322) and the
|
||
gravity_sig_cards lookup (L357) — both fall through the cracks of the
|
||
default founder-as-PC-levity tests."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||
# gamers[5] is BC → gravity polarity
|
||
self.bc = self.gamers[5]
|
||
self.client.force_login(self.bc)
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_gravity_gamer_room_context_has_gravity_polarity(self):
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.context["user_polarity"], "gravity")
|
||
|
||
def test_gravity_gamer_sees_gravity_sig_cards(self):
|
||
"""Levity + gravity get the same 16 court cards (filtered by major-arcana
|
||
Note unlocks); this test just asserts the gravity branch was taken."""
|
||
from apps.epic.models import gravity_sig_cards
|
||
response = self.client.get(self.url)
|
||
# Same underlying card set; assertion is that the context was populated
|
||
# (the gravity branch returned, vs falling into the empty `else`).
|
||
self.assertEqual(
|
||
list(response.context["sig_cards"]),
|
||
list(gravity_sig_cards(self.room, self.bc)),
|
||
)
|
||
|
||
def test_gravity_gamer_sig_card_set_non_empty(self):
|
||
response = self.client.get(self.url)
|
||
self.assertGreater(len(response.context["sig_cards"]), 0)
|
||
|
||
|
||
# ── SEA_DECK draw view ───────────────────────────────────────────────────────
|
||
|
||
class SeaDeckViewTest(TestCase):
|
||
"""sea_deck — JSON view returning shuffled levity + gravity halves.
|
||
|
||
Mirrors the FT in test_game_room_select_sea.py:DRAW SEA — that test walks
|
||
the full UI; this one isolates the JSON contract + filter semantics."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
# Use PC seat (founder) — already logged in by _full_sig_setUp
|
||
self.url = reverse("epic:sea_deck", kwargs={"room_id": self.room.id})
|
||
|
||
def test_returns_403_when_not_seated(self):
|
||
outsider = User.objects.create(email="outsider@test.io")
|
||
self.client.force_login(outsider)
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 403)
|
||
|
||
def test_returns_empty_halves_when_seat_has_no_deck_variant(self):
|
||
"""sea_deck early-outs to {levity:[],gravity:[]} if the seat hasn't
|
||
committed a deck — guards against null deck_variant FK access."""
|
||
TableSeat.objects.filter(room=self.room).update(deck_variant=None)
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 200)
|
||
data = response.json()
|
||
self.assertEqual(data, {"levity": [], "gravity": []})
|
||
|
||
def test_returns_two_halves(self):
|
||
response = self.client.get(self.url)
|
||
data = response.json()
|
||
self.assertIn("levity", data)
|
||
self.assertIn("gravity", data)
|
||
|
||
def test_card_count_roughly_split_between_halves(self):
|
||
"""Total card pool is split in half — within 1 of perfectly even."""
|
||
response = self.client.get(self.url)
|
||
data = response.json()
|
||
self.assertAlmostEqual(len(data["levity"]), len(data["gravity"]), delta=1)
|
||
|
||
def test_card_dict_contains_expected_keys(self):
|
||
response = self.client.get(self.url)
|
||
data = response.json()
|
||
sample = data["levity"][0]
|
||
for key in (
|
||
"id", "name", "arcana", "corner_rank", "suit_icon",
|
||
"name_group", "name_title", "reversed",
|
||
"levity_qualifier", "gravity_qualifier",
|
||
):
|
||
self.assertIn(key, sample, f"missing key {key!r} in card dict")
|
||
|
||
def test_reversed_field_is_boolean(self):
|
||
response = self.client.get(self.url)
|
||
data = response.json()
|
||
for card in data["levity"] + data["gravity"]:
|
||
self.assertIsInstance(card["reversed"], bool)
|
||
|
||
def test_excludes_claimed_significators(self):
|
||
"""A card already set as a seat.significator must not appear in either
|
||
half — it's been claimed for the game and is out of the sea-draw pool."""
|
||
sig_card = TarotCard.objects.get(
|
||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||
)
|
||
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||
pc_seat.significator = sig_card
|
||
pc_seat.save()
|
||
response = self.client.get(self.url)
|
||
data = response.json()
|
||
all_ids = {c["id"] for c in data["levity"]} | {c["id"] for c in data["gravity"]}
|
||
self.assertNotIn(sig_card.id, all_ids)
|
||
|
||
|
||
class RoomBurgerBtnRenderTest(TestCase):
|
||
"""Burger btn + fan of 5 sub-btns renders on room.html unconditionally —
|
||
from the gatekeeper state through every table_status. Sub-btns are
|
||
pure scaffolding (no handlers in this sprint)."""
|
||
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="founder@test.io")
|
||
self.room = Room.objects.create(name="Burger Room", owner=self.user)
|
||
self.client.force_login(self.user)
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_burger_btn_renders(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, 'id="id_burger_btn"')
|
||
self.assertContains(response, "fa-burger")
|
||
|
||
def test_burger_fan_container_renders(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, 'id="id_burger_fan"')
|
||
|
||
def test_five_fan_sub_btns_render_with_correct_ids(self):
|
||
response = self.client.get(self.url)
|
||
for btn_id in (
|
||
"id_sky_btn", "id_earth_btn", "id_sea_btn",
|
||
"id_voice_btn", "id_text_btn",
|
||
):
|
||
self.assertContains(response, f'id="{btn_id}"')
|
||
|
||
def test_fan_sub_btn_icons_match_spec(self):
|
||
response = self.client.get(self.url)
|
||
for icon in (
|
||
"fa-cloud", "fa-earth-americas", "fa-bridge-water",
|
||
"fa-headset", "fa-keyboard",
|
||
):
|
||
self.assertContains(response, icon)
|
||
|
||
def test_each_sub_btn_renders_dual_icon_for_inactive_flash_swap(self):
|
||
"""Sub-btns carry BOTH the real icon (.burger-fan-icon--on) + a
|
||
fa-ban placeholder (.burger-fan-icon--off). CSS keeps the real
|
||
icon visible by default; .flash-inactive swaps to fa-ban during
|
||
the click-while-inactive pulse. fa-ban itself isn't counted
|
||
directly — _table_positions.html also renders fa-ban for
|
||
non-starter seats — but the burger-fan-icon classes are unique
|
||
to the fan + load-bearing for the CSS swap rule."""
|
||
response = self.client.get(self.url)
|
||
body = response.content.decode()
|
||
self.assertEqual(body.count("burger-fan-icon--on"), 5)
|
||
self.assertEqual(body.count("burger-fan-icon--off"), 5)
|
||
|
||
def test_burger_btn_script_loaded(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "burger-btn.js")
|
||
|
||
def test_burger_persists_in_table_status_state(self):
|
||
"""Burger renders past the gatekeeper too — confirmed for ROLE_SELECT."""
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, 'id="id_burger_btn"')
|
||
|
||
|
||
class RoomGateViewTest(TestCase):
|
||
"""Room renewal gate-view (sprint 2026-05-31) — reachable mid-game (the
|
||
gatekeeper redirects to the table once table_status is set; this view
|
||
does not). Shows the viewer's own seat/circle + token time-remaining + a
|
||
RENEW affordance; the gear-menu NVM returns to the table hex."""
|
||
|
||
def setUp(self):
|
||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||
self.room = Room.objects.create(
|
||
name="Renewal Room", owner=self.owner,
|
||
renewal_period=timedelta(days=7),
|
||
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
|
||
)
|
||
self.slot = self.room.gate_slots.get(slot_number=1)
|
||
self.slot.gamer = self.owner
|
||
self.slot.status = GateSlot.FILLED
|
||
self.slot.filled_at = timezone.now()
|
||
self.slot.debited_token_type = Token.FREE
|
||
self.slot.save()
|
||
self.client.force_login(self.owner)
|
||
self.url = reverse("epic:room_gate", args=[self.room.id])
|
||
|
||
def test_renders_200_even_when_table_status_set(self):
|
||
# gatekeeper would 302 to the room; the gate-view must render mid-game.
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_shows_viewer_own_seat_circle(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, 'data-slot="1"')
|
||
self.assertContains(response, "PC") # slot 1 role label
|
||
|
||
def test_shows_time_remaining_data_attr(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "data-cost-until=")
|
||
self.assertContains(response, "id_room_gate_remaining")
|
||
|
||
def test_renew_button_posts_to_renew_endpoint(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(
|
||
response, reverse("epic:renew_token", args=[self.room.id]))
|
||
self.assertContains(response, "RENEW")
|
||
|
||
def test_nvm_returns_to_room_hex(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(
|
||
response, f'href="{reverse("epic:room", args=[self.room.id])}"')
|
||
|
||
def test_page_class_carries_page_room(self):
|
||
response = self.client.get(self.url)
|
||
self.assertIn("page-room", response.context["page_class"])
|
||
|
||
def test_no_seat_viewer_still_renders(self):
|
||
stranger = User.objects.create(email="stranger@test.io")
|
||
self.client.force_login(stranger)
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertNotContains(response, "id_room_renew_btn")
|
||
|
||
|
||
class RoomRenewTokenTest(TestCase):
|
||
"""The RENEW endpoint — re-deposit a token into the viewer's own FILLED
|
||
slot, resetting filled_at=now (via debit_token) so the cost-current
|
||
window restarts. Distinct from confirm_token (which needs a RESERVED
|
||
slot). 402 when token-depleted."""
|
||
|
||
def setUp(self):
|
||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||
self.owner.equipped_trinket = None
|
||
self.owner.save(update_fields=["equipped_trinket"])
|
||
self.owner.tokens.exclude(token_type=Token.FREE).delete() # keep FREE only
|
||
self.room = Room.objects.create(
|
||
name="Renewal Room", owner=self.owner,
|
||
renewal_period=timedelta(days=7),
|
||
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
|
||
)
|
||
self.slot = self.room.gate_slots.get(slot_number=1)
|
||
self.slot.gamer = self.owner
|
||
self.slot.status = GateSlot.FILLED
|
||
self.slot.filled_at = timezone.now() - timedelta(days=8) # in grace
|
||
self.slot.debited_token_type = Token.FREE
|
||
self.slot.save()
|
||
self.client.force_login(self.owner)
|
||
self.url = reverse("epic:renew_token", args=[self.room.id])
|
||
|
||
def test_renew_resets_filled_at(self):
|
||
self.client.post(self.url)
|
||
self.slot.refresh_from_db()
|
||
self.assertGreater(self.slot.filled_at, timezone.now() - timedelta(minutes=1))
|
||
self.assertTrue(self.slot.cost_current)
|
||
|
||
def test_renew_consumes_free_token(self):
|
||
self.client.post(self.url)
|
||
self.assertFalse(
|
||
self.owner.tokens.filter(token_type=Token.FREE).exists())
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.debited_token_type, Token.FREE)
|
||
|
||
def test_renew_records_slot_filled_event(self):
|
||
self.client.post(self.url)
|
||
self.assertTrue(
|
||
self.room.events.filter(
|
||
actor=self.owner, verb=GameEvent.SLOT_FILLED).exists())
|
||
|
||
def test_renew_without_filled_slot_redirects(self):
|
||
self.slot.status = GateSlot.EMPTY
|
||
self.slot.gamer = None
|
||
self.slot.save()
|
||
response = self.client.post(self.url)
|
||
self.assertEqual(response.status_code, 302)
|
||
|
||
def test_renew_402_when_token_depleted(self):
|
||
self.owner.tokens.all().delete()
|
||
response = self.client.post(self.url)
|
||
self.assertEqual(response.status_code, 402)
|
||
|
||
def test_renew_get_redirects(self):
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 302)
|
||
|
||
|
||
class RoomNavbarGateViewTest(TestCase):
|
||
"""Navbar swaps CONT GAME → GATE VIEW on room pages (mirror my-sea),
|
||
routing to the room gate-view. The gameboard listing keeps CONT GAME
|
||
(no `page-room` marker)."""
|
||
|
||
def setUp(self):
|
||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||
self.room = Room.objects.create(
|
||
name="Nav Room", owner=self.owner,
|
||
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
|
||
)
|
||
self.client.force_login(self.owner)
|
||
|
||
def test_room_page_shows_gate_view_not_cont_game(self):
|
||
response = self.client.get(reverse("epic:room", args=[self.room.id]))
|
||
self.assertContains(response, "id_navbar_gate_view_btn")
|
||
self.assertNotContains(response, 'id="id_cont_game"')
|
||
|
||
def test_gate_view_btn_links_to_room_gate(self):
|
||
response = self.client.get(reverse("epic:room", args=[self.room.id]))
|
||
self.assertContains(
|
||
response, reverse("epic:room_gate", args=[self.room.id]))
|
||
|
||
def test_room_gate_page_also_shows_gate_view(self):
|
||
response = self.client.get(reverse("epic:room_gate", args=[self.room.id]))
|
||
self.assertContains(response, "id_navbar_gate_view_btn")
|
||
|
||
def test_room_page_carries_page_room_marker(self):
|
||
response = self.client.get(reverse("epic:room", args=[self.room.id]))
|
||
self.assertIn("page-room", response.context["page_class"])
|