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 from apps.lyric.models import Token, User from apps.epic.models import ( 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/") 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]) ) 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): self.gamer.is_staff = True self.gamer.save() pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS) 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_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 RoomViewAllRolesFilledTest(TestCase): """Room view in ROLE_SELECT with all seats assigned shows PICK 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, ) 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_18_sig_cards(self): 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, "sig-flip-btn") self.assertContains(response, "stat-face--upright") self.assertContains(response, "stat-face--reversed") def test_sig_cards_render_cautions_data_attribute(self): response = self.client.get(self.url) self.assertContains(response, "data-cautions=") def test_sig_caution_tooltip_structure_rendered(self): response = self.client.get(self.url) self.assertContains(response, "sig-caution-tooltip") self.assertContains(response, "sig-caution-btn") self.assertContains(response, "sig-caution-effect") self.assertContains(response, "sig-caution-index") self.assertContains(response, "sig-caution-prev") self.assertContains(response, "sig-caution-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) # ── guards ──────────────────────────────────────────────────────────── 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