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

This commit is contained in:
Disco DeDisco
2026-03-17 00:24:23 -04:00
parent c9defa5a81
commit 01de6e7548
32 changed files with 2148 additions and 63 deletions

View File

@@ -1,10 +1,12 @@
from datetime import timedelta
from unittest.mock import patch
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.lyric.models import Token, User
from apps.epic.models import GateSlot, Room, RoomInvite
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat
class RoomCreationViewTest(TestCase):
@@ -346,6 +348,298 @@ class ConfirmTokenPriorityViewTest(TestCase):
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)
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 26 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)
def test_select_role_redirects_to_room(self):
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])
)
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")
class RoomActionsViewTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="owner@test.io")