room center: GATE VIEW supersedes SCAN SIGS / CAST SKY / DRAW SEA / sig overlay when token cost lapses — ROLE pick survives grace — TDD

Phase 3 of the room GATE VIEW + seat-renewal sprint. When the viewer's
own FILLED gate-slot cost has lapsed (filled_at past the cost-current
window), the center hex shows a GATE VIEW button (→ room gate-view)
instead of the phase affordances, so they must renew before advancing.

- _role_select_context: adds viewer_cost_current / viewer_in_grace from
  the viewer's FILLED slot (no slot → current, defensive)
- room.html: the ROLE card-stack renders OUTSIDE the cost gate (the
  gamer's own role pick survives the renewal grace — deposit privilege);
  GATE VIEW supersedes the rest of .table-center; #id_pick_sigs_wrap
  (SCAN SIGS, advancing the whole table) is gated on viewer_cost_current;
  the SIG/SKY/SEA overlays are gated too (they embed their trigger-btn
  ids in JS, so they must not render alongside GATE VIEW)
- per user-spec: only the ROLE pick stays in grace; SCAN SIGS + every
  later phase get GATE VIEW

Tests: RoomCenterSupersessionTest (9) — GATE VIEW supersedes sig overlay
/ CAST SKY / DRAW SEA / SCAN SIGS when lapsed, normal buttons when
current; RoomRoleStackGraceTest (1) — card-stack (eligible) kept
alongside GATE VIEW when lapsed. 838 epic+gameboard ITs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-31 23:29:43 -04:00
parent e78ba730e3
commit 4b3dc91e7f
3 changed files with 172 additions and 27 deletions

View File

@@ -2783,3 +2783,113 @@ class RoomNavbarGateViewTest(TestCase):
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")