Files
python-tdd/src/apps/gameboard/tests/integrated/test_spectate_consumer.py

101 lines
4.0 KiB
Python
Raw Normal View History

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>
2026-05-29 23:14:48 -04:00
"""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)
my-sea spectate: live spread-sync + owner seat-ring push + visit caption fix — TDD Three fixes to the my-sea spectator (bud-sea), all flowing over the existing `mysea_<owner>` spectate consumer: VISIT CAPTIONS (.sea-pos-label) — two bugs left every CROWN/COVER/… caption blank on my_sea_visit: - empty-hand: `label_by_position` was built from `latest_draw_slots`, which returns [] when the owner's hand is empty (only a significator placed) — so an owner mid-setup showed no captions, while her OWN my_sea (whose JS seeds labels from the POSITION_LABELS constant) showed them. Now the view pulls captions straight from POSITION_LABELS[spread], drawn-cards-independent. - `--seciUser` typo (used once, never defined) → invalid colour dropped → the labels inherited the body colour, contrasting on some palettes but blending into the felt on others (read as "missing"). → `--secUser`. SPREAD-SYNC — the owner's live draw pushed only the hand, not the spread, so a post-DEL spread switch landed the new cards into the OLD spread's cells (the asymmetry the user hit: owner on desire-obstacle-solution, visitor still laid out as escape-velocity). The spread now rides each `sea_draw` broadcast; `_applySpread` re-sets `data-spread` (CSS keys cell visibility off it), re-captions from a server-sourced POSITION_LABELS json_script, + clears stale fills before `_applyHand` repopulates against the right layout. OWNER-SIDE LIVE SEAT PUSH — the owner's my_sea now subscribes to her own spectate WS for `sea_seats`, so visitors arriving (deposit → 2C-6C) / leaving (BYE) appear without a refresh, same broadcast the spectators get. The visit page's inline `_renderSeats` is hoisted into my-sea-seats.js as the shared `mySeaRenderSeats(seats, myToken)` (+ `mySeaConnectSeatRing`); each page passes its own self-token (owner page passes '' — her 1C isn't --self server-side). Coverage: - ITs: MySeaVisitEmptyHandLabelsTest (captions present + rendered for an empty hand); MySeaLockHandViewTest broadcast test asserts the spread arg; spectate consumer test asserts the hand+spread relay (channels). - Jasmine: 6 new MySeaSeatsSpec cases for mySeaRenderSeats (per-seat rebuild, --self by token, owner-page no-self, no-duplicate re-render, one-shot flare). - Live-verified in Firefox: captions paint khaki on the brown palette; a desire-obstacle-solution sync flips data-spread + relabels Solution/Obstacle/ Desire + hides leave/cover/lay. [[feedback-jsonfield-exclude-sqlite-null]] not implicated; spread map is a plain dict lookup. 304 gameboard ITs + Jasmine green. Code architected by Disco DeDisco <discodedisco@outlook.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:35:18 -04:00
async def test_present_invitee_receives_the_owners_hand_and_spread(self):
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>
2026-05-29 23:14:48 -04:00
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"}]
my-sea spectate: live spread-sync + owner seat-ring push + visit caption fix — TDD Three fixes to the my-sea spectator (bud-sea), all flowing over the existing `mysea_<owner>` spectate consumer: VISIT CAPTIONS (.sea-pos-label) — two bugs left every CROWN/COVER/… caption blank on my_sea_visit: - empty-hand: `label_by_position` was built from `latest_draw_slots`, which returns [] when the owner's hand is empty (only a significator placed) — so an owner mid-setup showed no captions, while her OWN my_sea (whose JS seeds labels from the POSITION_LABELS constant) showed them. Now the view pulls captions straight from POSITION_LABELS[spread], drawn-cards-independent. - `--seciUser` typo (used once, never defined) → invalid colour dropped → the labels inherited the body colour, contrasting on some palettes but blending into the felt on others (read as "missing"). → `--secUser`. SPREAD-SYNC — the owner's live draw pushed only the hand, not the spread, so a post-DEL spread switch landed the new cards into the OLD spread's cells (the asymmetry the user hit: owner on desire-obstacle-solution, visitor still laid out as escape-velocity). The spread now rides each `sea_draw` broadcast; `_applySpread` re-sets `data-spread` (CSS keys cell visibility off it), re-captions from a server-sourced POSITION_LABELS json_script, + clears stale fills before `_applyHand` repopulates against the right layout. OWNER-SIDE LIVE SEAT PUSH — the owner's my_sea now subscribes to her own spectate WS for `sea_seats`, so visitors arriving (deposit → 2C-6C) / leaving (BYE) appear without a refresh, same broadcast the spectators get. The visit page's inline `_renderSeats` is hoisted into my-sea-seats.js as the shared `mySeaRenderSeats(seats, myToken)` (+ `mySeaConnectSeatRing`); each page passes its own self-token (owner page passes '' — her 1C isn't --self server-side). Coverage: - ITs: MySeaVisitEmptyHandLabelsTest (captions present + rendered for an empty hand); MySeaLockHandViewTest broadcast test asserts the spread arg; spectate consumer test asserts the hand+spread relay (channels). - Jasmine: 6 new MySeaSeatsSpec cases for mySeaRenderSeats (per-seat rebuild, --self by token, owner-page no-self, no-duplicate re-render, one-shot flare). - Live-verified in Firefox: captions paint khaki on the brown palette; a desire-obstacle-solution sync flips data-spread + relabels Solution/Obstacle/ Desire + hides leave/cover/lay. [[feedback-jsonfield-exclude-sqlite-null]] not implicated; spread map is a plain dict lookup. 304 gameboard ITs + Jasmine green. Code architected by Disco DeDisco <discodedisco@outlook.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:35:18 -04:00
# The spread rides along so a spectator under a stale spread re-syncs its
# cross layout/captions before filling the hand (asymmetry fix
# 2026-05-30).
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>
2026-05-29 23:14:48 -04:00
await get_channel_layer().group_send(
my-sea spectate: live spread-sync + owner seat-ring push + visit caption fix — TDD Three fixes to the my-sea spectator (bud-sea), all flowing over the existing `mysea_<owner>` spectate consumer: VISIT CAPTIONS (.sea-pos-label) — two bugs left every CROWN/COVER/… caption blank on my_sea_visit: - empty-hand: `label_by_position` was built from `latest_draw_slots`, which returns [] when the owner's hand is empty (only a significator placed) — so an owner mid-setup showed no captions, while her OWN my_sea (whose JS seeds labels from the POSITION_LABELS constant) showed them. Now the view pulls captions straight from POSITION_LABELS[spread], drawn-cards-independent. - `--seciUser` typo (used once, never defined) → invalid colour dropped → the labels inherited the body colour, contrasting on some palettes but blending into the felt on others (read as "missing"). → `--secUser`. SPREAD-SYNC — the owner's live draw pushed only the hand, not the spread, so a post-DEL spread switch landed the new cards into the OLD spread's cells (the asymmetry the user hit: owner on desire-obstacle-solution, visitor still laid out as escape-velocity). The spread now rides each `sea_draw` broadcast; `_applySpread` re-sets `data-spread` (CSS keys cell visibility off it), re-captions from a server-sourced POSITION_LABELS json_script, + clears stale fills before `_applyHand` repopulates against the right layout. OWNER-SIDE LIVE SEAT PUSH — the owner's my_sea now subscribes to her own spectate WS for `sea_seats`, so visitors arriving (deposit → 2C-6C) / leaving (BYE) appear without a refresh, same broadcast the spectators get. The visit page's inline `_renderSeats` is hoisted into my-sea-seats.js as the shared `mySeaRenderSeats(seats, myToken)` (+ `mySeaConnectSeatRing`); each page passes its own self-token (owner page passes '' — her 1C isn't --self server-side). Coverage: - ITs: MySeaVisitEmptyHandLabelsTest (captions present + rendered for an empty hand); MySeaLockHandViewTest broadcast test asserts the spread arg; spectate consumer test asserts the hand+spread relay (channels). - Jasmine: 6 new MySeaSeatsSpec cases for mySeaRenderSeats (per-seat rebuild, --self by token, owner-page no-self, no-duplicate re-render, one-shot flare). - Live-verified in Firefox: captions paint khaki on the brown palette; a desire-obstacle-solution sync flips data-spread + relabels Solution/Obstacle/ Desire + hides leave/cover/lay. [[feedback-jsonfield-exclude-sqlite-null]] not implicated; spread map is a plain dict lookup. 304 gameboard ITs + Jasmine green. Code architected by Disco DeDisco <discodedisco@outlook.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:35:18 -04:00
f"mysea_{self.owner.id}",
{"type": "sea_draw", "hand": hand, "spread": "escape-velocity"})
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>
2026-05-29 23:14:48 -04:00
msg = await comm.receive_json_from()
my-sea spectate: live spread-sync + owner seat-ring push + visit caption fix — TDD Three fixes to the my-sea spectator (bud-sea), all flowing over the existing `mysea_<owner>` spectate consumer: VISIT CAPTIONS (.sea-pos-label) — two bugs left every CROWN/COVER/… caption blank on my_sea_visit: - empty-hand: `label_by_position` was built from `latest_draw_slots`, which returns [] when the owner's hand is empty (only a significator placed) — so an owner mid-setup showed no captions, while her OWN my_sea (whose JS seeds labels from the POSITION_LABELS constant) showed them. Now the view pulls captions straight from POSITION_LABELS[spread], drawn-cards-independent. - `--seciUser` typo (used once, never defined) → invalid colour dropped → the labels inherited the body colour, contrasting on some palettes but blending into the felt on others (read as "missing"). → `--secUser`. SPREAD-SYNC — the owner's live draw pushed only the hand, not the spread, so a post-DEL spread switch landed the new cards into the OLD spread's cells (the asymmetry the user hit: owner on desire-obstacle-solution, visitor still laid out as escape-velocity). The spread now rides each `sea_draw` broadcast; `_applySpread` re-sets `data-spread` (CSS keys cell visibility off it), re-captions from a server-sourced POSITION_LABELS json_script, + clears stale fills before `_applyHand` repopulates against the right layout. OWNER-SIDE LIVE SEAT PUSH — the owner's my_sea now subscribes to her own spectate WS for `sea_seats`, so visitors arriving (deposit → 2C-6C) / leaving (BYE) appear without a refresh, same broadcast the spectators get. The visit page's inline `_renderSeats` is hoisted into my-sea-seats.js as the shared `mySeaRenderSeats(seats, myToken)` (+ `mySeaConnectSeatRing`); each page passes its own self-token (owner page passes '' — her 1C isn't --self server-side). Coverage: - ITs: MySeaVisitEmptyHandLabelsTest (captions present + rendered for an empty hand); MySeaLockHandViewTest broadcast test asserts the spread arg; spectate consumer test asserts the hand+spread relay (channels). - Jasmine: 6 new MySeaSeatsSpec cases for mySeaRenderSeats (per-seat rebuild, --self by token, owner-page no-self, no-duplicate re-render, one-shot flare). - Live-verified in Firefox: captions paint khaki on the brown palette; a desire-obstacle-solution sync flips data-spread + relabels Solution/Obstacle/ Desire + hides leave/cover/lay. [[feedback-jsonfield-exclude-sqlite-null]] not implicated; spread map is a plain dict lookup. 304 gameboard ITs + Jasmine green. Code architected by Disco DeDisco <discodedisco@outlook.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 00:35:18 -04:00
self.assertEqual(
msg,
{"type": "sea_draw", "hand": hand, "spread": "escape-velocity"})
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>
2026-05-29 23:14:48 -04:00
await comm.disconnect()
async def test_present_invitee_receives_seat_updates(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
seats = [{"n": 1, "label": "1C", "present": True, "token": "owner-x"}]
await get_channel_layer().group_send(
f"mysea_{self.owner.id}", {"type": "sea_seats", "seats": seats})
msg = await comm.receive_json_from()
self.assertEqual(msg, {"type": "sea_seats", "seats": seats})
await comm.disconnect()