room gate-view: mid-game renewal area (room_gate) + renew_token endpoint + _room_gear nvm_url — TDD

Phase 4 of the room GATE VIEW + seat-renewal sprint. The 3rd-person
mirror of my_sea_gate: a gate-view a seated gamer can open at any time
to check token TIME REMAINING or RENEW, reachable even mid-game (the
gatekeeper redirects to the table once table_status is set — this view
does not).

- `room_gate` view + `room/<uuid>/gate/view/` URL — renders the viewer's
  own seat/position circle, a live time-remaining ticker (counts down to
  cost_current_until, then to grace_expires_at in renewal grace), and a
  RENEW affordance. page_class carries `page-room` (drives the navbar
  GATE VIEW in Phase 0). No seat → "no seat" copy, no RENEW btn.
- `renew_token` view + `room/<uuid>/gate/renew` URL — re-deposits a token
  into the viewer's already-FILLED slot via the existing `debit_token`
  (resets filled_at=now → restarts the cost-current window). Reuses
  select_token / debit_token wholesale; distinct from confirm_token,
  which needs a RESERVED slot. 402 when token-depleted; no-op redirect
  when the user holds no filled slot (already auto-BYE'd).
- `room_gate.html` — reuses the gatekeeper's .gate-overlay/.gate-modal
  chrome (hand-rolled like my_sea_gate, inner content differs) + an
  inline countdown ticker mirroring the status-dots IIFE.
- DRY: `_room_gear.html` now takes an `nvm_url` param (default the
  gameboard listing — room.html's own gear unchanged); the gate-view
  passes the table-hex URL so NVM returns to the hex, mirroring
  _my_sea_gear's contract.

Tests: RoomGateViewTest (7) + RoomRenewTokenTest (6) — renders mid-game,
own seat circle, data-cost-until, RENEW posts to renew_token, NVM→hex,
page-room marker, no-seat render; renew resets filled_at + consumes FREE
+ records SLOT_FILLED, no-slot/GET redirects, 402 when depleted. 504
epic tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-31 22:55:17 -04:00
parent 6fd515bc6d
commit 516b917420
5 changed files with 293 additions and 1 deletions

View File

@@ -2609,3 +2609,124 @@ class RoomBurgerBtnRenderTest(TestCase):
self.room.save()
response = self.client.get(self.url)
self.assertContains(response, 'id="id_burger_btn"')
class RoomGateViewTest(TestCase):
"""Room renewal gate-view (sprint 2026-05-31) — reachable mid-game (the
gatekeeper redirects to the table once table_status is set; this view
does not). Shows the viewer's own seat/circle + token time-remaining + a
RENEW affordance; the gear-menu NVM returns to the table hex."""
def setUp(self):
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.room = Room.objects.create(
name="Renewal Room", owner=self.owner,
renewal_period=timedelta(days=7),
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
)
self.slot = self.room.gate_slots.get(slot_number=1)
self.slot.gamer = self.owner
self.slot.status = GateSlot.FILLED
self.slot.filled_at = timezone.now()
self.slot.debited_token_type = Token.FREE
self.slot.save()
self.client.force_login(self.owner)
self.url = reverse("epic:room_gate", args=[self.room.id])
def test_renders_200_even_when_table_status_set(self):
# gatekeeper would 302 to the room; the gate-view must render mid-game.
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
def test_shows_viewer_own_seat_circle(self):
response = self.client.get(self.url)
self.assertContains(response, 'data-slot="1"')
self.assertContains(response, "PC") # slot 1 role label
def test_shows_time_remaining_data_attr(self):
response = self.client.get(self.url)
self.assertContains(response, "data-cost-until=")
self.assertContains(response, "id_room_gate_remaining")
def test_renew_button_posts_to_renew_endpoint(self):
response = self.client.get(self.url)
self.assertContains(
response, reverse("epic:renew_token", args=[self.room.id]))
self.assertContains(response, "RENEW")
def test_nvm_returns_to_room_hex(self):
response = self.client.get(self.url)
self.assertContains(
response, f'href="{reverse("epic:room", args=[self.room.id])}"')
def test_page_class_carries_page_room(self):
response = self.client.get(self.url)
self.assertIn("page-room", response.context["page_class"])
def test_no_seat_viewer_still_renders(self):
stranger = User.objects.create(email="stranger@test.io")
self.client.force_login(stranger)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "id_room_renew_btn")
class RoomRenewTokenTest(TestCase):
"""The RENEW endpoint — re-deposit a token into the viewer's own FILLED
slot, resetting filled_at=now (via debit_token) so the cost-current
window restarts. Distinct from confirm_token (which needs a RESERVED
slot). 402 when token-depleted."""
def setUp(self):
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.owner.equipped_trinket = None
self.owner.save(update_fields=["equipped_trinket"])
self.owner.tokens.exclude(token_type=Token.FREE).delete() # keep FREE only
self.room = Room.objects.create(
name="Renewal Room", owner=self.owner,
renewal_period=timedelta(days=7),
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
)
self.slot = self.room.gate_slots.get(slot_number=1)
self.slot.gamer = self.owner
self.slot.status = GateSlot.FILLED
self.slot.filled_at = timezone.now() - timedelta(days=8) # in grace
self.slot.debited_token_type = Token.FREE
self.slot.save()
self.client.force_login(self.owner)
self.url = reverse("epic:renew_token", args=[self.room.id])
def test_renew_resets_filled_at(self):
self.client.post(self.url)
self.slot.refresh_from_db()
self.assertGreater(self.slot.filled_at, timezone.now() - timedelta(minutes=1))
self.assertTrue(self.slot.cost_current)
def test_renew_consumes_free_token(self):
self.client.post(self.url)
self.assertFalse(
self.owner.tokens.filter(token_type=Token.FREE).exists())
self.slot.refresh_from_db()
self.assertEqual(self.slot.debited_token_type, Token.FREE)
def test_renew_records_slot_filled_event(self):
self.client.post(self.url)
self.assertTrue(
self.room.events.filter(
actor=self.owner, verb=GameEvent.SLOT_FILLED).exists())
def test_renew_without_filled_slot_redirects(self):
self.slot.status = GateSlot.EMPTY
self.slot.gamer = None
self.slot.save()
response = self.client.post(self.url)
self.assertEqual(response.status_code, 302)
def test_renew_402_when_token_depleted(self):
self.owner.tokens.all().delete()
response = self.client.post(self.url)
self.assertEqual(response.status_code, 402)
def test_renew_get_redirects(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 302)