added PICK SKY ready gate: SigReservation.ready + countdown_remaining fields, Room.SKY_SELECT status + sig_select_started_at, sig_ready + sig_confirm views, WS notifiers for countdown_start/cancel/polarity_room_done/pick_sky_available, migration 0031, PICK SKY btn in hex center at SKY_SELECT, tray cell 2 sig card placeholder; FTs SRG1-8 written (pending JS/consumer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-09 01:17:24 -04:00
parent 3800c5bdad
commit df421fb6c0
9 changed files with 1003 additions and 1 deletions

View File

@@ -1306,3 +1306,307 @@ class SigReserveViewTest(TestCase):
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_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)
# ── 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_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_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 PICK 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_absent_during_sig_select(self):
self.room.table_status = Room.SIG_SELECT
self.room.save()
response = self.client.get(self.url)
self.assertNotContains(response, "id_pick_sky_btn")