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:
Disco DeDisco
2026-04-05 22:01:23 -04:00
parent a15d91dfe6
commit c7370bda03
18 changed files with 1616 additions and 172 deletions

View File

@@ -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()