import datetime import re import time from django.utils import timezone from selenium.webdriver.common.by import By from .base import FunctionalTest from apps.applets.models import Applet from apps.drama.models import GameEvent, record from apps.epic.models import Room, GateSlot from apps.lyric.models import User class BillboardScrollTest(FunctionalTest): """ FT: game actions in room.html are logged to the drama stream, and the founder can navigate from any page to /billboard/ via the footer fa-scroll icon, select a room, and read the provenance scroll. Events are seeded via ORM — the IT suite covers the recording side; here we test the user-visible navigation and prose display. """ def setUp(self): super().setUp() for slug, name, cols, rows in [ ("billboard-my-scrolls", "My Scrolls", 4, 3), ("billboard-my-contacts", "Contacts", 4, 3), ("billboard-most-recent", "Most Recent", 8, 6), ]: Applet.objects.get_or_create( slug=slug, defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"}, ) self.founder = User.objects.create(email="founder@test.io") self.other = User.objects.create(email="other@test.io") self.room = Room.objects.create(name="Blissful Ignorance", owner=self.founder) # Simulate two gate fills — one by founder, one by the other gamer record( self.room, GameEvent.SLOT_FILLED, actor=self.founder, slot_number=1, token_type="coin", token_display="Coin-on-a-String", renewal_days=7, ) record( self.room, GameEvent.SLOT_FILLED, actor=self.other, slot_number=2, token_type="Free", token_display="Free Token", renewal_days=7, ) # Simulate founder selecting a role record( self.room, GameEvent.ROLE_SELECTED, actor=self.founder, role="PC", slot_number=1, role_display="Player", ) # ------------------------------------------------------------------ # # Test 1 — footer icon navigates to billboard, rooms listed # # ------------------------------------------------------------------ # def test_footer_scroll_icon_leads_to_billboard_with_rooms(self): # Founder logs in and lands on the dashboard self.create_pre_authenticated_session("founder@test.io") self.browser.get(self.live_server_url + "/") # Footer contains a scroll icon link pointing to /billboard/ scroll_link = self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_footer_nav a[href='/billboard/']") ) scroll_link.click() # Billboard page lists the founder's room self.wait_for( lambda: self.assertIn("/billboard/", self.browser.current_url) ) body = self.browser.find_element(By.TAG_NAME, "body") self.assertIn("Blissful Ignorance", body.text) # ------------------------------------------------------------------ # # Test 2 — scroll page renders human-readable prose # # ------------------------------------------------------------------ # def test_scroll_shows_human_readable_event_log(self): self.create_pre_authenticated_session("founder@test.io") self.browser.get(self.live_server_url + "/billboard/") # Click the room link to reach the scroll self.wait_for( lambda: self.browser.find_element(By.LINK_TEXT, "Blissful Ignorance") ).click() self.wait_for( lambda: self.assertIn("/scroll/", self.browser.current_url) ) scroll = self.wait_for( lambda: self.browser.find_element(By.ID, "id_drama_scroll") ) # Gate fill events are rendered as prose self.assertIn("deposits a Coin-on-a-String for slot 1 (7 days)", scroll.text) self.assertIn("deposits a Free Token for slot 2 (7 days)", scroll.text) # Role selection event is rendered as prose self.assertIn("elects to start as Player", scroll.text) # ------------------------------------------------------------------ # # Test 3 — current user's events are right-aligned; others' are left # # ------------------------------------------------------------------ # def test_scroll_aligns_own_events_right_and_others_left(self): self.create_pre_authenticated_session("founder@test.io") self.browser.get(self.live_server_url + "/billboard/") self.wait_for( lambda: self.browser.find_element(By.LINK_TEXT, "Blissful Ignorance") ).click() self.wait_for( lambda: self.browser.find_element(By.ID, "id_drama_scroll") ) mine_events = self.browser.find_elements(By.CSS_SELECTOR, ".drama-event.mine") theirs_events = self.browser.find_elements(By.CSS_SELECTOR, ".drama-event.theirs") # Founder has 2 events (slot fill + role select); other gamer has 1 self.assertEqual(len(mine_events), 2) self.assertEqual(len(theirs_events), 1) # The other gamer's event mentions their display name 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, dispatch scroll event, and capture the position value the # JS listener will save — all in one synchronous script so the layout # snapshot is identical to what the scroll handler sees. scroll_top = 100 saved_pos = self.browser.execute_script(""" var el = arguments[0]; var remPx = parseFloat(getComputedStyle(document.documentElement).fontSize); el.style.overflow = 'auto'; el.style.height = '150px'; el.scrollTop = arguments[1]; var pos = Math.round(el.scrollTop + el.clientHeight + remPx * 2.5); el.dispatchEvent(new Event('scroll')); return pos; """, scroll_el, scroll_top) # 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, saved_pos) class BillboardAppletsTest(FunctionalTest): """ FT: billboard page renders three applets in the grid — My Scrolls, My Contacts, and Most Recent — with a functioning gear menu. """ def setUp(self): super().setUp() self.founder = User.objects.create(email="founder@test.io") self.room = Room.objects.create(name="Arcane Assembly", owner=self.founder) for slug, name, cols, rows in [ ("billboard-my-scrolls", "My Scrolls", 4, 3), ("billboard-my-contacts", "Contacts", 4, 3), ("billboard-most-recent", "Most Recent", 8, 6), ]: Applet.objects.get_or_create( slug=slug, defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"}, ) def test_billboard_shows_three_applets(self): # 1. Log in, navigate to billboard self.create_pre_authenticated_session("founder@test.io") self.browser.get(self.live_server_url + "/billboard/") # 2. Assert all three applet sections present self.wait_for( lambda: self.browser.find_element(By.ID, "id_applet_billboard_my_scrolls") ) self.browser.find_element(By.ID, "id_applet_billboard_my_contacts") self.browser.find_element(By.ID, "id_applet_billboard_most_recent") def test_billboard_my_scrolls_lists_rooms(self): # 1. Log in, navigate to billboard self.create_pre_authenticated_session("founder@test.io") self.browser.get(self.live_server_url + "/billboard/") # 2. My Scrolls applet contains a link to the room's scroll self.wait_for( lambda: self.assertIn( "Arcane Assembly", self.browser.find_element(By.ID, "id_applet_billboard_my_scrolls").text, ) ) def test_billboard_gear_btn_opens_applet_menu(self): # 1. Log in, navigate to billboard self.create_pre_authenticated_session("founder@test.io") self.browser.get(self.live_server_url + "/billboard/") # 2. Gear button is visible gear_btn = self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".billboard-page .gear-btn") ) # 3. Menu is hidden before click menu = self.browser.find_element(By.ID, "id_billboard_applet_menu") self.assertFalse(menu.is_displayed()) # 4. Clicking gear opens the menu (JS click bypasses kit-bag overlap in headless) self.browser.execute_script("arguments[0].click()", gear_btn) self.wait_for_slow(lambda: self.assertTrue(menu.is_displayed())) class BillscrollAppletsTest(FunctionalTest): """ FT: billscroll page renders as a single full-width applet that fills the viewport aperture and contains the room's drama events. """ def setUp(self): super().setUp() self.founder = User.objects.create(email="founder@billtest.io") self.room = Room.objects.create(name="Spectral Council", owner=self.founder) record( self.room, GameEvent.SLOT_FILLED, actor=self.founder, slot_number=1, token_type="coin", token_display="Coin-on-a-String", renewal_days=7, ) def test_billscroll_shows_full_width_applet(self): # 1. Log in, navigate to the room's scroll self.create_pre_authenticated_session("founder@billtest.io") self.browser.get( self.live_server_url + f"/billboard/room/{self.room.id}/scroll/" ) # 2. The full-width applet section is present self.wait_for( lambda: self.browser.find_element(By.ID, "id_applet_billboard_scroll") ) def test_billscroll_applet_contains_drama_events(self): # 1. Log in, navigate to the room's scroll self.create_pre_authenticated_session("founder@billtest.io") self.browser.get( self.live_server_url + f"/billboard/room/{self.room.id}/scroll/" ) # 2. Drama scroll is inside the applet and shows the event scroll = self.wait_for( lambda: self.browser.find_element(By.ID, "id_drama_scroll") ) self.assertIn("Coin-on-a-String", scroll.text) class BillscrollEntryLayoutTest(FunctionalTest): """ FT: each drama entry renders as a 90/10 row — event body at 90%, relative timestamp at 10%; timestamp text format varies with age. """ def setUp(self): super().setUp() self.founder = User.objects.create(email="founder@layout.io") self.room = Room.objects.create(name="Layout Chamber", owner=self.founder) # A fresh (< 24 h) event — timestamp is auto_now_add so always recent record( self.room, GameEvent.SLOT_FILLED, actor=self.founder, slot_number=1, token_type="coin", token_display="Fresh Coin", renewal_days=7, ) # An old (> 1 year) event — backdate via queryset update to bypass auto_now_add old = record( self.room, GameEvent.SLOT_FILLED, actor=self.founder, slot_number=2, token_type="coin", token_display="Ancient Coin", renewal_days=7, ) GameEvent.objects.filter(pk=old.pk).update( timestamp=timezone.now() - datetime.timedelta(days=400) ) def _go_to_scroll(self): self.create_pre_authenticated_session("founder@layout.io") self.browser.get( self.live_server_url + f"/billboard/room/{self.room.id}/scroll/" ) return self.wait_for( lambda: self.browser.find_elements(By.CSS_SELECTOR, ".drama-event") ) # ------------------------------------------------------------------ # # Test 1 — each entry has a body column and a time column # # ------------------------------------------------------------------ # def test_each_drama_entry_has_body_and_time_columns(self): events = self._go_to_scroll() self.assertEqual(len(events), 2) for event_el in events: event_el.find_element(By.CSS_SELECTOR, ".drama-event-body") event_el.find_element(By.CSS_SELECTOR, ".drama-event-time") # ------------------------------------------------------------------ # # Test 2 — recent entry timestamp shows HH:MM a.m./p.m. # # ------------------------------------------------------------------ # def test_recent_event_shows_time_format(self): events = self._go_to_scroll() # events[0] is the backdated record (oldest); events[1] is fresh recent_ts = events[1].find_element(By.CSS_SELECTOR, ".drama-event-time") self.assertRegex(recent_ts.text, r"\d+:\d+\s+[ap]\.m\.") # ------------------------------------------------------------------ # # Test 3 — entry > 1 year old shows DD Mon YYYY # # ------------------------------------------------------------------ # def test_old_event_shows_date_with_year(self): events = self._go_to_scroll() # events[0] is the backdated record (oldest first, ascending order) old_ts = events[0].find_element(By.CSS_SELECTOR, ".drama-event-time") self.assertRegex(old_ts.text, r"\d{2}\s+[A-Z][a-z]{2}\s+\d{4}")