"""Room scroll-of-events FT — the table-hex aperture's binary scroll-snap toggle (mirrors my_sky's wheel<->form swap). From Role Select onwards the viewer scrolls DOWN past the hex to reveal the room's provenance feed (the same GameEvent stream the Billscroll page shows); scrolling back up snaps to the hex. No partial scroll — `scroll-snap-stop: always` enforces the binary. FT bucket: game_room. Built to DRY-template onto my_sea next. """ from django.test import tag from selenium.webdriver.common.by import By from .base import FunctionalTest, ChannelsFunctionalTest from .room_page import _equip_earthman_deck, _fill_room_via_orm from apps.drama.models import GameEvent, record from apps.epic.models import Room from apps.lyric.models import User class RoomScrollOfEventsTest(FunctionalTest): def setUp(self): super().setUp() self.viewer = User.objects.create(email="disco@test.io", username="disco") _equip_earthman_deck(self.viewer) self.room = Room.objects.create(name="Whataburgher", owner=self.viewer) _fill_room_via_orm( self.room, ["disco@test.io", "amigo@test.io", "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io"], ) # A deposit line for the feed (data-label="frame") — renders via the # shared scroll partial as "deposits a Carte Blanche for slot 1 …". GameEvent.objects.create( room=self.room, actor=self.viewer, verb=GameEvent.SLOT_FILLED, data={"token_type": "carte", "token_display": "Carte Blanche", "slot_number": 1, "renewal_days": 7}, ) # A retracted SIG_READY (struck → data-label="redact") so the Frame / # Redact filter has both kinds of row to toggle. GameEvent.objects.create( room=self.room, actor=self.viewer, verb=GameEvent.SIG_READY, data={"retracted": True, "card_name": "The Nomad"}, ) self.room.table_status = Room.ROLE_SELECT self.room.gate_status = Room.OPEN self.room.save() self.url = self.live_server_url + f"/gameboard/room/{self.room.id}/" def _open(self): self.create_pre_authenticated_session("disco@test.io") self.browser.set_window_size(820, 600) self.browser.get(self.url) return self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".room-aperture") ) def _in_viewport(self, css): return self.browser.execute_script( "var e=document.querySelector(arguments[0]); if(!e) return false;" "var r=e.getBoundingClientRect();" "return r.top < window.innerHeight && r.bottom > 0;", css) def test_aperture_binary_scroll_snap_with_two_panes(self): """The aperture is scroll-snap-y-mandatory and both panes snap to the start with `always` — without these it's a free scroll, not the binary hex<->scroll toggle.""" self._open() snap = self.browser.execute_script( "return getComputedStyle(document.querySelector('.room-aperture'))" ".scrollSnapType;" ) self.assertIn("y", snap) self.assertIn("mandatory", snap) for pane in (".room-hex-pane", ".room-scroll-pane"): align = self.browser.execute_script( "return getComputedStyle(document.querySelector(arguments[0]))" ".scrollSnapAlign;", pane, ) self.assertIn("start", align) def test_scroll_down_reveals_event_feed(self): """The feed sits below the hex (off-screen); scrolling the aperture down brings it into view — the hex content is replaced entirely.""" self._open() # The event feed starts off-screen, below the hex pane. self.assertFalse(self._in_viewport("#id_drama_scroll")) self.browser.execute_script( "document.querySelector('.room-aperture').scrollTop = 99999;" ) # The aperture actually scrolled to the 2nd snap section… self.wait_for(lambda: self.assertGreater( self.browser.execute_script( "return document.querySelector('.room-aperture').scrollTop;" ), 10, )) # …and the provenance feed (with the deposit line) is now in view. self.wait_for(lambda: self.assertTrue(self._in_viewport("#id_drama_scroll"))) self.assertIn( "deposits a Carte Blanche for slot 1", self.browser.find_element(By.ID, "id_drama_scroll").text, ) def _open_gear(self): gear = self.browser.find_element( By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_room_menu']") self.browser.execute_script("arguments[0].click();", gear) def test_gear_swaps_to_filter_when_scrolled_to_feed(self): """At the hex the gear shows the room menu (NVM/DEL/BYE); scrolled to the feed it swaps to the Frame/Redact filter.""" self._open() # Hex view: open the gear → the room menu is visible, the filter isn't. self._open_gear() self.wait_for(lambda: self.assertTrue( self.browser.find_element( By.CSS_SELECTOR, "#id_room_menu .room-menu-default a.btn-cancel" ).is_displayed())) self.assertFalse( self.browser.find_element(By.ID, "id_scroll_filter_form").is_displayed()) # Scroll to the feed → the gear live-swaps to the filter pane. self.browser.execute_script( "document.querySelector('.room-aperture').scrollTop = 99999;") self.wait_for(lambda: self.assertTrue( self.browser.find_element(By.ID, "id_scroll_filter_form").is_displayed())) self.assertFalse( self.browser.find_element( By.CSS_SELECTOR, "#id_room_menu .room-menu-default a.btn-cancel" ).is_displayed()) def test_scroll_swaps_room_title_to_scroll(self): """Scrolling the aperture to the feed advances the page-title reel GAME ROOM → GAME SCROLL (the

