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:
@@ -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