The felt cross's CENTER significator was hardcoded to the corner-rank + suit-icon text thumbnail, so an image deck (RWS / Minchiate) showed a bare "0"+icon card instead of its face. Mirror my_sea: render the `.sig-stage-card-img` (image mode) when the sig's deck has card images, else fall through to the text thumbnail. The tray sig stays the simple thumbnail (user-spec). The sig's `deck_variant` is the card's OWN deck — the Sig Select pick is drawn from the Role Select contributed deck (`_room_deck_variant`), so this is the correct image source (no equipped_deck bug, unlike the earlier FLIP back-img). - IT: an image-deck significator renders the center `sea-sig-card sig-stage-card--image` - 949+ epic ITs green ; FLIP tint tweak (parallel edit): flipped-back overlay alpha 0.6 → 0.3 [[project-deck-segment-model]] Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4313 lines
194 KiB
Python
4313 lines
194 KiB
Python
from datetime import timedelta
|
||
from unittest.mock import ANY, patch
|
||
|
||
from django.test import TestCase
|
||
from django.urls import reverse
|
||
from django.utils import timezone
|
||
|
||
from apps.drama.models import GameEvent, Note
|
||
from apps.lyric.models import Token, User
|
||
from apps.epic.models import (
|
||
Character, DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||
)
|
||
|
||
|
||
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"},
|
||
)
|
||
|
||
def test_create_room_get_redirects_to_gameboard(self):
|
||
response = self.client.get(reverse("epic:create_room"))
|
||
self.assertRedirects(response, "/gameboard/")
|
||
|
||
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!")
|
||
|
||
|
||
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"])
|
||
|
||
|
||
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)
|
||
|
||
def test_gate_status_returns_launch_btn_when_open(self):
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.save()
|
||
response = self.client.get(reverse("epic:gate_status", kwargs={"room_id": self.room.id}))
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, "launch-game-btn")
|
||
|
||
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")
|
||
|
||
|
||
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])
|
||
)
|
||
|
||
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
|
||
|
||
|
||
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)
|
||
|
||
|
||
class ReturnTokenViewTest(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()
|
||
|
||
def test_return_clears_reserved_slot(self):
|
||
self.client.post(
|
||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
||
self.assertIsNone(self.slot.gamer)
|
||
self.assertIsNone(self.slot.reserved_at)
|
||
|
||
def test_return_after_confirm_clears_filled_slot(self):
|
||
self.slot.status = GateSlot.FILLED
|
||
self.slot.save()
|
||
self.client.post(
|
||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
||
self.assertIsNone(self.slot.gamer)
|
||
|
||
def test_return_redirects_to_gatekeeper(self):
|
||
response = self.client.post(
|
||
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertRedirects(
|
||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||
)
|
||
|
||
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()
|
||
)
|
||
|
||
|
||
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):
|
||
"""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."""
|
||
self.gamer.is_staff = True
|
||
self.gamer.save()
|
||
pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
|
||
self.gamer.equipped_trinket = pass_token
|
||
self.gamer.save(update_fields=["equipped_trinket"])
|
||
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)
|
||
|
||
|
||
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)
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_room_view_includes_card_stack_when_role_select(self):
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(
|
||
self.url
|
||
)
|
||
self.assertContains(response, "card-stack")
|
||
|
||
def test_card_stack_eligible_for_slot1_gamer(self):
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(
|
||
self.url
|
||
)
|
||
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(
|
||
self.url
|
||
)
|
||
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(
|
||
self.url
|
||
)
|
||
self.assertContains(response, "fa-ban")
|
||
|
||
def test_card_stack_eligible_omits_fa_ban(self):
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(
|
||
self.url
|
||
)
|
||
# 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"')
|
||
|
||
def test_gatekeeper_overlay_absent_when_role_select(self):
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(
|
||
self.url
|
||
)
|
||
self.assertNotContains(response, "gate-overlay")
|
||
|
||
def test_tray_wrap_has_role_select_phase_class(self):
|
||
# Tray handle hidden until gamer confirms a role pick
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, 'id="id_tray_wrap" class="role-select-phase"')
|
||
|
||
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)
|
||
response = self.client.get(
|
||
reverse("epic:gatekeeper", kwargs={"room_id": room.id})
|
||
)
|
||
self.assertNotContains(response, 'id="id_tray_wrap"')
|
||
|
||
def test_six_table_seats_rendered(self):
|
||
self.client.force_login(self.founder)
|
||
response = self.client.get(
|
||
self.url
|
||
)
|
||
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)
|
||
|
||
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(
|
||
self.url
|
||
)
|
||
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(
|
||
self.url
|
||
)
|
||
self.assertContains(response, 'data-user-slots="2"')
|
||
|
||
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)
|
||
|
||
|
||
def _circle_start(content, slot_number):
|
||
"""Index of the gate-slot circle's opening `<div` for the given slot.
|
||
Scopes to `.gate-slot` (room.html also renders `.table-seat` data-slot=N
|
||
elements first, so a bare data-slot search would hit the seat, not the
|
||
circle)."""
|
||
needle = f'data-slot="{slot_number}"'
|
||
pos = 0
|
||
while True:
|
||
idx = content.index('<div class="gate-slot', pos)
|
||
end = content.index(">", idx)
|
||
if needle in content[idx:end]:
|
||
return idx
|
||
pos = end
|
||
|
||
|
||
def _circle_tag(content, slot_number):
|
||
"""Return the opening `<div ...>` tag of the gate-slot circle for the
|
||
given slot — class + every data-tt-* attr live on this one tag."""
|
||
idx = _circle_start(content, slot_number)
|
||
return content[idx:content.index(">", idx)]
|
||
|
||
|
||
class PositionTooltipRenderTest(TestCase):
|
||
"""Render-level coverage for the rich position-circle tooltip payload
|
||
(sprint 2026-06-02) — the fast IT counterpart to the Selenium
|
||
PositionTooltipTest in functional_tests/test_game_room_position_tooltips.py.
|
||
Exercised on the GATE VIEW (room_gate), which rendered no circles before
|
||
this sprint."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import DeckVariant
|
||
self.viewer = User.objects.create(email="disco@test.io", username="disco")
|
||
self.deck, _ = DeckVariant.objects.get_or_create(
|
||
slug="earthman",
|
||
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
|
||
)
|
||
self.viewer.equipped_deck = self.deck
|
||
self.viewer.save(update_fields=["equipped_deck"])
|
||
self.room = Room.objects.create(name="Whataburgher", owner=self.viewer)
|
||
self.gamers = [self.viewer]
|
||
for i in range(2, 7):
|
||
self.gamers.append(
|
||
User.objects.create(email=f"g{i}@test.io", username=f"g{i}")
|
||
)
|
||
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.filled_at = timezone.now()
|
||
slot.debited_token_type = Token.FREE
|
||
slot.save()
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
self.gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
|
||
self.client.force_login(self.viewer)
|
||
|
||
def _gate_content(self):
|
||
return self.client.get(self.gate_url).content.decode()
|
||
|
||
def test_gate_view_renders_six_position_circles(self):
|
||
content = self._gate_content()
|
||
self.assertContains(self.client.get(self.gate_url), "position-strip")
|
||
self.assertEqual(content.count('class="gate-slot'), 6)
|
||
|
||
def test_own_slot_is_me_current_others_are_gamer(self):
|
||
content = self._gate_content()
|
||
self.assertIn("tt-pos-me-current", _circle_tag(content, 1))
|
||
slot2 = _circle_tag(content, 2)
|
||
self.assertIn("tt-pos-gamer", slot2)
|
||
self.assertNotIn("tt-pos-me", slot2)
|
||
|
||
def test_other_gamer_handle_in_title_not_email(self):
|
||
slot2 = _circle_tag(self._gate_content(), 2)
|
||
self.assertIn('data-tt-title="@g2"', slot2)
|
||
# No email field in the tooltip payload (user-spec).
|
||
self.assertNotIn("data-tt-email", slot2)
|
||
# Title carries the article — "the Earthman", not bare "Earthman".
|
||
self.assertIn('data-tt-description="the Earthman"', slot2)
|
||
|
||
def test_bud_occupant_carries_bud_class_and_shoptalk(self):
|
||
from apps.billboard.models import BudshipNote
|
||
amigo = self.gamers[1]
|
||
self.viewer.buds.add(amigo)
|
||
BudshipNote.objects.create(user=self.viewer, bud=amigo, shoptalk="met at the deli")
|
||
slot2 = _circle_tag(self._gate_content(), 2)
|
||
self.assertIn("tt-pos-bud", slot2)
|
||
self.assertIn('data-tt-shoptalk="met at the deli"', slot2)
|
||
|
||
def test_deposit_count_and_expiry_present(self):
|
||
slot2 = _circle_tag(self._gate_content(), 2)
|
||
self.assertIn('data-tt-tokens="1"', slot2)
|
||
# FREE-funded slot → the deposited-token list reads "Free Token".
|
||
self.assertIn('data-tt-token-types="Free Token"', slot2)
|
||
# Lowercase "expires <when>" (relative timescale), not an ISO/locale date.
|
||
self.assertIn('data-tt-expiry="expires ', slot2)
|
||
|
||
def test_seat_significator_rank_rides_the_circle(self):
|
||
sig = TarotCard.objects.create(
|
||
deck_variant=self.deck, slug="queen-of-brands-em",
|
||
arcana="MIDDLE", suit="BRANDS", number=13, name="Queen of Brands",
|
||
)
|
||
TableSeat.objects.create(
|
||
room=self.room, gamer=self.gamers[1], slot_number=2, significator=sig,
|
||
)
|
||
slot2 = _circle_tag(self._gate_content(), 2)
|
||
self.assertIn(f'data-tt-sign-rank="{sig.corner_rank}"', slot2)
|
||
|
||
def test_no_occupant_email_anywhere_in_page_source(self):
|
||
# The position circles render @handle (at_handle), never the raw login
|
||
# email — not in the tooltip payload AND not in the hidden .slot-gamer
|
||
# span. Assert on the FULL response, not just the circle's opening tag.
|
||
content = self._gate_content()
|
||
self.assertNotIn(self.gamers[1].email, content) # g2@test.io
|
||
self.assertNotIn(self.viewer.email, content) # disco@test.io
|
||
|
||
def test_gate_view_circles_never_fade_when_roles_assigned(self):
|
||
# The gatekeeper's GATE VIEW is the canonical "who holds which
|
||
# position" surface: it must ALWAYS show all six circles, even after
|
||
# gamers get seated and the main table-hex has faded their circles away
|
||
# one-by-one (the role-assigned animation). So when every role is
|
||
# assigned — the moment SCAN SIGS appears on the hex — the gate view
|
||
# keeps all six circles solid, with NO role-assigned (fade-out) class.
|
||
roles = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||
for i, gamer in enumerate(self.gamers, start=1):
|
||
TableSeat.objects.create(
|
||
room=self.room, gamer=gamer, slot_number=i, role=roles[i - 1],
|
||
)
|
||
content = self._gate_content()
|
||
self.assertEqual(content.count('class="gate-slot'), 6)
|
||
self.assertNotIn("role-assigned", content)
|
||
|
||
|
||
class PositionTooltipCarteRenderTest(TestCase):
|
||
"""CARTE-solo render contract: a single gamer owns all six slots — their
|
||
non-current circles read tt-pos-me-also + carry a ?seat=N switch href, and
|
||
the deposited count reflects the CARTE token's slots_claimed."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import DeckVariant
|
||
self.viewer = User.objects.create(email="disco@test.io", username="disco")
|
||
self.deck, _ = DeckVariant.objects.get_or_create(
|
||
slug="earthman",
|
||
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
|
||
)
|
||
self.viewer.equipped_deck = self.deck
|
||
self.viewer.save(update_fields=["equipped_deck"])
|
||
self.room = Room.objects.create(name="Carte Room", owner=self.viewer)
|
||
self.room.gate_slots.update(
|
||
gamer=self.viewer, status=GateSlot.FILLED,
|
||
filled_at=timezone.now(), debited_token_type=Token.CARTE,
|
||
)
|
||
Token.objects.create(
|
||
user=self.viewer, token_type=Token.CARTE,
|
||
current_room=self.room, slots_claimed=6,
|
||
)
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
self.client.force_login(self.viewer)
|
||
self.room_url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_each_carte_slot_costs_one_token(self):
|
||
# A CARTE covers each seat at cost 1 — the per-slot expenditure count
|
||
# is 1 (GateSlot.token_cost), NOT the CARTE slots_claimed high-water
|
||
# mark. The deposited-token list reads "Carte Blanche".
|
||
content = self.client.get(self.room_url).content.decode()
|
||
# One CARTE covers ALL six seats — every slot reads cost 1 + "Carte
|
||
# Blanche" (token combinations per slot aren't possible: a re-deposit
|
||
# ejects the slot's token), so there is no mixed CARTE+FREE spread.
|
||
for n in (1, 4, 6):
|
||
tag = _circle_tag(content, n)
|
||
self.assertIn('data-tt-tokens="1"', tag)
|
||
self.assertIn('data-tt-token-types="Carte Blanche"', tag)
|
||
|
||
def test_own_other_seat_is_me_also_with_switch_href(self):
|
||
content = self.client.get(self.room_url).content.decode()
|
||
slot4 = _circle_tag(content, 4)
|
||
self.assertIn("tt-pos-me-also", slot4)
|
||
# The switch anchor lands just after the opening tag.
|
||
idx = _circle_start(content, 4)
|
||
chunk = content[idx:idx + 800]
|
||
self.assertIn("seat=4", chunk)
|
||
|
||
def test_seat_param_previews_that_seat_in_card_stack(self):
|
||
# Un-assigned owned seats → ?seat=4 previews seat 4, which is not the
|
||
# table's current turn (slot 1) → card-stack ineligible (.fa-ban).
|
||
for n in range(1, 7):
|
||
TableSeat.objects.create(room=self.room, gamer=self.viewer, slot_number=n)
|
||
content = self.client.get(self.room_url + "?seat=4").content.decode()
|
||
self.assertIn('data-active-slot="4"', content)
|
||
self.assertIn('data-state="ineligible"', content)
|
||
|
||
def test_no_seat_param_keeps_canonical_active_slot(self):
|
||
for n in range(1, 7):
|
||
TableSeat.objects.create(room=self.room, gamer=self.viewer, slot_number=n)
|
||
content = self.client.get(self.room_url).content.decode()
|
||
# Default: the table's turn is slot 1, and the viewer owns it → eligible.
|
||
self.assertIn('data-active-slot="1"', content)
|
||
self.assertIn('data-state="eligible"', content)
|
||
|
||
def test_room_gate_renders_circles_but_no_carte_action_forms(self):
|
||
# The renewal gate-view shows the circles (for their hover tooltips)
|
||
# but is NOT a gather surface — no CARTE drop/release forms.
|
||
gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
|
||
content = self.client.get(gate_url).content.decode()
|
||
self.assertIn("position-strip", content)
|
||
self.assertEqual(content.count('class="gate-slot'), 6)
|
||
self.assertNotIn("slot-release-btn", content)
|
||
self.assertNotIn("drop-token-btn", content)
|
||
|
||
|
||
class CarteTrayFollowsSelectedSeatTest(TestCase):
|
||
"""A multi-seat (CARTE) gamer's tray must mirror the seat at the
|
||
?seat-selected pos-circle (current_slot), not the canonical PC seat.
|
||
|
||
Regression: my_tray_role / my_tray_sig were pinned to the role-sorted
|
||
assigned_seats[0] (always PC) and _canonical_user_seat, so clicking ANY
|
||
pos-circle put the PC icon in the tray. The seat-switch (?seat=N) only
|
||
re-pointed the sig OVERLAY; the tray ignored it entirely."""
|
||
|
||
# Reverse canonical pick order: select_role fills the lowest open slot
|
||
# each turn, so picking BC,AC,SC,EC,NC,PC seats them at slots 1..6.
|
||
SLOT_ROLES = {1: "BC", 2: "AC", 3: "SC", 4: "EC", 5: "NC", 6: "PC"}
|
||
|
||
def setUp(self):
|
||
self.viewer = User.objects.create(email="disco@test.io", username="disco")
|
||
self.deck, _ = DeckVariant.objects.get_or_create(
|
||
slug="earthman",
|
||
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
|
||
)
|
||
self.room = Room.objects.create(name="Carte Room", owner=self.viewer)
|
||
self.room.gate_slots.update(
|
||
gamer=self.viewer, status=GateSlot.FILLED,
|
||
filled_at=timezone.now(), debited_token_type=Token.CARTE,
|
||
)
|
||
Token.objects.create(
|
||
user=self.viewer, token_type=Token.CARTE,
|
||
current_room=self.room, slots_claimed=6,
|
||
)
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
for n, role in self.SLOT_ROLES.items():
|
||
TableSeat.objects.create(
|
||
room=self.room, gamer=self.viewer, slot_number=n,
|
||
role=role, deck_variant=self.deck,
|
||
)
|
||
self.client.force_login(self.viewer)
|
||
self.room_url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_seat_param_points_tray_role_at_that_seat(self):
|
||
# ?seat=4 → pos-circle 4 holds EC, so the tray role card is EC, not PC.
|
||
response = self.client.get(self.room_url + "?seat=4")
|
||
self.assertEqual(response.context["my_tray_role"], "EC")
|
||
|
||
def test_each_owned_circle_yields_its_own_seat_role(self):
|
||
for n, role in self.SLOT_ROLES.items():
|
||
response = self.client.get(self.room_url + f"?seat={n}")
|
||
self.assertEqual(
|
||
response.context["my_tray_role"], role,
|
||
f"pos-circle {n} should put role {role} in the tray",
|
||
)
|
||
|
||
def test_default_no_seat_tray_follows_lowest_owned_circle(self):
|
||
# No ?seat → current_slot is the lowest owned pos-circle (1 = BC),
|
||
# NOT the role-canonical PC seat (slot 6).
|
||
response = self.client.get(self.room_url)
|
||
self.assertEqual(response.context["my_tray_role"], "BC")
|
||
|
||
def test_seat_param_points_tray_sig_at_that_seat(self):
|
||
sig = TarotCard.objects.create(
|
||
deck_variant=self.deck, slug="ec-sig",
|
||
arcana="MIDDLE", suit="BRANDS", number=13, name="Queen of Brands",
|
||
)
|
||
ec_seat = TableSeat.objects.get(room=self.room, slot_number=4)
|
||
ec_seat.significator = sig
|
||
ec_seat.save(update_fields=["significator"])
|
||
# Switch to EC's circle → its sig rides the tray.
|
||
response = self.client.get(self.room_url + "?seat=4")
|
||
self.assertEqual(response.context["my_tray_sig"], sig)
|
||
# The PC circle (slot 6) has no sig → switching there shows none
|
||
# (proving the tray no longer falls back to a single canonical seat).
|
||
response = self.client.get(self.room_url + "?seat=6")
|
||
self.assertIsNone(response.context["my_tray_sig"])
|
||
|
||
def test_gate_view_button_carries_acting_seat(self):
|
||
# Regression (gate-view pos-1 lockout): the GATE VIEW buttons linked
|
||
# to room_gate with NO ?seat, so the gate view fell back to owned[0]
|
||
# (pos 1) → pos 1 was always me-current + never got a switch href:
|
||
# the one seat you could never return to. The acting ?seat must ride
|
||
# the gate-view nav so the gate view's current matches the table.
|
||
gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
|
||
response = self.client.get(self.room_url + "?seat=4")
|
||
self.assertEqual(response.context["current_slot"], 4)
|
||
self.assertContains(response, f"{gate_url}?seat=4")
|
||
|
||
def test_gate_view_button_default_targets_lowest_owned(self):
|
||
# No ?seat on the table → the gate button carries the lowest owned
|
||
# slot (current_slot), keeping the table + gate views in agreement.
|
||
gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
|
||
response = self.client.get(self.room_url)
|
||
self.assertEqual(response.context["current_slot"], 1)
|
||
self.assertContains(response, f"{gate_url}?seat=1")
|
||
|
||
def test_gate_page_exposes_and_re_carries_acting_seat(self):
|
||
# The gate page's own navbar GATE VIEW re-click must keep the acting
|
||
# seat → _gate_context exposes current_slot too, and pos 1 is now
|
||
# me-also (switchable) while pos 4 is me-current — no lockout.
|
||
gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
|
||
response = self.client.get(gate_url + "?seat=4")
|
||
self.assertEqual(response.context["current_slot"], 4)
|
||
self.assertContains(response, f"{gate_url}?seat=4")
|
||
by_slot = {
|
||
p["slot"].slot_number: p
|
||
for p in response.context["gate_positions"]
|
||
}
|
||
self.assertEqual(by_slot[4]["state_class"], "tt-pos-me-current")
|
||
self.assertTrue(by_slot[1]["is_me_also"])
|
||
self.assertEqual(by_slot[1]["state_class"], "tt-pos-me-also")
|
||
|
||
def test_gate_view_cont_game_and_nvm_carry_acting_seat(self):
|
||
# Regression (CONT GAME pos-1 shuttle): the gate-view CONT GAME btn +
|
||
# the gear NVM both linked to epic:room with NO ?seat, so a CARTE
|
||
# multi-seat gamer acting at pos 4 got shuttled back to owned[0] (pos 1)
|
||
# instead of staying on his acting seat. Both must carry the acting
|
||
# ?seat through to the table (same fix shape as the GATE VIEW nav btns).
|
||
gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
|
||
response = self.client.get(gate_url + "?seat=4")
|
||
self.assertEqual(response.context["current_slot"], 4)
|
||
# CONT GAME btn returns to the table hex at the acting seat.
|
||
self.assertContains(
|
||
response,
|
||
f"window.location.href='{self.room_url}?seat=4'",
|
||
)
|
||
# Gear NVM (back to the hex) carries the acting seat too.
|
||
self.assertContains(
|
||
response,
|
||
f'href="{self.room_url}?seat=4" class="btn btn-cancel"',
|
||
)
|
||
|
||
def test_gate_view_cont_game_default_targets_lowest_owned(self):
|
||
# No ?seat on the gate → CONT GAME + NVM carry the lowest owned slot
|
||
# (current_slot), keeping the gate + table views in agreement.
|
||
gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
|
||
response = self.client.get(gate_url)
|
||
self.assertEqual(response.context["current_slot"], 1)
|
||
self.assertContains(
|
||
response,
|
||
f"window.location.href='{self.room_url}?seat=1'",
|
||
)
|
||
|
||
|
||
class PickRolesViewTest(TestCase):
|
||
def setUp(self):
|
||
self.founder = User.objects.create(email="founder@test.io")
|
||
self.client.force_login(self.founder)
|
||
self.room = Room.objects.create(name="Test Room", owner=self.founder)
|
||
for i in range(1, 7):
|
||
gamer = self.founder if i == 1 else User.objects.create(email=f"g{i}@test.io")
|
||
slot = self.room.gate_slots.get(slot_number=i)
|
||
slot.gamer = gamer
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.save()
|
||
|
||
def test_pick_roles_transitions_room_to_role_select(self):
|
||
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||
self.room.refresh_from_db()
|
||
self.assertEqual(self.room.table_status, Room.ROLE_SELECT)
|
||
|
||
def test_pick_roles_creates_one_table_seat_per_filled_slot(self):
|
||
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||
self.assertEqual(TableSeat.objects.filter(room=self.room).count(), 6)
|
||
|
||
def test_pick_roles_table_seats_carry_gamer_and_slot_number(self):
|
||
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||
seat = TableSeat.objects.get(room=self.room, slot_number=1)
|
||
self.assertEqual(seat.gamer, self.founder)
|
||
|
||
def test_only_open_room_can_start_role_select(self):
|
||
self.room.gate_status = Room.GATHERING
|
||
self.room.save()
|
||
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||
self.room.refresh_from_db()
|
||
self.assertIsNone(self.room.table_status)
|
||
|
||
def test_pick_roles_requires_login(self):
|
||
self.client.logout()
|
||
response = self.client.post(
|
||
reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertIn("/accounts/login/", response.url)
|
||
|
||
def test_pick_roles_redirects_to_room(self):
|
||
response = self.client.post(
|
||
reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
|
||
)
|
||
self.assertRedirects(
|
||
response, reverse("epic:room", args=[self.room.id])
|
||
)
|
||
|
||
def test_pick_roles_notifies_channel_layer(self):
|
||
with patch("apps.epic.views._notify_role_select_start") as mock_notify:
|
||
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||
mock_notify.assert_called_once_with(self.room.id)
|
||
|
||
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)
|
||
|
||
|
||
class SelectRoleViewTest(TestCase):
|
||
def setUp(self):
|
||
self.founder = User.objects.create(email="founder@test.io")
|
||
self.room = Room.objects.create(name="Test Room", owner=self.founder)
|
||
self.gamers = [self.founder]
|
||
for i in range(2, 7):
|
||
self.gamers.append(User.objects.create(email=f"g{i}@test.io"))
|
||
for i, gamer in enumerate(self.gamers, start=1):
|
||
slot = self.room.gate_slots.get(slot_number=i)
|
||
slot.gamer = gamer
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
for i, gamer in enumerate(self.gamers, start=1):
|
||
TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
|
||
self.client.force_login(self.founder)
|
||
|
||
def test_select_role_records_choice(self):
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
seat = TableSeat.objects.get(room=self.room, slot_number=1)
|
||
self.assertEqual(seat.role, "PC")
|
||
|
||
def test_select_role_wrong_turn_makes_no_change(self):
|
||
self.client.force_login(self.gamers[1]) # slot 2 — not their turn
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "BC"},
|
||
)
|
||
seat = TableSeat.objects.get(room=self.room, slot_number=2)
|
||
self.assertIsNone(seat.role)
|
||
|
||
def test_turn_advances_after_selection(self):
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
next_active = TableSeat.objects.filter(
|
||
room=self.room, role__isnull=True
|
||
).order_by("slot_number").first()
|
||
self.assertEqual(next_active.slot_number, 2)
|
||
|
||
def test_all_selected_stays_role_select_status(self):
|
||
roles = ["PC", "BC", "SC", "AC", "NC"]
|
||
for i, role in enumerate(roles):
|
||
seat = TableSeat.objects.get(room=self.room, slot_number=i + 1)
|
||
seat.role = role
|
||
seat.save()
|
||
self.client.force_login(self.gamers[5]) # slot 6 — last
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "EC"},
|
||
)
|
||
self.room.refresh_from_db()
|
||
self.assertEqual(self.room.table_status, Room.ROLE_SELECT)
|
||
|
||
def test_select_role_notifies_turn_changed(self):
|
||
with patch("apps.epic.views._notify_turn_changed") as mock_notify:
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
mock_notify.assert_called_once_with(self.room.id)
|
||
|
||
def test_select_role_notifies_all_roles_filled_when_last(self):
|
||
roles = ["PC", "BC", "SC", "AC", "NC"]
|
||
for i, role in enumerate(roles):
|
||
seat = TableSeat.objects.get(room=self.room, slot_number=i + 1)
|
||
seat.role = role
|
||
seat.save()
|
||
self.client.force_login(self.gamers[5])
|
||
with patch("apps.epic.views._notify_all_roles_filled") as mock_notify:
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "EC"},
|
||
)
|
||
mock_notify.assert_called_once_with(self.room.id)
|
||
|
||
def test_select_role_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)
|
||
|
||
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)
|
||
|
||
def test_select_role_requires_login(self):
|
||
self.client.logout()
|
||
response = self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertIn("/accounts/login/", response.url)
|
||
|
||
def test_select_role_returns_ok(self):
|
||
response = self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
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"},
|
||
)
|
||
self.assertEqual(response.status_code, 409)
|
||
|
||
def test_select_role_redirects_when_not_role_select_phase(self):
|
||
self.room.table_status = None
|
||
self.room.save()
|
||
response = self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
self.assertRedirects(
|
||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||
)
|
||
|
||
def test_select_role_redirects_for_invalid_role_code(self):
|
||
response = self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "BOGUS"},
|
||
)
|
||
self.assertRedirects(
|
||
response, reverse("epic:room", args=[self.room.id])
|
||
)
|
||
|
||
def test_same_gamer_cannot_double_pick_sequentially(self):
|
||
"""A second POST from the active gamer — after their role has been
|
||
saved — must redirect rather than assign a second role."""
|
||
self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "PC"},
|
||
)
|
||
response = self.client.post(
|
||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||
data={"role": "BC"},
|
||
)
|
||
self.assertRedirects(
|
||
response, reverse("epic:room", args=[self.room.id])
|
||
)
|
||
self.assertEqual(
|
||
TableSeat.objects.filter(room=self.room, role__isnull=False).count(), 1
|
||
)
|
||
|
||
|
||
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
|
||
|
||
# ── 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}"')
|
||
|
||
|
||
class RoomViewAllRolesFilledTest(TestCase):
|
||
"""Room view in ROLE_SELECT with all seats assigned shows SCAN SIGS button."""
|
||
def setUp(self):
|
||
import lxml.html
|
||
self.lxml = lxml.html
|
||
self.owner = User.objects.create(email="owner@test.io")
|
||
self.room = Room.objects.create(name="Test Room", owner=self.owner)
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
all_roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
|
||
for i, role in enumerate(all_roles, start=1):
|
||
user = User.objects.create(email=f"p{i}@test.io")
|
||
TableSeat.objects.create(room=self.room, gamer=user, slot_number=i, role=role)
|
||
self.client.force_login(self.owner)
|
||
|
||
def test_pick_sigs_btn_present_when_all_roles_filled(self):
|
||
response = self.client.get(reverse("epic:room", kwargs={"room_id": self.room.id}))
|
||
parsed = self.lxml.fromstring(response.content)
|
||
[_] = parsed.cssselect("#id_pick_sigs_btn")
|
||
self.assertEqual(parsed.cssselect(".card-stack"), [])
|
||
|
||
def test_pick_sigs_btn_hidden_during_role_select(self):
|
||
# Clear one role — still mid-pick, wrap must be hidden
|
||
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)
|
||
[wrap] = parsed.cssselect("#id_pick_sigs_wrap")
|
||
self.assertIn("display:none", wrap.get("style", "").replace(" ", ""))
|
||
|
||
|
||
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)
|
||
|
||
|
||
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/")
|
||
|
||
|
||
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)
|
||
|
||
|
||
# ── Significator Selection ────────────────────────────────────────────────────
|
||
|
||
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||
|
||
|
||
def _full_sig_setUp(test_case, role_order=None):
|
||
"""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."""
|
||
if role_order is None:
|
||
role_order = SIG_SEAT_ORDER[:]
|
||
earthman, _ = DeckVariant.objects.get_or_create(
|
||
slug="earthman",
|
||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||
)
|
||
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(
|
||
room=room, gamer=gamer, slot_number=i, role=role,
|
||
role_revealed=True, deck_variant=earthman,
|
||
)
|
||
room.gate_status = Room.OPEN
|
||
room.table_status = Room.SIG_SELECT
|
||
room.save()
|
||
card_in_deck = TarotCard.objects.get(
|
||
deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||
)
|
||
test_case.client.force_login(founder)
|
||
return room, gamers, earthman, card_in_deck
|
||
|
||
|
||
class SigReserveCarteMultiSeatTest(TestCase):
|
||
"""CARTE multi-seat: one gamer owns every seat. He reserves a DISTINCT sig
|
||
per seat (no NVM between switches), readies each, and each polarity room
|
||
fires its own 12s countdown — the same flow as multi-gamer. Reserve no
|
||
longer commits the significator immediately (the solo immediate-commit
|
||
shortcut was demolished 2026-06-05 once the countdown mechanism existed)."""
|
||
|
||
def setUp(self):
|
||
from apps.epic.models import DeckVariant
|
||
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||
slug="earthman",
|
||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||
)
|
||
self.viewer = User.objects.create(email="disco@test.io", username="disco")
|
||
self.viewer.equipped_deck = self.earthman
|
||
self.viewer.save(update_fields=["equipped_deck"])
|
||
self.room = Room.objects.create(name="Carte Sig", owner=self.viewer)
|
||
for i, role in enumerate(SIG_SEAT_ORDER, start=1):
|
||
slot = self.room.gate_slots.get(slot_number=i)
|
||
slot.gamer = self.viewer
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
TableSeat.objects.create(
|
||
room=self.room, gamer=self.viewer, slot_number=i, role=role,
|
||
role_revealed=True, deck_variant=self.earthman,
|
||
)
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.table_status = Room.SIG_SELECT
|
||
self.room.save()
|
||
self.client.force_login(self.viewer)
|
||
self.url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})
|
||
self.ready_url = reverse("epic:sig_ready", kwargs={"room_id": self.room.id})
|
||
# Three distinct levity court cards. Levity seats = slots 1(PC), 2(NC), 4(SC).
|
||
self.card_a, self.card_b, self.card_c = [
|
||
TarotCard.objects.get(
|
||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=n)
|
||
for n in (11, 12, 13)
|
||
]
|
||
|
||
def test_reserve_does_not_commit_significator(self):
|
||
# Demolished solo shortcut: OK creates a provisional reservation; the
|
||
# countdown later commits seat.significator (no instant write).
|
||
self.client.post(self.url + "?seat=1",
|
||
data={"card_id": self.card_a.id, "action": "reserve"})
|
||
seat1 = TableSeat.objects.get(room=self.room, slot_number=1)
|
||
self.assertIsNone(seat1.significator_id)
|
||
self.assertTrue(SigReservation.objects.filter(
|
||
room=self.room, gamer=self.viewer, seat=seat1).exists())
|
||
|
||
def test_reserve_distinct_card_per_seat_without_nvm(self):
|
||
# The reported bug: switching seats and OK-ing a second sig must NOT 409.
|
||
r1 = self.client.post(self.url + "?seat=1",
|
||
data={"card_id": self.card_a.id, "action": "reserve"})
|
||
r2 = self.client.post(self.url + "?seat=2",
|
||
data={"card_id": self.card_b.id, "action": "reserve"})
|
||
self.assertEqual(r1.status_code, 200)
|
||
self.assertEqual(r2.status_code, 200)
|
||
self.assertEqual(SigReservation.objects.filter(
|
||
room=self.room, gamer=self.viewer).count(), 2)
|
||
# Neither seat committed yet — that's the countdown's job.
|
||
self.assertFalse(self.room.table_seats.filter(significator__isnull=False).exists())
|
||
|
||
def test_reserve_same_seat_different_card_returns_409(self):
|
||
# Per-seat guard intact: a different card for the SAME seat → NVM first.
|
||
self.client.post(self.url + "?seat=1",
|
||
data={"card_id": self.card_a.id, "action": "reserve"})
|
||
r = self.client.post(self.url + "?seat=1",
|
||
data={"card_id": self.card_b.id, "action": "reserve"})
|
||
self.assertEqual(r.status_code, 409)
|
||
|
||
def test_three_levity_seats_ready_fires_one_countdown(self):
|
||
# Reserve + ready each levity seat (1,2,4). The 3rd ready fires the
|
||
# levity countdown — for a SINGLE gamer owning all three seats.
|
||
for seat_n, card in ((1, self.card_a), (2, self.card_b), (4, self.card_c)):
|
||
self.client.post(self.url + f"?seat={seat_n}",
|
||
data={"card_id": card.id, "action": "reserve"})
|
||
with patch("apps.epic.views._notify_countdown_start") as mock_notify, \
|
||
patch("apps.epic.tasks.schedule_polarity_confirm") as mock_sched:
|
||
for seat_n in (1, 2, 4):
|
||
self.client.post(self.ready_url + f"?seat={seat_n}",
|
||
data={"action": "ready"})
|
||
mock_notify.assert_called_once()
|
||
self.assertIn("levity", mock_notify.call_args[0])
|
||
mock_sched.assert_called_once()
|
||
|
||
|
||
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)
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_sig_deck_element_present(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_sig_deck")
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
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())
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.content.decode().count('data-card-id='), 18)
|
||
|
||
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")
|
||
|
||
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")
|
||
self.assertContains(response, "spin-btn")
|
||
self.assertContains(response, "stat-face--upright")
|
||
self.assertContains(response, "stat-face--reversed")
|
||
|
||
def test_sig_cards_render_energies_operations_data_attributes(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "data-energies=")
|
||
self.assertContains(response, "data-operations=")
|
||
|
||
def test_sig_info_panel_structure_rendered(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "sig-info")
|
||
self.assertContains(response, "fyi-btn")
|
||
self.assertContains(response, "sig-info-effect")
|
||
self.assertContains(response, "sig-info-index")
|
||
self.assertContains(response, "fyi-prev")
|
||
self.assertContains(response, "fyi-next")
|
||
|
||
def test_overlay_carries_live_countdown_remaining_on_load(self):
|
||
# Restore-on-load: a gamer landing on his seat view mid-countdown must
|
||
# get the live flashing numeral, not a static WAIT NVM. countdown_start
|
||
# is a one-shot WS event, so the seconds-left are seeded server-side
|
||
# from the timer deadline stored in the cache.
|
||
from django.core.cache import cache
|
||
from apps.epic.tasks import _cache_key
|
||
import time
|
||
founder = self.gamers[0] # PC → levity
|
||
seat = self.room.table_seats.get(gamer=founder, role="PC")
|
||
card = TarotCard.objects.get(
|
||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11)
|
||
SigReservation.objects.create(
|
||
room=self.room, gamer=founder, card=card,
|
||
polarity=SigReservation.LEVITY, seat=seat, ready=True)
|
||
cache.set(
|
||
_cache_key(str(self.room.id), SigReservation.LEVITY),
|
||
{"token": "t", "deadline": time.time() + 10}, 60)
|
||
response = self.client.get(self.url)
|
||
self.assertTrue(8 <= response.context["countdown_remaining"] <= 10)
|
||
self.assertContains(
|
||
response,
|
||
f'data-countdown-remaining="{response.context["countdown_remaining"]}"')
|
||
|
||
def test_overlay_countdown_remaining_zero_when_no_countdown(self):
|
||
# No live countdown → context None → attribute renders "0" (JS falls
|
||
# back to WAIT NVM on load).
|
||
response = self.client.get(self.url)
|
||
self.assertIsNone(response.context["countdown_remaining"])
|
||
self.assertContains(response, 'data-countdown-remaining="0"')
|
||
|
||
|
||
class SigSelectUnifiedStageTest(TestCase):
|
||
"""Sig Select stage unified with the my_sign card-stage apparatus:
|
||
the shared DRY _stat_face.html stat-block (rank-chip + title + arcana,
|
||
not just keywords), per-card face-image plumbing, and the green
|
||
--duoUser felt that replaces the hex-pane content (my_sea-style) instead
|
||
of the old fixed dark-Gaussian modal. Founder is PC (levity), mid-pick."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
# ── Workstream A — DRY stat-block ───────────────────────────────────────
|
||
def test_stat_block_uses_dry_stat_face_partial(self):
|
||
# The overlay must render the SHARED _stat_face.html (rank-chip +
|
||
# title + arcana), not the old reduced label-only stat-face.
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("stat-face-title", content)
|
||
self.assertIn("stat-chip-rank", content)
|
||
self.assertIn("stat-face-arcana", content)
|
||
# Keyword <ul> IDs preserved so sig-select.js's selectors still resolve.
|
||
self.assertIn('id="id_stat_keywords_upright"', content)
|
||
self.assertIn('id="id_stat_keywords_reversed"', content)
|
||
|
||
# ── Workstream B — per-card face-image plumbing ─────────────────────────
|
||
def test_sig_cards_carry_image_url_and_arcana_key(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("data-image-url=", content)
|
||
self.assertIn("data-arcana-key=", content)
|
||
|
||
def test_stage_card_has_image_slot(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("sig-stage-card-img", content)
|
||
|
||
def test_image_deck_renders_nonempty_face_url(self):
|
||
# Image-equipped seat deck → each sig card carries a real static path.
|
||
self.earthman.has_card_images = True
|
||
self.earthman.save(update_fields=["has_card_images"])
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn('data-image-url="/static', content)
|
||
|
||
def test_textonly_deck_renders_empty_face_url(self):
|
||
# Glyph-only deck (Earthman as seeded) → empty data-image-url, glyph
|
||
# fallback. The attribute still renders (plumbing present).
|
||
self.earthman.has_card_images = False
|
||
self.earthman.save(update_fields=["has_card_images"])
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn('data-image-url=""', content)
|
||
|
||
# ── Workstream C — felt replaces hex-pane content (my_sea-style) ────────
|
||
def test_sig_stage_on_felt_in_hex_pane_no_dark_backdrop(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
# Felt modifier on the hex pane; the old dark Gaussian backdrop is gone.
|
||
self.assertIn("has-sig-stage", content)
|
||
self.assertNotIn("sig-backdrop", content)
|
||
# The overlay lives INSIDE the hex pane (before the scroll/views pane).
|
||
hex_pos = content.find("room-hex-pane")
|
||
overlay_pos = content.find("sig-overlay")
|
||
scroll_pos = content.find("room-scroll-pane")
|
||
self.assertNotEqual(overlay_pos, -1)
|
||
self.assertLess(hex_pos, overlay_pos)
|
||
self.assertLess(overlay_pos, scroll_pos)
|
||
|
||
|
||
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):
|
||
# Create a pip card (number=5) — not in the sig deck (only court 11–14 + major 0–1)
|
||
other = TarotCard.objects.create(
|
||
deck_variant=self.earthman, arcana="MINOR", suit="BRANDS", number=5,
|
||
name="Five of Brands Test", slug="five-of-brands-test",
|
||
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()
|
||
mock_notify.assert_called_once()
|
||
|
||
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(
|
||
response, reverse("epic:room", args=[self.room.id])
|
||
)
|
||
|
||
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))
|
||
|
||
|
||
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)
|
||
|
||
|
||
# ── 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(
|
||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12
|
||
).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(
|
||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12
|
||
).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)
|
||
|
||
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())
|
||
|
||
# ── guards ────────────────────────────────────────────────────────────
|
||
|
||
def test_reserve_non_post_returns_405(self):
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 405)
|
||
|
||
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()
|
||
|
||
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
|
||
|
||
|
||
# ── 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 ────────────────────────────────────────────────────────────
|
||
|
||
def test_sig_ready_non_post_returns_405(self):
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 405)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
# ── 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()
|
||
|
||
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)
|
||
|
||
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 ────────────────────────────────────────────────────────────
|
||
|
||
def test_sig_confirm_non_post_returns_405(self):
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 405)
|
||
|
||
def test_sig_confirm_requires_login(self):
|
||
self.client.logout()
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 302)
|
||
self.assertIn("/accounts/login/", response.url)
|
||
|
||
def test_sig_confirm_requires_seated_gamer(self):
|
||
outsider = User.objects.create(email="outsider@test.io")
|
||
outsider_client = self.client.__class__()
|
||
outsider_client.force_login(outsider)
|
||
response = self._post(client=outsider_client)
|
||
self.assertEqual(response.status_code, 403)
|
||
|
||
def test_sig_confirm_wrong_phase_returns_400(self):
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_sig_confirm_not_all_polarity_ready_returns_400(self):
|
||
"""If any of the three in the polarity group isn't ready, reject."""
|
||
self.lev_res[1].ready = False
|
||
self.lev_res[1].save()
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
# ── happy-path ────────────────────────────────────────────────────────
|
||
|
||
def test_sig_confirm_sets_significator_on_seats_from_reservations(self):
|
||
self._post()
|
||
for res in self.lev_res:
|
||
seat = TableSeat.objects.get(room=self.room, role=res.role)
|
||
self.assertEqual(seat.significator, res.card)
|
||
|
||
def test_sig_confirm_returns_200(self):
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_sig_confirm_broadcasts_polarity_room_done(self):
|
||
with patch("apps.epic.views._notify_polarity_room_done") as mock_notify:
|
||
self._post()
|
||
mock_notify.assert_called_once()
|
||
args = mock_notify.call_args[0]
|
||
self.assertIn("levity", args)
|
||
|
||
def test_sig_confirm_is_idempotent_if_significators_already_set(self):
|
||
"""Second call from another browser returns 200 without re-running logic."""
|
||
self._post()
|
||
response = self._post()
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
# ── both polarities done ──────────────────────────────────────────────
|
||
|
||
def test_sig_confirm_broadcasts_pick_sky_available_when_both_polarities_done(self):
|
||
"""After both levity and gravity confirm, pick_sky_available fires."""
|
||
# Pre-set gravity seats to already have significators (simulating earlier confirm)
|
||
grav_cards = [
|
||
TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
||
for n in (11, 12, 13)
|
||
]
|
||
for role, card in zip(["EC", "AC", "BC"], grav_cards):
|
||
seat = TableSeat.objects.get(room=self.room, role=role)
|
||
seat.significator = card
|
||
seat.save()
|
||
with patch("apps.epic.views._notify_pick_sky_available") as mock_notify:
|
||
self._post(polarity="levity")
|
||
mock_notify.assert_called_once()
|
||
|
||
def test_sig_confirm_sets_room_to_sky_select_when_both_polarities_done(self):
|
||
grav_cards = [
|
||
TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
||
for n in (11, 12, 13)
|
||
]
|
||
for role, card in zip(["EC", "AC", "BC"], grav_cards):
|
||
seat = TableSeat.objects.get(room=self.room, role=role)
|
||
seat.significator = card
|
||
seat.save()
|
||
self._post(polarity="levity")
|
||
self.room.refresh_from_db()
|
||
self.assertEqual(self.room.table_status, Room.SKY_SELECT)
|
||
|
||
def test_sig_confirm_does_not_broadcast_pick_sky_available_when_only_one_polarity_done(self):
|
||
with patch("apps.epic.views._notify_pick_sky_available") as mock_notify:
|
||
self._post(polarity="levity")
|
||
mock_notify.assert_not_called()
|
||
|
||
|
||
# ── SKY_SELECT rendering ──────────────────────────────────────────────────────
|
||
|
||
class PickSkyRenderingTest(TestCase):
|
||
"""Room page at SKY_SELECT renders CAST SKY btn and sig card in tray cell 2."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
self.sig_card = TarotCard.objects.get(
|
||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||
)
|
||
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||
pc_seat.significator = self.sig_card
|
||
pc_seat.save()
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_pick_sky_btn_present_in_sky_select_phase(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_pick_sky_btn")
|
||
|
||
def test_tray_cell_2_contains_sig_card_icon_in_sky_select(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "tray-sig-card")
|
||
|
||
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
|
||
self.room.table_status = Room.SIG_SELECT
|
||
self.room.save()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, 'id="id_pick_sky_btn"')
|
||
self.assertContains(response, 'style="display:none"')
|
||
|
||
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")
|
||
|
||
def test_no_sky_delete_btn_in_blank_sky_select_modal(self):
|
||
"""A fresh CAST SKY modal (no preview wheel rendered yet) must not
|
||
carry the DEL btn — it would otherwise float in the empty wheel area
|
||
suggesting there's something to delete when the user has only seen
|
||
the form. The JS schedulePreview success handler is the contract that
|
||
injects the btn after the wheel paints — so the rendered HTML should
|
||
carry no <button id="id_sky_delete_btn"> markup. (The literal string
|
||
does still appear inside the inline <script> that does the injection,
|
||
so the assertion targets the rendered attribute syntax, not the bare
|
||
identifier.)"""
|
||
response = self.client.get(self.url)
|
||
self.assertNotContains(response, 'id="id_sky_delete_btn"')
|
||
|
||
|
||
class PickSkyUnifiedFeltTest(TestCase):
|
||
"""CAST SKY unified with the my_sky apparatus: the dark Gaussian modal
|
||
(.sky-backdrop + .sky-modal-wrap) is replaced by a --duoUser felt .sky-page
|
||
rendered INSIDE .room-hex-pane (my_sea-style, mirroring the Sig Select
|
||
SigSelectUnifiedStageTest precedent). Founder is PC (levity), sky not yet
|
||
confirmed → the felt form is the active surface."""
|
||
|
||
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_sky_felt_in_hex_pane_no_dark_backdrop(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
# Felt modifier on the hex pane; the old dark Gaussian chrome is gone.
|
||
self.assertIn("has-sky-stage", content)
|
||
self.assertNotIn("sky-backdrop", content)
|
||
self.assertNotIn("sky-modal-wrap", content)
|
||
# The --duoUser felt wrapper (shared .sky-page class) is present.
|
||
self.assertIn("sky-page", content)
|
||
|
||
def test_sky_overlay_lives_inside_hex_pane(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
# The overlay sits INSIDE the hex pane, before the scroll/views pane —
|
||
# so it fills the hex (my_sea-style) instead of floating over the page.
|
||
hex_pos = content.find("room-hex-pane")
|
||
overlay_pos = content.find("id_sky_overlay")
|
||
scroll_pos = content.find("room-scroll-pane")
|
||
self.assertNotEqual(overlay_pos, -1)
|
||
self.assertLess(hex_pos, overlay_pos)
|
||
self.assertLess(overlay_pos, scroll_pos)
|
||
|
||
def test_sky_felt_absent_once_confirmed(self):
|
||
# A confirmed sky flips the hex to DRAW SEA — the felt form must not
|
||
# render alongside the sea overlay.
|
||
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||
Character.objects.create(seat=pc_seat, confirmed_at=timezone.now())
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertNotIn("has-sky-stage", content)
|
||
|
||
def test_gear_has_sky_nvm_pane_returning_to_hex(self):
|
||
# NVM moved off the felt into the room gear's own sky pane; its NVM
|
||
# returns to the table-hex (epic:room), where the server re-renders
|
||
# DRAW SEA (if saved) or CAST SKY (if not).
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("room-menu-sky", content)
|
||
hex_url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
sky_pane = content[content.find("room-menu-sky"):]
|
||
sky_pane = sky_pane[: sky_pane.find("</div>")]
|
||
self.assertIn(hex_url, sky_pane)
|
||
self.assertIn("NVM", sky_pane)
|
||
|
||
|
||
# ── SEA_SELECT rendering ──────────────────────────────────────────────────────
|
||
|
||
class PickSeaRenderingTest(TestCase):
|
||
"""At SKY_SELECT, a confirmed Character swaps CAST SKY → DRAW SEA + sea overlay."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
self.sig_card = TarotCard.objects.get(
|
||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||
)
|
||
self.pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||
self.pc_seat.significator = self.sig_card
|
||
self.pc_seat.save()
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def _confirm_sky(self, seat=None):
|
||
target = seat or self.pc_seat
|
||
return Character.objects.create(seat=target, confirmed_at=timezone.now())
|
||
|
||
def test_sky_confirmed_false_when_no_character(self):
|
||
response = self.client.get(self.url)
|
||
self.assertFalse(response.context["sky_confirmed"])
|
||
|
||
def test_sky_confirmed_true_when_character_confirmed(self):
|
||
self._confirm_sky()
|
||
response = self.client.get(self.url)
|
||
self.assertTrue(response.context["sky_confirmed"])
|
||
|
||
def test_pick_sea_btn_shown_when_sky_confirmed(self):
|
||
self._confirm_sky()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_pick_sea_btn")
|
||
|
||
def test_pick_sky_btn_shown_when_sky_not_confirmed(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_pick_sky_btn")
|
||
|
||
def test_sea_overlay_included_when_sky_confirmed(self):
|
||
self._confirm_sky()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_sea_overlay")
|
||
|
||
# ── Burger Sky sub-btn reopen + glow (Phase 5) ──────────────────────────
|
||
def test_sky_btn_active_and_glow_flag_when_confirmed(self):
|
||
self._confirm_sky()
|
||
response = self.client.get(self.url)
|
||
self.assertTrue(response.context["sky_btn_active"])
|
||
content = response.content.decode()
|
||
# Burger carries the load-pulse flag; the Sky sub-btn goes active.
|
||
self.assertIn("data-sky-glow", content)
|
||
# The felt stays in the DOM (hidden) so the burger can reopen the wheel.
|
||
self.assertIn("id_sky_overlay", content)
|
||
|
||
def test_sky_btn_inactive_and_no_glow_when_not_confirmed(self):
|
||
response = self.client.get(self.url)
|
||
self.assertFalse(response.context.get("sky_btn_active"))
|
||
self.assertNotIn("data-sky-glow", response.content.decode())
|
||
|
||
def test_saved_sky_json_primes_reopen_when_confirmed(self):
|
||
char = self._confirm_sky()
|
||
char.chart_data = {"planets": {"Sun": {"sign": "Gemini"}}}
|
||
char.save()
|
||
response = self.client.get(self.url)
|
||
# The confirmed chart is handed to the felt so the burger reopen draws
|
||
# the saved wheel without a fresh PySwiss round-trip.
|
||
self.assertIn("Gemini", response.context["saved_sky_json"])
|
||
|
||
def test_saved_sky_json_is_null_literal_when_not_confirmed(self):
|
||
# The felt's `const _savedSky = {{ saved_sky_json|...|safe }};` must be
|
||
# valid JS even with no saved sky — a bare `null`, never empty.
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.context["saved_sky_json"], "null")
|
||
|
||
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
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
|
||
# ── 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)
|
||
|
||
|
||
# ── sky_preview (epic) ──────────────────────────────────────────────────────
|
||
|
||
class SkyPreviewViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="pc@sky.io")
|
||
self.client.force_login(self.user)
|
||
self.room, _ = _make_sig_room(self.user)
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
self.url = reverse("epic:sky_preview", kwargs={"room_id": self.room.id})
|
||
|
||
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"])
|
||
|
||
|
||
# ── 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,
|
||
)
|
||
|
||
|
||
# ── sky_save (epic) ─────────────────────────────────────────────────────────
|
||
|
||
class SkySaveViewTest(TestCase):
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="pc@skysave.io")
|
||
self.client.force_login(self.user)
|
||
self.room, _ = _make_sig_room(self.user)
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
self.url = reverse("epic:sky_save", kwargs={"room_id": self.room.id})
|
||
|
||
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"])
|
||
|
||
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};
|
||
sky_save should read .count rather than treating the dict as a value."""
|
||
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"])
|
||
|
||
def test_confirm_copies_seat_significator_to_character(self):
|
||
"""sky_save with action=confirm copies seat.significator onto Character."""
|
||
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)
|
||
|
||
|
||
# ── 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)
|
||
|
||
|
||
class RoomBurgerBtnRenderTest(TestCase):
|
||
"""Burger btn + fan of 5 sub-btns renders on room.html unconditionally —
|
||
from the gatekeeper state through every table_status. Sub-btns are
|
||
pure scaffolding (no handlers in this sprint)."""
|
||
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="founder@test.io")
|
||
self.room = Room.objects.create(name="Burger Room", owner=self.user)
|
||
self.client.force_login(self.user)
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_burger_btn_renders(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, 'id="id_burger_btn"')
|
||
self.assertContains(response, "fa-burger")
|
||
|
||
def test_burger_fan_container_renders(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, 'id="id_burger_fan"')
|
||
|
||
def test_five_fan_sub_btns_render_with_correct_ids(self):
|
||
response = self.client.get(self.url)
|
||
for btn_id in (
|
||
"id_sky_btn", "id_earth_btn", "id_sea_btn",
|
||
"id_voice_btn", "id_text_btn",
|
||
):
|
||
self.assertContains(response, f'id="{btn_id}"')
|
||
|
||
def test_fan_sub_btn_icons_match_spec(self):
|
||
response = self.client.get(self.url)
|
||
for icon in (
|
||
"fa-cloud", "fa-earth-americas", "fa-bridge-water",
|
||
"fa-headset", "fa-keyboard",
|
||
):
|
||
self.assertContains(response, icon)
|
||
|
||
def test_each_sub_btn_renders_dual_icon_for_inactive_flash_swap(self):
|
||
"""Sub-btns carry BOTH the real icon (.burger-fan-icon--on) + a
|
||
fa-ban placeholder (.burger-fan-icon--off). CSS keeps the real
|
||
icon visible by default; .flash-inactive swaps to fa-ban during
|
||
the click-while-inactive pulse. fa-ban itself isn't counted
|
||
directly — _table_positions.html also renders fa-ban for
|
||
non-starter seats — but the burger-fan-icon classes are unique
|
||
to the fan + load-bearing for the CSS swap rule."""
|
||
response = self.client.get(self.url)
|
||
body = response.content.decode()
|
||
self.assertEqual(body.count("burger-fan-icon--on"), 5)
|
||
self.assertEqual(body.count("burger-fan-icon--off"), 5)
|
||
|
||
def test_burger_btn_script_loaded(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "burger-btn.js")
|
||
|
||
def test_burger_persists_in_table_status_state(self):
|
||
"""Burger renders past the gatekeeper too — confirmed for ROLE_SELECT."""
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, 'id="id_burger_btn"')
|
||
|
||
|
||
class RoomGateViewTest(TestCase):
|
||
"""Room renewal gate-view (sprint 2026-05-31) — reachable mid-game (the
|
||
gatekeeper redirects to the table once table_status is set; this view
|
||
does not). Shows the viewer's own seat/circle + token time-remaining + a
|
||
RENEW affordance; the gear-menu NVM returns to the table hex."""
|
||
|
||
def setUp(self):
|
||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||
self.room = Room.objects.create(
|
||
name="Renewal Room", owner=self.owner,
|
||
renewal_period=timedelta(days=7),
|
||
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
|
||
)
|
||
self.slot = self.room.gate_slots.get(slot_number=1)
|
||
self.slot.gamer = self.owner
|
||
self.slot.status = GateSlot.FILLED
|
||
self.slot.filled_at = timezone.now()
|
||
self.slot.debited_token_type = Token.FREE
|
||
self.slot.save()
|
||
self.client.force_login(self.owner)
|
||
self.url = reverse("epic:room_gate", args=[self.room.id])
|
||
|
||
def _lapse(self):
|
||
# Backdate the seat into the renewal-grace window (cost lapsed, seat
|
||
# still FILLED) so the gate-view renders its renew state.
|
||
self.slot.filled_at = timezone.now() - timedelta(days=8)
|
||
self.slot.save()
|
||
|
||
def test_renders_200_even_when_table_status_set(self):
|
||
# gatekeeper would 302 to the room; the gate-view must render mid-game.
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
def test_cost_current_shows_cont_game_to_hex(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_room_cont_game_btn")
|
||
self.assertContains(response, "CONT")
|
||
self.assertContains(response, reverse("epic:room", args=[self.room.id]))
|
||
|
||
def test_cost_current_status_shows_deposited_count(self):
|
||
# One filled slot (the owner's) → "1 Token(s) Deposited" (literal "(s)").
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "1 Token(s) Deposited")
|
||
|
||
def test_cost_current_has_no_renew_form(self):
|
||
# Rails are static (claimed) while the cost is current — renewing is a
|
||
# lapsed-state affordance only.
|
||
response = self.client.get(self.url)
|
||
self.assertNotContains(
|
||
response, reverse("epic:renew_token", args=[self.room.id]))
|
||
|
||
def test_cost_lapsed_shows_please_deposit_and_renew_rails(self):
|
||
self._lapse()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "Please Deposit Token")
|
||
self.assertContains(
|
||
response, reverse("epic:renew_token", args=[self.room.id]))
|
||
|
||
def test_cost_lapsed_hides_cont_game(self):
|
||
self._lapse()
|
||
response = self.client.get(self.url)
|
||
self.assertNotContains(response, "id_room_cont_game_btn")
|
||
|
||
def test_nvm_returns_to_room_hex(self):
|
||
# NVM lands the gamer back on the table hex at his acting seat (?seat),
|
||
# not owned[0] — so a CARTE multi-seat gamer keeps his switched seat.
|
||
# The owner holds slot 1, so current_slot=1.
|
||
response = self.client.get(self.url)
|
||
self.assertContains(
|
||
response,
|
||
f'href="{reverse("epic:room", args=[self.room.id])}?seat=1"')
|
||
|
||
def test_page_class_carries_page_room(self):
|
||
response = self.client.get(self.url)
|
||
self.assertIn("page-room", response.context["page_class"])
|
||
|
||
def test_no_seat_circle_or_countdown_rendered(self):
|
||
# Seat circle + countdown removed (user-spec) — the hex .fa-chair + the
|
||
# next-sprint user/seat tooltips carry that info.
|
||
response = self.client.get(self.url)
|
||
self.assertNotContains(response, "room-gate-seat")
|
||
self.assertNotContains(response, "data-cost-until")
|
||
self.assertNotContains(response, "id_room_gate_remaining")
|
||
|
||
|
||
class RoomRenewTokenTest(TestCase):
|
||
"""The RENEW endpoint — re-deposit a token into the viewer's own FILLED
|
||
slot, resetting filled_at=now (via debit_token) so the cost-current
|
||
window restarts. Distinct from confirm_token (which needs a RESERVED
|
||
slot). 402 when token-depleted."""
|
||
|
||
def setUp(self):
|
||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||
self.owner.equipped_trinket = None
|
||
self.owner.save(update_fields=["equipped_trinket"])
|
||
self.owner.tokens.exclude(token_type=Token.FREE).delete() # keep FREE only
|
||
self.room = Room.objects.create(
|
||
name="Renewal Room", owner=self.owner,
|
||
renewal_period=timedelta(days=7),
|
||
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
|
||
)
|
||
self.slot = self.room.gate_slots.get(slot_number=1)
|
||
self.slot.gamer = self.owner
|
||
self.slot.status = GateSlot.FILLED
|
||
self.slot.filled_at = timezone.now() - timedelta(days=8) # in grace
|
||
self.slot.debited_token_type = Token.FREE
|
||
self.slot.save()
|
||
self.client.force_login(self.owner)
|
||
self.url = reverse("epic:renew_token", args=[self.room.id])
|
||
|
||
def test_renew_resets_filled_at(self):
|
||
self.client.post(self.url)
|
||
self.slot.refresh_from_db()
|
||
self.assertGreater(self.slot.filled_at, timezone.now() - timedelta(minutes=1))
|
||
self.assertTrue(self.slot.cost_current)
|
||
|
||
def test_renew_consumes_free_token(self):
|
||
self.client.post(self.url)
|
||
self.assertFalse(
|
||
self.owner.tokens.filter(token_type=Token.FREE).exists())
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.debited_token_type, Token.FREE)
|
||
|
||
def test_renew_records_slot_filled_event(self):
|
||
self.client.post(self.url)
|
||
self.assertTrue(
|
||
self.room.events.filter(
|
||
actor=self.owner, verb=GameEvent.SLOT_FILLED).exists())
|
||
|
||
def test_renew_without_filled_slot_redirects(self):
|
||
self.slot.status = GateSlot.EMPTY
|
||
self.slot.gamer = None
|
||
self.slot.save()
|
||
response = self.client.post(self.url)
|
||
self.assertEqual(response.status_code, 302)
|
||
|
||
def test_renew_402_when_token_depleted(self):
|
||
self.owner.tokens.all().delete()
|
||
response = self.client.post(self.url)
|
||
self.assertEqual(response.status_code, 402)
|
||
|
||
def test_renew_get_redirects(self):
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 302)
|
||
|
||
|
||
class RoomNavbarGateViewTest(TestCase):
|
||
"""Navbar swaps CONT GAME → GATE VIEW on room pages (mirror my-sea),
|
||
routing to the room gate-view. The gameboard listing keeps CONT GAME
|
||
(no `page-room` marker)."""
|
||
|
||
def setUp(self):
|
||
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||
self.room = Room.objects.create(
|
||
name="Nav Room", owner=self.owner,
|
||
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
|
||
)
|
||
self.client.force_login(self.owner)
|
||
|
||
def test_room_page_shows_gate_view_not_cont_game(self):
|
||
response = self.client.get(reverse("epic:room", args=[self.room.id]))
|
||
self.assertContains(response, "id_navbar_gate_view_btn")
|
||
self.assertNotContains(response, 'id="id_cont_game"')
|
||
|
||
def test_gate_view_btn_links_to_room_gate(self):
|
||
response = self.client.get(reverse("epic:room", args=[self.room.id]))
|
||
self.assertContains(
|
||
response, reverse("epic:room_gate", args=[self.room.id]))
|
||
|
||
def test_room_gate_page_also_shows_gate_view(self):
|
||
response = self.client.get(reverse("epic:room_gate", args=[self.room.id]))
|
||
self.assertContains(response, "id_navbar_gate_view_btn")
|
||
|
||
def test_room_page_carries_page_room_marker(self):
|
||
response = self.client.get(reverse("epic:room", args=[self.room.id]))
|
||
self.assertIn("page-room", response.context["page_class"])
|
||
|
||
|
||
class RoomCenterSupersessionTest(TestCase):
|
||
"""When the viewer's seat token cost lapses (filled_at past the cost-
|
||
current window), GATE VIEW supersedes the center-hex phase buttons —
|
||
SCAN SIGS, CAST SKY, DRAW SEA, the sig overlay — EXCEPT the gamer's own
|
||
ROLE card-stack pick (covered in RoomRoleStackGraceTest)."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||
self.founder = self.gamers[0]
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def _lapse_viewer(self):
|
||
slot = self.room.gate_slots.get(slot_number=1) # founder = slot 1
|
||
slot.filled_at = timezone.now() - timedelta(days=8) # grace (S=7d)
|
||
slot.save()
|
||
|
||
def test_viewer_cost_current_true_by_default(self):
|
||
# _full_sig_setUp leaves filled_at None → never-expires → current.
|
||
self.assertTrue(self.client.get(self.url).context["viewer_cost_current"])
|
||
|
||
def test_cost_current_no_gate_view_btn_in_center(self):
|
||
self.assertNotContains(self.client.get(self.url), "id_room_gate_view_btn")
|
||
|
||
def test_cost_lapsed_supersedes_sig_overlay(self):
|
||
self._lapse_viewer()
|
||
response = self.client.get(self.url) # _full_sig_setUp room is SIG_SELECT
|
||
self.assertContains(response, "id_room_gate_view_btn")
|
||
self.assertNotContains(response, "id_sig_deck")
|
||
|
||
def test_cost_current_shows_sig_overlay(self):
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_sig_deck")
|
||
self.assertNotContains(response, "id_room_gate_view_btn")
|
||
|
||
def test_cost_lapsed_supersedes_cast_sky(self):
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
self._lapse_viewer()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_room_gate_view_btn")
|
||
self.assertNotContains(response, "id_pick_sky_btn")
|
||
|
||
def test_cost_current_shows_cast_sky(self):
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_pick_sky_btn")
|
||
self.assertNotContains(response, "id_room_gate_view_btn")
|
||
|
||
def test_cost_lapsed_supersedes_draw_sea(self):
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
pc = TableSeat.objects.get(room=self.room, gamer=self.founder)
|
||
Character.objects.create(seat=pc, confirmed_at=timezone.now())
|
||
self._lapse_viewer()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_room_gate_view_btn")
|
||
self.assertNotContains(response, "id_pick_sea_btn")
|
||
|
||
def test_cost_lapsed_supersedes_scan_sigs(self):
|
||
self.room.table_status = Room.ROLE_SELECT # roles assigned → SCAN SIGS
|
||
self.room.save()
|
||
self._lapse_viewer()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_room_gate_view_btn")
|
||
self.assertNotContains(response, "id_pick_sigs_btn")
|
||
|
||
def test_cost_current_shows_scan_sigs(self):
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
response = self.client.get(self.url)
|
||
self.assertContains(response, "id_pick_sigs_btn")
|
||
self.assertNotContains(response, "id_room_gate_view_btn")
|
||
|
||
|
||
class RoomRoleStackGraceTest(TestCase):
|
||
"""The gamer's own ROLE card-stack pick survives a lapsed token cost
|
||
(deposit-privilege grace) — only SCAN SIGS + later phases get GATE VIEW
|
||
(user-spec 2026-05-31)."""
|
||
|
||
def setUp(self):
|
||
self.founder = User.objects.create(email="founder@test.io")
|
||
self.room = Room.objects.create(name="Role Room", owner=self.founder)
|
||
gamers = [self.founder] + [
|
||
User.objects.create(email=f"g{i}@test.io") for i in range(2, 7)
|
||
]
|
||
for i, gamer in enumerate(gamers, start=1):
|
||
slot = self.room.gate_slots.get(slot_number=i)
|
||
slot.gamer = gamer
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.table_status = Room.ROLE_SELECT
|
||
self.room.save()
|
||
self.client.force_login(self.founder)
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_card_stack_kept_when_cost_lapsed(self):
|
||
slot = self.room.gate_slots.get(slot_number=1)
|
||
slot.filled_at = timezone.now() - timedelta(days=8)
|
||
slot.save()
|
||
response = self.client.get(self.url)
|
||
# ROLE pick (the gamer's own turn) stays available within grace…
|
||
self.assertContains(response, "card-stack")
|
||
self.assertContains(response, 'data-state="eligible"')
|
||
# …alongside the GATE VIEW supersession of the non-ROLE affordances.
|
||
self.assertContains(response, "id_room_gate_view_btn")
|
||
|
||
|
||
class ExpireLapsedSeatsTest(TestCase):
|
||
"""Auto-BYE: a seat whose token cost lapsed past the renewal grace
|
||
(filled_at + 2*renewal_period) is freed — GateSlot blanked, TableSeat
|
||
blanked (row kept for seat-count integrity), SLOT_RETURNED recorded,
|
||
room flagged RENEWAL_DUE (gamer needed). Lazy on room/gate-view access.
|
||
NULL filled_at never expires (protects fixtures / RESERVED holds)."""
|
||
|
||
def setUp(self):
|
||
self.founder = User.objects.create(email="founder@test.io", username="founder")
|
||
self.room = Room.objects.create(
|
||
name="Lapse Room", owner=self.founder,
|
||
renewal_period=timedelta(days=7),
|
||
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
|
||
)
|
||
self.slot = self.room.gate_slots.get(slot_number=1)
|
||
self.slot.gamer = self.founder
|
||
self.slot.status = GateSlot.FILLED
|
||
self.slot.debited_token_type = Token.FREE
|
||
self.slot.save()
|
||
self.seat = TableSeat.objects.create(
|
||
room=self.room, gamer=self.founder, slot_number=1, role="PC",
|
||
)
|
||
self.client.force_login(self.founder)
|
||
|
||
def _set_filled(self, when):
|
||
self.slot.filled_at = when
|
||
self.slot.save()
|
||
|
||
def test_frees_gateslot_past_grace(self):
|
||
from apps.epic.views import _expire_lapsed_seats
|
||
self._set_filled(timezone.now() - timedelta(days=15)) # > 2S = 14d
|
||
_expire_lapsed_seats(self.room)
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
||
self.assertIsNone(self.slot.gamer)
|
||
self.assertIsNone(self.slot.filled_at)
|
||
self.assertIsNone(self.slot.debited_token_type)
|
||
|
||
def test_blanks_table_seat(self):
|
||
from apps.epic.views import _expire_lapsed_seats
|
||
self._set_filled(timezone.now() - timedelta(days=15))
|
||
_expire_lapsed_seats(self.room)
|
||
self.seat.refresh_from_db()
|
||
self.assertIsNone(self.seat.gamer)
|
||
self.assertIsNone(self.seat.role)
|
||
|
||
def test_noop_within_grace(self):
|
||
from apps.epic.views import _expire_lapsed_seats
|
||
self._set_filled(timezone.now() - timedelta(days=8)) # in grace [7,14)
|
||
_expire_lapsed_seats(self.room)
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.status, GateSlot.FILLED)
|
||
|
||
def test_noop_within_cost_window(self):
|
||
from apps.epic.views import _expire_lapsed_seats
|
||
self._set_filled(timezone.now())
|
||
_expire_lapsed_seats(self.room)
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.status, GateSlot.FILLED)
|
||
|
||
def test_ignores_null_filled_at(self):
|
||
from apps.epic.views import _expire_lapsed_seats
|
||
self.slot.filled_at = None
|
||
self.slot.save()
|
||
_expire_lapsed_seats(self.room)
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.status, GateSlot.FILLED)
|
||
|
||
def test_sets_renewal_due(self):
|
||
from apps.epic.views import _expire_lapsed_seats
|
||
self._set_filled(timezone.now() - timedelta(days=15))
|
||
_expire_lapsed_seats(self.room)
|
||
self.room.refresh_from_db()
|
||
self.assertEqual(self.room.gate_status, Room.RENEWAL_DUE)
|
||
|
||
def test_records_slot_returned(self):
|
||
from apps.epic.views import _expire_lapsed_seats
|
||
self._set_filled(timezone.now() - timedelta(days=15))
|
||
_expire_lapsed_seats(self.room)
|
||
self.assertTrue(
|
||
self.room.events.filter(
|
||
actor=self.founder, verb=GameEvent.SLOT_RETURNED).exists())
|
||
|
||
def test_room_view_runs_expiry_on_access(self):
|
||
self._set_filled(timezone.now() - timedelta(days=15))
|
||
self.client.get(reverse("epic:room", args=[self.room.id]))
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
||
|
||
def test_room_gate_runs_expiry_on_access(self):
|
||
self._set_filled(timezone.now() - timedelta(days=15))
|
||
self.client.get(reverse("epic:room_gate", args=[self.room.id]))
|
||
self.slot.refresh_from_db()
|
||
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
||
|
||
def test_gamer_needed_stub_renders(self):
|
||
self.room.gate_status = Room.RENEWAL_DUE
|
||
self.room.save()
|
||
response = self.client.get(reverse("epic:room", args=[self.room.id]))
|
||
self.assertContains(response, "id_gamer_needed")
|
||
|
||
|
||
class ExpireLapsedRoomSeatsCommandTest(TestCase):
|
||
"""Cron backstop for rooms nobody reopens — `expire_lapsed_room_seats`
|
||
runs the same `_expire_lapsed_seats` sweep the lazy view path uses, for
|
||
mid-game tables left idle past the grace window."""
|
||
|
||
def _room_with_filled_slot(self, email, filled_at):
|
||
owner = User.objects.create(email=email)
|
||
room = Room.objects.create(
|
||
name="Cron Room", owner=owner, renewal_period=timedelta(days=7),
|
||
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
|
||
)
|
||
slot = room.gate_slots.get(slot_number=1)
|
||
slot.gamer = owner
|
||
slot.status = GateSlot.FILLED
|
||
slot.filled_at = filled_at
|
||
slot.debited_token_type = Token.FREE
|
||
slot.save()
|
||
return room, slot
|
||
|
||
def test_command_frees_lapsed_seat_and_sets_renewal_due(self):
|
||
from django.core.management import call_command
|
||
room, slot = self._room_with_filled_slot(
|
||
"lapsed@test.io", timezone.now() - timedelta(days=15))
|
||
call_command("expire_lapsed_room_seats")
|
||
slot.refresh_from_db()
|
||
room.refresh_from_db()
|
||
self.assertEqual(slot.status, GateSlot.EMPTY)
|
||
self.assertEqual(room.gate_status, Room.RENEWAL_DUE)
|
||
|
||
def test_command_noop_within_grace(self):
|
||
from django.core.management import call_command
|
||
room, slot = self._room_with_filled_slot(
|
||
"ingrace@test.io", timezone.now() - timedelta(days=8))
|
||
call_command("expire_lapsed_room_seats")
|
||
slot.refresh_from_db()
|
||
self.assertEqual(slot.status, GateSlot.FILLED)
|
||
|
||
|
||
class RoomScrollOfEventsTest(TestCase):
|
||
"""The table-hex aperture gains a 2nd scroll-snap section: scrolling down
|
||
past the hex reveals the room's provenance feed (the same GameEvent stream
|
||
the Billscroll page shows). Available from Role Select onwards — the gate
|
||
phase (no `table_status`) shows no scroll. Mirrors my_sky's wheel<->form
|
||
binary scroll-snap toggle; built to DRY-template onto my_sea next.
|
||
|
||
The shared row list comes from `core/_partials/_scroll.html` (the same
|
||
partial the Billscroll page uses), so the feed renders identically here."""
|
||
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="founder@test.io", username="disco")
|
||
self.client.force_login(self.user)
|
||
self.room = Room.objects.create(
|
||
name="Whataburgher", owner=self.user,
|
||
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
|
||
)
|
||
TableSeat.objects.create(
|
||
room=self.room, gamer=self.user, slot_number=1, role="PC",
|
||
)
|
||
# A welcome line (system-authored) + a deposit line — feed content.
|
||
GameEvent.objects.create(room=self.room, verb=GameEvent.ROOM_CREATED)
|
||
GameEvent.objects.create(
|
||
room=self.room, actor=self.user, verb=GameEvent.SLOT_FILLED,
|
||
data={"token_type": "carte", "token_display": "Carte Blanche",
|
||
"slot_number": 1, "renewal_days": 7},
|
||
)
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_aperture_wraps_hex_and_scroll_panes(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("room-aperture", content)
|
||
self.assertIn("room-hex-pane", content)
|
||
self.assertIn("room-scroll-pane", content)
|
||
|
||
def test_aperture_is_scrollable_from_role_select(self):
|
||
# `is-scrollable` engages the binary scroll-snap (2 panes present).
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("room-aperture is-scrollable", content)
|
||
|
||
def test_scroll_pane_renders_the_event_feed(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("id_drama_scroll", content)
|
||
self.assertIn("Welcome to Whataburgher!", content)
|
||
self.assertIn("deposits a Carte Blanche for slot 1", content)
|
||
|
||
def test_scroll_feed_scoped_to_this_room(self):
|
||
other = Room.objects.create(
|
||
name="Elsewhere", owner=self.user,
|
||
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
|
||
)
|
||
GameEvent.objects.create(room=other, verb=GameEvent.ROOM_CREATED)
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertNotIn("Welcome to Elsewhere!", content)
|
||
|
||
def test_no_scroll_pane_in_gate_phase(self):
|
||
"""The gate phase (table_status unset) shows no scroll — the feed is
|
||
reachable only from Role Select onwards."""
|
||
gate_room = Room.objects.create(name="Gatehouse", owner=self.user)
|
||
content = self.client.get(
|
||
reverse("epic:gatekeeper", args=[gate_room.id])
|
||
).content.decode()
|
||
self.assertNotIn("room-scroll-pane", content)
|
||
self.assertNotIn("is-scrollable", content)
|
||
|
||
def test_scroll_pane_uses_applet_box_card_with_room_name(self):
|
||
"""The feed sits in an `.applet-scroll` card (same %applet-box chrome as
|
||
the Billscroll page), with the room name as its rotated title."""
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("applet-scroll", content)
|
||
# The rotated <h2> title inside the card carries the room name.
|
||
self.assertIn("<h2>Whataburgher</h2>", content)
|
||
|
||
def test_scroll_gear_filter_renders_in_table_phase(self):
|
||
"""From Role Select onwards the gear menu carries BOTH panes: the
|
||
default NVM/DEL/BYE (`.room-menu-default`) and the Frame/Redact log
|
||
filter (`.room-menu-scroll`), swapped by scroll position client-side."""
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("room-menu-default", content)
|
||
self.assertIn("room-menu-scroll", content)
|
||
self.assertIn("id_scroll_filter_form", content)
|
||
self.assertIn('value="frame"', content)
|
||
self.assertIn('value="redact"', content)
|
||
|
||
def test_scroll_gear_filter_absent_in_gate_phase(self):
|
||
"""The gate phase has no scroll, so no Frame/Redact filter pane — the
|
||
gear keeps only its NVM/DEL/BYE menu."""
|
||
gate_room = Room.objects.create(name="Gatehouse", owner=self.user)
|
||
content = self.client.get(
|
||
reverse("epic:gatekeeper", args=[gate_room.id])
|
||
).content.decode()
|
||
self.assertNotIn("room-menu-scroll", content)
|
||
self.assertNotIn("id_scroll_filter_form", content)
|
||
|
||
def test_title_is_room_scroll_reel_in_table_phase(self):
|
||
"""The page title is a two-word vertical reel so it can swap GAME ROOM
|
||
⇄ GAME SCROLL as the aperture snaps to the feed (client-side via
|
||
room-scroll.js's `.is-scroll` toggle). Both words ship in the header;
|
||
CSS slides between them. The window span carries `data-letters-split`
|
||
so base.html's letter-splitter leaves it alone and splits the two
|
||
inner `.gr-word`s instead."""
|
||
content = self.client.get(self.url).content.decode()
|
||
# Match the rendered class attribute, not the bare token — base.html's
|
||
# letter-splitter comment mentions `.gr-swap`/`.gr-word` on every page.
|
||
self.assertIn('class="gr-swap"', content)
|
||
self.assertIn("gr-word gr-word--base", content)
|
||
self.assertIn("gr-word gr-word--scroll", content)
|
||
# The swap-in word reads "scroll" (lowercase in markup; CSS uppercases).
|
||
self.assertIn(">scroll</span>", content)
|
||
|
||
def test_title_reel_absent_in_gate_phase(self):
|
||
"""The gate phase has no scroll pane to reveal, so the title stays a
|
||
plain GAME ROOM — no reel to slide."""
|
||
gate_room = Room.objects.create(name="Gatehouse", owner=self.user)
|
||
content = self.client.get(
|
||
reverse("epic:gatekeeper", args=[gate_room.id])
|
||
).content.decode()
|
||
# The reel markup is absent (the bare `gr-swap`/`gr-word` tokens still
|
||
# appear in base.html's splitter comment, so assert on the rendered
|
||
# class attribute + the unique swap-word marker instead).
|
||
self.assertNotIn('class="gr-swap"', content)
|
||
self.assertNotIn("gr-word--scroll", content)
|
||
|
||
|
||
class ScrollStatusViewTest(TestCase):
|
||
"""`scroll_status` renders JUST the scroll-of-events feed partial — the
|
||
endpoint room-scroll.js re-fetches on a `scroll_update` WS nudge to refresh
|
||
the SCROLL applet live (no reload). Same feed the room page paints inline."""
|
||
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="founder@test.io", username="disco")
|
||
self.client.force_login(self.user)
|
||
self.room = Room.objects.create(
|
||
name="Willawonky", owner=self.user,
|
||
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
|
||
)
|
||
GameEvent.objects.create(
|
||
room=self.room, actor=self.user, verb=GameEvent.SLOT_FILLED,
|
||
data={"token_type": "carte", "token_display": "Carte Blanche",
|
||
"slot_number": 1, "renewal_days": 7},
|
||
)
|
||
self.url = reverse("epic:scroll_status", kwargs={"room_id": self.room.id})
|
||
|
||
def test_renders_the_scroll_feed_section(self):
|
||
response = self.client.get(self.url)
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertContains(response, "id_drama_scroll")
|
||
self.assertContains(response, "drama-event")
|
||
|
||
def test_feed_reflects_latest_events(self):
|
||
# A freshly-recorded event shows up on the next fetch (the live-refresh
|
||
# contract) without re-rendering the whole room page.
|
||
from apps.drama.models import record
|
||
record(self.room, GameEvent.ROLE_SELECTED, actor=self.user, role="PC")
|
||
content = self.client.get(self.url).content.decode()
|
||
# One `.drama-event-body` per row (the bare class appears 3×/row).
|
||
self.assertEqual(content.count("drama-event-body"), 2)
|
||
|
||
def test_only_partial_not_full_page(self):
|
||
# It is the bare feed section — no room chrome (navbar / aperture).
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertNotIn("room-aperture", content)
|
||
self.assertNotIn("navbar", content)
|
||
|
||
|
||
class RoomViewsCarouselTest(TestCase):
|
||
"""The scroll pane becomes a horizontal carousel of 5 views (ATLAS /
|
||
SCROLL / POST / CHAT / PULSE — [[project-room-game-views-carousel]]).
|
||
Server contract: the 5 view panes + the root-level icon strip render in
|
||
the table phase; the SCROLL view still wraps the provenance feed; the POST
|
||
view embeds the room thread; the Text sub-btn lights `.active`."""
|
||
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="founder@test.io", username="disco")
|
||
self.client.force_login(self.user)
|
||
self.room = Room.objects.create(
|
||
name="Whataburgher", owner=self.user,
|
||
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
|
||
)
|
||
# Seat the viewer + one bud (TableSeat = committed to a seat). A third
|
||
# gamer fills a gate slot ONLY (transient depositor) — must NOT show as
|
||
# a chat participant. The viewer's seat is what grants POST access.
|
||
self.seatmate = User.objects.create(email="amigo@test.io", username="amigo")
|
||
self.transient = User.objects.create(email="pal@test.io", username="pal")
|
||
TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=1, role="PC")
|
||
TableSeat.objects.create(room=self.room, gamer=self.seatmate, slot_number=2, role="NC")
|
||
slot = self.room.gate_slots.get(slot_number=3)
|
||
slot.gamer = self.transient
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_renders_five_view_panes_in_order(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("id_room_views", content)
|
||
for view in ("atlas", "scroll", "yarn", "post", "pulse"):
|
||
self.assertIn(f'data-view="{view}"', content)
|
||
# Order: atlas precedes scroll precedes yarn precedes post precedes pulse.
|
||
positions = [content.index(f'room-view--{v}')
|
||
for v in ("atlas", "scroll", "yarn", "post", "pulse")]
|
||
self.assertEqual(positions, sorted(positions))
|
||
|
||
def test_renders_root_level_icon_strip(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("id_room_views_strip", content)
|
||
for view in ("atlas", "scroll", "yarn", "post", "pulse"):
|
||
self.assertIn(f'data-view="{view}"', content)
|
||
self.assertIn("fa-scroll", content)
|
||
self.assertIn("fa-book-atlas", content)
|
||
self.assertIn("fa-route", content)
|
||
|
||
def test_scroll_view_still_wraps_the_provenance_feed(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("id_drama_scroll", content)
|
||
|
||
def test_post_view_embeds_room_thread_composer(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("id_post_table", content)
|
||
self.assertIn("id_post_line_text", content)
|
||
self.assertIn("id_post_line_btn", content)
|
||
# Composer posts to the room-scoped endpoint, not billboard:view_post.
|
||
self.assertIn(reverse("epic:room_post", args=[self.room.id]), content)
|
||
|
||
def test_post_view_renders_existing_thread_lines(self):
|
||
from apps.billboard.models import Line
|
||
post = self.room.get_thread_post()
|
||
Line.objects.create(post=post, author=self.user, text="opening move")
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("opening move", content)
|
||
|
||
def test_yarn_and_pulse_render_stubs(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("room-view-stub", content)
|
||
|
||
def test_post_chat_header_lists_seat_occupants_not_token_depositors(self):
|
||
"""The POST chat header hardcodes a 'Gamer Introduction' title and lists
|
||
the gamers OCCUPYING SEATS as recipients — the viewer's seatmate shows;
|
||
a gate-slot-only (transient) depositor does not (he could retract +
|
||
leave). Scoped to the shared-recipients paragraph (the position strip
|
||
carries every gate-slot gamer's handle elsewhere on the page)."""
|
||
import re
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("Gamer Introduction", content)
|
||
recipients = re.search(
|
||
r'<p class="post-shared-recipients">(.*?)</p>', content, re.S)
|
||
recipients_html = recipients.group(1) if recipients else ""
|
||
self.assertIn("@amigo", recipients_html) # seated → a participant
|
||
self.assertNotIn("@pal", recipients_html) # gate-slot-only → not
|
||
|
||
def test_atlas_gear_menu_has_source_checkboxes(self):
|
||
"""The ATLAS view's gear pane carries a source checkbox per other
|
||
reelhouse view; scroll + post are wired (checked), yarn + pulse have no
|
||
model yet (disabled)."""
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("room-menu-atlas", content)
|
||
self.assertIn("id_atlas_source_form", content)
|
||
for view in ("scroll", "yarn", "post", "pulse"):
|
||
self.assertIn(f'value="{view}"', content)
|
||
self.assertRegex(content, r'value="yarn"[^>]*disabled')
|
||
self.assertRegex(content, r'value="pulse"[^>]*disabled')
|
||
|
||
def test_text_btn_active_on_the_table(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
# The Text sub-btn carries `.active` so the burger fan routes its click
|
||
# to the swipe machine instead of the inactive flash-stub.
|
||
self.assertRegex(content, r'id_text_btn[^>]*class="[^"]*\bactive\b')
|
||
|
||
def test_text_btn_inactive_in_gate_phase(self):
|
||
gate_room = Room.objects.create(name="Gatehouse", owner=self.user)
|
||
content = self.client.get(
|
||
reverse("epic:gatekeeper", args=[gate_room.id])
|
||
).content.decode()
|
||
self.assertNotRegex(content, r'id_text_btn[^>]*class="[^"]*\bactive\b')
|
||
|
||
|
||
class RoomPostEndpointTest(TestCase):
|
||
"""`epic:room_post` — the AJAX composer behind the carousel's POST view.
|
||
Appends a Line to the room thread + returns the rendered line partial as
|
||
JSON so room-views.js can splice it in without a reload."""
|
||
|
||
def setUp(self):
|
||
self.user = User.objects.create(email="founder@test.io", username="disco")
|
||
self.client.force_login(self.user)
|
||
self.room = Room.objects.create(
|
||
name="Whataburgher", owner=self.user,
|
||
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
|
||
)
|
||
# Access is SEAT-based: the poster must occupy a TableSeat, not merely
|
||
# have a filled gate slot.
|
||
TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=1, role="PC")
|
||
self.url = reverse("epic:room_post", args=[self.room.id])
|
||
|
||
def test_post_appends_line_and_returns_line_html(self):
|
||
resp = self.client.post(self.url, data={"text": "lets gooo"})
|
||
self.assertEqual(resp.status_code, 200)
|
||
payload = resp.json()
|
||
self.assertTrue(payload["ok"])
|
||
self.assertIn("lets gooo", payload["line_html"])
|
||
self.assertIn("post-line", payload["line_html"])
|
||
post = self.room.get_thread_post()
|
||
self.assertTrue(post.lines.filter(text="lets gooo").exists())
|
||
|
||
def test_non_seated_user_forbidden(self):
|
||
outsider = User.objects.create(email="outsider@test.io", username="rando")
|
||
self.client.force_login(outsider)
|
||
resp = self.client.post(self.url, data={"text": "let me in"})
|
||
self.assertEqual(resp.status_code, 403)
|
||
self.assertFalse(self.room.get_thread_post().lines.exists())
|
||
|
||
def test_gate_slot_only_depositor_forbidden(self):
|
||
"""A token-depositor who filled a gate slot but never took a SEAT can't
|
||
post — they could retract their token + leave, so they must not have
|
||
R/W access to the private chat."""
|
||
transient = User.objects.create(email="pal@test.io", username="pal")
|
||
slot = self.room.gate_slots.get(slot_number=2)
|
||
slot.gamer = transient
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
self.client.force_login(transient)
|
||
resp = self.client.post(self.url, data={"text": "let me in"})
|
||
self.assertEqual(resp.status_code, 403)
|
||
self.assertFalse(self.room.get_thread_post().lines.exists())
|
||
|
||
def test_duplicate_line_rejected(self):
|
||
self.client.post(self.url, data={"text": "echo"})
|
||
resp = self.client.post(self.url, data={"text": "echo"})
|
||
self.assertEqual(resp.status_code, 400)
|
||
self.assertFalse(resp.json()["ok"])
|
||
self.assertEqual(self.room.get_thread_post().lines.filter(text="echo").count(), 1)
|
||
|
||
def test_get_redirects_to_room(self):
|
||
resp = self.client.get(self.url)
|
||
self.assertRedirects(
|
||
resp, reverse("epic:room", args=[self.room.id]),
|
||
fetch_redirect_response=False,
|
||
)
|
||
|
||
|
||
class PickSeaPersistTest(TestCase):
|
||
"""Gameroom DRAW SEA persistence — sea_save upserts the Celtic-Cross hand
|
||
onto the seat's confirmed Character.celtic_cross (the gameroom analogue of
|
||
my_sea_lock, but Character-backed: no MySeaDraw quota / Brief machinery).
|
||
sea_delete clears it; the room context exposes saved_by_position +
|
||
saved_sea_spread so a reload re-renders the filled cross."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
self.pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||
self.sig_card = TarotCard.objects.get(
|
||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||
)
|
||
self.pc_seat.significator = self.sig_card
|
||
self.pc_seat.save()
|
||
# Confirmed sky → DRAW SEA reachable for this seat.
|
||
self.char = Character.objects.create(
|
||
seat=self.pc_seat,
|
||
significator=self.sig_card,
|
||
chart_data={"planets": {"Sun": {"sign": "Gemini"}}},
|
||
confirmed_at=timezone.now(),
|
||
)
|
||
self.save_url = reverse("epic:sea_save", kwargs={"room_id": self.room.id})
|
||
self.delete_url = reverse("epic:sea_delete", kwargs={"room_id": self.room.id})
|
||
self.room_url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def _hand(self):
|
||
"""A 6-card Celtic Cross hand referencing real deck cards."""
|
||
cards = list(
|
||
TarotCard.objects.filter(deck_variant=self.earthman)
|
||
.exclude(id=self.sig_card.id)[:6]
|
||
)
|
||
positions = ["cover", "cross", "crown", "lay", "loom", "leave"]
|
||
return [
|
||
{"position": p, "card_id": c.id, "reversed": False, "polarity": "gravity"}
|
||
for p, c in zip(positions, cards)
|
||
]
|
||
|
||
def test_sea_save_upserts_celtic_cross_onto_confirmed_character(self):
|
||
hand = self._hand()
|
||
resp = self.client.post(
|
||
self.save_url,
|
||
data={"spread": "waite-smith", "hand": hand},
|
||
content_type="application/json",
|
||
)
|
||
self.assertEqual(resp.status_code, 200)
|
||
self.assertTrue(resp.json()["ok"])
|
||
self.char.refresh_from_db()
|
||
self.assertEqual(self.char.celtic_cross["spread"], "waite-smith")
|
||
self.assertEqual(len(self.char.celtic_cross["hand"]), 6)
|
||
|
||
def test_sea_save_403_for_non_seated_user(self):
|
||
outsider = User.objects.create(email="outsider@test.io")
|
||
self.client.force_login(outsider)
|
||
resp = self.client.post(
|
||
self.save_url,
|
||
data={"spread": "waite-smith", "hand": self._hand()},
|
||
content_type="application/json",
|
||
)
|
||
self.assertEqual(resp.status_code, 403)
|
||
|
||
def test_sea_save_403_when_sky_not_confirmed(self):
|
||
# Drop the confirmed Character → DRAW SEA isn't reachable → 403.
|
||
self.char.delete()
|
||
resp = self.client.post(
|
||
self.save_url,
|
||
data={"spread": "waite-smith", "hand": self._hand()},
|
||
content_type="application/json",
|
||
)
|
||
self.assertEqual(resp.status_code, 403)
|
||
|
||
def test_sea_save_400_on_missing_hand(self):
|
||
resp = self.client.post(
|
||
self.save_url,
|
||
data={"spread": "waite-smith"},
|
||
content_type="application/json",
|
||
)
|
||
self.assertEqual(resp.status_code, 400)
|
||
|
||
def test_sea_save_405_on_get(self):
|
||
self.assertEqual(self.client.get(self.save_url).status_code, 405)
|
||
|
||
def test_sea_delete_clears_celtic_cross(self):
|
||
self.char.celtic_cross = {"spread": "waite-smith", "hand": self._hand()}
|
||
self.char.save(update_fields=["celtic_cross"])
|
||
resp = self.client.post(self.delete_url)
|
||
self.assertEqual(resp.status_code, 200)
|
||
self.char.refresh_from_db()
|
||
self.assertIsNone(self.char.celtic_cross)
|
||
|
||
def test_room_context_exposes_saved_by_position_after_save(self):
|
||
hand = self._hand()
|
||
self.char.celtic_cross = {"spread": "waite-smith", "hand": hand}
|
||
self.char.save(update_fields=["celtic_cross"])
|
||
resp = self.client.get(self.room_url)
|
||
self.assertEqual(resp.context["saved_sea_spread"], "waite-smith")
|
||
saved = resp.context["saved_by_position"]
|
||
self.assertIn("cover", saved)
|
||
self.assertEqual(saved["cover"]["card_id"], hand[0]["card_id"])
|
||
|
||
|
||
class PickSeaUnifiedFeltTest(TestCase):
|
||
"""DRAW SEA rebuilt as a --duoUser felt + a Gaussian spread modal, mirroring
|
||
my_sea.html (2026-06-07). The legacy single dark modal (.sea-backdrop +
|
||
.sea-modal-wrap + .sea-modal w. LOCK HAND) is gone; the felt fills the hex
|
||
pane (.sea-page--room) and the modal (#id_sea_spread_modal) carries the
|
||
.sea-select combobox + AUTO DRAW + a mini preview + a corner NVM. The
|
||
combobox offers ONLY the two 6-card Celtic Cross spreads."""
|
||
|
||
def setUp(self):
|
||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
self.pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||
self.sig_card = TarotCard.objects.get(
|
||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||
)
|
||
self.pc_seat.significator = self.sig_card
|
||
self.pc_seat.save()
|
||
Character.objects.create(
|
||
seat=self.pc_seat, significator=self.sig_card,
|
||
chart_data={"planets": {"Sun": {"sign": "Gemini"}}},
|
||
confirmed_at=timezone.now(),
|
||
)
|
||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def test_sea_felt_replaces_dark_modal(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("has-sea-stage", content)
|
||
self.assertIn("sea-page--room", content)
|
||
# Legacy single-modal chrome is gone.
|
||
self.assertNotIn("sea-backdrop", content)
|
||
self.assertNotIn("sea-modal-wrap", content)
|
||
|
||
def test_gaussian_spread_modal_with_preview_and_corner_nvm(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn('id="id_sea_spread_modal"', content)
|
||
self.assertIn("my-sea-spread-modal__backdrop", content)
|
||
self.assertIn("sea-cross--preview", content) # mini preview
|
||
self.assertIn('id="id_sea_cancel"', content) # corner NVM
|
||
|
||
def test_auto_draw_replaces_lock_hand(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn('id="id_sea_action_btn"', content)
|
||
self.assertIn("AUTO", content)
|
||
self.assertNotIn("id_sea_lock_hand", content)
|
||
|
||
def test_only_two_celtic_cross_spread_options(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn('data-value="waite-smith"', content)
|
||
self.assertIn('data-value="escape-velocity"', content)
|
||
# No 3-card spreads in the gameroom (user-spec 2026-06-07).
|
||
self.assertNotIn('data-value="past-present-future"', content)
|
||
self.assertNotIn('data-value="situation-action-outcome"', content)
|
||
|
||
def test_room_gear_has_sea_nvm_pane(self):
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("room-menu-sea", content)
|
||
|
||
def test_sea_stage_renders_back_img_from_seat_deck(self):
|
||
"""The sea-stage FLIP reveals the card back. The back-img is sourced from
|
||
the SEAT's contributed deck (NOT request.user.equipped_deck, which
|
||
select_role nulls out → the back silently never rendered + FLIP no-op'd)
|
||
and renders for any image-equipped deck w. a back (user-flagged
|
||
2026-06-07)."""
|
||
minch = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
|
||
self.pc_seat.deck_variant = minch
|
||
self.pc_seat.save(update_fields=["deck_variant"])
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("sig-stage-card-back-img", content)
|
||
|
||
def test_sea_stage_no_back_img_for_text_seat_deck(self):
|
||
"""Earthman (text-mode, no card images) → no back-img element (FLIP is a
|
||
no-op for text decks, by design)."""
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertNotIn("sig-stage-card-back-img", content)
|
||
|
||
def test_center_sig_renders_image_for_image_deck_sig(self):
|
||
"""The spread's CENTER significator supplies the card-face image when its
|
||
deck has one (mirrors my_sea); the tray sig stays a simple thumbnail. The
|
||
sig's deck_variant is the card's OWN deck — the Sig Select pick comes from
|
||
the Role Select contributed deck, so this is the right source (no
|
||
equipped_deck bug, unlike the FLIP back-img). User-spec 2026-06-07."""
|
||
minch = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
|
||
il_matto = TarotCard.objects.filter(deck_variant=minch).first()
|
||
self.pc_seat.significator = il_matto
|
||
self.pc_seat.save(update_fields=["significator"])
|
||
# my_tray_sig = confirmed_char.significator OR seat.significator → keep both.
|
||
char = Character.objects.get(seat=self.pc_seat, confirmed_at__isnull=False)
|
||
char.significator = il_matto
|
||
char.save(update_fields=["significator"])
|
||
content = self.client.get(self.url).content.decode()
|
||
# Image-mode marker is unique to the felt cross center (the tray + preview
|
||
# sigs stay text-only).
|
||
self.assertIn("sea-sig-card sig-stage-card--image", content)
|
||
|
||
def test_always_two_deck_stacks_gravity_and_levity(self):
|
||
"""Unlike my_sea / Sig Select, the room Sea Select ALWAYS shows BOTH the
|
||
Gravity + Levity stacks — the gamer draws from either populated half
|
||
(even a CARTE monodeck split across the two), so the polarization-
|
||
conditional single-stack collapse must NOT apply here (user-spec
|
||
2026-06-07)."""
|
||
content = self.client.get(self.url).content.decode()
|
||
self.assertIn("sea-deck-stack--gravity", content)
|
||
self.assertIn("sea-deck-stack--levity", content)
|
||
self.assertNotIn("sea-deck-stack--single", content)
|
||
self.assertNotIn("sea-stacks--single", content)
|
||
|
||
|
||
class CarteSeatSwitchSkySeaTest(TestCase):
|
||
"""A CARTE owner (one gamer owns all 6 seats) must drive EACH seat through
|
||
CAST SKY + DRAW SEA independently. The sky/sea state is per-SEAT
|
||
(Character.seat), so ?seat=N must switch the DISPLAYED + SAVED Character —
|
||
the bug stuck everything on the canonical (PC) seat, so the tray switched but
|
||
the sky wheel / sea spread did not, and saves wrote back to the PC seat
|
||
(user-flagged 2026-06-07)."""
|
||
|
||
def setUp(self):
|
||
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||
slug="earthman",
|
||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||
)
|
||
self.viewer = User.objects.create(email="disco@test.io", username="disco")
|
||
self.room = Room.objects.create(name="Carte Sky", owner=self.viewer)
|
||
sig = TarotCard.objects.filter(deck_variant=self.earthman, arcana="MAJOR").first()
|
||
self.seats = {}
|
||
for i, role in enumerate(["PC", "NC", "EC", "SC", "AC", "BC"], start=1):
|
||
slot = self.room.gate_slots.get(slot_number=i)
|
||
slot.gamer = self.viewer
|
||
slot.status = GateSlot.FILLED
|
||
slot.save()
|
||
self.seats[i] = TableSeat.objects.create(
|
||
room=self.room, gamer=self.viewer, slot_number=i, role=role,
|
||
role_revealed=True, deck_variant=self.earthman, significator=sig,
|
||
)
|
||
self.room.gate_status = Room.OPEN
|
||
self.room.table_status = Room.SKY_SELECT
|
||
self.room.save()
|
||
self.client.force_login(self.viewer)
|
||
# Slot 1 (PC = canonical) — sky confirmed WITH a sea hand.
|
||
Character.objects.create(
|
||
seat=self.seats[1], significator=sig,
|
||
chart_data={"planets": {}}, confirmed_at=timezone.now(),
|
||
celtic_cross={"spread": "waite-smith", "hand": self._hand()},
|
||
)
|
||
# Slot 3 (EC) — sky confirmed, NO sea hand yet.
|
||
Character.objects.create(
|
||
seat=self.seats[3], significator=sig,
|
||
chart_data={"planets": {}}, confirmed_at=timezone.now(),
|
||
)
|
||
self.room_url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||
|
||
def _hand(self):
|
||
cards = list(TarotCard.objects.filter(deck_variant=self.earthman)[:6])
|
||
positions = ["cover", "cross", "crown", "lay", "loom", "leave"]
|
||
return [{"position": p, "card_id": c.id, "reversed": False, "polarity": "gravity"}
|
||
for p, c in zip(positions, cards)]
|
||
|
||
def test_seat_param_switches_displayed_spread(self):
|
||
# ?seat=1 (PC) → its saved hand; ?seat=3 (EC) → empty. The bug showed
|
||
# seat 1's hand for BOTH (the context stuck on the canonical PC seat).
|
||
r1 = self.client.get(self.room_url + "?seat=1")
|
||
self.assertTrue(r1.context["saved_by_position"])
|
||
self.assertEqual(r1.context["saved_sea_spread"], "waite-smith")
|
||
r3 = self.client.get(self.room_url + "?seat=3")
|
||
self.assertEqual(r3.context["saved_by_position"], {})
|
||
self.assertEqual(r3.context["saved_sea_spread"], "")
|
||
|
||
def test_sea_save_targets_switched_seat_not_canonical(self):
|
||
save_url = reverse("epic:sea_save", kwargs={"room_id": self.room.id})
|
||
resp = self.client.post(
|
||
save_url + "?seat=3",
|
||
data={"spread": "escape-velocity", "hand": self._hand()},
|
||
content_type="application/json",
|
||
)
|
||
self.assertEqual(resp.status_code, 200)
|
||
# Seat 3 got the new hand; seat 1's (canonical) hand is untouched.
|
||
self.assertEqual(
|
||
Character.objects.get(seat=self.seats[3]).celtic_cross["spread"],
|
||
"escape-velocity",
|
||
)
|
||
self.assertEqual(
|
||
Character.objects.get(seat=self.seats[1]).celtic_cross["spread"],
|
||
"waite-smith",
|
||
)
|
||
|
||
def test_sky_save_confirms_switched_seat_independently(self):
|
||
# Slot 5 (AC) — no Character yet. sky_save?seat=5 confirms ITS Character.
|
||
save_url = reverse("epic:sky_save", kwargs={"room_id": self.room.id})
|
||
resp = self.client.post(
|
||
save_url + "?seat=5",
|
||
data={"chart_data": {"planets": {}}, "action": "confirm"},
|
||
content_type="application/json",
|
||
)
|
||
self.assertEqual(resp.status_code, 200)
|
||
self.assertTrue(Character.objects.filter(
|
||
seat=self.seats[5], confirmed_at__isnull=False).exists())
|
||
# The PC seat's Character is NOT touched (still its own confirmed row).
|
||
self.assertEqual(
|
||
Character.objects.filter(seat=self.seats[1], confirmed_at__isnull=False).count(),
|
||
1,
|
||
)
|
||
|
||
def test_felt_action_urls_carry_seat_param(self):
|
||
content = self.client.get(self.room_url + "?seat=3").content.decode()
|
||
self.assertIn("/sea/save?seat=3", content)
|
||
self.assertIn("/sky/save?seat=3", content)
|