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-05-12 23:14:01 -04:00
|
|
|
|
def test_create_room_records_welcome_event_with_no_actor(self):
|
|
|
|
|
|
"""First scroll log on a fresh room is a system-authored welcome —
|
|
|
|
|
|
not a user action, so actor=None. The visible greeting is the
|
|
|
|
|
|
ROOM_CREATED event's `to_prose` ("Welcome to <name>!")."""
|
|
|
|
|
|
self.client.post(
|
|
|
|
|
|
reverse("epic:create_room"),
|
|
|
|
|
|
data={"name": "Welcoming Room"},
|
|
|
|
|
|
)
|
|
|
|
|
|
room = Room.objects.get(owner=self.user)
|
|
|
|
|
|
event = room.events.first()
|
|
|
|
|
|
self.assertIsNotNone(event, "no ROOM_CREATED event recorded")
|
|
|
|
|
|
self.assertEqual(event.verb, GameEvent.ROOM_CREATED)
|
|
|
|
|
|
self.assertIsNone(event.actor, "welcome line must be system-authored")
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_room_welcome_event_renders_welcome_prose(self):
|
|
|
|
|
|
self.client.post(
|
|
|
|
|
|
reverse("epic:create_room"),
|
|
|
|
|
|
data={"name": "Greenroom"},
|
|
|
|
|
|
)
|
|
|
|
|
|
room = Room.objects.get(owner=self.user)
|
|
|
|
|
|
event = room.events.first()
|
|
|
|
|
|
self.assertEqual(event.to_prose(), "Welcome to Greenroom!")
|
|
|
|
|
|
|
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):
|
2026-05-21 13:56:59 -04:00
|
|
|
|
"""Equipped PASS picked over the still-equipped-by-default COIN —
|
|
|
|
|
|
confirm leaves PASS untouched + doesn't lease the COIN (PASS is
|
|
|
|
|
|
never-consumed; COIN stays free for a future room). The equip
|
|
|
|
|
|
slot is the precondition; DON-ing PASS swaps it in for the auto-
|
|
|
|
|
|
equipped COIN."""
|
2026-03-14 22:00:16 -04:00
|
|
|
|
self.gamer.is_staff = True
|
|
|
|
|
|
self.gamer.save()
|
|
|
|
|
|
pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
|
2026-05-21 13:56:59 -04:00
|
|
|
|
self.gamer.equipped_trinket = pass_token
|
|
|
|
|
|
self.gamer.save(update_fields=["equipped_trinket"])
|
2026-03-14 22:00:16 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
fix CARTE multi-seat Role-Select bug on navigate-away + back; My Sign applet rename
**CARTE bug** (user-reported on iPhone): a CARTE gamer who contributed their deck to multiple gate slots could fill ≥1 role for ≥1 seat, navigate away (BYE → dashboard, CONT GAME → return, etc.), come back to the room — and the JS guard on .card-stack would wrongly fire "Equip card deck before Role select" + block further role picks, even though the deck was demonstrably in play on existing seats. Symmetric for the "stay in room during Role Select" variant the user thought we'd squashed before (the prior fix was 759ce8d for the multi-slot SELECT path, but the room VIEW context never got the same treatment) ; **root cause**: `select_role()` at epic/views.py:619-621 clears `user.equipped_deck` after the first role pick ("deck committed to room"). The room view's role-select context at epic/views.py:286 then passes `equipped_deck_id = user.equipped_deck_id` to the template — which is now None — and the template renders `data-equipped-deck=""` → JS guard at role-select.js:165 sees the empty string and fires the "no deck" warning. The deck IS in play; the context just isn't recognizing seat-level deck assignment as a deck source ; **fix** (epic/views.py:286ish): when `user.equipped_deck_id` is None, fall back to the deck_variant of any of the user's seats in this room (order_by slot_number for determinism). The guard now sees a non-empty id and the fan opens. Storage-side unchanged — seat.deck_variant remains the canonical "this deck is in play on this seat" signal, and the user's deck-third contribution per role (PC=levity brands+crowns / NC=levity trumps / SC=levity grails+blades / AC=gravity grails+blades / EC=gravity trumps / BC=gravity brands+crowns) flows from existing `select_role` logic that inherits deck_variant from the first seat ; **TDD trail** — 2 new ITs in `SelectRoleMultiSeatTest` (apps.epic.tests.integrated.test_views): T1 pins the context (`response.context["equipped_deck_id"]` equals the existing seat's deck_variant_id after `user.equipped_deck` clears); T2 pins the template (rendered `data-equipped-deck="<id>"` not `""`). Initial reds — `None != 2` + `data-equipped-deck=""` substring assertion. Fix lands both green ; **bundled: My Sign applet rename** — user clarified naming convention 2026-05-18: **applets** use the "My X" prefix (My Sign, My Sea, My Posts), **standalone pages** use the "Game/Dash/Bill X" prefix (Game Sign page, Game Sea page, Game Kit page). Sprint 4a's initial migration set the applet name to "Game Sign" — corrected after the user saw the gear-menu toggle list reading the wrong word. Applet template header link "Game Sign" → "My Sign" (user-edited); migration 0010 added to update the Applet row's `name` in already-migrated DBs (dev + staging); applets/0009 frontmatter + defaults updated to "My Sign" in case of a fresh migrate-from-zero; test seed helpers in billboard test_views.py + functional_tests/test_bill_my_sign.py updated to "My Sign". Slug stays `my-sign` (URL + selectors stable) ; **bundled: rootvars.scss** — user-modified mid-session (pre-staged) ; 1022 IT/UT green in 46s — no regressions; 4 ITs in SelectRoleMultiSeatTest green (2 pre-existing CARTE multi-seat ITs + 2 new return-trip context ITs)
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:18:32 -04:00
|
|
|
|
# ── Return-trip Role-Select bug — CARTE user navigates away + back ───── #
|
|
|
|
|
|
#
|
|
|
|
|
|
# After the FIRST role pick, select_role() clears user.equipped_deck
|
|
|
|
|
|
# ("deck committed to room"). The room view's role-select context then
|
|
|
|
|
|
# passes `equipped_deck_id = user.equipped_deck_id` to the template,
|
|
|
|
|
|
# which sets `data-equipped-deck=""` and JS guards against role-select
|
|
|
|
|
|
# with "Equip card deck before Role select." → blocks a CARTE user from
|
|
|
|
|
|
# continuing to pick roles for their remaining seats. Fix: the context's
|
|
|
|
|
|
# `equipped_deck_id` should also accept the deck_variant of any seat the
|
|
|
|
|
|
# user already holds in this room (the deck IS in play — it's just
|
|
|
|
|
|
# committed to existing seats, not to user.equipped_deck).
|
|
|
|
|
|
|
|
|
|
|
|
def test_role_select_context_recovers_deck_id_from_existing_seat(self):
|
|
|
|
|
|
"""User cleared their equipped_deck after first role pick, but they
|
|
|
|
|
|
still have a seat in this room w. deck_variant set → context should
|
|
|
|
|
|
report that deck's id so the guard doesn't fire on return."""
|
|
|
|
|
|
TableSeat.objects.create(
|
|
|
|
|
|
room=self.room, gamer=self.founder, slot_number=1,
|
|
|
|
|
|
role="PC", deck_variant=self.earthman,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Slot 2 still needs role (CARTE user's next seat)
|
|
|
|
|
|
TableSeat.objects.create(room=self.room, gamer=self.founder, slot_number=2)
|
|
|
|
|
|
self.founder.equipped_deck = None
|
|
|
|
|
|
self.founder.save(update_fields=["equipped_deck"])
|
|
|
|
|
|
# Simulate filled gate slots so the room renders in role-select state
|
|
|
|
|
|
for i in (1, 2):
|
|
|
|
|
|
slot = self.room.gate_slots.get(slot_number=i)
|
|
|
|
|
|
slot.gamer = self.founder
|
|
|
|
|
|
slot.status = GateSlot.FILLED
|
|
|
|
|
|
slot.save()
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
reverse("epic:room", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
response.context["equipped_deck_id"], self.earthman.id,
|
|
|
|
|
|
"Returning CARTE user should see their in-play deck reflected in "
|
|
|
|
|
|
"the role-select context so the JS guard doesn't fire.",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_role_select_context_renders_data_equipped_deck_non_empty(self):
|
|
|
|
|
|
"""Template-level check — the rendered `data-equipped-deck` attribute
|
|
|
|
|
|
should be non-empty so the JS guard at role-select.js:165 lets the
|
|
|
|
|
|
fan open."""
|
|
|
|
|
|
TableSeat.objects.create(
|
|
|
|
|
|
room=self.room, gamer=self.founder, slot_number=1,
|
|
|
|
|
|
role="PC", deck_variant=self.earthman,
|
|
|
|
|
|
)
|
|
|
|
|
|
TableSeat.objects.create(room=self.room, gamer=self.founder, slot_number=2)
|
|
|
|
|
|
self.founder.equipped_deck = None
|
|
|
|
|
|
self.founder.save(update_fields=["equipped_deck"])
|
|
|
|
|
|
for i in (1, 2):
|
|
|
|
|
|
slot = self.room.gate_slots.get(slot_number=i)
|
|
|
|
|
|
slot.gamer = self.founder
|
|
|
|
|
|
slot.status = GateSlot.FILLED
|
|
|
|
|
|
slot.save()
|
|
|
|
|
|
response = self.client.get(
|
|
|
|
|
|
reverse("epic:room", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
)
|
|
|
|
|
|
# Should NOT contain the empty-string version that triggers the guard.
|
|
|
|
|
|
self.assertNotContains(response, 'data-equipped-deck=""')
|
|
|
|
|
|
self.assertContains(response, f'data-equipped-deck="{self.earthman.id}"')
|
|
|
|
|
|
|
2026-04-27 23:24:43 -04:00
|
|
|
|
|
2026-04-04 14:33:35 -04:00
|
|
|
|
class RoomViewAllRolesFilledTest(TestCase):
|
btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default `.sig-stage .sig-stage-card .fan-card-face .sig-qualifier-*` rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each `.fan-card-reversal-*` class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-`<p>` skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two `<p>`s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 -04:00
|
|
|
|
"""Room view in ROLE_SELECT with all seats assigned shows SCAN SIGS button."""
|
2026-04-04 14:33:35 -04:00
|
|
|
|
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):
|
btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default `.sig-stage .sig-stage-card .fan-card-face .sig-qualifier-*` rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each `.fan-card-reversal-*` class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-`<p>` skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two `<p>`s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 -04:00
|
|
|
|
"""Room page at SKY_SELECT renders CAST SKY btn and sig card in tray cell 2."""
|
2026-04-09 01:17:24 -04:00
|
|
|
|
|
|
|
|
|
|
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):
|
btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default `.sig-stage .sig-stage-card .fan-card-face .sig-qualifier-*` rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each `.fan-card-reversal-*` class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-`<p>` skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two `<p>`s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 -04:00
|
|
|
|
"""A fresh CAST SKY modal (no preview wheel rendered yet) must not
|
2026-05-08 14:56:43 -04:00
|
|
|
|
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):
|
btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default `.sig-stage .sig-stage-card .fan-card-face .sig-qualifier-*` rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each `.fan-card-reversal-*` class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-`<p>` skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two `<p>`s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 -04:00
|
|
|
|
"""At SKY_SELECT, a confirmed Character swaps CAST SKY → DRAW SEA + sea overlay."""
|
2026-04-26 21:30:27 -04:00
|
|
|
|
|
|
|
|
|
|
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)
|
+52 IT/UT to close IT/UT-only coverage gaps (93% → 96%) — full suite 983 tests in 47s ; UTs in epic/tests/unit/test_models.py — `TarotCardEmanationForTest` (4) covers `emanation_for(polarity)` w. levity/gravity overrides + fallback to name_title for cards w.o a polarity split (cards 48-49 are the only polarity-split cards in the deck so this method is sparsely exercised by ITs); `TarotCardReversalForTest` (4) covers `reversal_for(polarity)` w. polarity-split + reversal_qualifier fallback + further fallthrough to emanation_for; `TarotCardNameSplitTest` (4) covers `name_group`/`name_title` colon-split parsing (prefix-w-colon / suffix / no-colon edge); `TarotCardCautionsJsonTest` (2) covers the `cautions_json` JSON serialiser ; UTs in epic/tests/unit/test_utils.py — `PlanetHouseFallbackTest` +1 happy-path test (degree=15 lands in house 1 w. sequential cusps) for the normal cusp-match branch alongside the existing pathological fallback test; `TopCapacitorsTest` (6) covers all `top_capacitors()` branches — empty dict / None / all-zero counts (the L56 `max(counts.values()) <= 0` fallback that was uncovered) / single-winner / tie-clockwise-order / enriched dict {"count":N} input shape ; ITs in epic/tests/integrated/test_models.py — `TarotDeckDrawTest` extended w. 5 tests for `remaining_count` (happy + no-deck-variant fallback to 0) + `draw()` happy-path (returns n tuples of (TarotCard, bool) / appends to drawn_card_ids / never repeats cards across consecutive draws); existing ValueError + shuffle tests preserved ; ITs in epic/tests/integrated/test_views.py — `SigEventRetractionTest` (4 tests) covers the three `data["retracted"] = True` paths that the FT `test_game_room_select_sig.py` walks transitively but no IT pins directly: sig_unready retracts prior SIG_READY (L937), sig_ready retracts prior SIG_UNREADY (L907), sig_reserve action=release while ready retracts prior SIG_READY + records fresh SIG_UNREADY (L823); `SigReserveInvalidCardIdTest` (1) covers `TarotCard.DoesNotExist` → 400 (L840-841) ; `SigSelectGravityContextTest` (3) covers the `user_polarity = 'gravity'` branch (L322) + the `gravity_sig_cards` lookup (L357) — all existing SIG_SELECT context tests use the founder-as-PC-levity setup so these branches sat uncovered; logs in as gamers[5] (BC role) + asserts user_polarity + sig_cards match `gravity_sig_cards()` output ; `SeaDeckViewTest` (7) mirrors the `test_game_room_select_sea.py` FT but isolates the JSON contract — covers 403 when unseated, empty halves when seat has no deck_variant (L1255-1256 early-out), two-halves shape, ~even split, card_dict keys (`id`/`name`/`arcana`/`corner_rank`/`suit_icon`/`name_group`/`name_title`/`reversed`/qualifiers), `reversed` field is bool, claimed-significator exclusion via `room.table_seats.exclude(significator__isnull=True)` ; ITs in dashboard/tests/integrated/test_views.py — `ProfileViewTest` +2 (reserved-handle "adman" rejection — L116-117: username stays unchanged + redirect to /); `KitBagViewTest` (3) covers the `kit_bag` view's panel render w. TITHE-sort branch (L169-175) + login guard ; ITs in dashboard/tests/integrated/test_sky_views.py — `SkyViewTest` +2 (saved birth datetime renders in user's `sky_birth_tz` via astimezone L300-306 — 16:00 UTC → 12:00 EDT; invalid-tz string triggers `ZoneInfoNotFoundError` → swallowed `pass` → UTC fallback at 16:00) ; ITs in gameboard/tests/integrated/test_views.py — `EquipTrinketViewTest` +2 (POST equips trinket + returns 204 — L83-85; non-owner POST returns 404 via `get_object_or_404`); `UnequipTrinketViewTest` +2 (POST clears matching equipped_trinket — L107-110; POST of non-matching token is a 204 no-op, the implicit `else` branch) ; .coveragerc omit gains `*/reset_staging_db.py` per user — mgmt cmd was the only 0%-stmt module that wasn't exercised by tests at all + we agreed it's deliberately untested staging-side code ; palette-monochrome-dark rebalance in rootvars.scss — --quiUser/--sixUser/--sepUser remapped to (secAg / quaAg / priPt) instead of (quaAg / terAg / secAg), shifting the secondary/subtle/deep-subtle anchors up the silver gradient so the palette reads more cleanly under the new sig-stage card colours from 3242873 ; uncovered remnants from earlier analysis intentionally left in place — consumers.py at 68% (channels-tag tests excluded; would need --tag=channels run), Carte Blanche slot navigation + sky_dice + tarot_deck preview view paths (the "bigger investments" tier from session triage; FT-covered + the IT setup is heavier than the immediate value), defensive `except` fallbacks that need contrived inputs to fire, and a handful of __str__s/`pass` branches not worth a test apiece — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 01:07:13 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── SIG event-retraction branches ────────────────────────────────────────────
|
|
|
|
|
|
# The provenance scrolls use a `data["retracted"] = True` flag to soft-cancel
|
|
|
|
|
|
# prior events when a gamer reverses themselves (WAIT NVM after SAVE SIG, etc).
|
|
|
|
|
|
# These three branches in sig_reserve / sig_ready are the load-bearing ones —
|
|
|
|
|
|
# without them a recanted action stays visible in the billboard scrollback.
|
|
|
|
|
|
|
|
|
|
|
|
class SigEventRetractionTest(TestCase):
|
|
|
|
|
|
"""`data["retracted"] = True` writes on the three reverse-direction paths."""
|
|
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
self.room, self.gamers, self.earthman, self.card = _full_sig_setUp(self)
|
|
|
|
|
|
# PC (founder) already logged in; reserve + go ready so subsequent
|
|
|
|
|
|
# actions have prior SIG_READY events to retract.
|
|
|
|
|
|
self.reserve_url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
self.ready_url = reverse("epic:sig_ready", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
|
|
|
|
|
|
def _reserve(self):
|
|
|
|
|
|
return self.client.post(self.reserve_url, data={
|
|
|
|
|
|
"card_id": self.card.id, "action": "reserve",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
def _ready(self, action="ready"):
|
|
|
|
|
|
return self.client.post(self.ready_url, data={"action": action})
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_unready_retracts_prior_sig_ready_event(self):
|
|
|
|
|
|
"""sig_ready action=unready flips `data["retracted"]=True` on the most
|
|
|
|
|
|
recent un-retracted SIG_READY event for this actor (views.py L937)."""
|
|
|
|
|
|
self._reserve()
|
|
|
|
|
|
self._ready(action="ready")
|
|
|
|
|
|
prior = self.room.events.filter(
|
|
|
|
|
|
actor=self.gamers[0], verb=GameEvent.SIG_READY
|
|
|
|
|
|
).last()
|
|
|
|
|
|
self.assertFalse(prior.data.get("retracted"), "precondition: not yet retracted")
|
|
|
|
|
|
|
|
|
|
|
|
self._ready(action="unready")
|
|
|
|
|
|
|
|
|
|
|
|
prior.refresh_from_db()
|
|
|
|
|
|
self.assertTrue(prior.data.get("retracted"))
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_ready_retracts_prior_sig_unready_event(self):
|
|
|
|
|
|
"""sig_ready action=ready retracts the most recent un-retracted
|
|
|
|
|
|
SIG_UNREADY event (views.py L907) — the cancellation is now moot."""
|
|
|
|
|
|
self._reserve()
|
|
|
|
|
|
self._ready(action="ready")
|
|
|
|
|
|
self._ready(action="unready")
|
|
|
|
|
|
prior_unready = self.room.events.filter(
|
|
|
|
|
|
actor=self.gamers[0], verb=GameEvent.SIG_UNREADY
|
|
|
|
|
|
).last()
|
|
|
|
|
|
self.assertFalse(prior_unready.data.get("retracted"))
|
|
|
|
|
|
|
|
|
|
|
|
self._ready(action="ready")
|
|
|
|
|
|
|
|
|
|
|
|
prior_unready.refresh_from_db()
|
|
|
|
|
|
self.assertTrue(prior_unready.data.get("retracted"))
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_release_while_ready_retracts_prior_sig_ready_event(self):
|
|
|
|
|
|
"""sig_reserve action=release on a ready reservation acts as implicit
|
|
|
|
|
|
WAIT NVM — retracts the most recent SIG_READY (views.py L823)."""
|
|
|
|
|
|
self._reserve()
|
|
|
|
|
|
self._ready(action="ready")
|
|
|
|
|
|
prior = self.room.events.filter(
|
|
|
|
|
|
actor=self.gamers[0], verb=GameEvent.SIG_READY
|
|
|
|
|
|
).last()
|
|
|
|
|
|
self.assertFalse(prior.data.get("retracted"))
|
|
|
|
|
|
|
|
|
|
|
|
self.client.post(self.reserve_url, data={
|
|
|
|
|
|
"card_id": self.card.id, "action": "release",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
prior.refresh_from_db()
|
|
|
|
|
|
self.assertTrue(prior.data.get("retracted"))
|
|
|
|
|
|
|
|
|
|
|
|
def test_sig_release_while_ready_records_sig_unready_event(self):
|
|
|
|
|
|
"""Same release-while-ready path also records a fresh SIG_UNREADY
|
|
|
|
|
|
(the implicit cancellation event)."""
|
|
|
|
|
|
self._reserve()
|
|
|
|
|
|
self._ready(action="ready")
|
|
|
|
|
|
unready_count_before = self.room.events.filter(
|
|
|
|
|
|
actor=self.gamers[0], verb=GameEvent.SIG_UNREADY
|
|
|
|
|
|
).count()
|
|
|
|
|
|
self.client.post(self.reserve_url, data={
|
|
|
|
|
|
"card_id": self.card.id, "action": "release",
|
|
|
|
|
|
})
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
self.room.events.filter(
|
|
|
|
|
|
actor=self.gamers[0], verb=GameEvent.SIG_UNREADY
|
|
|
|
|
|
).count(),
|
|
|
|
|
|
unready_count_before + 1,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── SIG_RESERVE invalid card-id branch ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class SigReserveInvalidCardIdTest(TestCase):
|
|
|
|
|
|
"""sig_reserve POSTed with a card_id that doesn't exist returns 400."""
|
|
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
|
|
|
|
|
self.url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
|
|
|
|
|
|
def test_unknown_card_id_returns_400(self):
|
|
|
|
|
|
"""TarotCard.DoesNotExist branch (views.py L840-841)."""
|
|
|
|
|
|
response = self.client.post(self.url, data={
|
|
|
|
|
|
"card_id": 999999, "action": "reserve",
|
|
|
|
|
|
})
|
|
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── SIG_SELECT gravity-polarity rendering ────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class SigSelectGravityContextTest(TestCase):
|
|
|
|
|
|
"""SIG_SELECT room context for a gravity-polarity gamer.
|
|
|
|
|
|
|
|
|
|
|
|
Covers the `user_polarity = 'gravity'` branch (views.py L322) and the
|
|
|
|
|
|
gravity_sig_cards lookup (L357) — both fall through the cracks of the
|
|
|
|
|
|
default founder-as-PC-levity tests."""
|
|
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
|
|
|
|
|
# gamers[5] is BC → gravity polarity
|
|
|
|
|
|
self.bc = self.gamers[5]
|
|
|
|
|
|
self.client.force_login(self.bc)
|
|
|
|
|
|
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
|
|
|
|
|
|
def test_gravity_gamer_room_context_has_gravity_polarity(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertEqual(response.context["user_polarity"], "gravity")
|
|
|
|
|
|
|
|
|
|
|
|
def test_gravity_gamer_sees_gravity_sig_cards(self):
|
|
|
|
|
|
"""Levity + gravity get the same 16 court cards (filtered by major-arcana
|
|
|
|
|
|
Note unlocks); this test just asserts the gravity branch was taken."""
|
|
|
|
|
|
from apps.epic.models import gravity_sig_cards
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
# Same underlying card set; assertion is that the context was populated
|
|
|
|
|
|
# (the gravity branch returned, vs falling into the empty `else`).
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
list(response.context["sig_cards"]),
|
|
|
|
|
|
list(gravity_sig_cards(self.room, self.bc)),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_gravity_gamer_sig_card_set_non_empty(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertGreater(len(response.context["sig_cards"]), 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── SEA_DECK draw view ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class SeaDeckViewTest(TestCase):
|
|
|
|
|
|
"""sea_deck — JSON view returning shuffled levity + gravity halves.
|
|
|
|
|
|
|
|
|
|
|
|
Mirrors the FT in test_game_room_select_sea.py:DRAW SEA — that test walks
|
|
|
|
|
|
the full UI; this one isolates the JSON contract + filter semantics."""
|
|
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
|
|
|
|
|
self.room.table_status = Room.SKY_SELECT
|
|
|
|
|
|
self.room.save()
|
|
|
|
|
|
# Use PC seat (founder) — already logged in by _full_sig_setUp
|
|
|
|
|
|
self.url = reverse("epic:sea_deck", kwargs={"room_id": self.room.id})
|
|
|
|
|
|
|
|
|
|
|
|
def test_returns_403_when_not_seated(self):
|
|
|
|
|
|
outsider = User.objects.create(email="outsider@test.io")
|
|
|
|
|
|
self.client.force_login(outsider)
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertEqual(response.status_code, 403)
|
|
|
|
|
|
|
|
|
|
|
|
def test_returns_empty_halves_when_seat_has_no_deck_variant(self):
|
|
|
|
|
|
"""sea_deck early-outs to {levity:[],gravity:[]} if the seat hasn't
|
|
|
|
|
|
committed a deck — guards against null deck_variant FK access."""
|
|
|
|
|
|
TableSeat.objects.filter(room=self.room).update(deck_variant=None)
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
self.assertEqual(data, {"levity": [], "gravity": []})
|
|
|
|
|
|
|
|
|
|
|
|
def test_returns_two_halves(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
self.assertIn("levity", data)
|
|
|
|
|
|
self.assertIn("gravity", data)
|
|
|
|
|
|
|
|
|
|
|
|
def test_card_count_roughly_split_between_halves(self):
|
|
|
|
|
|
"""Total card pool is split in half — within 1 of perfectly even."""
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
self.assertAlmostEqual(len(data["levity"]), len(data["gravity"]), delta=1)
|
|
|
|
|
|
|
|
|
|
|
|
def test_card_dict_contains_expected_keys(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
sample = data["levity"][0]
|
|
|
|
|
|
for key in (
|
|
|
|
|
|
"id", "name", "arcana", "corner_rank", "suit_icon",
|
|
|
|
|
|
"name_group", "name_title", "reversed",
|
|
|
|
|
|
"levity_qualifier", "gravity_qualifier",
|
|
|
|
|
|
):
|
|
|
|
|
|
self.assertIn(key, sample, f"missing key {key!r} in card dict")
|
|
|
|
|
|
|
|
|
|
|
|
def test_reversed_field_is_boolean(self):
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
for card in data["levity"] + data["gravity"]:
|
|
|
|
|
|
self.assertIsInstance(card["reversed"], bool)
|
|
|
|
|
|
|
|
|
|
|
|
def test_excludes_claimed_significators(self):
|
|
|
|
|
|
"""A card already set as a seat.significator must not appear in either
|
|
|
|
|
|
half — it's been claimed for the game and is out of the sea-draw pool."""
|
|
|
|
|
|
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 = sig_card
|
|
|
|
|
|
pc_seat.save()
|
|
|
|
|
|
response = self.client.get(self.url)
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
all_ids = {c["id"] for c in data["levity"]} | {c["id"] for c in data["gravity"]}
|
|
|
|
|
|
self.assertNotIn(sig_card.id, all_ids)
|