PICK SEA Sprint A: async sky→sea transition via WS room:sky_confirmed — TDD
- natus_save: group_send room:sky_confirmed after confirm (carries seat_role) - consumer: sky_confirmed handler rebroadcasts to room group - _notify_sky_confirmed() helper mirrors _notify_pick_sky_available - sea_partial view: renders _sea_overlay.html partial for in-page injection (403 if not sky_confirmed) - epic:sea_partial URL registered - _natus_overlay.html: data-user-seat-role attr; _onSkyConfirmed() fetches sea partial, removes natus overlay + backdrop, injects sea HTML, toggles sea-open on html root; room:sky_confirmed WS listener calls _onSkyConfirmed only for matching seat role - user_seat_role added to SKY_SELECT context - FT: PickSeaAsyncTransitionTest (3 tests, ChannelsFunctionalTest) — sea overlay, natus gone, sea-open class — all green Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -90,5 +90,8 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||
async def pick_sky_available(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def sky_confirmed(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def cursor_move(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
@@ -27,4 +27,5 @@ urlpatterns = [
|
||||
path('room/<uuid:room_id>/tarot/deal', views.tarot_deal, name='tarot_deal'),
|
||||
path('room/<uuid:room_id>/natus/preview', views.natus_preview, name='natus_preview'),
|
||||
path('room/<uuid:room_id>/natus/save', views.natus_save, name='natus_save'),
|
||||
path('room/<uuid:room_id>/sea/partial', views.sea_partial, name='sea_partial'),
|
||||
]
|
||||
|
||||
@@ -126,6 +126,13 @@ def _notify_pick_sky_available(room_id):
|
||||
)
|
||||
|
||||
|
||||
def _notify_sky_confirmed(room_id, seat_role):
|
||||
async_to_sync(get_channel_layer().group_send)(
|
||||
f'room_{room_id}',
|
||||
{'type': 'sky_confirmed', 'seat_role': seat_role},
|
||||
)
|
||||
|
||||
|
||||
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||||
|
||||
_SIG_SEAT_ORDERING = Case(
|
||||
@@ -362,6 +369,7 @@ def _role_select_context(room, user):
|
||||
)
|
||||
sky_confirmed = confirmed_char is not None
|
||||
ctx["sky_confirmed"] = sky_confirmed
|
||||
ctx["user_seat_role"] = _canonical_seat.role if _canonical_seat else ''
|
||||
if sky_confirmed:
|
||||
ctx["my_tray_sig"] = confirmed_char.significator
|
||||
|
||||
@@ -1093,5 +1101,19 @@ def natus_save(request, room_id):
|
||||
char.confirmed_at = timezone.now()
|
||||
|
||||
char.save()
|
||||
|
||||
if char.is_confirmed:
|
||||
_notify_sky_confirmed(room_id, seat.role)
|
||||
|
||||
return JsonResponse({'id': char.id, 'confirmed': char.is_confirmed})
|
||||
|
||||
|
||||
@login_required
|
||||
def sea_partial(request, room_id):
|
||||
"""Return the rendered sea overlay partial for in-page injection after sky confirm."""
|
||||
room = Room.objects.get(id=room_id)
|
||||
ctx = _role_select_context(room, request.user)
|
||||
if not ctx.get('sky_confirmed'):
|
||||
return HttpResponse(status=403)
|
||||
return render(request, 'apps/gameboard/_partials/_sea_overlay.html', ctx)
|
||||
|
||||
|
||||
131
src/functional_tests/test_room_sea_select.py
Normal file
131
src/functional_tests/test_room_sea_select.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Functional tests for the PICK SEA overlay — Celtic Cross draw."""
|
||||
|
||||
from django.urls import reverse
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from apps.applets.models import Applet
|
||||
from apps.epic.models import GateSlot, Room, TableSeat, TarotCard, DeckVariant
|
||||
|
||||
from .base import ChannelsFunctionalTest
|
||||
|
||||
|
||||
def _make_sky_confirmed_room(live_server_url, user, earthman):
|
||||
"""Create a SKY_SELECT room with one gamer seated and sig assigned.
|
||||
|
||||
Returns (room, seat). The Character is NOT yet confirmed — call
|
||||
_confirm_sky() in the browser to trigger the async transition.
|
||||
"""
|
||||
room = Room.objects.create(
|
||||
name="Sea Test Room", table_status=Room.SKY_SELECT, owner=user
|
||||
)
|
||||
slot = room.gate_slots.get(slot_number=1)
|
||||
slot.gamer = user
|
||||
slot.status = GateSlot.FILLED
|
||||
slot.save()
|
||||
room.gate_status = Room.OPEN
|
||||
room.save()
|
||||
|
||||
sig_card = TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR").first()
|
||||
seat = TableSeat.objects.create(
|
||||
room=room, gamer=user, role="PC", slot_number=1,
|
||||
deck_variant=earthman, significator=sig_card,
|
||||
)
|
||||
return room, seat
|
||||
|
||||
|
||||
class PickSeaAsyncTransitionTest(ChannelsFunctionalTest):
|
||||
"""After sky confirm, PICK SEA overlay appears without a page refresh."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.browser.set_window_size(800, 1200)
|
||||
Applet.objects.get_or_create(
|
||||
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||||
)
|
||||
Applet.objects.get_or_create(
|
||||
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||||
)
|
||||
from apps.lyric.models import User
|
||||
gamer, _ = User.objects.get_or_create(email="founder@test.io")
|
||||
earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
gamer.unlocked_decks.add(earthman)
|
||||
gamer.equipped_deck = earthman
|
||||
gamer.save(update_fields=["equipped_deck"])
|
||||
|
||||
self.gamer = gamer
|
||||
self.room, self.seat = _make_sky_confirmed_room(self.live_server_url, gamer, earthman)
|
||||
self.room_url = self.live_server_url + reverse(
|
||||
"epic:room", kwargs={"room_id": self.room.id}
|
||||
)
|
||||
self.natus_save_url = self.live_server_url + reverse(
|
||||
"epic:natus_save", kwargs={"room_id": self.room.id}
|
||||
)
|
||||
|
||||
def _confirm_sky(self):
|
||||
"""POST to natus_save with action=confirm from browser JS (bypasses chart form)."""
|
||||
# Wait for the room WS connection to be ready before triggering confirm
|
||||
self.wait_for(lambda: self.browser.execute_script(
|
||||
"return !!(window._roomSocket && window._roomSocket.readyState === 1);"
|
||||
))
|
||||
self.browser.execute_script(f"""
|
||||
const csrf = (document.cookie.match(/csrftoken=([^;]+)/) || ['',''])[1];
|
||||
fetch('{self.natus_save_url}', {{
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {{'Content-Type': 'application/json', 'X-CSRFToken': csrf}},
|
||||
body: JSON.stringify({{
|
||||
birth_dt: '1990-06-15T09:00:00Z',
|
||||
birth_lat: 51.5, birth_lon: -0.1,
|
||||
birth_place: 'London', house_system: 'O',
|
||||
chart_data: {{}}, action: 'confirm',
|
||||
}}),
|
||||
}});
|
||||
""")
|
||||
|
||||
def test_sea_overlay_appears_without_page_refresh(self):
|
||||
"""Confirming sky replaces the natus overlay with the sea overlay in-place."""
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
|
||||
# Sky not yet confirmed — PICK SKY btn present, no sea overlay
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
|
||||
self.assertEqual(self.browser.find_elements(By.ID, "id_sea_overlay"), [])
|
||||
|
||||
self._confirm_sky()
|
||||
|
||||
# Sea overlay appears without page refresh
|
||||
sea_overlay = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_sea_overlay")
|
||||
)
|
||||
self.assertTrue(sea_overlay.is_displayed())
|
||||
|
||||
def test_natus_overlay_not_visible_after_sky_confirm(self):
|
||||
"""Natus overlay is removed from the DOM after sky confirm."""
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
|
||||
|
||||
self._confirm_sky()
|
||||
|
||||
# Sea overlay must appear first (confirms transition happened)
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_overlay"))
|
||||
|
||||
natus = self.browser.find_elements(By.ID, "id_natus_overlay")
|
||||
self.assertTrue(not natus or not natus[0].is_displayed())
|
||||
|
||||
def test_sea_open_class_on_html_after_confirm(self):
|
||||
"""html.sea-open is set after sky confirm, giving the sea overlay its backdrop."""
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
|
||||
|
||||
self._confirm_sky()
|
||||
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_overlay"))
|
||||
has_sea_open = self.browser.execute_script(
|
||||
"return document.documentElement.classList.contains('sea-open');"
|
||||
)
|
||||
self.assertTrue(has_sea_open)
|
||||
@@ -8,7 +8,9 @@
|
||||
<div class="natus-overlay"
|
||||
id="id_natus_overlay"
|
||||
data-preview-url="{% url 'epic:natus_preview' room.id %}"
|
||||
data-save-url="{% url 'epic:natus_save' room.id %}">
|
||||
data-save-url="{% url 'epic:natus_save' room.id %}"
|
||||
data-sea-partial-url="{% url 'epic:sea_partial' room.id %}"
|
||||
data-user-seat-role="{{ user_seat_role }}">
|
||||
|
||||
<div class="natus-modal-wrap">
|
||||
<div class="natus-modal">
|
||||
@@ -369,9 +371,11 @@
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then(() => {
|
||||
.then(data => {
|
||||
if (!data.confirmed) {
|
||||
setStatus('Sky saved!');
|
||||
setTimeout(closeNatus, 1200);
|
||||
}
|
||||
// Confirmed state is driven by the room:sky_confirmed WS event
|
||||
})
|
||||
.catch(err => {
|
||||
setStatus(`Save failed: ${err.message}`, 'error');
|
||||
@@ -379,6 +383,28 @@
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sky confirmed → inject sea partial ───────────────────────────────────
|
||||
|
||||
const SEA_PARTIAL_URL = overlay.dataset.seaPartialUrl;
|
||||
|
||||
function _onSkyConfirmed() {
|
||||
fetch(SEA_PARTIAL_URL, { credentials: 'same-origin' })
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
// Remove natus overlay + backdrop; inject sea partial before body close
|
||||
var backdrop = document.querySelector('.natus-backdrop');
|
||||
if (backdrop) backdrop.remove();
|
||||
overlay.remove();
|
||||
document.documentElement.classList.remove('natus-open');
|
||||
document.body.insertAdjacentHTML('beforeend', html);
|
||||
document.documentElement.classList.add('sea-open');
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback: just close natus and let page refresh handle the transition
|
||||
closeNatus();
|
||||
});
|
||||
}
|
||||
|
||||
// ── CSRF ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function _getCsrf() {
|
||||
@@ -391,6 +417,15 @@
|
||||
// openNatus() so the animation plays when the modal opens, not silently
|
||||
// in the background on page load.
|
||||
|
||||
// WS: server broadcasts sky_confirmed when any gamer confirms their sky.
|
||||
// Only act when the event's seat_role matches this browser's seat.
|
||||
const MY_SEAT_ROLE = overlay.dataset.userSeatRole;
|
||||
|
||||
window.addEventListener('room:sky_confirmed', function (e) {
|
||||
if (MY_SEAT_ROLE && e.detail.seat_role && e.detail.seat_role !== MY_SEAT_ROLE) return;
|
||||
_onSkyConfirmed();
|
||||
});
|
||||
|
||||
_restoreForm();
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user