sig-select sprint: SigReservation model + sig_reserve view (OK/NVM hold); full sig-select.js rewrite with stage preview, WS hover cursors, reservation lock (must NVM before OK-ing another card — enforced server-side 409 + JS guard); sizeSigModal() + sizeSigCard() in room.js (JS-based card sizing avoids libsass cqw/cqh limitation); stat block hidden until OK pressed; mobile touch: dismiss stage on outside-grid tap when unfocused; 17 IT + Jasmine specs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -164,3 +164,98 @@ class CursorMoveConsumerTest(TransactionTestCase):
|
||||
|
||||
await pc_comm.disconnect()
|
||||
await bc_comm.disconnect()
|
||||
|
||||
|
||||
@tag('channels')
|
||||
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||
class SigHoverConsumerTest(TransactionTestCase):
|
||||
"""sig_hover messages sent by a client are forwarded within the polarity group only."""
|
||||
|
||||
async def _make_communicator(self, user, room):
|
||||
client = Client()
|
||||
await database_sync_to_async(client.force_login)(user)
|
||||
session_key = await database_sync_to_async(lambda: client.session.session_key)()
|
||||
comm = WebsocketCommunicator(
|
||||
application,
|
||||
f"/ws/room/{room.id}/",
|
||||
headers=[(b"cookie", f"sessionid={session_key}".encode())],
|
||||
)
|
||||
connected, _ = await comm.connect()
|
||||
self.assertTrue(connected)
|
||||
return comm
|
||||
|
||||
async def test_sig_hover_forwarded_to_polarity_group(self):
|
||||
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||
nc_user = await database_sync_to_async(User.objects.create)(email="nc@test.io")
|
||||
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||
)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=nc_user, slot_number=2, role="NC"
|
||||
)
|
||||
|
||||
pc_comm = await self._make_communicator(pc_user, room)
|
||||
nc_comm = await self._make_communicator(nc_user, room)
|
||||
|
||||
await pc_comm.send_json_to({
|
||||
"type": "sig_hover", "card_id": "abc-123", "role": "PC", "active": True
|
||||
})
|
||||
|
||||
msg = await nc_comm.receive_json_from(timeout=2)
|
||||
self.assertEqual(msg["type"], "sig_hover")
|
||||
self.assertEqual(msg["card_id"], "abc-123")
|
||||
self.assertEqual(msg["role"], "PC")
|
||||
self.assertTrue(msg["active"])
|
||||
|
||||
await pc_comm.disconnect()
|
||||
await nc_comm.disconnect()
|
||||
|
||||
async def test_sig_hover_not_forwarded_to_other_polarity(self):
|
||||
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||
bc_user = await database_sync_to_async(User.objects.create)(email="bc@test.io")
|
||||
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||
)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=bc_user, slot_number=2, role="BC"
|
||||
)
|
||||
|
||||
pc_comm = await self._make_communicator(pc_user, room)
|
||||
bc_comm = await self._make_communicator(bc_user, room)
|
||||
|
||||
await pc_comm.send_json_to({
|
||||
"type": "sig_hover", "card_id": "abc-123", "role": "PC", "active": True
|
||||
})
|
||||
|
||||
self.assertTrue(await bc_comm.receive_nothing(timeout=1))
|
||||
|
||||
await pc_comm.disconnect()
|
||||
await bc_comm.disconnect()
|
||||
|
||||
async def test_sig_reserved_broadcast_received_by_polarity_group(self):
|
||||
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||
nc_user = await database_sync_to_async(User.objects.create)(email="nc@test.io")
|
||||
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||
)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=nc_user, slot_number=2, role="NC"
|
||||
)
|
||||
|
||||
nc_comm = await self._make_communicator(nc_user, room)
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
await channel_layer.group_send(
|
||||
f"cursors_{room.id}_levity",
|
||||
{"type": "sig_reserved", "card_id": "card-xyz", "role": "PC", "reserved": True},
|
||||
)
|
||||
|
||||
msg = await nc_comm.receive_json_from(timeout=2)
|
||||
self.assertEqual(msg["type"], "sig_reserved")
|
||||
self.assertEqual(msg["card_id"], "card-xyz")
|
||||
self.assertTrue(msg["reserved"])
|
||||
|
||||
await nc_comm.disconnect()
|
||||
|
||||
@@ -4,10 +4,13 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from django.db import IntegrityError
|
||||
|
||||
from apps.lyric.models import Token, User
|
||||
from apps.epic.models import (
|
||||
DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard,
|
||||
debit_token, select_token, sig_deck_cards, sig_seat_order, active_sig_seat,
|
||||
DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||||
debit_token, select_token, sig_deck_cards, levity_sig_cards, gravity_sig_cards,
|
||||
sig_seat_order, active_sig_seat,
|
||||
)
|
||||
|
||||
|
||||
@@ -360,3 +363,119 @@ class SigCardFieldTest(TestCase):
|
||||
self.card.delete()
|
||||
self.seat.refresh_from_db()
|
||||
self.assertIsNone(self.seat.significator)
|
||||
|
||||
|
||||
# ── SigReservation model ──────────────────────────────────────────────────────
|
||||
|
||||
def _make_sig_card(deck_variant, suit, number):
|
||||
name_map = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
|
||||
card, _ = TarotCard.objects.get_or_create(
|
||||
deck_variant=deck_variant,
|
||||
slug=f"{name_map[number].lower()}-of-{suit.lower()}-em",
|
||||
defaults={
|
||||
"arcana": "MINOR", "suit": suit, "number": number,
|
||||
"name": f"{name_map[number]} of {suit.capitalize()}",
|
||||
},
|
||||
)
|
||||
return card
|
||||
|
||||
|
||||
class SigReservationModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
self.owner = User.objects.create(email="founder@test.io")
|
||||
self.room = Room.objects.create(name="Sig Room", owner=self.owner)
|
||||
self.card = _make_sig_card(self.earthman, "WANDS", 14)
|
||||
self.seat = TableSeat.objects.create(
|
||||
room=self.room, gamer=self.owner, slot_number=1, role="PC"
|
||||
)
|
||||
|
||||
def test_can_create_sig_reservation(self):
|
||||
res = SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
self.assertEqual(res.role, "PC")
|
||||
self.assertEqual(res.polarity, "levity")
|
||||
self.assertIsNotNone(res.reserved_at)
|
||||
|
||||
def test_one_reservation_per_gamer_per_room(self):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
card2 = _make_sig_card(self.earthman, "CUPS", 13)
|
||||
with self.assertRaises(IntegrityError):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=card2, role="PC", polarity="levity"
|
||||
)
|
||||
|
||||
def test_same_card_blocked_within_same_polarity(self):
|
||||
gamer2 = User.objects.create(email="nc@test.io")
|
||||
TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="NC")
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
with self.assertRaises(IntegrityError):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=gamer2, card=self.card, role="NC", polarity="levity"
|
||||
)
|
||||
|
||||
def test_same_card_allowed_across_polarity(self):
|
||||
"""A gravity gamer may reserve the same card instance as a levity gamer
|
||||
— each polarity has its own independent pile."""
|
||||
gamer2 = User.objects.create(email="bc@test.io")
|
||||
TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="BC")
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
res2 = SigReservation.objects.create(
|
||||
room=self.room, gamer=gamer2, card=self.card, role="BC", polarity="gravity"
|
||||
)
|
||||
self.assertIsNotNone(res2.pk)
|
||||
|
||||
def test_deleting_reservation_clears_slot(self):
|
||||
res = SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
res.delete()
|
||||
self.assertFalse(SigReservation.objects.filter(room=self.room, gamer=self.owner).exists())
|
||||
|
||||
|
||||
class SigCardHelperTest(TestCase):
|
||||
"""levity_sig_cards() and gravity_sig_cards() return 18 cards each.
|
||||
Relies on the Earthman deck seeded by migrations (no manual card creation).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# Earthman deck is already seeded by migrations
|
||||
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||
self.owner = User.objects.create(email="founder@test.io")
|
||||
self.owner.equipped_deck = self.earthman
|
||||
self.owner.save()
|
||||
self.room = Room.objects.create(name="Card Test", owner=self.owner)
|
||||
|
||||
def test_levity_sig_cards_returns_18(self):
|
||||
cards = levity_sig_cards(self.room)
|
||||
self.assertEqual(len(cards), 18)
|
||||
|
||||
def test_gravity_sig_cards_returns_18(self):
|
||||
cards = gravity_sig_cards(self.room)
|
||||
self.assertEqual(len(cards), 18)
|
||||
|
||||
def test_levity_and_gravity_share_same_card_objects(self):
|
||||
"""Both piles draw from the same 18 TarotCard instances — visual distinction
|
||||
comes from CSS polarity class, not separate card model records."""
|
||||
levity = levity_sig_cards(self.room)
|
||||
gravity = gravity_sig_cards(self.room)
|
||||
self.assertEqual(
|
||||
sorted(c.pk for c in levity),
|
||||
sorted(c.pk for c in gravity),
|
||||
)
|
||||
|
||||
def test_returns_empty_when_no_equipped_deck(self):
|
||||
self.owner.equipped_deck = None
|
||||
self.owner.save()
|
||||
self.assertEqual(levity_sig_cards(self.room), [])
|
||||
self.assertEqual(gravity_sig_cards(self.room), [])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
@@ -8,7 +8,7 @@ from django.utils import timezone
|
||||
from apps.drama.models import GameEvent
|
||||
from apps.lyric.models import Token, User
|
||||
from apps.epic.models import (
|
||||
DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard,
|
||||
DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||||
)
|
||||
|
||||
|
||||
@@ -943,9 +943,9 @@ class SigSelectRenderingTest(TestCase):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "id_sig_deck")
|
||||
|
||||
def test_sig_deck_contains_36_sig_cards(self):
|
||||
def test_sig_deck_contains_18_sig_cards(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.content.decode().count('sig-card'), 36)
|
||||
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)
|
||||
@@ -1119,3 +1119,154 @@ class SelectRoleRecordsRoleSelectedTest(TestCase):
|
||||
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="MINOR", suit="WANDS", 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="MINOR", suit="WANDS", 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)
|
||||
|
||||
# ── guards ────────────────────────────────────────────────────────────
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user