room SCROLL applet: live async refresh over WebSocket (not just on page reload)
The room's scroll-of-events feed only updated on refresh — a gamer watching the SCROLL view never saw a co-player's deposit / role pick / sig appear. Now every recorded GameEvent nudges all open room sockets to re-fetch the feed. - drama.models.record() broadcasts a `scroll_update` to the `room_<id>` group via transaction.on_commit — so the live re-fetch sees the committed row, and a rolled-back TestCase never fires it (zero overhead / channel-layer traffic for the plain IT suite). _broadcast_scroll_update is fully guarded: a missing/unreachable channel layer must NEVER break event recording (falls back to refresh-to-update). One central hook covers every event writer, current + future. - RoomConsumer gains a `scroll_update` relay handler (same one-liner shape as gate_update / turn_changed). - New `scroll_status` view + url (epic:scroll_status, room/<id>/scroll/status) renders JUST core/_partials/_scroll.html with the same events/viewer/scroll_position context as room_view's inline paint, so the swapped feed is identical. - room-scroll.js listens for `room:scroll_update`, fetches the partial, swaps #id_drama_scroll, then re-applies the saved Frame/Redact filter + restarts the buffer dots on the fresh nodes. URL comes from .room-page[data-scroll-status-url]. Refactored the dots + filter into re-runnable helpers; existing behavior (title reel IO, filter form, localStorage) preserved. TDD: - drama RecordBroadcast ITs: record() schedules the broadcast on commit (captureOnCommitCallbacks execute=True) and NOT before commit. - RoomConsumer relays scroll_update (InMemory layer, WebsocketCommunicator). - ScrollStatusViewTest: endpoint renders the feed section, reflects the latest events, is the bare partial (no navbar/aperture chrome). 544 drama+epic ITs green — the on_commit hook is inert under TestCase, so no existing event-writer test regressed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -214,9 +214,34 @@ class ScrollPosition(models.Model):
|
||||
return f"{self.user.email} @ {self.room.name}: {self.position}px"
|
||||
|
||||
|
||||
def _broadcast_scroll_update(room_id):
|
||||
"""Nudge every open room socket to re-fetch the scroll-of-events feed so the
|
||||
SCROLL applet updates live (not just on page refresh). Guarded — a missing
|
||||
or unreachable channel layer must NEVER break event recording, so any error
|
||||
is swallowed (the feed simply falls back to refresh-to-update)."""
|
||||
try:
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
layer = get_channel_layer()
|
||||
if layer is None:
|
||||
return
|
||||
async_to_sync(layer.group_send)(
|
||||
f"room_{room_id}", {"type": "scroll_update"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def record(room, verb, actor=None, **data):
|
||||
"""Record a game event in the drama log."""
|
||||
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
|
||||
"""Record a game event in the drama log.
|
||||
|
||||
Broadcasts a `scroll_update` to the room group AFTER the surrounding
|
||||
transaction commits (`on_commit`) so the live re-fetch sees the new row,
|
||||
and so a rolled-back TestCase never fires it (zero overhead/risk for the
|
||||
plain IT suite). RoomConsumer relays it → room-scroll.js swaps the feed."""
|
||||
from django.db import transaction
|
||||
event = GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
|
||||
transaction.on_commit(lambda: _broadcast_scroll_update(room.id))
|
||||
return event
|
||||
|
||||
|
||||
_NOTE_DISPLAY = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.db import IntegrityError
|
||||
from django.utils import timezone
|
||||
@@ -20,6 +22,22 @@ class GameEventModelTest(TestCase):
|
||||
self.assertEqual(event.verb, GameEvent.SLOT_FILLED)
|
||||
self.assertEqual(event.data, {"slot_number": 1, "token_type": "tithe"})
|
||||
|
||||
@patch("apps.drama.models._broadcast_scroll_update")
|
||||
def test_record_broadcasts_scroll_update_on_commit(self, mock_bcast):
|
||||
# record() schedules a live SCROLL refresh — but only AFTER the
|
||||
# surrounding transaction commits, so the re-fetch sees the new row.
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
record(self.room, GameEvent.SLOT_FILLED, actor=self.user, slot_number=1)
|
||||
mock_bcast.assert_called_once_with(self.room.id)
|
||||
|
||||
@patch("apps.drama.models._broadcast_scroll_update")
|
||||
def test_record_does_not_broadcast_before_commit(self, mock_bcast):
|
||||
# Pre-commit (or a rolled-back TestCase) must NOT fire the broadcast —
|
||||
# this is what keeps the plain IT suite free of channel-layer traffic.
|
||||
with self.captureOnCommitCallbacks(execute=False):
|
||||
record(self.room, GameEvent.SLOT_FILLED, actor=self.user, slot_number=1)
|
||||
mock_bcast.assert_not_called()
|
||||
|
||||
def test_record_without_actor(self):
|
||||
event = record(self.room, GameEvent.ROOM_CREATED)
|
||||
self.assertIsNone(event.actor)
|
||||
|
||||
@@ -95,3 +95,6 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
async def cursor_move(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def scroll_update(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
@@ -3,24 +3,76 @@
|
||||
// 2. swap the gear menu by scroll position — the hex view keeps the default
|
||||
// NVM/DEL/BYE pane; scrolled to the feed it shows the Frame/Redact filter,
|
||||
// 3. the Frame/Redact filter itself (per-room localStorage; mirrors the
|
||||
// Billscroll page's scroll.html filter so the two surfaces behave alike).
|
||||
// Billscroll page's scroll.html filter so the two surfaces behave alike),
|
||||
// 4. live async refresh — re-fetch the feed on a `scroll_update` WS nudge so
|
||||
// the SCROLL applet updates without a page reload.
|
||||
// All guards no-op on surfaces without a scroll pane (gate phase, room_gate).
|
||||
(function () {
|
||||
var scroll = document.getElementById('id_drama_scroll');
|
||||
if (!scroll) return;
|
||||
if (!document.getElementById('id_drama_scroll')) return;
|
||||
|
||||
// 1 ── buffer dots ──────────────────────────────────────────────────────
|
||||
var dotsWrap = scroll.querySelector('.scroll-buffer-dots');
|
||||
if (dotsWrap) {
|
||||
var page = document.querySelector('.room-page');
|
||||
var STORAGE_KEY = 'room-scroll-labels-' + (page ? page.dataset.roomId : '');
|
||||
var _dotsTimer = null;
|
||||
|
||||
// 1 ── buffer dots (re-runnable — restarted on the fresh nodes after an
|
||||
// async feed swap; clears any prior interval so it never doubles up).
|
||||
function startDots() {
|
||||
if (_dotsTimer) { clearInterval(_dotsTimer); _dotsTimer = null; }
|
||||
var scroll = document.getElementById('id_drama_scroll');
|
||||
var dotsWrap = scroll && scroll.querySelector('.scroll-buffer-dots');
|
||||
if (!dotsWrap) return;
|
||||
var dots = dotsWrap.querySelectorAll('span');
|
||||
var n = 0;
|
||||
setInterval(function () {
|
||||
_dotsTimer = setInterval(function () {
|
||||
dots.forEach(function (d, i) {
|
||||
d.textContent = i < n ? (i === 3 ? '?' : '.') : '';
|
||||
});
|
||||
n = (n + 1) % 5;
|
||||
}, 400);
|
||||
}
|
||||
startDots();
|
||||
|
||||
// 3 ── Frame/Redact filter (re-applied after async swaps). `applyFilter`
|
||||
// re-queries the CURRENT #id_drama_scroll so it works on swapped nodes.
|
||||
function savedLabels() {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); }
|
||||
catch (_) { return null; } // localStorage unavailable — non-fatal
|
||||
}
|
||||
function applyFilter(checked) {
|
||||
if (!checked) return;
|
||||
var scroll = document.getElementById('id_drama_scroll');
|
||||
if (!scroll) return;
|
||||
scroll.querySelectorAll('.drama-event[data-label]').forEach(function (el) {
|
||||
el.style.display = checked.indexOf(el.dataset.label) !== -1 ? '' : 'none';
|
||||
});
|
||||
}
|
||||
function applySavedFilter() {
|
||||
var checked = savedLabels();
|
||||
if (checked) applyFilter(checked);
|
||||
}
|
||||
applySavedFilter();
|
||||
|
||||
// 4 ── live async refresh ────────────────────────────────────────────────
|
||||
// RoomConsumer relays `scroll_update` after ANY GameEvent is recorded
|
||||
// (drama.models.record → on_commit broadcast). Re-fetch just the feed
|
||||
// partial + swap #id_drama_scroll, then re-apply the saved redact filter and
|
||||
// restart the buffer dots on the fresh nodes. No-ops if the page has no
|
||||
// scroll-status URL (non-table surfaces).
|
||||
function refreshScroll() {
|
||||
var url = page && page.dataset.scrollStatusUrl;
|
||||
var cur = document.getElementById('id_drama_scroll');
|
||||
if (!url || !cur) return;
|
||||
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) {
|
||||
var node = document.getElementById('id_drama_scroll');
|
||||
if (node) node.outerHTML = html;
|
||||
applySavedFilter();
|
||||
startDots();
|
||||
})
|
||||
.catch(function () { /* transient — next nudge or a refresh recovers */ });
|
||||
}
|
||||
window.addEventListener('room:scroll_update', refreshScroll);
|
||||
|
||||
// 2 ── scroll-driven title reel ─────────────────────────────────────────
|
||||
// The page title (h2) is a reel — toggling `.is-scroll` slides it GAME ROOM
|
||||
@@ -41,28 +93,18 @@
|
||||
io.observe(scrollSection);
|
||||
}
|
||||
|
||||
// 3 ── Frame/Redact filter ──────────────────────────────────────────────
|
||||
// 3 (cont.) ── Frame/Redact filter form ─────────────────────────────────
|
||||
var form = document.getElementById('id_scroll_filter_form');
|
||||
if (!form) return;
|
||||
var page = document.querySelector('.room-page');
|
||||
var STORAGE_KEY = 'room-scroll-labels-' + (page ? page.dataset.roomId : '');
|
||||
syncCheckboxes(savedLabels());
|
||||
|
||||
function applyFilter(checked) {
|
||||
scroll.querySelectorAll('.drama-event[data-label]').forEach(function (el) {
|
||||
el.style.display = checked.indexOf(el.dataset.label) !== -1 ? '' : 'none';
|
||||
});
|
||||
}
|
||||
function syncCheckboxes(checked) {
|
||||
if (!checked) return;
|
||||
form.querySelectorAll('input[name="labels"]').forEach(function (cb) {
|
||||
cb.checked = checked.indexOf(cb.value) !== -1;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
|
||||
if (saved) { applyFilter(saved); syncCheckboxes(saved); }
|
||||
} catch (_) { /* localStorage unavailable — non-fatal */ }
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var checked = Array.prototype.slice.call(
|
||||
|
||||
@@ -39,6 +39,24 @@ class RoomConsumerTest(SimpleTestCase):
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_receives_scroll_update_broadcast(self):
|
||||
# The SCROLL applet's live refresh: RoomConsumer relays a scroll_update
|
||||
# (sent by drama.models.record on commit) to every open room socket;
|
||||
# room-scroll.js then re-fetches the feed partial.
|
||||
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
|
||||
await communicator.connect()
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
await channel_layer.group_send(
|
||||
"room_00000000-0000-0000-0000-000000000001",
|
||||
{"type": "scroll_update"},
|
||||
)
|
||||
|
||||
response = await communicator.receive_json_from()
|
||||
self.assertEqual(response["type"], "scroll_update")
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_receives_turn_changed_broadcast(self):
|
||||
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
|
||||
await communicator.connect()
|
||||
|
||||
@@ -3493,6 +3493,47 @@ class RoomScrollOfEventsTest(TestCase):
|
||||
self.assertNotIn("gr-word--scroll", content)
|
||||
|
||||
|
||||
class ScrollStatusViewTest(TestCase):
|
||||
"""`scroll_status` renders JUST the scroll-of-events feed partial — the
|
||||
endpoint room-scroll.js re-fetches on a `scroll_update` WS nudge to refresh
|
||||
the SCROLL applet live (no reload). Same feed the room page paints inline."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="founder@test.io", username="disco")
|
||||
self.client.force_login(self.user)
|
||||
self.room = Room.objects.create(
|
||||
name="Willawonky", owner=self.user,
|
||||
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
|
||||
)
|
||||
GameEvent.objects.create(
|
||||
room=self.room, actor=self.user, verb=GameEvent.SLOT_FILLED,
|
||||
data={"token_type": "carte", "token_display": "Carte Blanche",
|
||||
"slot_number": 1, "renewal_days": 7},
|
||||
)
|
||||
self.url = reverse("epic:scroll_status", kwargs={"room_id": self.room.id})
|
||||
|
||||
def test_renders_the_scroll_feed_section(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "id_drama_scroll")
|
||||
self.assertContains(response, "drama-event")
|
||||
|
||||
def test_feed_reflects_latest_events(self):
|
||||
# A freshly-recorded event shows up on the next fetch (the live-refresh
|
||||
# contract) without re-rendering the whole room page.
|
||||
from apps.drama.models import record
|
||||
record(self.room, GameEvent.ROLE_SELECTED, actor=self.user, role="PC")
|
||||
content = self.client.get(self.url).content.decode()
|
||||
# One `.drama-event-body` per row (the bare class appears 3×/row).
|
||||
self.assertEqual(content.count("drama-event-body"), 2)
|
||||
|
||||
def test_only_partial_not_full_page(self):
|
||||
# It is the bare feed section — no room chrome (navbar / aperture).
|
||||
content = self.client.get(self.url).content.decode()
|
||||
self.assertNotIn("room-aperture", content)
|
||||
self.assertNotIn("navbar", content)
|
||||
|
||||
|
||||
class RoomViewsCarouselTest(TestCase):
|
||||
"""The scroll pane becomes a horizontal carousel of 5 views (ATLAS /
|
||||
SCROLL / POST / CHAT / PULSE — [[project-room-game-views-carousel]]).
|
||||
|
||||
@@ -8,6 +8,7 @@ urlpatterns = [
|
||||
path('rooms/create_room', views.create_room, name='create_room'),
|
||||
path('room/<uuid:room_id>/', views.room_view, name='room'),
|
||||
path('room/<uuid:room_id>/post', views.room_post, name='room_post'),
|
||||
path('room/<uuid:room_id>/scroll/status', views.scroll_status, name='scroll_status'),
|
||||
path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
|
||||
path('room/<uuid:room_id>/gate/view/', views.room_gate, name='room_gate'),
|
||||
path('room/<uuid:room_id>/gate/renew', views.renew_token, name='renew_token'),
|
||||
|
||||
@@ -681,6 +681,21 @@ def room_view(request, room_id):
|
||||
return render(request, "apps/gameboard/room.html", ctx)
|
||||
|
||||
|
||||
def scroll_status(request, room_id):
|
||||
"""Render JUST the room scroll-of-events feed (`core/_partials/_scroll.html`)
|
||||
— fetched by room-scroll.js on a `scroll_update` WS nudge so the SCROLL
|
||||
applet refreshes live without a page reload. Same query + context keys as
|
||||
the `events`/`viewer`/`scroll_position` block in `room_view`, so the swapped
|
||||
feed renders identically to the initial paint (the redact filter + buffer
|
||||
dots are re-applied client-side after the swap)."""
|
||||
room = Room.objects.get(id=room_id)
|
||||
return render(request, "core/_partials/_scroll.html", {
|
||||
"events": room.events.select_related("actor").all(),
|
||||
"viewer": request.user,
|
||||
"scroll_position": 0,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def room_post(request, room_id):
|
||||
"""Append a Line to the room's game-table thread (the POST view of the
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="room-page" data-room-id="{{ room.id }}"
|
||||
{% if room.table_status %}data-select-role-url="{% url 'epic:select_role' room.id %}"{% endif %}>
|
||||
{% if room.table_status %}data-select-role-url="{% url 'epic:select_role' room.id %}"{% endif %}
|
||||
data-scroll-status-url="{% url 'epic:scroll_status' room.id %}">
|
||||
<div id="id_aperture_fill"></div>
|
||||
{# Table-hex aperture — a binary scroll-snap viewport (mirrors my_sky's #}
|
||||
{# wheel<->form swap): the hex pane is the default view; from Role #}
|
||||
|
||||
Reference in New Issue
Block a user