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:
175
src/apps/gameboard/tests/integrated/test_sea_visit.py
Normal file
175
src/apps/gameboard/tests/integrated/test_sea_visit.py
Normal 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)
|
||||
Reference in New Issue
Block a user