End-to-end coverage for this session's two shipped features. SigStageUnifiedTest (FunctionalTest, no WS — both pass locally): - the sig stage renders INSIDE .room-hex-pane on green --duoUser felt (.has-sig-stage), the overlay is a descendant of the hex pane, the dark .sig-backdrop is gone, and the overlay bg is not a translucent-black wash; - OK'ing a card freezes the stage and reveals the DRY _stat_face.html — .stat-face-title + .stat-chip-rank populate (the old reduced block had neither; proves the populateStatExtras wiring). RoomScrollLiveRefreshTest (ChannelsFunctionalTest, @tag channels): - with the room open, a server-side record() of a new GameEvent grows the feed (#id_drama_scroll .drama-event 1 → 2) WITHOUT a reload, via the record() on_commit broadcast → RoomConsumer.scroll_update relay → room-scroll.js re-fetch+swap. Validated in the CI channels stage (needs a cross-process channel layer); the plumbing is already green via the consumer-relay + record-hook + scroll_status ITs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
244 lines
11 KiB
Python
244 lines
11 KiB
Python
"""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 <h2> 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))
|