2026-04-02 14:51:08 -04:00
|
|
|
|
import datetime
|
|
|
|
|
|
import re
|
2026-03-24 17:44:34 -04:00
|
|
|
|
import time
|
|
|
|
|
|
|
2026-04-02 14:51:08 -04:00
|
|
|
|
from django.utils import timezone
|
2026-03-19 15:48:59 -04:00
|
|
|
|
from selenium.webdriver.common.by import By
|
|
|
|
|
|
|
|
|
|
|
|
from .base import FunctionalTest
|
2026-03-24 16:46:46 -04:00
|
|
|
|
from apps.applets.models import Applet
|
2026-03-19 15:48:59 -04:00
|
|
|
|
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()
|
2026-03-24 16:46:46 -04:00
|
|
|
|
for slug, name, cols, rows in [
|
2026-05-03 23:22:01 -04:00
|
|
|
|
("my-scrolls", "My Scrolls", 4, 3),
|
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
- applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
- billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
- global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
- new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
- my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
- my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
- SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
- 841 ITs + 5 my_buds/my_posts FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:33 -04:00
|
|
|
|
("my-buds", "My Buds", 4, 3),
|
2026-05-03 23:22:01 -04:00
|
|
|
|
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
2026-03-24 16:46:46 -04:00
|
|
|
|
]:
|
|
|
|
|
|
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")
|
2026-03-19 15:48:59 -04:00
|
|
|
|
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
|
2026-03-24 16:46:46 -04:00
|
|
|
|
self.create_pre_authenticated_session("founder@test.io")
|
2026-03-19 15:48:59 -04:00
|
|
|
|
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):
|
2026-03-24 16:46:46 -04:00
|
|
|
|
self.create_pre_authenticated_session("founder@test.io")
|
2026-03-19 15:48:59 -04:00
|
|
|
|
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
|
2026-04-02 22:16:40 -04:00
|
|
|
|
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)
|
2026-03-19 15:48:59 -04:00
|
|
|
|
|
|
|
|
|
|
# Role selection event is rendered as prose
|
2026-04-13 00:34:05 -04:00
|
|
|
|
self.assertIn("assumes 1st Chair", scroll.text)
|
2026-03-19 15:48:59 -04:00
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Test 3 — current user's events are right-aligned; others' are left #
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
|
|
|
|
|
|
def test_scroll_aligns_own_events_right_and_others_left(self):
|
2026-03-24 16:46:46 -04:00
|
|
|
|
self.create_pre_authenticated_session("founder@test.io")
|
2026-03-19 15:48:59 -04:00
|
|
|
|
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)
|
2026-03-24 16:46:46 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-24 17:44:34 -04:00
|
|
|
|
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),
|
2026-03-24 21:36:02 -04:00
|
|
|
|
# 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.
|
2026-03-24 19:02:29 -04:00
|
|
|
|
scroll_top = 100
|
2026-03-24 21:36:02 -04:00
|
|
|
|
saved_pos = self.browser.execute_script("""
|
2026-03-24 17:44:34 -04:00
|
|
|
|
var el = arguments[0];
|
2026-03-24 21:36:02 -04:00
|
|
|
|
var remPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
2026-03-24 17:44:34 -04:00
|
|
|
|
el.style.overflow = 'auto';
|
|
|
|
|
|
el.style.height = '150px';
|
|
|
|
|
|
el.scrollTop = arguments[1];
|
2026-03-24 21:36:02 -04:00
|
|
|
|
var pos = Math.round(el.scrollTop + el.clientHeight + remPx * 2.5);
|
2026-03-24 17:44:34 -04:00
|
|
|
|
el.dispatchEvent(new Event('scroll'));
|
2026-03-24 21:36:02 -04:00
|
|
|
|
return pos;
|
2026-03-24 19:02:29 -04:00
|
|
|
|
""", scroll_el, scroll_top)
|
2026-03-24 17:44:34 -04:00
|
|
|
|
|
|
|
|
|
|
# 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"))
|
2026-03-24 21:36:02 -04:00
|
|
|
|
self.assertEqual(restored, saved_pos)
|
2026-03-24 17:44:34 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-24 16:46:46 -04:00
|
|
|
|
class BillboardAppletsTest(FunctionalTest):
|
|
|
|
|
|
"""
|
|
|
|
|
|
FT: billboard page renders three applets in the grid — My Scrolls,
|
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
- applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
- billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
- global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
- new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
- my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
- my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
- SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
- 841 ITs + 5 my_buds/my_posts FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:33 -04:00
|
|
|
|
My Buds, and Most Recent Scroll — with a functioning gear menu.
|
2026-03-24 16:46:46 -04:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
super().setUp()
|
2026-05-03 17:15:26 -04:00
|
|
|
|
self.browser.set_window_size(800, 1200)
|
2026-03-24 16:46:46 -04:00
|
|
|
|
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 [
|
2026-05-03 23:22:01 -04:00
|
|
|
|
("my-scrolls", "My Scrolls", 4, 3),
|
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
- applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
- billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
- global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
- new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
- my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
- my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
- SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
- 841 ITs + 5 my_buds/my_posts FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:33 -04:00
|
|
|
|
("my-buds", "My Buds", 4, 3),
|
2026-05-03 23:22:01 -04:00
|
|
|
|
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
2026-03-24 16:46:46 -04:00
|
|
|
|
]:
|
|
|
|
|
|
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(
|
2026-05-03 23:22:01 -04:00
|
|
|
|
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
|
2026-03-24 16:46:46 -04:00
|
|
|
|
)
|
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
- applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
- billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
- global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
- new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
- my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
- my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
- SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
- 841 ITs + 5 my_buds/my_posts FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:33 -04:00
|
|
|
|
self.browser.find_element(By.ID, "id_applet_my_buds")
|
2026-05-03 23:22:01 -04:00
|
|
|
|
self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
|
2026-03-24 16:46:46 -04:00
|
|
|
|
|
|
|
|
|
|
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",
|
2026-05-03 23:22:01 -04:00
|
|
|
|
self.browser.find_element(By.ID, "id_applet_my_scrolls").text,
|
2026-03-24 16:46:46 -04:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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()))
|
|
|
|
|
|
|
2026-05-03 17:15:26 -04:00
|
|
|
|
def test_toggling_applets_keeps_content_and_persists_per_applet(self):
|
2026-05-03 23:22:01 -04:00
|
|
|
|
# Seed an event so Most Recent Scroll renders prose, not the empty fallback
|
2026-05-03 17:15:26 -04:00
|
|
|
|
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/")
|
|
|
|
|
|
|
2026-05-03 23:22:01 -04:00
|
|
|
|
# All three applets visible; Most Recent Scroll shows event prose, My Scrolls shows the room
|
2026-05-03 17:15:26 -04:00
|
|
|
|
self.wait_for(
|
2026-05-03 23:22:01 -04:00
|
|
|
|
lambda: self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
|
2026-05-03 17:15:26 -04:00
|
|
|
|
)
|
2026-05-03 23:22:01 -04:00
|
|
|
|
most_recent_scroll = self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
|
|
|
|
|
|
self.assertIn("Coin-on-a-String", most_recent_scroll.text)
|
2026-05-03 17:15:26 -04:00
|
|
|
|
self.assertIn(
|
|
|
|
|
|
"Arcane Assembly",
|
2026-05-03 23:22:01 -04:00
|
|
|
|
self.browser.find_element(By.ID, "id_applet_my_scrolls").text,
|
2026-05-03 17:15:26 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 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(
|
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
- applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
- billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
- global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
- new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
- my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
- my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
- SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
- 841 ITs + 5 my_buds/my_posts FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:33 -04:00
|
|
|
|
By.CSS_SELECTOR, "input[value='my-buds']"
|
2026-05-03 17:15:26 -04:00
|
|
|
|
)
|
|
|
|
|
|
self.browser.execute_script("arguments[0].click()", contacts_cb)
|
|
|
|
|
|
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
|
|
|
|
|
|
|
2026-05-03 23:22:01 -04:00
|
|
|
|
# Contacts is hidden; Most Recent Scroll + My Scrolls keep their content (bug #2)
|
2026-05-03 17:15:26 -04:00
|
|
|
|
self.wait_for(
|
|
|
|
|
|
lambda: self.assertEqual(
|
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
- applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
- billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
- global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
- new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
- my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
- my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
- SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
- 841 ITs + 5 my_buds/my_posts FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:33 -04:00
|
|
|
|
self.browser.find_elements(By.ID, "id_applet_my_buds"),
|
2026-05-03 17:15:26 -04:00
|
|
|
|
[],
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-05-03 23:22:01 -04:00
|
|
|
|
most_recent_scroll = self.browser.find_element(By.ID, "id_applet_most_recent_scroll")
|
|
|
|
|
|
self.assertIn("Coin-on-a-String", most_recent_scroll.text)
|
2026-05-03 17:15:26 -04:00
|
|
|
|
self.assertIn(
|
|
|
|
|
|
"Arcane Assembly",
|
2026-05-03 23:22:01 -04:00
|
|
|
|
self.browser.find_element(By.ID, "id_applet_my_scrolls").text,
|
2026-05-03 17:15:26 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-03 23:22:01 -04:00
|
|
|
|
# Second toggle: hide Most Recent Scroll. Contacts must NOT come back (bug #1)
|
2026-05-03 17:15:26 -04:00
|
|
|
|
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(
|
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
- applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
- billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
- global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
- new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
- my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
- my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
- SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
- 841 ITs + 5 my_buds/my_posts FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:33 -04:00
|
|
|
|
By.CSS_SELECTOR, "input[value='my-buds']"
|
2026-05-03 17:15:26 -04:00
|
|
|
|
)
|
|
|
|
|
|
self.assertFalse(contacts_cb.is_selected())
|
2026-05-03 23:22:01 -04:00
|
|
|
|
most_recent_scroll_cb = menu.find_element(
|
|
|
|
|
|
By.CSS_SELECTOR, "input[value='most-recent-scroll']"
|
2026-05-03 17:15:26 -04:00
|
|
|
|
)
|
2026-05-03 23:22:01 -04:00
|
|
|
|
self.browser.execute_script("arguments[0].click()", most_recent_scroll_cb)
|
2026-05-03 17:15:26 -04:00
|
|
|
|
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
|
|
|
|
|
|
|
2026-05-03 23:22:01 -04:00
|
|
|
|
# Most Recent Scroll gone; Contacts still hidden (the stale-form bug would re-show it)
|
2026-05-03 17:15:26 -04:00
|
|
|
|
self.wait_for(
|
|
|
|
|
|
lambda: self.assertEqual(
|
2026-05-03 23:22:01 -04:00
|
|
|
|
self.browser.find_elements(By.ID, "id_applet_most_recent_scroll"),
|
2026-05-03 17:15:26 -04:00
|
|
|
|
[],
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(
|
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
- applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
- billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
- global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
- new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
- my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
- my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
- SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
- 841 ITs + 5 my_buds/my_posts FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:33 -04:00
|
|
|
|
self.browser.find_elements(By.ID, "id_applet_my_buds"),
|
2026-05-03 17:15:26 -04:00
|
|
|
|
[],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# And after a hard refresh both stay hidden, menu reflects DB
|
|
|
|
|
|
self.browser.refresh()
|
|
|
|
|
|
self.wait_for(
|
2026-05-03 23:22:01 -04:00
|
|
|
|
lambda: self.browser.find_element(By.ID, "id_applet_my_scrolls")
|
2026-05-03 17:15:26 -04:00
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(
|
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
- applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
- billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
- global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
- new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
- my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
- my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
- SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
- 841 ITs + 5 my_buds/my_posts FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:33 -04:00
|
|
|
|
self.browser.find_elements(By.ID, "id_applet_my_buds"),
|
2026-05-03 17:15:26 -04:00
|
|
|
|
[],
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(
|
2026-05-03 23:22:01 -04:00
|
|
|
|
self.browser.find_elements(By.ID, "id_applet_most_recent_scroll"),
|
2026-05-03 17:15:26 -04:00
|
|
|
|
[],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-24 16:46:46 -04:00
|
|
|
|
|
|
|
|
|
|
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(
|
2026-05-03 23:22:01 -04:00
|
|
|
|
lambda: self.browser.find_element(By.ID, "id_applet_scroll")
|
2026-03-24 16:46:46 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-04-02 14:51:08 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
2026-04-02 15:22:04 -04:00
|
|
|
|
# events[0] is the backdated record (oldest); events[1] is fresh
|
|
|
|
|
|
recent_ts = events[1].find_element(By.CSS_SELECTOR, ".drama-event-time")
|
2026-04-02 14:51:08 -04:00
|
|
|
|
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()
|
2026-04-02 15:22:04 -04:00
|
|
|
|
# events[0] is the backdated record (oldest first, ascending order)
|
|
|
|
|
|
old_ts = events[0].find_element(By.CSS_SELECTOR, ".drama-event-time")
|
2026-04-02 14:51:08 -04:00
|
|
|
|
self.assertRegex(old_ts.text, r"\d{2}\s+[A-Z][a-z]{2}\s+\d{4}")
|
2026-04-13 00:34:05 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BillscrollGearMenuTest(FunctionalTest):
|
|
|
|
|
|
"""
|
|
|
|
|
|
FT: the billscroll page has a gear menu that filters events by label.
|
|
|
|
|
|
|
|
|
|
|
|
Frame = all regular (non-struck) drama entries.
|
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
|
|
|
|
Redact = struck-through (retracted) entries, e.g. a WAIT NVM after SAVE SIG.
|
2026-04-13 00:34:05 -04:00
|
|
|
|
|
|
|
|
|
|
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())
|