my-sea spectator Phase B: seat-2C occupancy + visitor token gate + one-shot seated glow + gear BYE — TDD

Phase B of the my-sea invite → spectator → voice blueprint. An ACCEPTED
invitee can watch the owner's my-sea read-only, deposit a token to occupy
seat 2C (opening a 24h voice window for Phase C), and BYE out. Owner's
my_sea.html is left structurally intact — the spectator gets a dedicated,
simpler my_sea_visit.html; the read-only draw reuses the existing
`latest_draw_slots` payload (no picker surgery).

- B1: my_sea_visit(owner_id) spectator view — 403 unless an ACCEPTED
  SeaInvite(owner, request.user); owner bounced to their own my_sea. Context
  forces owner-only controls off (sea_btn_active=False, read_only=True);
  renders the table hex (1C owner / 2C visitor) + owner draw read-only.
- B2: visitor gate — my_sea_visit_gate reuses my_sea_gate.html w. a
  spectator branch (titles the OWNER's Sea, INSERT posts to the visitor
  endpoint, bud-panel suppressed, gear NVM→visit + BYE). Single-step
  my_sea_visit_insert_token selects+debits the visitor's token (same
  priority chain) and records token_deposited_at + a 24h voice_until on the
  SeaInvite → seat 2C present. Center btn flips GATE VIEW → VIEW DRAW.
- B3: spectator gear BYE — my_sea_visit_leave sets status=LEFT, left_at,
  clears voice_until (frees 2C, ends voice), redirects /gameboard/.
  _my_sea_gear.html gains a `leave_url`-gated BYE below NVM (owner pages
  pass no leave_url, so unchanged).
- B-seat: one-shot "seated" glow per user-spec 2026-05-27 — new shared
  apps/gameboard/my-sea-seats.js: on first view (localStorage-gated by a
  per-occupancy data-seat-token) an occupied seat flares --terUser +
  --ninUser glow ~1.5s then settles to full-opacity --secUser (.fa-ban
  already swapped to .fa-circle-check). _room.scss adds .seated /
  .seat-just-seated + the my-sea-seat-flare keyframes (mirrors the room's
  .active→.role-confirmed handoff). Wired on BOTH the spectator page (load)
  and the owner page (load + on the FREE DRAW seat-1 transition).
  MySeaSeatsSpec.js Jasmine spec covers the gating + timed class removal.
- B5: MySeaSpectatorFlowTest FT — accept → visit → GATE VIEW → deposit →
  VIEW DRAW + seat 2C seated.

URLs: my-sea/visit/<uuid:owner_id>/ (+ /gate/, /insert, /leave). 470 IT/UT
green; spectator FT + full Jasmine suite green. Phase C (WebRTC mesh voice
+ coturn droplet) next — the 24h voice_until window set here drives it.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-27 13:35:00 -04:00
parent fb8563eed2
commit d0c39b51b6
15 changed files with 740 additions and 9 deletions

View File

