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

@@ -15,18 +15,66 @@ TEST_CHANNEL_LAYERS = {
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
class RoomConsumerTest(SimpleTestCase):
async def test_can_connect_and_disconnect(self):
communicator = WebsocketCommunicator(application, "/ws/room/test-room/")
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
connected, _ = await communicator.connect()
self.assertTrue(connected)
await communicator.disconnect()
async def test_receives_gate_update_broadcast(self):
communicator = WebsocketCommunicator(application, "/ws/room/test-room/")
async def test_receives_role_select_start_broadcast(self):
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
await communicator.connect()
channel_layer = get_channel_layer()
await channel_layer.group_send(
"room_test-room",
"room_00000000-0000-0000-0000-000000000001",
{"type": "role_select_start", "slot_order": [1, 2, 3, 4, 5, 6]},
)
response = await communicator.receive_json_from()
self.assertEqual(response["type"], "role_select_start")
self.assertEqual(response["slot_order"], [1, 2, 3, 4, 5, 6])
await communicator.disconnect()
async def test_receives_turn_changed_broadcast(self):
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
await communicator.connect()
channel_layer = get_channel_layer()
await channel_layer.group_send(
"room_00000000-0000-0000-0000-000000000001",
{"type": "turn_changed", "active_slot": 2},
)
response = await communicator.receive_json_from()
self.assertEqual(response["type"], "turn_changed")
self.assertEqual(response["active_slot"], 2)
await communicator.disconnect()
async def test_receives_roles_revealed_broadcast(self):
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
await communicator.connect()
channel_layer = get_channel_layer()
await channel_layer.group_send(
"room_00000000-0000-0000-0000-000000000001",
{"type": "roles_revealed", "assignments": {"1": "PC", "2": "BC"}},
)
response = await communicator.receive_json_from()
self.assertEqual(response["type"], "roles_revealed")
self.assertIn("assignments", response)
await communicator.disconnect()
async def test_receives_gate_update_broadcast(self):
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
await communicator.connect()
channel_layer = get_channel_layer()
await channel_layer.group_send(
"room_00000000-0000-0000-0000-000000000001",
{"type": "gate_update", "gate_state": "some_state"},
)

View File

@@ -5,7 +5,7 @@ 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, debit_token, select_token
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat, debit_token, select_token
class RoomCreationTest(TestCase):
@@ -132,6 +132,62 @@ class SelectTokenTest(TestCase):
self.assertEqual(token.token_type, Token.PASS)
class RoomTableStatusTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="founder@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.owner)
def test_table_status_defaults_to_blank(self):
self.room.refresh_from_db()
self.assertFalse(self.room.table_status)
def test_room_has_role_select_constant(self):
self.assertEqual(Room.ROLE_SELECT, "ROLE_SELECT")
def test_room_has_sig_select_constant(self):
self.assertEqual(Room.SIG_SELECT, "SIG_SELECT")
def test_room_has_in_game_constant(self):
self.assertEqual(Room.IN_GAME, "IN_GAME")
def test_table_status_accepts_role_select(self):
self.room.table_status = Room.ROLE_SELECT
self.room.save()
self.room.refresh_from_db()
self.assertEqual(self.room.table_status, Room.ROLE_SELECT)
class TableSeatModelTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="founder@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.owner)
def test_table_seat_can_be_created(self):
seat = TableSeat.objects.create(
room=self.room,
gamer=self.owner,
slot_number=1,
)
self.assertEqual(seat.slot_number, 1)
self.assertIsNone(seat.role)
self.assertFalse(seat.role_revealed)
self.assertIsNone(seat.seat_position)
def test_table_seat_role_choices_cover_all_six(self):
role_codes = [c[0] for c in TableSeat.ROLE_CHOICES]
for code in ["PC", "BC", "SC", "AC", "NC", "EC"]:
self.assertIn(code, role_codes)
def test_partner_map_pairs_are_mutual(self):
for a, b in [(TableSeat.PC, TableSeat.BC), (TableSeat.SC, TableSeat.AC), (TableSeat.NC, TableSeat.EC)]:
self.assertEqual(TableSeat.PARTNER_MAP[a], b)
self.assertEqual(TableSeat.PARTNER_MAP[b], a)
def test_room_table_seats_reverse_relation(self):
TableSeat.objects.create(room=self.room, gamer=self.owner, slot_number=1)
self.assertEqual(self.room.table_seats.count(), 1)
class RoomInviteTest(TestCase):
def setUp(self):
self.founder = User.objects.create(email="founder@example.com")

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")