2026-03-15 16:08:34 -04:00
|
|
|
|
from datetime import timedelta
|
2026-04-05 22:01:23 -04:00
|
|
|
|
from unittest.mock import ANY, patch
|
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-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-04-28 01:05:25 -04:00
|
|
|
|
from apps.drama.models import GameEvent, Note
|
2026-03-14 02:03:44 -04:00
|
|
|
|
from apps.lyric.models import Token, User
|
2026-03-25 01:30:18 -04:00
|
|
|
|
from apps.epic.models import (
|
2026-04-26 21:30:27 -04:00
|
|
|
|
Character, DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
2026-03-25 01:30:18 -04:00
|
|
|
|
)
|
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])
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-27 23:24:43 -04:00
|
|
|
|
def test_carte_drop_sets_current_room(self):
|
|
|
|
|
|
carte = Token.objects.create(user=self.gamer, token_type=Token.CARTE)
|
|
|
|
|
|
self.client.post(
|
|
|
|
|
|
reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
|
|
|
|
|
|
data={"token_id": carte.pk},
|
|
|
|
|
|
)
|
|
|
|
|
|
carte.refresh_from_db()
|
|
|
|
|
|
self.assertEqual(carte.current_room, self.room)
|
|
|
|
|
|
|
|
|
|
|
|
def test_carte_drop_unequips_trinket(self):
|
|
|
|
|
|
carte = Token.objects.create(user=self.gamer, token_type=Token.CARTE)
|
|
|
|
|
|
self.gamer.equipped_trinket = carte
|
|
|
|
|
|
self.gamer.save(update_fields=["equipped_trinket"])
|
|
|
|
|
|
self.client.post(
|
|
|
|
|
|
reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
|
|
|
|
|
|
data={"token_id": carte.pk},
|
|
|
|
|
|
)
|
|
|
|
|
|
self.gamer.refresh_from_db()
|
|
|
|
|
|
self.assertIsNone(self.gamer.equipped_trinket)
|
|
|
|
|
|
|
|
|
|
|
|
def test_carte_drop_rejected_when_already_in_different_room(self):
|
|
|
|
|
|
other_room = Room.objects.create(name="Other Room", owner=self.gamer)
|
|
|
|
|
|
carte = Token.objects.create(
|
|
|
|
|
|
user=self.gamer, token_type=Token.CARTE, current_room=other_room,
|
|
|
|
|
|
)
|
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
|
reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
|
|
|
|
|
|
data={"token_id": carte.pk},
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(response.status_code, 409)
|
|
|
|
|
|
carte.refresh_from_db()
|
|
|
|
|
|
self.assertEqual(carte.current_room, other_room) # unchanged
|
|
|
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-03-30 18:31:05 -04:00
|
|
|
|
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
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
|
|
|
|
|
|
|
|
|
|
def test_room_view_includes_card_stack_when_role_select(self):
|
|
|
|
|
|
self.client.force_login(self.founder)
|
|
|
|
|
|
response = self.client.get(
|
2026-03-30 18:31:05 -04:00
|
|
|
|
self.url
|
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
|
|
|
|
)
|
|
|
|
|
|
self.assertContains(response, "card-stack")
|
|
|
|
|
|
|
|
|
|
|
|
def test_card_stack_eligible_for_slot1_gamer(self):
|
|
|
|
|
|
self.client.force_login(self.founder)
|
|
|
|
|
|
response = self.client.get(
|
2026-03-30 18:31:05 -04:00
|
|
|
|
self.url
|
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
|
|
|
|
)
|
|
|
|
|
|
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(
|
2026-03-30 18:31:05 -04:00
|
|
|
|
self.url
|
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
|
|
|
|
)
|
|
|
|
|
|
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(
|
2026-03-30 18:31:05 -04:00
|
|
|
|
self.url
|
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
|
|
|
|
)
|
|
|
|
|
|
self.assertContains(response, "fa-ban")
|
|
|
|
|
|
|
|
|
|
|
|
def test_card_stack_eligible_omits_fa_ban(self):
|
|
|
|
|
|
self.client.force_login(self.founder)
|
|
|
|
|
|
response = self.client.get(
|
2026-03-30 18:31:05 -04:00
|
|
|
|
self.url
|
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-31 00:01:04 -04:00
|
|
|
|
# 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"')
|
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
|
|
|
|
|
|
|
|
|
|
def test_gatekeeper_overlay_absent_when_role_select(self):
|
|
|
|
|
|
self.client.force_login(self.founder)
|
|
|
|
|
|
response = self.client.get(
|
2026-03-30 18:31:05 -04:00
|
|
|
|
self.url
|
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
|
|
|
|
)
|
|
|
|
|
|
self.assertNotContains(response, "gate-overlay")
|
|
|
|
|
|
|
2026-03-31 00:01:04 -04:00
|
|
|
|
def test_tray_wrap_has_role_select_phase_class(self):
|
|
|
|
|
|
# Tray handle hidden until gamer confirms a role pick
|
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
|
|
|
|
self.client.force_login(self.founder)
|
2026-03-31 00:01:04 -04:00
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertContains(response, 'id="id_tray_wrap" class="role-select-phase"')
|
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-31 00:01:04 -04:00
|
|
|
|
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)
|
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.get(
|
2026-03-31 00:01:04 -04:00
|
|
|
|
reverse("epic:gatekeeper", kwargs={"room_id": room.id})
|
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-31 00:01:04 -04:00
|
|
|
|
self.assertNotContains(response, 'id="id_tray_wrap"')
|
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-31 00:01:04 -04:00
|
|
|
|
def test_six_table_seats_rendered(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
|
|
|
|
self.client.force_login(self.founder)
|
|
|
|
|
|
response = self.client.get(
|
2026-03-30 18:31:05 -04:00
|
|
|
|
self.url
|
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-31 00:01:04 -04:00
|
|
|
|
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)
|
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
|
|
|
|
|
|
|
|
|
|
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(
|
2026-03-30 18:31:05 -04:00
|
|
|
|
self.url
|
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
|
|
|
|
)
|
|
|
|
|
|
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(
|
2026-03-30 18:31:05 -04:00
|
|
|
|
self.url
|
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
|
|
|
|
)
|
|
|
|
|
|
self.assertContains(response, 'data-user-slots="2"')
|
|
|
|
|
|
|
2026-03-31 00:01:04 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
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 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(
|
2026-03-30 18:31:05 -04:00
|
|
|
|
response, reverse("epic:room", args=[self.room.id])
|
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
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-03-21 22:22:06 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
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 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)
|
|
|
|
|
|
|
2026-04-04 14:33:35 -04:00
|
|
|
|
def test_all_selected_stays_role_select_status(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
|
|
|
|
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()
|
2026-04-04 14:33:35 -04:00
|
|
|
|
self.assertEqual(self.room.table_status, Room.ROLE_SELECT)
|
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
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-04-04 14:33:35 -04:00
|
|
|
|
def test_select_role_notifies_all_roles_filled_when_last(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
|
|
|
|
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])
|
2026-04-04 14:33:35 -04:00
|
|
|
|
with patch("apps.epic.views._notify_all_roles_filled") as mock_notify:
|
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
|
|
|
|
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)
|
|
|
|
|
|
|
2026-04-27 22:38:07 -04:00
|
|
|
|
def test_select_role_assigns_equipped_deck_to_seat(self):
|
|
|
|
|
|
earthman = DeckVariant.objects.get(slug="earthman")
|
|
|
|
|
|
self.founder.equipped_deck = earthman
|
|
|
|
|
|
self.founder.save(update_fields=["equipped_deck"])
|
|
|
|
|
|
self.client.post(
|
|
|
|
|
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
|
|
|
|
|
data={"role": "PC"},
|
|
|
|
|
|
)
|
|
|
|
|
|
seat = TableSeat.objects.get(room=self.room, slot_number=1)
|
|
|
|
|
|
self.assertEqual(seat.deck_variant, earthman)
|
|
|
|
|
|
|
|
|
|
|
|
def test_select_role_no_deck_leaves_deck_variant_null(self):
|
|
|
|
|
|
self.founder.equipped_deck = None
|
|
|
|
|
|
self.founder.save(update_fields=["equipped_deck"])
|
|
|
|
|
|
self.client.post(
|
|
|
|
|
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
|
|
|
|
|
data={"role": "PC"},
|
|
|
|
|
|
)
|
|
|
|
|
|
seat = TableSeat.objects.get(room=self.room, slot_number=1)
|
|
|
|
|
|
self.assertIsNone(seat.deck_variant)
|
|
|
|
|
|
|
2026-04-27 23:24:43 -04:00
|
|
|
|
def test_select_role_unequips_deck_from_user(self):
|
|
|
|
|
|
earthman = DeckVariant.objects.get(slug="earthman")
|
|
|
|
|
|
self.client.post(
|
|
|
|
|
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
|
|
|
|
|
data={"role": "PC"},
|
|
|
|
|
|
)
|
|
|
|
|
|
self.founder.refresh_from_db()
|
|
|
|
|
|
self.assertIsNone(self.founder.equipped_deck)
|
|
|
|
|
|
|
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
|
|
|
|
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(
|
2026-03-30 18:31:05 -04:00
|
|
|
|
response, reverse("epic:room", args=[self.room.id])
|
2026-03-19 00:00:00 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
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(
|
2026-03-30 18:31:05 -04:00
|
|
|
|
response, reverse("epic:room", args=[self.room.id])
|
2026-03-21 14:33:06 -04:00
|
|
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
2026-04-27 23:24:43 -04:00
|
|
|
|
class SelectRoleMultiSeatTest(TestCase):
|
|
|
|
|
|
"""Carte Blanche multi-seat: second role reuses the deck from the first seat."""
|
|
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
self.founder = User.objects.create(email="founder@test.io")
|
|
|
|
|
|
self.client.force_login(self.founder)
|
|
|
|
|
|
self.room = Room.objects.create(name="Test Room", owner=self.founder)
|
|
|
|
|
|
self.room.gate_status = Room.OPEN
|
|
|
|
|
|
self.room.table_status = Room.ROLE_SELECT
|
|
|
|
|
|
self.room.save()
|
|
|
|
|
|
self.earthman = DeckVariant.objects.get(slug="earthman")
|
|
|
|
|
|
|
|
|
|
|
|
def test_second_role_inherits_deck_from_first_seat_in_room(self):
|
|
|
|
|
|
# Founder's first seat: PC already taken with deck assigned
|
|
|
|
|
|
TableSeat.objects.create(
|
|
|
|
|
|
room=self.room, gamer=self.founder, slot_number=1,
|
|
|
|
|
|
role="PC", deck_variant=self.earthman,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Deck unequipped after first role
|
|
|
|
|
|
self.founder.equipped_deck = None
|
|
|
|
|
|
self.founder.save(update_fields=["equipped_deck"])
|
|
|
|
|
|
# Founder's second seat (Carte Blanche): no role yet
|
|
|
|
|
|
second_seat = TableSeat.objects.create(
|
|
|
|
|
|
room=self.room, gamer=self.founder, slot_number=2,
|
|
|
|
|
|
)
|
|
|
|
|
|
self.client.post(
|
|
|
|
|
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
|
|
|
|
|
data={"role": "BC"},
|
|
|
|
|
|
)
|
|
|
|
|
|
second_seat.refresh_from_db()
|
|
|
|
|
|
self.assertEqual(second_seat.deck_variant, self.earthman)
|
|
|
|
|
|
|
|
|
|
|
|
def test_second_role_does_not_unequip_again(self):
|
|
|
|
|
|
"""No-op unequip when deck was already cleared by the first role."""
|
|
|
|
|
|
TableSeat.objects.create(
|
|
|
|
|
|
room=self.room, gamer=self.founder, slot_number=1,
|
|
|
|
|
|
role="PC", deck_variant=self.earthman,
|
|
|
|
|
|
)
|
|
|
|
|
|
self.founder.equipped_deck = None
|
|
|
|
|
|
self.founder.save(update_fields=["equipped_deck"])
|
|
|
|
|
|
TableSeat.objects.create(room=self.room, gamer=self.founder, slot_number=2)
|
|
|
|
|
|
self.client.post(
|
|
|
|
|
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
|
|
|
|
|
data={"role": "BC"},
|
|
|
|
|
|
)
|
|
|
|
|
|
self.founder.refresh_from_db()
|
|
|
|
|
|
self.assertIsNone(self.founder.equipped_deck) # still None, not broken
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-04 14:33:35 -04:00
|
|
|
|
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)
|
2026-04-04 15:05:55 -04:00
|
|
|
|
[_] = parsed.cssselect("#id_pick_sigs_btn")
|
2026-04-05 01:14:31 -04:00
|
|
|
|
self.assertEqual(parsed.cssselect(".card-stack"), [])
|
2026-04-04 14:33:35 -04:00
|
|
|
|
|
2026-04-04 15:10:48 -04:00
|
|
|
|
def test_pick_sigs_btn_hidden_during_role_select(self):
|
|
|
|
|
|
# Clear one role — still mid-pick, wrap must be hidden
|
2026-04-04 14:33:35 -04:00
|
|
|
|
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)
|
2026-04-04 15:10:48 -04:00
|
|
|
|
[wrap] = parsed.cssselect("#id_pick_sigs_wrap")
|
|
|
|
|
|
self.assertIn("display:none", wrap.get("style", "").replace(" ", ""))
|
2026-04-04 14:33:35 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-03-25 01:30:18 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Significator Selection ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _full_sig_setUp(test_case, role_order=None):
|
2026-03-25 01:50:06 -04:00
|
|
|
|
"""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."""
|
2026-03-25 01:30:18 -04:00
|
|
|
|
if role_order is None:
|
|
|
|
|
|
role_order = SIG_SEAT_ORDER[:]
|
2026-03-25 01:50:06 -04:00
|
|
|
|
earthman, _ = DeckVariant.objects.get_or_create(
|
|
|
|
|
|
slug="earthman",
|
|
|
|
|
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
2026-03-25 01:30:18 -04:00
|
|
|
|
)
|
|
|
|
|
|
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(
|
2026-04-28 01:05:25 -04:00
|
|
|
|
room=room, gamer=gamer, slot_number=i, role=role,
|
|
|
|
|
|
role_revealed=True, deck_variant=earthman,
|
2026-03-25 01:30:18 -04:00
|
|
|
|
)
|
|
|
|
|
|
room.gate_status = Room.OPEN
|
|
|
|
|
|
room.table_status = Room.SIG_SELECT
|
|
|
|
|
|
room.save()
|
|
|
|
|
|
card_in_deck = TarotCard.objects.get(
|
2026-04-07 00:22:04 -04:00
|
|
|
|
deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
2026-03-25 01:30:18 -04:00
|
|
|
|
)
|
|
|
|
|
|
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)
|
2026-03-30 18:31:05 -04:00
|
|
|
|
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
2026-03-25 01:30:18 -04:00
|
|
|
|
|
|
|
|
|
|
def test_sig_deck_element_present(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertContains(response, "id_sig_deck")
|
|
|
|
|
|
|
2026-04-28 01:05:25 -04:00
|
|
|
|
def test_sig_deck_contains_16_sig_cards_by_default(self):
|
|
|
|
|
|
"""Without Note unlocks the deck shows only 16 court cards (no Nomad/Schizo)."""
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertEqual(response.content.decode().count('data-card-id='), 16)
|
|
|
|
|
|
|
|
|
|
|
|
def test_nomad_note_adds_nomad_to_sig_deck(self):
|
|
|
|
|
|
Note.objects.create(user=self.gamers[0], slug="nomad", earned_at=timezone.now())
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertEqual(response.content.decode().count('data-card-id='), 17)
|
|
|
|
|
|
|
|
|
|
|
|
def test_schizo_note_adds_schizo_to_sig_deck(self):
|
|
|
|
|
|
Note.objects.create(user=self.gamers[0], slug="schizo", earned_at=timezone.now())
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertEqual(response.content.decode().count('data-card-id='), 17)
|
|
|
|
|
|
|
2026-04-28 01:30:02 -04:00
|
|
|
|
def test_super_nomad_note_also_unlocks_nomad(self):
|
|
|
|
|
|
Note.objects.create(user=self.gamers[0], slug="super-nomad", earned_at=timezone.now())
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertEqual(response.content.decode().count('data-card-id='), 17)
|
|
|
|
|
|
|
|
|
|
|
|
def test_super_schizo_note_also_unlocks_schizo(self):
|
|
|
|
|
|
Note.objects.create(user=self.gamers[0], slug="super-schizo", earned_at=timezone.now())
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertEqual(response.content.decode().count('data-card-id='), 17)
|
|
|
|
|
|
|
2026-04-28 01:05:25 -04:00
|
|
|
|
def test_both_notes_gives_18_sig_cards(self):
|
|
|
|
|
|
Note.objects.create(user=self.gamers[0], slug="nomad", earned_at=timezone.now())
|
|
|
|
|
|
Note.objects.create(user=self.gamers[0], slug="schizo", earned_at=timezone.now())
|
2026-03-25 01:30:18 -04:00
|
|
|
|
response = self.client.get(self.url)
|
2026-04-05 22:01:23 -04:00
|
|
|
|
self.assertEqual(response.content.decode().count('data-card-id='), 18)
|
2026-03-25 01:30:18 -04:00
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
2026-04-07 00:22:04 -04:00
|
|
|
|
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")
|
2026-04-30 21:01:52 -04:00
|
|
|
|
self.assertContains(response, "spin-btn")
|
2026-04-07 00:22:04 -04:00
|
|
|
|
self.assertContains(response, "stat-face--upright")
|
|
|
|
|
|
self.assertContains(response, "stat-face--reversed")
|
|
|
|
|
|
|
2026-04-28 20:25:47 -04:00
|
|
|
|
def test_sig_cards_render_energies_operations_data_attributes(self):
|
2026-04-07 00:22:04 -04:00
|
|
|
|
response = self.client.get(self.url)
|
2026-04-28 20:25:47 -04:00
|
|
|
|
self.assertContains(response, "data-energies=")
|
|
|
|
|
|
self.assertContains(response, "data-operations=")
|
2026-04-07 00:22:04 -04:00
|
|
|
|
|
2026-04-28 20:25:47 -04:00
|
|
|
|
def test_sig_info_panel_structure_rendered(self):
|
2026-04-07 00:22:04 -04:00
|
|
|
|
response = self.client.get(self.url)
|
2026-04-28 20:25:47 -04:00
|
|
|
|
self.assertContains(response, "sig-info")
|
2026-04-30 21:01:52 -04:00
|
|
|
|
self.assertContains(response, "fyi-btn")
|
2026-04-28 20:25:47 -04:00
|
|
|
|
self.assertContains(response, "sig-info-effect")
|
|
|
|
|
|
self.assertContains(response, "sig-info-index")
|
2026-04-30 21:01:52 -04:00
|
|
|
|
self.assertContains(response, "fyi-prev")
|
|
|
|
|
|
self.assertContains(response, "fyi-next")
|
2026-04-07 00:22:04 -04:00
|
|
|
|
|
2026-03-25 01:30:18 -04:00
|
|
|
|
|
|
|
|
|
|
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):
|
2026-03-25 01:50:06 -04:00
|
|
|
|
# Create a pip card (number=5) — not in the sig deck (only court 11–14 + major 0–1)
|
2026-03-25 01:30:18 -04:00
|
|
|
|
other = TarotCard.objects.create(
|
2026-04-07 00:22:04 -04:00
|
|
|
|
deck_variant=self.earthman, arcana="MINOR", suit="BRANDS", number=5,
|
|
|
|
|
|
name="Five of Brands Test", slug="five-of-brands-test",
|
2026-03-25 01:30:18 -04:00
|
|
|
|
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()
|
2026-03-25 11:03:53 -04:00
|
|
|
|
mock_notify.assert_called_once()
|
2026-03-25 01:30:18 -04:00
|
|
|
|
|
|
|
|
|
|
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(
|
2026-03-30 18:31:05 -04:00
|
|
|
|
response, reverse("epic:room", args=[self.room.id])
|
2026-03-25 01:30:18 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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))
|
2026-04-04 14:33:35 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-04-05 22:01:23 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 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(
|
2026-04-07 00:22:04 -04:00
|
|
|
|
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12
|
2026-04-05 22:01:23 -04:00
|
|
|
|
).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(
|
2026-04-07 00:22:04 -04:00
|
|
|
|
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12
|
2026-04-05 22:01:23 -04:00
|
|
|
|
).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)
|
|
|
|
|
|
|
2026-04-21 15:56:36 -04:00
|
|
|
|
def test_release_while_ready_records_sig_unready(self):
|
|
|
|
|
|
"""Releasing a ready reservation implicitly acts as WAIT NVM and records SIG_UNREADY."""
|
|
|
|
|
|
self._reserve()
|
|
|
|
|
|
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
|
|
|
|
|
res.ready = True
|
|
|
|
|
|
res.save()
|
|
|
|
|
|
self._reserve(action="release")
|
|
|
|
|
|
self.assertTrue(self.room.events.filter(
|
|
|
|
|
|
actor=self.gamers[0], verb=GameEvent.SIG_UNREADY
|
|
|
|
|
|
).exists())
|
|
|
|
|
|
|
2026-04-05 22:01:23 -04:00
|
|
|
|
# ── guards ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-04-21 15:56:36 -04:00
|
|
|
|
def test_reserve_non_post_returns_405(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertEqual(response.status_code, 405)
|
|
|
|
|
|
|
2026-04-05 22:01:23 -04:00
|
|
|
|
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()
|
2026-04-05 22:32:40 -04:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-09 01:17:24 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── sig_ready view ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def _make_levity_reservations(room, gamers, earthman, ready=False):
|
|
|
|
|
|
"""Create SigReservations for the three levity gamers (PC, NC, SC).
|
|
|
|
|
|
Returns the three reservations in PC→NC→SC order."""
|
|
|
|
|
|
cards = [
|
|
|
|
|
|
TarotCard.objects.get(deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=n)
|
|
|
|
|
|
for n in (11, 12, 13)
|
|
|
|
|
|
]
|
|
|
|
|
|
roles = ["PC", "NC", "SC"]
|
|
|
|
|
|
# gamers[0]=PC, gamers[1]=NC, gamers[3]=SC
|
|
|
|
|
|
gamer_indices = [0, 1, 3]
|
|
|
|
|
|
reservations = []
|
|
|
|
|
|
for gamer_idx, role, card in zip(gamer_indices, roles, cards):
|
|
|
|
|
|
seat = TableSeat.objects.get(room=room, role=role)
|
|
|
|
|
|
res = SigReservation.objects.create(
|
|
|
|
|
|
room=room, gamer=gamers[gamer_idx], card=card,
|
|
|
|
|
|
role=role, polarity="levity", seat=seat, ready=ready,
|
|
|
|
|
|
)
|
|
|
|
|
|
reservations.append(res)
|
|
|
|
|
|
return reservations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SigReadyViewTest(TestCase):
|
|
|
|
|
|
"""sig_ready — toggle ready/unready for the polarity-room countdown."""
|
|
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
|
|
|
|
|
self.reservations = _make_levity_reservations(self.room, self.gamers, self.earthman)
|
|
|
|
|
|
self.url = reverse("epic:sig_ready", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
|
|
|
|
|
|
def _post(self, action="ready", seconds_remaining=None, client=None):
|
|
|
|
|
|
c = client or self.client
|
|
|
|
|
|
data = {"action": action}
|
|
|
|
|
|
if seconds_remaining is not None:
|
|
|
|
|
|
data["seconds_remaining"] = seconds_remaining
|
|
|
|
|
|
return c.post(self.url, data=data)
|
|
|
|
|
|
|
|
|
|
|
|
# ── guards ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-04-21 15:56:36 -04:00
|
|
|
|
def test_sig_ready_non_post_returns_405(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertEqual(response.status_code, 405)
|
|
|
|
|
|
|
2026-04-09 01:17:24 -04:00
|
|
|
|
def test_sig_ready_requires_login(self):
|
|
|
|
|
|
self.client.logout()
|
|
|
|
|
|
response = self._post()
|
|
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
|
|
self.assertIn("/accounts/login/", response.url)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_ready_requires_seated_gamer(self):
|
|
|
|
|
|
outsider = User.objects.create(email="outsider@test.io")
|
|
|
|
|
|
outsider_client = self.client.__class__()
|
|
|
|
|
|
outsider_client.force_login(outsider)
|
|
|
|
|
|
response = self._post(client=outsider_client)
|
|
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_ready_wrong_phase_returns_400(self):
|
|
|
|
|
|
self.room.table_status = Room.ROLE_SELECT
|
|
|
|
|
|
self.room.save()
|
|
|
|
|
|
response = self._post()
|
|
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_ready_without_reservation_returns_400(self):
|
|
|
|
|
|
"""Can't go ready without an OK'd card."""
|
|
|
|
|
|
SigReservation.objects.filter(room=self.room, gamer=self.gamers[0]).delete()
|
|
|
|
|
|
response = self._post()
|
|
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
|
|
|
|
|
|
|
|
# ── happy-path ready ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_ready_sets_ready_true_on_reservation(self):
|
|
|
|
|
|
self._post(action="ready")
|
|
|
|
|
|
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
|
|
|
|
|
self.assertTrue(res.ready)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_ready_returns_200(self):
|
|
|
|
|
|
response = self._post(action="ready")
|
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
|
2026-04-21 15:56:36 -04:00
|
|
|
|
def test_sig_ready_already_ready_is_idempotent(self):
|
|
|
|
|
|
"""Re-posting ready when already ready returns 200 without re-triggering countdown."""
|
|
|
|
|
|
self.reservations[0].ready = True
|
|
|
|
|
|
self.reservations[0].save()
|
|
|
|
|
|
response = self._post(action="ready")
|
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
|
2026-04-09 01:17:24 -04:00
|
|
|
|
# ── unready ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_unready_sets_ready_false(self):
|
|
|
|
|
|
self.reservations[0].ready = True
|
|
|
|
|
|
self.reservations[0].save()
|
|
|
|
|
|
self._post(action="unready")
|
|
|
|
|
|
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
|
|
|
|
|
self.assertFalse(res.ready)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_unready_when_not_ready_is_harmless(self):
|
|
|
|
|
|
response = self._post(action="unready")
|
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
|
|
|
|
|
|
# ── countdown mechanics ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_ready_broadcasts_countdown_start_when_all_three_polarity_ready(self):
|
|
|
|
|
|
"""When all three levity gamers are ready, countdown_start broadcasts."""
|
|
|
|
|
|
# Make NC and SC ready first
|
|
|
|
|
|
for res in self.reservations[1:]:
|
|
|
|
|
|
res.ready = True
|
|
|
|
|
|
res.save()
|
|
|
|
|
|
# PC (founder) goes ready — triggers all-three condition
|
|
|
|
|
|
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
|
|
|
|
|
self._post(action="ready")
|
|
|
|
|
|
mock_notify.assert_called_once()
|
|
|
|
|
|
args = mock_notify.call_args[0]
|
|
|
|
|
|
self.assertIn("levity", args) # polarity in call
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_ready_does_not_broadcast_countdown_when_only_two_ready(self):
|
|
|
|
|
|
self.reservations[1].ready = True
|
|
|
|
|
|
self.reservations[1].save()
|
|
|
|
|
|
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
|
|
|
|
|
self._post(action="ready")
|
|
|
|
|
|
mock_notify.assert_not_called()
|
|
|
|
|
|
|
2026-04-21 15:56:36 -04:00
|
|
|
|
def test_sig_unready_invalid_seconds_defaults_to_12(self):
|
|
|
|
|
|
"""Non-numeric seconds_remaining falls back to 12."""
|
|
|
|
|
|
self.reservations[0].ready = True
|
|
|
|
|
|
self.reservations[0].save()
|
|
|
|
|
|
self._post(action="unready", seconds_remaining="abc")
|
|
|
|
|
|
self.reservations[0].refresh_from_db()
|
|
|
|
|
|
self.assertEqual(self.reservations[0].countdown_remaining, 12)
|
|
|
|
|
|
|
2026-04-09 01:17:24 -04:00
|
|
|
|
def test_sig_unready_saves_seconds_remaining_on_all_polarity_reservations(self):
|
|
|
|
|
|
for res in self.reservations:
|
|
|
|
|
|
res.ready = True
|
|
|
|
|
|
res.save()
|
|
|
|
|
|
self._post(action="unready", seconds_remaining=7)
|
|
|
|
|
|
for res in self.reservations:
|
|
|
|
|
|
res.refresh_from_db()
|
|
|
|
|
|
self.assertEqual(res.countdown_remaining, 7)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_unready_broadcasts_countdown_cancel(self):
|
|
|
|
|
|
for res in self.reservations:
|
|
|
|
|
|
res.ready = True
|
|
|
|
|
|
res.save()
|
|
|
|
|
|
with patch("apps.epic.views._notify_countdown_cancel") as mock_notify:
|
|
|
|
|
|
self._post(action="unready", seconds_remaining=7)
|
|
|
|
|
|
mock_notify.assert_called_once()
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_ready_uses_saved_seconds_for_countdown_restart(self):
|
|
|
|
|
|
"""If countdown_remaining is saved (e.g. 7), countdown_start sends 7 not 12."""
|
|
|
|
|
|
for res in self.reservations:
|
|
|
|
|
|
res.ready = True
|
|
|
|
|
|
res.countdown_remaining = 7
|
|
|
|
|
|
res.save()
|
|
|
|
|
|
# One unreadied; now goes ready again — all 3 ready → start from 7
|
|
|
|
|
|
self.reservations[0].ready = False
|
|
|
|
|
|
self.reservations[0].save()
|
|
|
|
|
|
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
|
|
|
|
|
self._post(action="ready")
|
|
|
|
|
|
mock_notify.assert_called_once()
|
|
|
|
|
|
args, kwargs = mock_notify.call_args
|
|
|
|
|
|
seconds_sent = kwargs.get("seconds") or args[1]
|
|
|
|
|
|
self.assertEqual(seconds_sent, 7)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── sig_confirm view ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def _make_gravity_reservations(room, gamers, earthman, ready=False):
|
|
|
|
|
|
"""Create SigReservations for the three gravity gamers (EC, AC, BC)."""
|
|
|
|
|
|
cards = [
|
|
|
|
|
|
TarotCard.objects.get(deck_variant=earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
|
|
|
|
|
for n in (11, 12, 13)
|
|
|
|
|
|
]
|
|
|
|
|
|
roles = ["EC", "AC", "BC"]
|
|
|
|
|
|
# gamers[2]=EC, gamers[4]=AC, gamers[5]=BC
|
|
|
|
|
|
gamer_indices = [2, 4, 5]
|
|
|
|
|
|
reservations = []
|
|
|
|
|
|
for gamer_idx, role, card in zip(gamer_indices, roles, cards):
|
|
|
|
|
|
seat = TableSeat.objects.get(room=room, role=role)
|
|
|
|
|
|
res = SigReservation.objects.create(
|
|
|
|
|
|
room=room, gamer=gamers[gamer_idx], card=card,
|
|
|
|
|
|
role=role, polarity="gravity", seat=seat, ready=ready,
|
|
|
|
|
|
)
|
|
|
|
|
|
reservations.append(res)
|
|
|
|
|
|
return reservations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SigConfirmViewTest(TestCase):
|
|
|
|
|
|
"""sig_confirm — finalize polarity group once countdown reaches zero."""
|
|
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
|
|
|
|
|
# All three levity gamers are ready
|
|
|
|
|
|
self.lev_res = _make_levity_reservations(
|
|
|
|
|
|
self.room, self.gamers, self.earthman, ready=True
|
|
|
|
|
|
)
|
|
|
|
|
|
# founder (PC) is already logged in from _full_sig_setUp
|
|
|
|
|
|
self.url = reverse("epic:sig_confirm", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
|
|
|
|
|
|
def _post(self, polarity="levity", client=None):
|
|
|
|
|
|
c = client or self.client
|
|
|
|
|
|
return c.post(self.url, data={"polarity": polarity})
|
|
|
|
|
|
|
|
|
|
|
|
# ── guards ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-04-21 15:56:36 -04:00
|
|
|
|
def test_sig_confirm_non_post_returns_405(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertEqual(response.status_code, 405)
|
|
|
|
|
|
|
2026-04-09 01:17:24 -04:00
|
|
|
|
def test_sig_confirm_requires_login(self):
|
|
|
|
|
|
self.client.logout()
|
|
|
|
|
|
response = self._post()
|
|
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
|
|
self.assertIn("/accounts/login/", response.url)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_confirm_requires_seated_gamer(self):
|
|
|
|
|
|
outsider = User.objects.create(email="outsider@test.io")
|
|
|
|
|
|
outsider_client = self.client.__class__()
|
|
|
|
|
|
outsider_client.force_login(outsider)
|
|
|
|
|
|
response = self._post(client=outsider_client)
|
|
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_confirm_wrong_phase_returns_400(self):
|
|
|
|
|
|
self.room.table_status = Room.ROLE_SELECT
|
|
|
|
|
|
self.room.save()
|
|
|
|
|
|
response = self._post()
|
|
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_confirm_not_all_polarity_ready_returns_400(self):
|
|
|
|
|
|
"""If any of the three in the polarity group isn't ready, reject."""
|
|
|
|
|
|
self.lev_res[1].ready = False
|
|
|
|
|
|
self.lev_res[1].save()
|
|
|
|
|
|
response = self._post()
|
|
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
|
|
|
|
|
|
|
|
# ── happy-path ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_confirm_sets_significator_on_seats_from_reservations(self):
|
|
|
|
|
|
self._post()
|
|
|
|
|
|
for res in self.lev_res:
|
|
|
|
|
|
seat = TableSeat.objects.get(room=self.room, role=res.role)
|
|
|
|
|
|
self.assertEqual(seat.significator, res.card)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_confirm_returns_200(self):
|
|
|
|
|
|
response = self._post()
|
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_confirm_broadcasts_polarity_room_done(self):
|
|
|
|
|
|
with patch("apps.epic.views._notify_polarity_room_done") as mock_notify:
|
|
|
|
|
|
self._post()
|
|
|
|
|
|
mock_notify.assert_called_once()
|
|
|
|
|
|
args = mock_notify.call_args[0]
|
|
|
|
|
|
self.assertIn("levity", args)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_confirm_is_idempotent_if_significators_already_set(self):
|
|
|
|
|
|
"""Second call from another browser returns 200 without re-running logic."""
|
|
|
|
|
|
self._post()
|
|
|
|
|
|
response = self._post()
|
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
|
|
|
|
|
|
# ── both polarities done ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_confirm_broadcasts_pick_sky_available_when_both_polarities_done(self):
|
|
|
|
|
|
"""After both levity and gravity confirm, pick_sky_available fires."""
|
|
|
|
|
|
# Pre-set gravity seats to already have significators (simulating earlier confirm)
|
|
|
|
|
|
grav_cards = [
|
|
|
|
|
|
TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
|
|
|
|
|
for n in (11, 12, 13)
|
|
|
|
|
|
]
|
|
|
|
|
|
for role, card in zip(["EC", "AC", "BC"], grav_cards):
|
|
|
|
|
|
seat = TableSeat.objects.get(room=self.room, role=role)
|
|
|
|
|
|
seat.significator = card
|
|
|
|
|
|
seat.save()
|
|
|
|
|
|
with patch("apps.epic.views._notify_pick_sky_available") as mock_notify:
|
|
|
|
|
|
self._post(polarity="levity")
|
|
|
|
|
|
mock_notify.assert_called_once()
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_confirm_sets_room_to_sky_select_when_both_polarities_done(self):
|
|
|
|
|
|
grav_cards = [
|
|
|
|
|
|
TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
|
|
|
|
|
for n in (11, 12, 13)
|
|
|
|
|
|
]
|
|
|
|
|
|
for role, card in zip(["EC", "AC", "BC"], grav_cards):
|
|
|
|
|
|
seat = TableSeat.objects.get(room=self.room, role=role)
|
|
|
|
|
|
seat.significator = card
|
|
|
|
|
|
seat.save()
|
|
|
|
|
|
self._post(polarity="levity")
|
|
|
|
|
|
self.room.refresh_from_db()
|
|
|
|
|
|
self.assertEqual(self.room.table_status, Room.SKY_SELECT)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_confirm_does_not_broadcast_pick_sky_available_when_only_one_polarity_done(self):
|
|
|
|
|
|
with patch("apps.epic.views._notify_pick_sky_available") as mock_notify:
|
|
|
|
|
|
self._post(polarity="levity")
|
|
|
|
|
|
mock_notify.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── SKY_SELECT rendering ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class PickSkyRenderingTest(TestCase):
|
|
|
|
|
|
"""Room page at SKY_SELECT renders PICK SKY btn and sig card in tray cell 2."""
|
|
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
|
|
|
|
|
self.room.table_status = Room.SKY_SELECT
|
|
|
|
|
|
self.room.save()
|
|
|
|
|
|
self.sig_card = TarotCard.objects.get(
|
|
|
|
|
|
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
|
|
|
|
|
)
|
|
|
|
|
|
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
|
|
|
|
|
pc_seat.significator = self.sig_card
|
|
|
|
|
|
pc_seat.save()
|
|
|
|
|
|
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
|
|
|
|
|
|
def test_pick_sky_btn_present_in_sky_select_phase(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertContains(response, "id_pick_sky_btn")
|
|
|
|
|
|
|
|
|
|
|
|
def test_tray_cell_2_contains_sig_card_icon_in_sky_select(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertContains(response, "tray-sig-card")
|
|
|
|
|
|
|
2026-04-13 00:34:05 -04:00
|
|
|
|
def test_pick_sky_btn_hidden_during_sig_select(self):
|
|
|
|
|
|
# Rendered hidden (display:none) so JS can reveal it on pick_sky_available WS event
|
2026-04-09 01:17:24 -04:00
|
|
|
|
self.room.table_status = Room.SIG_SELECT
|
|
|
|
|
|
self.room.save()
|
|
|
|
|
|
response = self.client.get(self.url)
|
2026-04-13 00:34:05 -04:00
|
|
|
|
self.assertContains(response, 'id="id_pick_sky_btn"')
|
|
|
|
|
|
self.assertContains(response, 'style="display:none"')
|
COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%
New/extended tests across billboard, dashboard, drama, epic, gameboard,
and lyric to cover previously untested branches: dev_login view, scroll
position endpoints, sky preview error paths, drama to_prose/to_activity
branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology
model __str__, character model, sig reserve/ready/confirm views, natus
preview/save views, and the full tasks.py countdown scheduler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:23:28 -04:00
|
|
|
|
|
2026-05-08 15:39:07 -04:00
|
|
|
|
def test_sky_delete_clears_seat_character_and_returns_json(self):
|
|
|
|
|
|
"""POST epic:sky_delete clears any Character on the requesting gamer's
|
|
|
|
|
|
seat — both unconfirmed drafts AND confirmed ones (the latter case is
|
|
|
|
|
|
why un-saved-via-DEL data was rehydrating on refresh: a SAVE SKY click
|
|
|
|
|
|
confirms a Character, and only that seat's Character row is the durable
|
|
|
|
|
|
target the in-room DEL has to purge)."""
|
|
|
|
|
|
# Seed both a draft & a confirmed Character — DEL must clear them both
|
|
|
|
|
|
from apps.epic.models import Character
|
|
|
|
|
|
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
|
|
|
|
|
# Confirmed (the SAVE SKY case)
|
|
|
|
|
|
confirmed = Character.objects.create(
|
|
|
|
|
|
seat=pc_seat,
|
|
|
|
|
|
chart_data={"planets": {"Sun": {"sign": "Gemini"}}},
|
|
|
|
|
|
confirmed_at=timezone.now(),
|
|
|
|
|
|
)
|
|
|
|
|
|
url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
response = self.client.post(url)
|
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
self.assertJSONEqual(response.content, {"deleted": True})
|
|
|
|
|
|
self.assertFalse(
|
|
|
|
|
|
Character.objects.filter(seat=pc_seat, retired_at__isnull=True).exists(),
|
|
|
|
|
|
"Both draft and confirmed Characters on the seat should be gone",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sky_delete_405_on_get(self):
|
|
|
|
|
|
url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
self.assertEqual(self.client.get(url).status_code, 405)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sky_delete_requires_seat_owner(self):
|
|
|
|
|
|
"""A gamer who isn't seated at this room can't purge another seat."""
|
|
|
|
|
|
outsider = User.objects.create(email="outsider@test.io")
|
|
|
|
|
|
self.client.force_login(outsider)
|
|
|
|
|
|
url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
self.assertEqual(self.client.post(url).status_code, 403)
|
|
|
|
|
|
|
|
|
|
|
|
def test_sky_delete_does_not_touch_user_model(self):
|
|
|
|
|
|
"""In-room DEL targets the seat's Character, never the User-level
|
|
|
|
|
|
sky_chart_data. (The Dashsky / My Sky applet DEL is the one that
|
|
|
|
|
|
clears the user's saved sky.)"""
|
|
|
|
|
|
founder = self.gamers[0]
|
|
|
|
|
|
founder.sky_chart_data = {"planets": {"Sun": {"sign": "Gemini"}}}
|
|
|
|
|
|
founder.sky_birth_tz = "America/New_York"
|
|
|
|
|
|
founder.save()
|
|
|
|
|
|
url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
self.client.post(url)
|
|
|
|
|
|
founder.refresh_from_db()
|
|
|
|
|
|
self.assertEqual(founder.sky_chart_data, {"planets": {"Sun": {"sign": "Gemini"}}})
|
|
|
|
|
|
self.assertEqual(founder.sky_birth_tz, "America/New_York")
|
|
|
|
|
|
|
2026-05-08 14:56:43 -04:00
|
|
|
|
def test_no_sky_delete_btn_in_blank_sky_select_modal(self):
|
|
|
|
|
|
"""A fresh PICK SKY modal (no preview wheel rendered yet) must not
|
|
|
|
|
|
carry the DEL btn — it would otherwise float in the empty wheel area
|
|
|
|
|
|
suggesting there's something to delete when the user has only seen
|
|
|
|
|
|
the form. The JS schedulePreview success handler is the contract that
|
|
|
|
|
|
injects the btn after the wheel paints — so the rendered HTML should
|
|
|
|
|
|
carry no <button id="id_sky_delete_btn"> markup. (The literal string
|
|
|
|
|
|
does still appear inside the inline <script> that does the injection,
|
|
|
|
|
|
so the assertion targets the rendered attribute syntax, not the bare
|
|
|
|
|
|
identifier.)"""
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertNotContains(response, 'id="id_sky_delete_btn"')
|
|
|
|
|
|
|
COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%
New/extended tests across billboard, dashboard, drama, epic, gameboard,
and lyric to cover previously untested branches: dev_login view, scroll
position endpoints, sky preview error paths, drama to_prose/to_activity
branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology
model __str__, character model, sig reserve/ready/confirm views, natus
preview/save views, and the full tasks.py countdown scheduler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:23:28 -04:00
|
|
|
|
|
2026-04-26 21:30:27 -04:00
|
|
|
|
# ── SEA_SELECT rendering ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class PickSeaRenderingTest(TestCase):
|
|
|
|
|
|
"""At SKY_SELECT, a confirmed Character swaps PICK SKY → PICK SEA + sea overlay."""
|
|
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
|
|
|
|
|
self.room.table_status = Room.SKY_SELECT
|
|
|
|
|
|
self.room.save()
|
|
|
|
|
|
self.sig_card = TarotCard.objects.get(
|
|
|
|
|
|
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
|
|
|
|
|
)
|
|
|
|
|
|
self.pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
|
|
|
|
|
self.pc_seat.significator = self.sig_card
|
|
|
|
|
|
self.pc_seat.save()
|
|
|
|
|
|
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
|
|
|
|
|
|
def _confirm_sky(self, seat=None):
|
|
|
|
|
|
target = seat or self.pc_seat
|
|
|
|
|
|
return Character.objects.create(seat=target, confirmed_at=timezone.now())
|
|
|
|
|
|
|
|
|
|
|
|
def test_sky_confirmed_false_when_no_character(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertFalse(response.context["sky_confirmed"])
|
|
|
|
|
|
|
|
|
|
|
|
def test_sky_confirmed_true_when_character_confirmed(self):
|
|
|
|
|
|
self._confirm_sky()
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertTrue(response.context["sky_confirmed"])
|
|
|
|
|
|
|
|
|
|
|
|
def test_pick_sea_btn_shown_when_sky_confirmed(self):
|
|
|
|
|
|
self._confirm_sky()
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertContains(response, "id_pick_sea_btn")
|
|
|
|
|
|
|
|
|
|
|
|
def test_pick_sky_btn_shown_when_sky_not_confirmed(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertContains(response, "id_pick_sky_btn")
|
|
|
|
|
|
|
|
|
|
|
|
def test_sea_overlay_included_when_sky_confirmed(self):
|
|
|
|
|
|
self._confirm_sky()
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertContains(response, "id_sea_overlay")
|
|
|
|
|
|
|
|
|
|
|
|
def test_sea_overlay_select_defaults_to_waite_smith_for_levity(self):
|
|
|
|
|
|
self._confirm_sky()
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertContains(response, "Celtic Cross, Waite-Smith")
|
|
|
|
|
|
|
|
|
|
|
|
def test_sea_overlay_select_defaults_to_escape_velocity_for_gravity(self):
|
|
|
|
|
|
ec_gamer = self.gamers[2] # EC — gravity
|
|
|
|
|
|
self.client.force_login(ec_gamer)
|
|
|
|
|
|
ec_seat = TableSeat.objects.get(room=self.room, role="EC")
|
|
|
|
|
|
self._confirm_sky(seat=ec_seat)
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertContains(response, "Celtic Cross, Escape Velocity")
|
|
|
|
|
|
|
|
|
|
|
|
def test_user_polarity_in_context_at_sky_select(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertIn("user_polarity", response.context)
|
|
|
|
|
|
self.assertEqual(response.context["user_polarity"], "levity") # PC is levity
|
|
|
|
|
|
|
2026-04-29 00:20:55 -04:00
|
|
|
|
def test_my_tray_sig_falls_back_to_seat_when_char_sig_is_none(self):
|
|
|
|
|
|
"""Characters created before the sig-sync fix have significator=None; fall back to seat."""
|
|
|
|
|
|
Character.objects.create(
|
|
|
|
|
|
seat=self.pc_seat,
|
|
|
|
|
|
significator=None,
|
|
|
|
|
|
confirmed_at=timezone.now(),
|
|
|
|
|
|
)
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertEqual(response.context["my_tray_sig"], self.sig_card)
|
|
|
|
|
|
|
2026-04-28 21:46:21 -04:00
|
|
|
|
def test_my_tray_sig_comes_from_character_significator_when_confirmed(self):
|
|
|
|
|
|
"""When sky_confirmed, my_tray_sig reads from Character.significator (not TableSeat)."""
|
|
|
|
|
|
char = Character.objects.create(
|
|
|
|
|
|
seat=self.pc_seat,
|
|
|
|
|
|
significator=self.sig_card,
|
|
|
|
|
|
confirmed_at=timezone.now(),
|
|
|
|
|
|
)
|
|
|
|
|
|
# Give the seat a *different* sig card so we can distinguish the sources
|
|
|
|
|
|
other_card = TarotCard.objects.get(
|
|
|
|
|
|
deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=11
|
|
|
|
|
|
)
|
|
|
|
|
|
self.pc_seat.significator = other_card
|
|
|
|
|
|
self.pc_seat.save()
|
|
|
|
|
|
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertEqual(response.context["my_tray_sig"], char.significator)
|
|
|
|
|
|
self.assertNotEqual(response.context["my_tray_sig"], other_card)
|
|
|
|
|
|
|
2026-04-26 21:30:27 -04:00
|
|
|
|
|
COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%
New/extended tests across billboard, dashboard, drama, epic, gameboard,
and lyric to cover previously untested branches: dev_login view, scroll
position endpoints, sky preview error paths, drama to_prose/to_activity
branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology
model __str__, character model, sig reserve/ready/confirm views, natus
preview/save views, and the full tasks.py countdown scheduler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:23:28 -04:00
|
|
|
|
# ── select_role GET redirect ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class SelectRoleGetRedirectTest(TestCase):
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
self.user = User.objects.create(email="gamer@sr.io")
|
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
self.room = Room.objects.create(name="R", owner=self.user)
|
|
|
|
|
|
self.room.table_status = Room.ROLE_SELECT
|
|
|
|
|
|
self.room.save()
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_redirects_to_room(self):
|
|
|
|
|
|
response = self.client.get(reverse("epic:select_role", kwargs={"room_id": self.room.id}))
|
|
|
|
|
|
self.assertRedirects(response, reverse("epic:room", kwargs={"room_id": self.room.id}),
|
|
|
|
|
|
fetch_redirect_response=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── sig_reserve / sig_ready / sig_confirm / select_sig helpers ────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def _make_sig_room(owner, *extra_gamers):
|
|
|
|
|
|
room = Room.objects.create(name="SR", owner=owner)
|
|
|
|
|
|
seat_map = {}
|
|
|
|
|
|
gamers = [owner] + list(extra_gamers)
|
|
|
|
|
|
roles = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
|
|
|
|
|
for i, (gamer, role) in enumerate(zip(gamers, roles), start=1):
|
|
|
|
|
|
seat = TableSeat.objects.create(room=room, gamer=gamer, slot_number=i, role=role)
|
|
|
|
|
|
seat_map[role] = seat
|
|
|
|
|
|
room.table_status = Room.SIG_SELECT
|
|
|
|
|
|
room.save()
|
|
|
|
|
|
return room, seat_map
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SelectSigViewTest(TestCase):
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
self.user = User.objects.create(email="pc@selsig.io")
|
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
self.room, self.seats = _make_sig_room(self.user)
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_post_redirects(self):
|
|
|
|
|
|
response = self.client.get(reverse("epic:select_sig", kwargs={"room_id": self.room.id}))
|
|
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
|
|
|
|
|
|
|
|
def test_nonexistent_card_returns_400(self):
|
|
|
|
|
|
response = self.client.post(
|
|
|
|
|
|
reverse("epic:select_sig", kwargs={"room_id": self.room.id}),
|
|
|
|
|
|
{"card_id": "99999999"},
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
|
|
|
|
|
|
|
|
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
|
# ── sky_preview (epic) ──────────────────────────────────────────────────────
|
COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%
New/extended tests across billboard, dashboard, drama, epic, gameboard,
and lyric to cover previously untested branches: dev_login view, scroll
position endpoints, sky preview error paths, drama to_prose/to_activity
branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology
model __str__, character model, sig reserve/ready/confirm views, natus
preview/save views, and the full tasks.py countdown scheduler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:23:28 -04:00
|
|
|
|
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
|
class SkyPreviewViewTest(TestCase):
|
COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%
New/extended tests across billboard, dashboard, drama, epic, gameboard,
and lyric to cover previously untested branches: dev_login view, scroll
position endpoints, sky preview error paths, drama to_prose/to_activity
branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology
model __str__, character model, sig reserve/ready/confirm views, natus
preview/save views, and the full tasks.py countdown scheduler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:23:28 -04:00
|
|
|
|
def setUp(self):
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
|
self.user = User.objects.create(email="pc@sky.io")
|
COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%
New/extended tests across billboard, dashboard, drama, epic, gameboard,
and lyric to cover previously untested branches: dev_login view, scroll
position endpoints, sky preview error paths, drama to_prose/to_activity
branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology
model __str__, character model, sig reserve/ready/confirm views, natus
preview/save views, and the full tasks.py countdown scheduler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:23:28 -04:00
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
self.room, _ = _make_sig_room(self.user)
|
|
|
|
|
|
self.room.table_status = Room.SKY_SELECT
|
|
|
|
|
|
self.room.save()
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
|
self.url = reverse("epic:sky_preview", kwargs={"room_id": self.room.id})
|
COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%
New/extended tests across billboard, dashboard, drama, epic, gameboard,
and lyric to cover previously untested branches: dev_login view, scroll
position endpoints, sky preview error paths, drama to_prose/to_activity
branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology
model __str__, character model, sig reserve/ready/confirm views, natus
preview/save views, and the full tasks.py countdown scheduler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:23:28 -04:00
|
|
|
|
|
|
|
|
|
|
def test_missing_params_returns_400(self):
|
|
|
|
|
|
response = self.client.get(self.url, {"date": "1990-06-15"})
|
|
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_numeric_lat_returns_400(self):
|
|
|
|
|
|
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "abc", "lon": "0"})
|
|
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
|
|
|
|
|
|
|
|
def test_out_of_range_lat_returns_400(self):
|
|
|
|
|
|
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "999", "lon": "0"})
|
|
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalid_tz_string_returns_400(self):
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
self.url,
|
|
|
|
|
|
{"date": "1990-06-15", "lat": "51.5", "lon": "-0.1", "tz": "Not/Real"},
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
|
|
|
|
|
|
|
|
def test_bad_date_format_returns_400(self):
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
self.url,
|
|
|
|
|
|
{"date": "baddate", "time": "09:00", "lat": "51.5", "lon": "-0.1", "tz": "UTC"},
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
|
|
|
|
|
|
|
|
@patch("apps.epic.views.http_requests")
|
|
|
|
|
|
def test_pyswiss_failure_returns_502(self, mock_requests):
|
|
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
|
|
tz_r = MagicMock()
|
|
|
|
|
|
tz_r.json.return_value = {"timezone": "UTC"}
|
|
|
|
|
|
tz_r.raise_for_status = MagicMock()
|
|
|
|
|
|
chart_r = MagicMock()
|
|
|
|
|
|
chart_r.raise_for_status.side_effect = Exception("timeout")
|
|
|
|
|
|
mock_requests.get.side_effect = [tz_r, chart_r]
|
|
|
|
|
|
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
|
|
|
|
|
|
self.assertEqual(response.status_code, 502)
|
|
|
|
|
|
|
|
|
|
|
|
@patch("apps.epic.views.http_requests")
|
|
|
|
|
|
def test_success_returns_chart_distinctions_timezone(self, mock_requests):
|
|
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"planets": {"Sun": {"degree": 84.5}},
|
|
|
|
|
|
"houses": {"cusps": [0] * 12},
|
|
|
|
|
|
"elements": {"Earth": 1},
|
|
|
|
|
|
"house_system": "O",
|
|
|
|
|
|
}
|
|
|
|
|
|
tz_r = MagicMock()
|
|
|
|
|
|
tz_r.json.return_value = {"timezone": "Europe/London"}
|
|
|
|
|
|
tz_r.raise_for_status = MagicMock()
|
|
|
|
|
|
ch_r = MagicMock()
|
|
|
|
|
|
ch_r.json.return_value = payload
|
|
|
|
|
|
ch_r.raise_for_status = MagicMock()
|
|
|
|
|
|
mock_requests.get.side_effect = [tz_r, ch_r]
|
|
|
|
|
|
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
|
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
self.assertIn("distinctions", data)
|
|
|
|
|
|
self.assertIn("Stone", data["elements"])
|
|
|
|
|
|
self.assertNotIn("Earth", data["elements"])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 15:56:36 -04:00
|
|
|
|
# ── tarot_deal ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class TarotDealViewTest(TestCase):
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
self.user = User.objects.create(email="dealer@test.io")
|
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
self.room, _ = _make_sig_room(self.user)
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_post_redirects_to_tarot_deck(self):
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
reverse("epic:tarot_deal", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertRedirects(
|
|
|
|
|
|
response,
|
|
|
|
|
|
reverse("epic:tarot_deck", kwargs={"room_id": self.room.id}),
|
|
|
|
|
|
fetch_redirect_response=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
|
# ── sky_save (epic) ─────────────────────────────────────────────────────────
|
COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%
New/extended tests across billboard, dashboard, drama, epic, gameboard,
and lyric to cover previously untested branches: dev_login view, scroll
position endpoints, sky preview error paths, drama to_prose/to_activity
branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology
model __str__, character model, sig reserve/ready/confirm views, natus
preview/save views, and the full tasks.py countdown scheduler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:23:28 -04:00
|
|
|
|
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
|
class SkySaveViewTest(TestCase):
|
COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%
New/extended tests across billboard, dashboard, drama, epic, gameboard,
and lyric to cover previously untested branches: dev_login view, scroll
position endpoints, sky preview error paths, drama to_prose/to_activity
branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology
model __str__, character model, sig reserve/ready/confirm views, natus
preview/save views, and the full tasks.py countdown scheduler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:23:28 -04:00
|
|
|
|
def setUp(self):
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
|
self.user = User.objects.create(email="pc@skysave.io")
|
COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%
New/extended tests across billboard, dashboard, drama, epic, gameboard,
and lyric to cover previously untested branches: dev_login view, scroll
position endpoints, sky preview error paths, drama to_prose/to_activity
branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology
model __str__, character model, sig reserve/ready/confirm views, natus
preview/save views, and the full tasks.py countdown scheduler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:23:28 -04:00
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
|
self.room, _ = _make_sig_room(self.user)
|
|
|
|
|
|
self.room.table_status = Room.SKY_SELECT
|
|
|
|
|
|
self.room.save()
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
|
self.url = reverse("epic:sky_save", kwargs={"room_id": self.room.id})
|
COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%
New/extended tests across billboard, dashboard, drama, epic, gameboard,
and lyric to cover previously untested branches: dev_login view, scroll
position endpoints, sky preview error paths, drama to_prose/to_activity
branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology
model __str__, character model, sig reserve/ready/confirm views, natus
preview/save views, and the full tasks.py countdown scheduler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:23:28 -04:00
|
|
|
|
|
|
|
|
|
|
def _post(self, payload):
|
|
|
|
|
|
import json as _json
|
|
|
|
|
|
return self.client.post(self.url, data=_json.dumps(payload), content_type="application/json")
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_returns_405(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertEqual(response.status_code, 405)
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalid_json_returns_400(self):
|
|
|
|
|
|
response = self.client.post(self.url, data="not json", content_type="application/json")
|
|
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_draft_returns_id_and_not_confirmed(self):
|
|
|
|
|
|
response = self._post({
|
|
|
|
|
|
"birth_dt": "1990-06-15T09:00:00Z",
|
|
|
|
|
|
"birth_lat": 51.5,
|
|
|
|
|
|
"birth_lon": -0.1,
|
|
|
|
|
|
"birth_place": "London",
|
|
|
|
|
|
"house_system": "O",
|
|
|
|
|
|
"chart_data": {},
|
|
|
|
|
|
})
|
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
self.assertIn("id", data)
|
|
|
|
|
|
self.assertFalse(data["confirmed"])
|
|
|
|
|
|
|
|
|
|
|
|
def test_confirm_action_locks_character(self):
|
|
|
|
|
|
response = self._post({
|
|
|
|
|
|
"birth_dt": "1990-06-15T09:00:00Z",
|
|
|
|
|
|
"birth_lat": 51.5,
|
|
|
|
|
|
"birth_lon": -0.1,
|
|
|
|
|
|
"birth_place": "",
|
|
|
|
|
|
"house_system": "O",
|
|
|
|
|
|
"chart_data": {},
|
|
|
|
|
|
"action": "confirm",
|
|
|
|
|
|
})
|
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
self.assertTrue(response.json()["confirmed"])
|
2026-04-28 21:46:21 -04:00
|
|
|
|
|
SAVE SKY provenance + sky→hex (not sky→sea) transition — TDD
- drama.GameEvent.SKY_SAVED verb + to_prose branch: "X beholds the skyscape of {poss} birth, which yields {obj} a unique {Cap} capacity."; tied highest scores switch "a unique" → "equal", join w. "and" (2-way) or Oxford comma (3+), and pluralize "capacity" → "capacities"; pronouns resolved from actor.pronouns at render time, same machinery as SIG_READY/ROLE_SELECTED
- epic.utils.ELEMENT_CAPACITOR_NAMES + ELEMENT_ORDER + top_capacitors(elements) helper: maps Fire→Ardor Stone→Ossum Time→Tempo Space→Nexus Air→Pneuma Water→Humor; tolerates both flat-int and enriched-dict (`{count, contributors}`) chart_data shapes; returns capacitor names tied for highest count, ordered by canonical wheel ring
- epic.natus_save: on action=confirm, records GameEvent.SKY_SAVED w. top_capacitors=[…] before _notify_sky_confirmed; per-room billscroll AND billboard Most Recent Scroll pick up the new prose
- _natus_overlay.html _onSkyConfirmed: removed sea-partial fetch+inject; now calls closeNatus() + window.location.reload() so the gamer lands on the table hex w. the PICK SKY → PICK SEA btn swap (server-side, driven by sky_confirmed=True), then opts into the sea overlay manually. The auto-launch via 39e12d6 was buried by FTs that were pinning the wrong contract — gamer never had a chance to witness PICK SEA on the hex
- test_room_sea_select.py: three FTs renamed/rewired from auto-launch assertions (sea_overlay_appears_without_page_refresh, natus_overlay_not_visible_after_sky_confirm, sea_open_class_on_html_after_confirm) to (pick_sea_btn_visible_after_sky_confirm, natus_overlay_closed_after_sky_confirm, clicking_pick_sea_btn_opens_sea_overlay) — sea overlay now requires explicit PICK SEA click
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:57:35 -04:00
|
|
|
|
def test_confirm_records_sky_saved_event_with_top_capacitors(self):
|
|
|
|
|
|
"""When action=confirm, log a SKY_SAVED GameEvent w. the highest-count
|
|
|
|
|
|
capacitor name(s) so the billscroll can render the new prose."""
|
|
|
|
|
|
from apps.drama.models import GameEvent
|
|
|
|
|
|
chart = {
|
|
|
|
|
|
"elements": {
|
|
|
|
|
|
# Earthman uses 6 elements; canonical names map to capacitors:
|
|
|
|
|
|
# Fire→Ardor Stone→Ossum Air→Pneuma Water→Humor Time→Tempo Space→Nexus.
|
|
|
|
|
|
"Fire": 3,
|
|
|
|
|
|
"Stone": 1,
|
|
|
|
|
|
"Air": 2,
|
|
|
|
|
|
"Water": 0,
|
|
|
|
|
|
"Time": 1,
|
|
|
|
|
|
"Space": 1,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
self._post({
|
|
|
|
|
|
"birth_dt": "1990-06-15T09:00:00Z",
|
|
|
|
|
|
"birth_lat": 51.5, "birth_lon": -0.1,
|
|
|
|
|
|
"birth_place": "", "house_system": "O",
|
|
|
|
|
|
"chart_data": chart, "action": "confirm",
|
|
|
|
|
|
})
|
|
|
|
|
|
event = GameEvent.objects.get(room=self.room, verb=GameEvent.SKY_SAVED)
|
|
|
|
|
|
self.assertEqual(event.actor, self.user)
|
|
|
|
|
|
self.assertEqual(event.data.get("top_capacitors"), ["Ardor"])
|
|
|
|
|
|
|
|
|
|
|
|
def test_confirm_records_sky_saved_event_with_two_way_tie(self):
|
|
|
|
|
|
from apps.drama.models import GameEvent
|
|
|
|
|
|
chart = {
|
|
|
|
|
|
"elements": {
|
|
|
|
|
|
"Fire": 3, "Stone": 3, # tied at top
|
|
|
|
|
|
"Air": 2, "Water": 0, "Time": 1, "Space": 1,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
self._post({
|
|
|
|
|
|
"birth_dt": "1990-06-15T09:00:00Z",
|
|
|
|
|
|
"birth_lat": 51.5, "birth_lon": -0.1,
|
|
|
|
|
|
"birth_place": "", "house_system": "O",
|
|
|
|
|
|
"chart_data": chart, "action": "confirm",
|
|
|
|
|
|
})
|
|
|
|
|
|
event = GameEvent.objects.get(room=self.room, verb=GameEvent.SKY_SAVED)
|
|
|
|
|
|
# Order follows the canonical ELEMENT_ORDER (Fire, Stone, Time, Space, Air, Water)
|
|
|
|
|
|
self.assertEqual(event.data.get("top_capacitors"), ["Ardor", "Ossum"])
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_without_confirm_does_not_record_sky_saved_event(self):
|
|
|
|
|
|
from apps.drama.models import GameEvent
|
|
|
|
|
|
self._post({
|
|
|
|
|
|
"birth_dt": "1990-06-15T09:00:00Z",
|
|
|
|
|
|
"birth_lat": 51.5, "birth_lon": -0.1,
|
|
|
|
|
|
"birth_place": "", "house_system": "O",
|
|
|
|
|
|
"chart_data": {"elements": {"Fire": 3}},
|
|
|
|
|
|
# no action=confirm — just a draft save
|
|
|
|
|
|
})
|
|
|
|
|
|
self.assertFalse(
|
|
|
|
|
|
GameEvent.objects.filter(room=self.room, verb=GameEvent.SKY_SAVED).exists()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_confirm_with_dict_shaped_elements_extracts_count(self):
|
|
|
|
|
|
"""Some chart payloads enrich each element to {count, contributors};
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
|
sky_save should read .count rather than treating the dict as a value."""
|
SAVE SKY provenance + sky→hex (not sky→sea) transition — TDD
- drama.GameEvent.SKY_SAVED verb + to_prose branch: "X beholds the skyscape of {poss} birth, which yields {obj} a unique {Cap} capacity."; tied highest scores switch "a unique" → "equal", join w. "and" (2-way) or Oxford comma (3+), and pluralize "capacity" → "capacities"; pronouns resolved from actor.pronouns at render time, same machinery as SIG_READY/ROLE_SELECTED
- epic.utils.ELEMENT_CAPACITOR_NAMES + ELEMENT_ORDER + top_capacitors(elements) helper: maps Fire→Ardor Stone→Ossum Time→Tempo Space→Nexus Air→Pneuma Water→Humor; tolerates both flat-int and enriched-dict (`{count, contributors}`) chart_data shapes; returns capacitor names tied for highest count, ordered by canonical wheel ring
- epic.natus_save: on action=confirm, records GameEvent.SKY_SAVED w. top_capacitors=[…] before _notify_sky_confirmed; per-room billscroll AND billboard Most Recent Scroll pick up the new prose
- _natus_overlay.html _onSkyConfirmed: removed sea-partial fetch+inject; now calls closeNatus() + window.location.reload() so the gamer lands on the table hex w. the PICK SKY → PICK SEA btn swap (server-side, driven by sky_confirmed=True), then opts into the sea overlay manually. The auto-launch via 39e12d6 was buried by FTs that were pinning the wrong contract — gamer never had a chance to witness PICK SEA on the hex
- test_room_sea_select.py: three FTs renamed/rewired from auto-launch assertions (sea_overlay_appears_without_page_refresh, natus_overlay_not_visible_after_sky_confirm, sea_open_class_on_html_after_confirm) to (pick_sea_btn_visible_after_sky_confirm, natus_overlay_closed_after_sky_confirm, clicking_pick_sea_btn_opens_sea_overlay) — sea overlay now requires explicit PICK SEA click
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:57:35 -04:00
|
|
|
|
from apps.drama.models import GameEvent
|
|
|
|
|
|
chart = {
|
|
|
|
|
|
"elements": {
|
|
|
|
|
|
"Fire": {"count": 4, "contributors": ["Sun", "Mars", "Jupiter", "Pluto"]},
|
|
|
|
|
|
"Stone": {"count": 1, "contributors": ["Venus"]},
|
|
|
|
|
|
"Air": {"count": 2, "contributors": ["Mercury", "Uranus"]},
|
|
|
|
|
|
"Water": {"count": 0, "contributors": []},
|
|
|
|
|
|
"Time": {"count": 1, "stellia": ["Saturn"]},
|
|
|
|
|
|
"Space": {"count": 1, "parades": ["Neptune"]},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
self._post({
|
|
|
|
|
|
"birth_dt": "1990-06-15T09:00:00Z",
|
|
|
|
|
|
"birth_lat": 51.5, "birth_lon": -0.1,
|
|
|
|
|
|
"birth_place": "", "house_system": "O",
|
|
|
|
|
|
"chart_data": chart, "action": "confirm",
|
|
|
|
|
|
})
|
|
|
|
|
|
event = GameEvent.objects.get(room=self.room, verb=GameEvent.SKY_SAVED)
|
|
|
|
|
|
self.assertEqual(event.data.get("top_capacitors"), ["Ardor"])
|
|
|
|
|
|
|
2026-04-28 21:46:21 -04:00
|
|
|
|
def test_confirm_copies_seat_significator_to_character(self):
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
|
"""sky_save with action=confirm copies seat.significator onto Character."""
|
2026-04-28 21:46:21 -04:00
|
|
|
|
earthman, _ = DeckVariant.objects.get_or_create(
|
|
|
|
|
|
slug="earthman", defaults={"name": "Earthman Deck", "card_count": 108}
|
|
|
|
|
|
)
|
|
|
|
|
|
sig_card = TarotCard.objects.filter(deck_variant=earthman).first()
|
|
|
|
|
|
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
|
|
|
|
|
pc_seat.significator = sig_card
|
|
|
|
|
|
pc_seat.save()
|
|
|
|
|
|
|
|
|
|
|
|
self._post({
|
|
|
|
|
|
"birth_dt": "1990-06-15T09:00:00Z",
|
|
|
|
|
|
"birth_lat": 51.5, "birth_lon": -0.1,
|
|
|
|
|
|
"birth_place": "", "house_system": "O",
|
|
|
|
|
|
"chart_data": {}, "action": "confirm",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
char = Character.objects.get(seat=pc_seat)
|
|
|
|
|
|
self.assertEqual(char.significator, sig_card)
|