gains `.is-scroll`, which the reel CSS uses to slide the words); scrolling back to the hex reverses it.""" self._open() # Both reel words ship in the header; the window starts at the hex. self.assertTrue( self.browser.find_elements(By.CSS_SELECTOR, ".gr-word--base")) self.assertTrue( self.browser.find_elements(By.CSS_SELECTOR, ".gr-word--scroll")) h2 = self.browser.find_element(By.CSS_SELECTOR, ".row .col-lg-6 h2") self.assertNotIn("is-scroll", h2.get_attribute("class") or "") # Scroll down to the feed → the reel advances to GAME SCROLL. self.browser.execute_script( "document.querySelector('.room-aperture').scrollTop = 99999;") self.wait_for(lambda: self.assertIn( "is-scroll", self.browser.find_element( By.CSS_SELECTOR, ".row .col-lg-6 h2" ).get_attribute("class") or "", )) # Scroll back up to the hex → the reel reverses to GAME ROOM. self.browser.execute_script( "document.querySelector('.room-aperture').scrollTop = 0;") self.wait_for(lambda: self.assertNotIn( "is-scroll", self.browser.find_element( By.CSS_SELECTOR, ".row .col-lg-6 h2" ).get_attribute("class") or "", )) def test_redact_filter_hides_struck_rows(self): """Unchecking Redact + OK in the scroll-view gear hides the struck (retracted) rows while the framed ones stay.""" self._open() self.browser.execute_script( "document.querySelector('.room-aperture').scrollTop = 99999;") self._open_gear() self.wait_for(lambda: self.assertTrue( self.browser.find_element(By.ID, "id_scroll_filter_form").is_displayed())) redact_cb = self.browser.find_element( By.CSS_SELECTOR, "#id_scroll_filter_form input[value='redact']") if redact_cb.is_selected(): self.browser.execute_script("arguments[0].click();", redact_cb) ok = self.browser.find_element( By.CSS_SELECTOR, "#id_scroll_filter_form button[type='submit']") self.browser.execute_script("arguments[0].click();", ok) self.wait_for(lambda: self.assertFalse( self.browser.find_element( By.CSS_SELECTOR, "#id_drama_scroll .drama-event[data-label='redact']", ).is_displayed())) self.assertTrue( self.browser.find_element( By.CSS_SELECTOR, "#id_drama_scroll .drama-event[data-label='frame']", ).is_displayed()) @tag("channels") class RoomScrollLiveRefreshTest(ChannelsFunctionalTest): """The SCROLL applet refreshes live over WebSocket — no page reload. A GameEvent recorded by any actor nudges every open room socket (record() → on_commit broadcast → RoomConsumer.scroll_update relay → room-scroll.js re-fetches `core/_partials/_scroll.html` + swaps #id_drama_scroll), so an open feed grows on its own. Needs daphne + a real channel layer (channels stage).""" EMAILS = [ "disco@test.io", "amigo@test.io", "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", ] def setUp(self): super().setUp() self.viewer = User.objects.create(email="disco@test.io", username="disco") _equip_earthman_deck(self.viewer) self.room = Room.objects.create(name="Willawonky", owner=self.viewer) _fill_room_via_orm(self.room, self.EMAILS) # One seed line so the feed isn't empty at open. record(self.room, GameEvent.SLOT_FILLED, actor=self.viewer, slot_number=1, token_type="carte", token_display="Carte Blanche", renewal_days=7) self.room.table_status = Room.ROLE_SELECT self.room.gate_status = Room.OPEN self.room.save() def _rows(self): return len(self.browser.find_elements( By.CSS_SELECTOR, "#id_drama_scroll .drama-event")) def test_feed_grows_live_when_an_event_is_recorded(self): self.create_pre_authenticated_session("disco@test.io") self.browser.set_window_size(820, 600) self.browser.get(self.live_server_url + f"/gameboard/room/{self.room.id}/") # Wait for the room WebSocket to actually open (room.js sets _roomSocket). self.wait_for(lambda: self.assertEqual( self.browser.execute_script( "return window._roomSocket ? window._roomSocket.readyState : -1;"), 1)) self.wait_for(lambda: self.assertEqual(self._rows(), 1)) # Another action is recorded server-side → broadcasts scroll_update on # commit; the open feed must grow WITHOUT a reload. record(self.room, GameEvent.SLOT_FILLED, actor=self.viewer, slot_number=2, token_type="carte", token_display="Carte Blanche", renewal_days=7) self.wait_for(lambda: self.assertEqual(self._rows(), 2))