@@ -0,0 +1,175 @@
"""ITs for the my-sea spectator (invitee) views — Phase B of
[[my-sea-invite-voice-blueprint]].
An ACCEPTED invitee can VISIT the owner's my-sea as a read-only spectator,
deposit a token at a visitor gate to occupy seat 2C (+ open a 24h voice
window), and BYE out (freeing the seat). All access is gated on an ACCEPTED
SeaInvite for (owner, request.user).
"""
from datetime import timedelta
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.gameboard.models import MySeaDraw, SeaInvite
from apps.lyric.models import Token, User
def _owner_with_sig(email="owner@test.io", username="discoman"):
owner = User.objects.create(email=email, username=username)
# A sig id is required to build a MySeaDraw; any int is fine for the
# spectator read-only path (no card lookup asserted here).
return owner
class MySeaVisitGuardTest(TestCase):
def setUp(self):
self.owner = _owner_with_sig()
self.bud = User.objects.create(email="bud@test.io", username="budster")
self.invite = SeaInvite.objects.create(
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
)
self.url = reverse("my_sea_visit", args=[self.owner.id])
def test_accepted_invitee_can_visit(self):
self.client.force_login(self.bud)
self.assertEqual(self.client.get(self.url).status_code, 200)
def test_non_invitee_is_forbidden(self):
stranger = User.objects.create(email="x@test.io", username="x")
self.client.force_login(stranger)
self.assertEqual(self.client.get(self.url).status_code, 403)
def test_pending_invitee_is_forbidden(self):
self.invite.status = SeaInvite.PENDING
self.invite.save()
self.client.force_login(self.bud)
self.assertEqual(self.client.get(self.url).status_code, 403)
def test_left_invitee_is_forbidden(self):
self.invite.status = SeaInvite.LEFT
self.invite.left_at = timezone.now()
self.invite.save()
self.client.force_login(self.bud)
self.assertEqual(self.client.get(self.url).status_code, 403)
def test_owner_visiting_own_sea_redirects_to_my_sea(self):
self.client.force_login(self.owner)
resp = self.client.get(self.url)
self.assertRedirects(resp, reverse("my_sea"), fetch_redirect_response=False)
class MySeaVisitContextTest(TestCase):
def setUp(self):
self.owner = _owner_with_sig()
self.bud = User.objects.create(email="bud@test.io", username="budster")
self.invite = SeaInvite.objects.create(
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
)
# Owner has drawn a hand → seat 1C is occupied + cards exist to view.
MySeaDraw.objects.create(
user=self.owner, spread="situation-action-outcome",
significator_id=1, hand=[
{"position": "lay", "card_id": 1, "reversed": False,
"polarity": "gravity"},
],
)
self.client.force_login(self.bud)
self.url = reverse("my_sea_visit", args=[self.owner.id])
def test_spectator_context_flags(self):
ctx = self.client.get(self.url).context
self.assertTrue(ctx["spectator"])
self.assertFalse(ctx["is_owner"])
self.assertEqual(ctx["owner"], self.owner)
self.assertEqual(ctx["sea_invite"], self.invite)
# Owner controls forced off on the spectator surface.
self.assertFalse(ctx["sea_btn_active"])
def test_not_present_shows_gate_view(self):
content = self.client.get(self.url).content.decode()
self.assertIn("GATE", content)
self.assertIn(reverse("my_sea_visit_gate", args=[self.owner.id]), content)
def test_present_shows_view_draw_and_seat2(self):
self.invite.token_deposited_at = timezone.now()
self.invite.voice_until = timezone.now() + timedelta(hours=24)
self.invite.save()
ctx = self.client.get(self.url).context
self.assertTrue(ctx["sea_invite"].is_present)
self.assertTrue(ctx["seat2_present"])
self.assertIn("VIEW", self.client.get(self.url).content.decode())
class MySeaVisitInsertTokenTest(TestCase):
def setUp(self):
self.owner = _owner_with_sig()
self.bud = User.objects.create(email="bud@test.io", username="budster")
self.invite = SeaInvite.objects.create(
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
)
self.url = reverse("my_sea_visit_insert_token", args=[self.owner.id])
def test_deposit_marks_present_and_opens_voice_window(self):
self.client.force_login(self.bud)
resp = self.client.post(self.url)
self.invite.refresh_from_db()
self.assertIsNotNone(self.invite.token_deposited_at)
self.assertIsNotNone(self.invite.voice_until)
self.assertTrue(self.invite.voice_active)
self.assertTrue(self.invite.is_present)
self.assertRedirects(
resp, reverse("my_sea_visit", args=[self.owner.id]),
fetch_redirect_response=False,
)
def test_non_invitee_cannot_deposit(self):
stranger = User.objects.create(email="x@test.io", username="x")
self.client.force_login(stranger)
self.assertEqual(self.client.post(self.url).status_code, 403)
def test_no_token_does_not_mark_present(self):
# Strip the visitor of every usable token.
self.bud.equipped_trinket = None
self.bud.save(update_fields=["equipped_trinket"])
Token.objects.filter(user=self.bud).delete()
self.client.force_login(self.bud)
self.client.post(self.url)
self.invite.refresh_from_db()
self.assertIsNone(self.invite.token_deposited_at)
self.assertFalse(self.invite.is_present)
class MySeaVisitLeaveTest(TestCase):
def setUp(self):
self.owner = _owner_with_sig()
self.bud = User.objects.create(email="bud@test.io", username="budster")
self.invite = SeaInvite.objects.create(
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
token_deposited_at=timezone.now(),
voice_until=timezone.now() + timedelta(hours=24),
)
self.url = reverse("my_sea_visit_leave", args=[self.owner.id])
def test_leave_sets_left_and_kills_voice(self):
self.client.force_login(self.bud)
resp = self.client.post(self.url)
self.invite.refresh_from_db()
self.assertEqual(self.invite.status, SeaInvite.LEFT)
self.assertIsNotNone(self.invite.left_at)
self.assertIsNone(self.invite.voice_until)
self.assertFalse(self.invite.is_present)
self.assertRedirects(
resp, reverse("gameboard"), fetch_redirect_response=False,
)
def test_non_invitee_cannot_leave(self):
stranger = User.objects.create(email="x@test.io", username="x")
self.client.force_login(stranger)
self.assertEqual(self.client.post(self.url).status_code, 403)