diff --git a/src/apps/billboard/tests/integrated/test_billboard_views.py b/src/apps/billboard/tests/integrated/test_billboard_views.py index 4c6b3e7..b0bc738 100644 --- a/src/apps/billboard/tests/integrated/test_billboard_views.py +++ b/src/apps/billboard/tests/integrated/test_billboard_views.py @@ -2,7 +2,7 @@ from django.test import TestCase from django.urls import reverse from apps.applets.models import Applet -from apps.drama.models import GameEvent, record +from apps.drama.models import GameEvent, ScrollPosition, record from apps.epic.models import Room from apps.lyric.models import User @@ -133,3 +133,52 @@ class BillscrollViewTest(TestCase): def test_passes_page_class_billscroll(self): response = self.client.get(f"/billboard/room/{self.room.id}/scroll/") self.assertEqual(response.context["page_class"], "page-billscroll") + + def test_passes_scroll_position_zero_when_none_saved(self): + response = self.client.get(f"/billboard/room/{self.room.id}/scroll/") + self.assertEqual(response.context["scroll_position"], 0) + + def test_passes_saved_scroll_position_in_context(self): + ScrollPosition.objects.create(user=self.user, room=self.room, position=250) + response = self.client.get(f"/billboard/room/{self.room.id}/scroll/") + self.assertEqual(response.context["scroll_position"], 250) + + +class SaveScrollPositionTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="test@savescroll.io") + self.client.force_login(self.user) + self.room = Room.objects.create(name="Test Room", owner=self.user) + + def test_post_saves_scroll_position(self): + self.client.post( + f"/billboard/room/{self.room.id}/scroll-position/", + {"position": 300}, + ) + sp = ScrollPosition.objects.get(user=self.user, room=self.room) + self.assertEqual(sp.position, 300) + + def test_post_updates_existing_position(self): + ScrollPosition.objects.create(user=self.user, room=self.room, position=100) + self.client.post( + f"/billboard/room/{self.room.id}/scroll-position/", + {"position": 450}, + ) + self.assertEqual( + ScrollPosition.objects.get(user=self.user, room=self.room).position, 450 + ) + + def test_post_returns_204(self): + response = self.client.post( + f"/billboard/room/{self.room.id}/scroll-position/", + {"position": 100}, + ) + self.assertEqual(response.status_code, 204) + + def test_post_requires_login(self): + self.client.logout() + response = self.client.post( + f"/billboard/room/{self.room.id}/scroll-position/", + {"position": 100}, + ) + self.assertEqual(response.status_code, 302) diff --git a/src/apps/billboard/urls.py b/src/apps/billboard/urls.py index e4527d3..735bc48 100644 --- a/src/apps/billboard/urls.py +++ b/src/apps/billboard/urls.py @@ -8,4 +8,5 @@ urlpatterns = [ path("", views.billboard, name="billboard"), path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"), path("room//scroll/", views.room_scroll, name="scroll"), + path("room//scroll-position/", views.save_scroll_position, name="save_scroll_position"), ] diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 3dd3844..e7784f9 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -4,7 +4,7 @@ from django.shortcuts import redirect, render from apps.applets.models import Applet, UserApplet from apps.applets.utils import applet_context -from apps.drama.models import GameEvent +from apps.drama.models import GameEvent, ScrollPosition from apps.epic.models import GateSlot, Room, RoomInvite @@ -61,9 +61,26 @@ def toggle_billboard_applets(request): def room_scroll(request, room_id): room = Room.objects.get(id=room_id) events = room.events.select_related("actor").all() + sp = ScrollPosition.objects.filter(user=request.user, room=room).first() return render(request, "apps/billboard/room_scroll.html", { "room": room, "events": events, "viewer": request.user, + "scroll_position": sp.position if sp else 0, "page_class": "page-billscroll", }) + + +@login_required(login_url="/") +def save_scroll_position(request, room_id): + if request.method != "POST": + from django.http import HttpResponseNotAllowed + return HttpResponseNotAllowed(["POST"]) + room = Room.objects.get(id=room_id) + position = int(request.POST.get("position", 0)) + ScrollPosition.objects.update_or_create( + user=request.user, room=room, + defaults={"position": position}, + ) + from django.http import HttpResponse + return HttpResponse(status=204) diff --git a/src/apps/drama/models.py b/src/apps/drama/models.py index 0866488..0abd811 100644 --- a/src/apps/drama/models.py +++ b/src/apps/drama/models.py @@ -83,6 +83,25 @@ class GameEvent(models.Model): return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor} → {self.verb}" +class ScrollPosition(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + related_name="scroll_positions", + ) + room = models.ForeignKey( + "epic.Room", on_delete=models.CASCADE, + related_name="scroll_positions", + ) + position = models.PositiveIntegerField(default=0) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [("user", "room")] + + def __str__(self): + return f"{self.user.email} @ {self.room.name}: {self.position}px" + + 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) diff --git a/src/apps/drama/tests/unit/test_models.py b/src/apps/drama/tests/unit/test_models.py index 4bd2a2a..9ff87ea 100644 --- a/src/apps/drama/tests/unit/test_models.py +++ b/src/apps/drama/tests/unit/test_models.py @@ -1,6 +1,7 @@ from django.test import TestCase +from django.db import IntegrityError -from apps.drama.models import GameEvent, record +from apps.drama.models import GameEvent, ScrollPosition, record from apps.epic.models import Room from apps.lyric.models import User @@ -42,3 +43,31 @@ class GameEventModelTest(TestCase): def test_str_without_actor_shows_system(self): event = record(self.room, GameEvent.ROLES_REVEALED) self.assertIn("system", str(event)) + + +class ScrollPositionModelTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="reader@test.io") + self.room = Room.objects.create(name="Test Room", owner=self.user) + + def test_can_save_scroll_position(self): + sp = ScrollPosition.objects.create(user=self.user, room=self.room, position=150) + self.assertEqual(ScrollPosition.objects.count(), 1) + self.assertEqual(sp.position, 150) + + def test_default_position_is_zero(self): + sp = ScrollPosition.objects.create(user=self.user, room=self.room) + self.assertEqual(sp.position, 0) + + def test_unique_per_user_and_room(self): + ScrollPosition.objects.create(user=self.user, room=self.room, position=50) + with self.assertRaises(IntegrityError): + ScrollPosition.objects.create(user=self.user, room=self.room, position=100) + + def test_upsert_updates_existing_position(self): + ScrollPosition.objects.create(user=self.user, room=self.room, position=50) + ScrollPosition.objects.update_or_create( + user=self.user, room=self.room, + defaults={"position": 200}, + ) + self.assertEqual(ScrollPosition.objects.get(user=self.user, room=self.room).position, 200) diff --git a/src/functional_tests/test_billboard.py b/src/functional_tests/test_billboard.py index f699d19..257a639 100644 --- a/src/functional_tests/test_billboard.py +++ b/src/functional_tests/test_billboard.py @@ -1,3 +1,5 @@ +import time + from selenium.webdriver.common.by import By from .base import FunctionalTest @@ -123,6 +125,63 @@ class BillboardScrollTest(FunctionalTest): self.assertIn("other", theirs_events[0].text) +class BillscrollPositionTest(FunctionalTest): + """ + FT: the user's scroll position in a billscroll is saved to the server + and restored when they return to the same scroll from any device/session. + """ + + def setUp(self): + super().setUp() + self.founder = User.objects.create(email="founder@scrollpos.io") + self.room = Room.objects.create(name="Persistent Chamber", owner=self.founder) + # Enough events to make #id_drama_scroll scrollable + for i in range(20): + record( + self.room, GameEvent.SLOT_FILLED, actor=self.founder, + slot_number=(i % 6) + 1, token_type="coin", + token_display=f"Coin-{i}", renewal_days=7, + ) + + def test_scroll_position_persists_across_sessions(self): + # 1. Log in and navigate to the room's billscroll + self.create_pre_authenticated_session("founder@scrollpos.io") + self.browser.get( + self.live_server_url + f"/billboard/room/{self.room.id}/scroll/" + ) + scroll_el = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_drama_scroll") + ) + + # 2. Force the element scrollable (CSS not served by StaticLiveServerTestCase), + # set position, and dispatch scroll event to trigger the debounced save + target = 100 + self.browser.execute_script(""" + var el = arguments[0]; + el.style.overflow = 'auto'; + el.style.height = '150px'; + el.scrollTop = arguments[1]; + el.dispatchEvent(new Event('scroll')); + """, scroll_el, target) + + # 3. Wait for debounce (800ms) + fetch to complete + time.sleep(3) + + # 4. Navigate away and back in a fresh session + self.browser.get(self.live_server_url + "/billboard/") + self.create_pre_authenticated_session("founder@scrollpos.io") + self.browser.get( + self.live_server_url + f"/billboard/room/{self.room.id}/scroll/" + ) + + # 5. The saved position is reflected in the page's data attribute + scroll_el = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_drama_scroll") + ) + restored = int(scroll_el.get_attribute("data-scroll-position")) + self.assertEqual(restored, target) + + class BillboardAppletsTest(FunctionalTest): """ FT: billboard page renders three applets in the grid — My Scrolls, diff --git a/src/templates/apps/billboard/_partials/_applet-billboard-scroll.html b/src/templates/apps/billboard/_partials/_applet-billboard-scroll.html index b172314..c36b6a8 100644 --- a/src/templates/apps/billboard/_partials/_applet-billboard-scroll.html +++ b/src/templates/apps/billboard/_partials/_applet-billboard-scroll.html @@ -2,3 +2,30 @@

{{ room.name }}

{% include "core/_partials/_scroll.html" %} + diff --git a/src/templates/apps/billboard/room_scroll.html b/src/templates/apps/billboard/room_scroll.html index 05b5120..79c3307 100644 --- a/src/templates/apps/billboard/room_scroll.html +++ b/src/templates/apps/billboard/room_scroll.html @@ -4,6 +4,7 @@ {% block header_text %}Billscroll{% endblock header_text %} {% block content %} +{% csrf_token %}
{% include "apps/billboard/_partials/_applet-billboard-scroll.html" %}
diff --git a/src/templates/core/_partials/_scroll.html b/src/templates/core/_partials/_scroll.html index 4c2e09f..69c788f 100644 --- a/src/templates/core/_partials/_scroll.html +++ b/src/templates/core/_partials/_scroll.html @@ -1,5 +1,5 @@ {% load lyric_extras %} -
+
{% for event in events %}