Files
python-tdd/src/functional_tests/test_billboard.py
Disco DeDisco 3242873625 btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default .sig-stage .sig-stage-card .fan-card-face .sig-qualifier-* rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each .fan-card-reversal-* class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-<p> skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two <p>s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 -04:00

595 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 [
("my-scrolls", "My Scrolls", 4, 3),
("my-buds", "My Buds", 4, 3),
("most-recent-scroll", "Most Recent Scroll", 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 (expires in 7 days).", scroll.text)
self.assertIn("deposits a Free Token for slot 2 (expires in 7 days).", scroll.text)
# Role selection event is rendered as prose
self.assertIn("assumes 1st Chair", 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 Buds, and Most Recent Scroll — with a functioning gear menu.
"""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
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 [
("my-scrolls", "My Scrolls", 4, 3),
("my-buds", "My Buds", 4, 3),
("most-recent-scroll", "Most Recent Scroll", 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_my_scrolls")
)
self.browser.find_element(By.ID, "id_applet_my_buds")
self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
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_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()))
def test_toggling_applets_keeps_content_and_persists_per_applet(self):
# Seed an event so Most Recent Scroll renders prose, not the empty fallback
record(
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/billboard/")
# All three applets visible; Most Recent Scroll shows event prose, My Scrolls shows the room
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
)
most_recent_scroll = self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
self.assertIn("Coin-on-a-String", most_recent_scroll.text)
self.assertIn(
"Arcane Assembly",
self.browser.find_element(By.ID, "id_applet_my_scrolls").text,
)
# Open gear, uncheck Contacts, click OK
gear = self.browser.find_element(By.CSS_SELECTOR, ".billboard-page .gear-btn")
self.browser.execute_script("arguments[0].click()", gear)
menu = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_billboard_applet_menu")
)
contacts_cb = menu.find_element(
By.CSS_SELECTOR, "input[value='my-buds']"
)
self.browser.execute_script("arguments[0].click()", contacts_cb)
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
# Contacts is hidden; Most Recent Scroll + My Scrolls keep their content (bug #2)
self.wait_for(
lambda: self.assertEqual(
self.browser.find_elements(By.ID, "id_applet_my_buds"),
[],
)
)
most_recent_scroll = self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
self.assertIn("Coin-on-a-String", most_recent_scroll.text)
self.assertIn(
"Arcane Assembly",
self.browser.find_element(By.ID, "id_applet_my_scrolls").text,
)
# Second toggle: hide Most Recent Scroll. Contacts must NOT come back (bug #1)
gear = self.browser.find_element(By.CSS_SELECTOR, ".billboard-page .gear-btn")
self.browser.execute_script("arguments[0].click()", gear)
menu = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_billboard_applet_menu")
)
# The freshly-rendered menu must reflect DB state (Contacts unchecked)
contacts_cb = menu.find_element(
By.CSS_SELECTOR, "input[value='my-buds']"
)
self.assertFalse(contacts_cb.is_selected())
most_recent_scroll_cb = menu.find_element(
By.CSS_SELECTOR, "input[value='most-recent-scroll']"
)
self.browser.execute_script("arguments[0].click()", most_recent_scroll_cb)
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
# Most Recent Scroll gone; Contacts still hidden (the stale-form bug would re-show it)
self.wait_for(
lambda: self.assertEqual(
self.browser.find_elements(By.ID, "id_applet_most_recent_scroll"),
[],
)
)
self.assertEqual(
self.browser.find_elements(By.ID, "id_applet_my_buds"),
[],
)
# And after a hard refresh both stay hidden, menu reflects DB
self.browser.refresh()
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
)
self.assertEqual(
self.browser.find_elements(By.ID, "id_applet_my_buds"),
[],
)
self.assertEqual(
self.browser.find_elements(By.ID, "id_applet_most_recent_scroll"),
[],
)
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_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}")
class BillscrollGearMenuTest(FunctionalTest):
"""
FT: the billscroll page has a gear menu that filters events by label.
Frame = all regular (non-struck) drama entries.
Redact = struck-through (retracted) entries, e.g. a WAIT NVM after SAVE SIG.
Scenario (one gamer, Role + Sig events):
1. Both labels checked by default — all events visible.
2. Uncheck Redact → OK: struck entries disappear.
3. Recheck Redact + uncheck Frame → OK: regular entries gone; struck
entries visible (but still render struck-through — they remain "redacted"
in the narrative sense).
4. Recheck Frame → OK: all entries return.
"""
def setUp(self):
super().setUp()
self.founder = User.objects.create(email="founder@geartest.io")
self.room = Room.objects.create(name="Gear Filter Room", owner=self.founder)
# Two Frame events — ROLE_SELECTED, non-struck
record(self.room, GameEvent.ROLE_SELECTED, actor=self.founder,
role="PC", slot_number=1, role_display="Player")
record(self.room, GameEvent.ROLE_SELECTED, actor=self.founder,
role="NC", slot_number=2, role_display="Narrator")
# Two Redact events — SIG_READY with retracted=True → event.struck is True
sig1 = record(self.room, GameEvent.SIG_READY, actor=self.founder,
card_name="The Wanderer", corner_rank="0", suit_icon="")
sig1.data["retracted"] = True
sig1.save(update_fields=["data"])
sig2 = record(self.room, GameEvent.SIG_READY, actor=self.founder,
card_name="Maid of Brands", corner_rank="M",
suit_icon="fa-wand-sparkles")
sig2.data["retracted"] = True
sig2.save(update_fields=["data"])
def _go_to_scroll(self):
self.create_pre_authenticated_session("founder@geartest.io")
self.browser.get(
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
)
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
)
def _open_gear(self):
"""Click the gear btn and return the now-visible menu element."""
gear = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
".gear-btn[data-menu-target='id_billscroll_menu']"
)
)
self.browser.execute_script("arguments[0].click()", gear)
return self.wait_for(
lambda: self.browser.find_element(By.ID, "id_billscroll_menu")
)
def _visible_events(self, label):
"""Count displayed .drama-event elements with the given data-label."""
els = self.browser.find_elements(
By.CSS_SELECTOR, f".drama-event[data-label='{label}']"
)
return sum(1 for e in els if e.is_displayed())
# ------------------------------------------------------------------ #
# Step 1 — gear menu opens; both labels present and pre-checked #
# ------------------------------------------------------------------ #
def test_gear_menu_shows_frame_and_redact_checkboxes(self):
self._go_to_scroll()
menu = self._open_gear()
self.assertTrue(menu.is_displayed())
frame_cb = menu.find_element(By.CSS_SELECTOR, "input[value='frame']")
redact_cb = menu.find_element(By.CSS_SELECTOR, "input[value='redact']")
self.assertTrue(frame_cb.is_selected())
self.assertTrue(redact_cb.is_selected())
self.assertIn("Frame", menu.text)
self.assertIn("Redact", menu.text)
# ------------------------------------------------------------------ #
# Steps 2 4 — filter flow #
# ------------------------------------------------------------------ #
def test_gear_menu_filter_flow(self):
self._go_to_scroll()
# Step 1: all 4 events visible (2 frame + 2 redact)
self.assertEqual(self._visible_events("frame"), 2)
self.assertEqual(self._visible_events("redact"), 2)
# Step 2: uncheck Redact → OK → struck entries disappear
menu = self._open_gear()
menu.find_element(By.CSS_SELECTOR, "input[value='redact']").click()
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
self.wait_for(lambda: self.assertEqual(self._visible_events("redact"), 0))
self.assertEqual(self._visible_events("frame"), 2)
# Step 3: recheck Redact + uncheck Frame → OK
# Redact events re-appear (still struck-through); Frame events gone.
menu = self._open_gear()
menu.find_element(By.CSS_SELECTOR, "input[value='redact']").click() # recheck
menu.find_element(By.CSS_SELECTOR, "input[value='frame']").click() # uncheck
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
self.wait_for(lambda: self.assertEqual(self._visible_events("frame"), 0))
self.assertEqual(self._visible_events("redact"), 2)
# Struck-through entries still carry the .struck class (visually "gone" in narrative)
redact_bodies = self.browser.find_elements(
By.CSS_SELECTOR, ".drama-event[data-label='redact'] .drama-event-body"
)
self.assertTrue(all("struck" in b.get_attribute("class") for b in redact_bodies))
# Step 4: recheck Frame → OK → all events return
menu = self._open_gear()
menu.find_element(By.CSS_SELECTOR, "input[value='frame']").click() # recheck
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
self.wait_for(lambda: self.assertEqual(self._visible_events("frame"), 2))
self.assertEqual(self._visible_events("redact"), 2)
# ------------------------------------------------------------------ #
# Persistence — filter survives a full page reload #
# ------------------------------------------------------------------ #
def test_filter_selection_persists_across_refresh(self):
self._go_to_scroll()
# Uncheck Redact → OK: struck entries disappear
menu = self._open_gear()
menu.find_element(By.CSS_SELECTOR, "input[value='redact']").click()
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
self.wait_for(lambda: self.assertEqual(self._visible_events("redact"), 0))
# Hard reload — same URL, same session cookie
self.browser.refresh()
self.wait_for(lambda: self.browser.find_element(By.ID, "id_drama_scroll"))
# Struck entries still absent after reload
self.wait_for(lambda: self.assertEqual(self._visible_events("redact"), 0))
self.assertEqual(self._visible_events("frame"), 2)
# Gear menu still shows Redact unchecked
menu = self._open_gear()
redact_cb = menu.find_element(By.CSS_SELECTOR, "input[value='redact']")
self.assertFalse(redact_cb.is_selected())