my-sea: async witness — spectators see the owner's draw land live via WS, no refresh — TDD
The bud watching @owner's sea now sees each card appear in real time instead of having to refresh. Follows the epic RoomConsumer broadcast pattern (view -> group_send -> consumer handler -> send_json), keyed on the owner (mysea_<owner>) since my-sea has no Room. - apps/gameboard/consumers.py: MySeaSpectateConsumer — read-only WS; membership gate matches voice (owner OR present invitee: ACCEPTED + deposited + not left). Relays a `sea_draw` event carrying the owner's full hand. - apps/gameboard/routing.py + core/asgi.py: ws/my-sea-spectate/<owner_id>/. - gameboard/views.py: _notify_sea_draw(owner_id, hand) — best-effort, guarded group_send so a down/missing channel layer can't break the solo draw. Fired from my_sea_lock (both the create + the mid-draw-upsert branch) and from my_sea_delete (empty hand -> clears the spectators' cross). - my_sea_visit.html: a WS listener fills the cross live — SeaDeal.register(card, '.sea-pos-'+pos, isLevity) reuses _fillSlot (incl. the --rank-long squeeze) + seeds the slot clickable into the stage; a DEL re-empties cleared slots. Capped reconnect for transient blips. Tests: 5 channels ITs (owner/present-invitee connect + receive; unauth / stranger / accepted-not-present rejected); +2 view ITs (lock broadcasts owner+ hand; lock still 200s when the broadcast raises). Client fill needs live two-party verification on staging (Redis up). 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:
@@ -0,0 +1,81 @@
|
||||
"""Channels ITs for MySeaSpectateConsumer — the async-witness WS that pushes
|
||||
the owner's draw to watching invitees (user-spec 2026-05-29).
|
||||
|
||||
Same shape as the voice consumer tests: scope injected directly, in-memory
|
||||
channel layer, `@tag("channels")` (excluded from the default run).
|
||||
"""
|
||||
|
||||
from channels.db import database_sync_to_async
|
||||
from channels.testing.websocket import WebsocketCommunicator
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test import TransactionTestCase, override_settings, tag
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.gameboard.consumers import MySeaSpectateConsumer
|
||||
from apps.gameboard.models import SeaInvite
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
TEST_CHANNEL_LAYERS = {
|
||||
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
|
||||
}
|
||||
|
||||
|
||||
@tag("channels")
|
||||
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||
class MySeaSpectateConsumerTest(TransactionTestCase):
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io", username="discoman")
|
||||
self.bud = User.objects.create(email="bud@test.io", username="budster")
|
||||
|
||||
def _comm(self, user):
|
||||
comm = WebsocketCommunicator(
|
||||
MySeaSpectateConsumer.as_asgi(),
|
||||
f"/ws/my-sea-spectate/{self.owner.id}/",
|
||||
)
|
||||
comm.scope["user"] = user
|
||||
comm.scope["url_route"] = {"kwargs": {"owner_id": self.owner.id}}
|
||||
return comm
|
||||
|
||||
def _invite(self, present):
|
||||
return 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() if present else None,
|
||||
)
|
||||
|
||||
async def test_owner_can_watch(self):
|
||||
comm = self._comm(self.owner)
|
||||
connected, _ = await comm.connect()
|
||||
self.assertTrue(connected)
|
||||
await comm.disconnect()
|
||||
|
||||
async def test_unauthenticated_is_rejected(self):
|
||||
comm = self._comm(AnonymousUser())
|
||||
connected, _ = await comm.connect()
|
||||
self.assertFalse(connected)
|
||||
|
||||
async def test_stranger_is_rejected(self):
|
||||
comm = self._comm(self.bud) # no invite
|
||||
connected, _ = await comm.connect()
|
||||
self.assertFalse(connected)
|
||||
|
||||
async def test_accepted_but_not_present_is_rejected(self):
|
||||
await database_sync_to_async(self._invite)(present=False)
|
||||
comm = self._comm(self.bud)
|
||||
connected, _ = await comm.connect()
|
||||
self.assertFalse(connected)
|
||||
|
||||
async def test_present_invitee_receives_the_owners_hand(self):
|
||||
await database_sync_to_async(self._invite)(present=True)
|
||||
comm = self._comm(self.bud)
|
||||
connected, _ = await comm.connect()
|
||||
self.assertTrue(connected)
|
||||
from channels.layers import get_channel_layer
|
||||
hand = [{"position": "lay", "card_id": 1,
|
||||
"reversed": False, "polarity": "gravity"}]
|
||||
await get_channel_layer().group_send(
|
||||
f"mysea_{self.owner.id}", {"type": "sea_draw", "hand": hand})
|
||||
msg = await comm.receive_json_from()
|
||||
self.assertEqual(msg, {"type": "sea_draw", "hand": hand})
|
||||
await comm.disconnect()
|
||||
Reference in New Issue
Block a user