2026-03-15 16:08:34 -04:00
|
|
|
|
from datetime import timedelta
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
2026-03-13 00:31:17 -04:00
|
|
|
|
from django.test import TestCase
|
|
|
|
|
|
from django.urls import reverse
|
2026-03-14 02:03:44 -04:00
|
|
|
|
from django.utils import timezone
|
2026-03-13 00:31:17 -04:00
|
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
|
from apps.lyric.models import Token, User
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
|
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat
|
2026-03-13 00:31:17 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"},
|
|
|
|
|
|
)
|
2026-03-13 17:31:52 -04:00
|
|
|
|
|
2026-03-13 22:51:42 -04:00
|
|
|
|
def test_create_room_get_redirects_to_gameboard(self):
|
|
|
|
|
|
response = self.client.get(reverse("epic:create_room"))
|
|
|
|
|
|
self.assertRedirects(response, "/gameboard/")
|
|
|
|
|
|
|
2026-03-13 17:31:52 -04:00
|
|
|
|
|
|
|
|
|
|
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"])
|
2026-03-13 22:51:42 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-03-16 00:07:52 -04:00
|
|
|
|
def test_gate_status_returns_launch_btn_when_open(self):
|
2026-03-13 22:51:42 -04:00
|
|
|
|
self.room.gate_status = Room.OPEN
|
|
|
|
|
|
self.room.save()
|
|
|
|
|
|
response = self.client.get(reverse("epic:gate_status", kwargs={"room_id": self.room.id}))
|
2026-03-16 00:07:52 -04:00
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
self.assertContains(response, "launch-game-btn")
|
2026-03-13 22:51:42 -04:00
|
|
|
|
|
|
|
|
|
|
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")
|
2026-03-14 00:10:40 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 16:08:34 -04:00
|
|
|
|
class ReturnTokenViewTest(TestCase):
|
2026-03-14 02:03:44 -04:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-03-15 16:08:34 -04:00
|
|
|
|
def test_return_clears_reserved_slot(self):
|
2026-03-14 02:03:44 -04:00
|
|
|
|
self.client.post(
|
2026-03-15 16:08:34 -04:00
|
|
|
|
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
2026-03-14 02:03:44 -04:00
|
|
|
|
)
|
|
|
|
|
|
self.slot.refresh_from_db()
|
|
|
|
|
|
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
|
|
|
|
|
self.assertIsNone(self.slot.gamer)
|
|
|
|
|
|
self.assertIsNone(self.slot.reserved_at)
|
|
|
|
|
|
|
2026-03-15 16:08:34 -04:00
|
|
|
|
def test_return_after_confirm_clears_filled_slot(self):
|
2026-03-14 02:03:44 -04:00
|
|
|
|
self.slot.status = GateSlot.FILLED
|
|
|
|
|
|
self.slot.save()
|
|
|
|
|
|
self.client.post(
|
2026-03-15 16:08:34 -04:00
|
|
|
|
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
2026-03-14 02:03:44 -04:00
|
|
|
|
)
|
|
|
|
|
|
self.slot.refresh_from_db()
|
|
|
|
|
|
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
|
|
|
|
|
self.assertIsNone(self.slot.gamer)
|
|
|
|
|
|
|
2026-03-15 16:08:34 -04:00
|
|
|
|
def test_return_redirects_to_gatekeeper(self):
|
2026-03-14 02:03:44 -04:00
|
|
|
|
response = self.client.post(
|
2026-03-15 16:08:34 -04:00
|
|
|
|
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
2026-03-14 02:03:44 -04:00
|
|
|
|
)
|
|
|
|
|
|
self.assertRedirects(
|
|
|
|
|
|
response, reverse("epic:gatekeeper", args=[self.room.id])
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-15 16:08:34 -04:00
|
|
|
|
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()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
|
|
2026-03-14 22:00:16 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
def test_room_view_includes_card_stack_when_role_select(self):
|
|
|
|
|
|
self.client.force_login(self.founder)
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertContains(response, "card-stack")
|
|
|
|
|
|
|
|
|
|
|
|
def test_card_stack_eligible_for_slot1_gamer(self):
|
|
|
|
|
|
self.client.force_login(self.founder)
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
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(
|
|
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
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(
|
|
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertContains(response, "fa-ban")
|
|
|
|
|
|
|
|
|
|
|
|
def test_card_stack_eligible_omits_fa_ban(self):
|
|
|
|
|
|
self.client.force_login(self.founder)
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertNotContains(response, "fa-ban")
|
|
|
|
|
|
|
|
|
|
|
|
def test_gatekeeper_overlay_absent_when_role_select(self):
|
|
|
|
|
|
self.client.force_login(self.founder)
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertNotContains(response, "gate-overlay")
|
|
|
|
|
|
|
|
|
|
|
|
def test_six_table_seats_rendered(self):
|
|
|
|
|
|
self.client.force_login(self.founder)
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertContains(response, "table-seat", count=6)
|
|
|
|
|
|
|
|
|
|
|
|
def test_active_table_seat_has_active_class(self):
|
|
|
|
|
|
self.client.force_login(self.founder) # slot 1 is active
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertContains(response, 'class="table-seat active"')
|
|
|
|
|
|
|
|
|
|
|
|
def test_inactive_table_seat_lacks_active_class(self):
|
|
|
|
|
|
self.client.force_login(self.founder)
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
# Slots 2–6 are not active, so at least one plain table-seat exists
|
|
|
|
|
|
self.assertContains(response, 'class="table-seat"')
|
|
|
|
|
|
|
|
|
|
|
|
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(
|
|
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
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(
|
|
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertContains(response, 'data-user-slots="2"')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:gatekeeper", 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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_sets_sig_select(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.SIG_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_roles_revealed_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_roles_revealed") 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)
|
|
|
|
|
|
|
2026-03-18 23:14:53 -04:00
|
|
|
|
def test_select_role_returns_ok(self):
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
|
response = self.client.post(
|
|
|
|
|
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
|
|
|
|
|
data={"role": "PC"},
|
|
|
|
|
|
)
|
2026-03-18 23:14:53 -04:00
|
|
|
|
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"},
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
|
)
|
2026-03-18 23:14:53 -04:00
|
|
|
|
self.assertEqual(response.status_code, 409)
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
|
|
2026-03-19 00:00:00 -04:00
|
|
|
|
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:gatekeeper", args=[self.room.id])
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-21 14:33:06 -04:00
|
|
|
|
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:gatekeeper", args=[self.room.id])
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
TableSeat.objects.filter(room=self.room, role__isnull=False).count(), 1
|
|
|
|
|
|
)
|
|
|
|
|
|
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
|
|
|
|
|
|
|
class RevealPhaseRenderingTest(TestCase):
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
self.founder = User.objects.create(email="founder@test.io")
|
|
|
|
|
|
self.room = Room.objects.create(name="Test Room", owner=self.founder)
|
|
|
|
|
|
gamers = [self.founder]
|
|
|
|
|
|
for i in range(2, 7):
|
|
|
|
|
|
gamers.append(User.objects.create(email=f"g{i}@test.io"))
|
|
|
|
|
|
roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
|
|
|
|
|
|
for i, (gamer, role) in enumerate(zip(gamers, roles), start=1):
|
|
|
|
|
|
TableSeat.objects.create(
|
|
|
|
|
|
room=self.room, gamer=gamer, slot_number=i,
|
|
|
|
|
|
role=role, role_revealed=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
self.room.gate_status = Room.OPEN
|
|
|
|
|
|
self.room.table_status = Room.SIG_SELECT
|
|
|
|
|
|
self.room.save()
|
|
|
|
|
|
self.client.force_login(self.founder)
|
|
|
|
|
|
|
|
|
|
|
|
def test_face_up_role_cards_rendered_when_sig_select(self):
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertContains(response, "face-up")
|
|
|
|
|
|
|
|
|
|
|
|
def test_inv_role_card_slot_present(self):
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertContains(response, "id_inv_role_card")
|
|
|
|
|
|
|
|
|
|
|
|
def test_partner_indicator_present_when_sig_select(self):
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertContains(response, "partner-indicator")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-14 00:10:40 -04:00
|
|
|
|
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/")
|
2026-03-19 00:00:00 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|