functional_tests + CI: rename pass + structural consolidations + parallel test-FTs split — every FT file now starts with one of 6 prefixes (test_admin_* / test_bill_* / test_core_* / test_dash_* / test_game_room_* / test_trinket_*) plus the 4 page-roots test_billboard / test_dashboard / test_gameboard / test_jasmine, so the partition is unambiguous and stable for tooling (the previous mix of test_applet_*, test_room_*, test_component_*, ad-hoc names had no consistent grouping); session-side: merged test_gatekeeper_bud_btn.py into test_bud_btn.py (then user renamed to test_core_bud_btn.py) — both files drove the same #id_bud_btn UI in two contexts (post-share + gatekeeper invite) and shared the bud-btn.js skeleton, so consolidation was overdue; split test_component_cards_tarot.py into test_admin_tarot.py (just TarotAdminTest, sitting next to test_admin / test_admin_post_readonly) + 3 classes (TarotDeckTest / GameKitDeckSelectionTest / GameKitPageTest) appended to test_game_room_tray.py; updated stale test_bud_btn.py references in the test_core_bud_btn.py docstring + test_admin_post_readonly.py comment to point at the new filename; user-driven renames (22 files): test_applet_my_notes/posts → test_bill_my_*, test_applet_new_post[_line_validation] → test_bill_new_post[_line_validation], test_applet_my_sky → test_dash_my_sky, test_applet_palette → test_dash_palette, test_wallet → test_dash_wallet, test_login → test_core_login, test_navbar → test_core_navbar, test_sharing → test_core_sharing, test_layout_and_styling → test_core_styling, test_my_buds → test_bill_my_buds, test_bud_btn → test_core_bud_btn, test_deck_contribution → test_game_room_deck_contrib, test_game_invite → test_game_room_invite, test_room_gatekeeper → test_game_room_gatekeeper, test_room_role_select → test_game_room_select_role, test_room_sea_select → test_game_room_select_sea, test_room_sig_select → test_game_room_select_sig, test_room_sky_select → test_game_room_select_sky, test_room_tray → test_game_room_tray, test_component_tray_tooltip → test_game_room_tray_tooltip; the post_page.py / room_page.py helper modules from the May-12 sprint absorbed the cross-file FT imports that would otherwise have cascade-broken on these renames
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

.woodpecker/main.yaml — CI test-FTs step splits into parallel siblings test-FTs-non-room (22 files via `ls functional_tests/test_*.py | grep -v 'test_game_room_'`) + test-FTs-room (9 files via `ls functional_tests/test_game_room_*.py`); room cluster is the heaviest (~70% of the pre-split ~40-min wall-clock) and now runs concurrently w. the rest instead of in series; DAG explicit via depends_on on every step (Woodpecker mixes default-sequential w. depends_on awkwardly, so each step pins its prerequisite); collectstatic stays in test-two-browser-FTs only — the shared workspace propagates assets to both parallel FT steps, no race + no duplication; screendumps + build-and-push fan back in (depends_on both parallel steps); deploy-staging + deploy-prod depend on build-and-push

smoke-import: 31/31 FT modules green after the rename pass

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-12 20:06:25 -04:00
parent af1a90e76b
commit f9c05a3eba
27 changed files with 713 additions and 625 deletions

View File

@@ -37,6 +37,8 @@ steps:
- name: test-two-browser-FTs - name: test-two-browser-FTs
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
depends_on:
- test-UTs-n-ITs
environment: environment:
HEADLESS: 1 HEADLESS: 1
CELERY_BROKER_URL: redis://redis:6379/0 CELERY_BROKER_URL: redis://redis:6379/0
@@ -49,6 +51,8 @@ steps:
commands: commands:
- pip install -r requirements.dev.txt - pip install -r requirements.dev.txt
- cd ./src - cd ./src
# Also collectstatic'd here; output sits in the shared workspace so
# the downstream parallel FT steps don't have to repeat it.
- python manage.py collectstatic --noinput - python manage.py collectstatic --noinput
- python manage.py test functional_tests --tag=two-browser - python manage.py test functional_tests --tag=two-browser
- python manage.py test functional_tests --tag=sequential - python manage.py test functional_tests --tag=sequential
@@ -60,8 +64,24 @@ steps:
- "requirements.txt" - "requirements.txt"
- ".woodpecker/main.yaml" - ".woodpecker/main.yaml"
- name: test-FTs # ── Parallel FT split ─────────────────────────────────────────────────
#
# test_game_room_* is the heaviest cluster — 9 Selenium-driven room-flow
# FTs that historically dominate the FT step wall-clock (~70% of the
# ~40-min single-step runs). Split off into its own step (`test-FTs-room`)
# that runs in parallel with the rest (`test-FTs-non-room`). Both depend
# on test-two-browser-FTs (which leaves collectstatic'd assets in the
# shared workspace), so neither parallel step re-runs collectstatic.
#
# Tradeoff: 2 concurrent Selenium/Firefox containers on the runner
# instead of 1, in exchange for roughly halved test-FT wall-clock when
# the partition stays balanced. If the runner thrashes, drop the
# `depends_on` from one to serialize them again.
- name: test-FTs-non-room
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
depends_on:
- test-two-browser-FTs
environment: environment:
HEADLESS: 1 HEADLESS: 1
CELERY_BROKER_URL: redis://redis:6379/0 CELERY_BROKER_URL: redis://redis:6379/0
@@ -74,8 +94,37 @@ steps:
commands: commands:
- pip install -r requirements.dev.txt - pip install -r requirements.dev.txt
- cd ./src - cd ./src
- python manage.py collectstatic --noinput # Every FT file EXCEPT test_game_room_* — that cluster runs in
- python manage.py test functional_tests --parallel --exclude-tag=channels --exclude-tag=two-browser # test-FTs-room. Channels + two-browser tags already covered upstream.
# `ls | grep -v | sed` enumerates module dotted-paths from filenames.
- python manage.py test --parallel --exclude-tag=channels --exclude-tag=two-browser $(ls functional_tests/test_*.py | grep -v 'test_game_room_' | sed 's|/|.|g;s|\.py||')
when:
- event: push
path:
- "src/**"
- "requirements.txt"
- ".woodpecker/main.yaml"
- name: test-FTs-room
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
depends_on:
- test-two-browser-FTs
environment:
HEADLESS: 1
CELERY_BROKER_URL: redis://redis:6379/0
REDIS_URL: redis://redis:6379/1
STRIPE_SECRET_KEY:
from_secret: stripe_secret_key
STRIPE_PUBLISHABLE_KEY:
from_secret: stripe_publishable_key
PIP_CACHE_DIR: .pip-cache
commands:
- pip install -r requirements.dev.txt
- cd ./src
# Heavy Selenium room flows — 9 files (deck_contrib, gatekeeper,
# invite, select_role/sea/sig/sky, tray, tray_tooltip) isolated into
# their own parallel sub-step.
- python manage.py test --parallel --exclude-tag=channels --exclude-tag=two-browser $(ls functional_tests/test_game_room_*.py | sed 's|/|.|g;s|\.py||')
when: when:
- event: push - event: push
path: path:
@@ -85,6 +134,9 @@ steps:
- name: screendumps - name: screendumps
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
depends_on:
- test-FTs-non-room
- test-FTs-room
commands: commands:
- cat ./src/functional_tests/screendumps/*.html || echo "No screendumps found" - cat ./src/functional_tests/screendumps/*.html || echo "No screendumps found"
when: when:
@@ -97,6 +149,9 @@ steps:
- name: build-and-push - name: build-and-push
image: docker:cli image: docker:cli
depends_on:
- test-FTs-non-room
- test-FTs-room
environment: environment:
REGISTRY_PASSWORD: REGISTRY_PASSWORD:
from_secret: gitea_registry_password from_secret: gitea_registry_password
@@ -115,6 +170,8 @@ steps:
- name: deploy-staging - name: deploy-staging
image: alpine image: alpine
depends_on:
- build-and-push
environment: environment:
SSH_KEY: SSH_KEY:
from_secret: deploy_ssh_key from_secret: deploy_ssh_key
@@ -136,6 +193,8 @@ steps:
- name: deploy-prod - name: deploy-prod
image: alpine image: alpine
depends_on:
- build-and-push
environment: environment:
SSH_KEY: SSH_KEY:
from_secret: deploy_ssh_key from_secret: deploy_ssh_key

View File

@@ -62,7 +62,7 @@ class AdminPostInputReadonlyTest(FunctionalTest):
class AdminPostHasNoBudBtnTest(FunctionalTest): class AdminPostHasNoBudBtnTest(FunctionalTest):
"""Admin-Post (note-unlock thread) suppresses #id_bud_btn — friend """Admin-Post (note-unlock thread) suppresses #id_bud_btn — friend
invites don't apply to system-authored threads. User-Post still invites don't apply to system-authored threads. User-Post still
renders the btn (regression coverage in test_bud_btn.py).""" renders the btn (regression coverage in test_core_bud_btn.py)."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()

View File

@@ -0,0 +1,100 @@
"""FT — Django admin tarot card browsing.
Admin user can land on /admin/, see the Epic section's Tarot card +
DeckVariant rows, filter the changelist by deck variant, and open an
Earthman card's detail page to read its name + arcana group +
Fiorentine correspondence.
Split from the legacy test_component_cards_tarot.py (2026-05-12) so the
admin surface lives next to test_admin.py + test_admin_post_readonly.py;
the non-admin classes from that file migrated to test_room_tray.py.
"""
from selenium.webdriver.common.by import By
from apps.epic.models import DeckVariant
from apps.lyric.models import User
from .base import FunctionalTest
class TarotAdminTest(FunctionalTest):
"""Admin can browse tarot cards by deck variant via Django admin."""
def setUp(self):
super().setUp()
from apps.epic.models import TarotCard
# DeckVariant + TarotCard rows are flushed by TransactionTestCase — recreate
self.earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
# Seed enough cards so admin filter shows a meaningful count
# The "108 tarot cards" assertion relies on deck_variant.card_count reported
# by the admin, not on actual row count (admin shows real rows, so we seed
# representative cards — 3 are enough to reach "The Schiz" in the list)
for number, name, slug, group, correspondence in [
(0, "The Schiz", "the-schiz-adm", "", "The Fool / Il Matto"),
(1, "Pope I: President","pope-i-president-adm","The Popes", "The Magician / Il Bagatto"),
(50, "The Eagle", "the-eagle-adm", "", "Judgement / L'Angelo"),
]:
TarotCard.objects.get_or_create(
deck_variant=self.earthman, slug=slug,
defaults={
"name": name, "arcana": "MAJOR", "number": number,
"group": group, "correspondence": correspondence,
},
)
self.superuser = User.objects.create_superuser(
email="admin@example.com",
password="correct-password",
)
def _login_to_admin(self):
self.browser.get(self.live_server_url + "/admin/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_username"))
self.browser.find_element(By.ID, "id_username").send_keys("admin@example.com")
self.browser.find_element(By.ID, "id_password").send_keys("correct-password")
self.browser.find_element(By.CSS_SELECTOR, "input[type=submit]").click()
# ------------------------------------------------------------------ #
# Test 1a — admin home lists Tarot cards + Deck variants under Epic #
# ------------------------------------------------------------------ #
def test_admin_epic_section_shows_tarot_cards_and_deck_variants(self):
self._login_to_admin()
body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body"))
self.assertIn("Tarot cards", body.text)
self.assertIn("Deck variants", body.text)
# ------------------------------------------------------------------ #
# Test 1b — changelist shows deck variant filter sidebar #
# ------------------------------------------------------------------ #
def test_admin_tarot_card_list_shows_deck_variant_filter(self):
self._login_to_admin()
self.browser.get(self.live_server_url + "/admin/epic/tarotcard/")
body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body"))
# Filter sidebar has a link for the Earthman deck
self.assertIn("Earthman Deck", body.text)
# Cards are listed — 3 seeded in setUp
self.assertIn("3 tarot cards", body.text)
# ------------------------------------------------------------------ #
# Test 1c — Earthman card detail shows name, group, and correspondence #
# ------------------------------------------------------------------ #
def test_admin_earthman_card_detail_shows_group_and_correspondence(self):
self._login_to_admin()
self.browser.get(self.live_server_url + "/admin/epic/tarotcard/")
self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body"))
# The Schiz is the Earthman Fool (card 0)
self.browser.find_element(By.LINK_TEXT, "The Schiz").click()
body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body"))
self.assertIn("Major Arcana", body.text) # arcana dropdown
self.assertIn("the-schiz-adm", body.text) # slug (readonly → rendered as text)
self.assertIn("The Fool / Il Matto", body.text) # correspondence (readonly → text)

View File

@@ -1,14 +1,25 @@
"""FT spec for the Bud btn sprint — post.html bottom-left handshake button. """FT spec for the #id_bud_btn slide-out — handshake button shared across:
Written red BEFORE implementation as a TDD handoff so the post-compaction post.html (bottom-left) async POST to billboard:share_post
agent (or future Disco) can land the feature without losing intent. Run: gatekeeper room.html (upper-right footer) async POST to epic:invite_gamer
my_buds.html async POST to billboard:add_bud
(covered by functional_tests.test_my_buds)
python src/manage.py test functional_tests.test_bud_btn This file covers the post-share + gatekeeper variants; My Buds lives in its
own FT module. All three drive the same `bindBudBtn(...)` skeleton in
apps/billboard/static/apps/billboard/bud-btn.js.
All tests should be RED initially. Implementation lands when they go green. The original test_bud_btn.py was written red BEFORE implementation as a
TDD handoff; test_gatekeeper_bud_btn.py was the parallel spec for the
gatekeeper port (2026-05-09 sprint). The two merged 2026-05-12 since they
exercise the same UI component in two contexts.
Run:
python src/manage.py test functional_tests.test_core_bud_btn
SPEC SUMMARY SPEC SUMMARY (original post.html sprint preserved for historical context)
A new #id_bud_btn (<i class="fa-solid fa-handshake">) sits bottom-left A new #id_bud_btn (<i class="fa-solid fa-handshake">) sits bottom-left
@@ -92,6 +103,7 @@ from selenium.webdriver.common.keys import Keys
from apps.applets.models import Applet from apps.applets.models import Applet
from apps.billboard.models import Brief, Line, Post from apps.billboard.models import Brief, Line, Post
from apps.epic.models import GateSlot, Room, RoomInvite
from apps.lyric.models import User from apps.lyric.models import User
from .base import FunctionalTest from .base import FunctionalTest
@@ -425,3 +437,176 @@ class BudBtnDuplicateShareErrorTest(FunctionalTest):
self.wait_for(lambda: self.assertIn( self.wait_for(lambda: self.assertIn(
"bud-duplicate-flash", chip.get_attribute("class") or "" "bud-duplicate-flash", chip.get_attribute("class") or ""
)) ))
# ─────────────────────────────────────────────────────────────────────────────
#
# Gatekeeper variant — bud-btn upper-right (footer in landscape) on room.html.
# Replaces the legacy inline `<form action="invite_gamer">` panel inside the
# gatekeeper modal. The bud-btn slide-out hosts the email/username field +
# OK btn; submit fires async POST to epic:invite_gamer w. Accept:
# application/json — server returns {brief, recipient_display, ...}; JS shows
# the slide-down Brief banner.
#
# ─────────────────────────────────────────────────────────────────────────────
class GatekeeperBudBtnPresenceTest(FunctionalTest):
"""The bud-btn renders for the room owner during gate phase, and is
absent for non-owners (friend invites are owner-only)."""
def setUp(self):
super().setUp()
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.gamer = User.objects.create(email="gamer@test.io", username="gamer")
self.room = Room.objects.create(name="Bingobango", owner=self.owner)
self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
def test_bud_btn_renders_for_owner(self):
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
def test_bud_btn_absent_for_non_owner(self):
# A registered non-owner viewer doesn't see the invite affordance.
self.create_pre_authenticated_session("gamer@test.io")
self.browser.get(self.room_url)
# Gatekeeper-specific element confirms page rendered
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal"))
self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn"))
def test_legacy_invite_email_input_is_gone(self):
"""Sanity: the old inline form has been removed."""
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal"))
self.assertFalse(self.browser.find_elements(By.ID, "id_invite_email"))
class GatekeeperBudBtnAsyncInviteTest(FunctionalTest):
"""OK on the bud-btn slide-out fires the async invite — RoomInvite
persisted, Brief w/ kind=GAME_INVITE created, slide-down banner shown."""
def setUp(self):
super().setUp()
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.room = Room.objects.create(name="Bingobango", owner=self.owner)
self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
def _open_panel_and_invite(self, recipient):
bud_btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
bud_btn.click()
recipient_input = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_recipient")
)
recipient_input.send_keys(recipient)
self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
).click()
return bud_btn
def test_invite_creates_room_invite(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertEqual(
RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io"
).count(),
1,
))
def test_invite_spawns_game_invite_brief(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertEqual(
Brief.objects.filter(
owner=self.owner, kind=Brief.KIND_GAME_INVITE,
).count(),
1,
))
def test_invite_renders_slide_down_banner(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner"))
def test_invite_closes_panel_after_success(self):
bud_btn = self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertNotIn("active", bud_btn.get_attribute("class")))
def test_invite_username_resolves_to_user_email(self):
"""Username-typed invite stores the resolved User's email."""
self._open_panel_and_invite("alice")
self.wait_for(lambda: self.assertEqual(
RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io"
).count(),
1,
))
def test_invite_auto_adds_recipient_to_owner_buds(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertIn(
self.alice, list(self.owner.buds.all())
))
class GatekeeperBudBtnDuplicateInviteErrorTest(FunctionalTest):
"""Re-inviting a recipient already seated in the room triggers the
error Brief titled `@<username> is already present`. FYI on the Brief
dismisses + adds .bud-duplicate-flash to the existing
.gate-slot.filled[data-user-id=] element. Pending-but-unseated
duplicates also surface the Brief but FYI has no slot to highlight."""
def setUp(self):
super().setUp()
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.room = Room.objects.create(name="Dup Room", owner=self.owner)
# Seat alice via a GateSlot — _gate_positions renders .gate-slot.filled
# cells from GateSlot records (TableSeat spins up later at SIG SELECT),
# so the duplicate-highlight target lives there during gatekeeper phase.
GateSlot.objects.create(
room=self.room, gamer=self.alice, slot_number=1,
status=GateSlot.FILLED,
)
self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
def test_duplicate_invite_shows_error_brief_and_fyi_flashes_slot(self):
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient.send_keys("alice@test.io")
self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm").click()
title = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__title"
))
self.assertEqual(title.text, "@alice is already present")
# No new RoomInvite or Brief persisted server-side on duplicate
self.assertFalse(RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io",
).exists())
self.assertEqual(
Brief.objects.filter(owner=self.owner, kind=Brief.KIND_GAME_INVITE).count(),
0,
)
slot = self.browser.find_element(
By.CSS_SELECTOR, f".gate-slot.filled[data-user-id='{self.alice.id}']"
)
self.assertNotIn("bud-duplicate-flash", slot.get_attribute("class") or "")
self.browser.find_element(
By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__fyi"
).click()
self.wait_for(lambda: self.assertEqual(
self.browser.find_elements(By.CSS_SELECTOR, ".note-banner--duplicate"),
[],
))
self.wait_for(lambda: self.assertIn(
"bud-duplicate-flash", slot.get_attribute("class") or ""
))

View File

@@ -1,95 +1,394 @@
import time
import unittest import unittest
from django.test import tag
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
from .room_page import _assign_all_roles, _fill_room_via_orm
from apps.applets.models import Applet from apps.applets.models import Applet
from apps.epic.models import DeckVariant, Room from apps.epic.models import DeckVariant, Room
from apps.lyric.models import User from apps.lyric.models import User
class TarotAdminTest(FunctionalTest): # ── Seat Tray ────────────────────────────────────────────────────────────────
"""Admin can browse tarot cards by deck variant via Django admin.""" #
# The Tray is a per-seat, per-room slide-out panel anchored to the right edge
# of the viewport. #id_tray_btn is a drawer-handle-shaped button: a circle
# with an icon (the "ivory centre") with decorative lines curving from its top
# and bottom to the right edge of the screen.
#
# Behaviour:
# - Closed by default; tray panel (#id_tray) is not visible.
# - Clicking the button while closed: wobbles the handle (adds "wobble"
# class) but does NOT open the tray.
# - Dragging the button leftward: reveals the tray.
# - Clicking the button while open: slides the tray closed.
# - On page reload: tray always starts closed (JS in-memory only).
#
# Contents (populated in later sprints): Role card, Significator, Celtic Cross
# draw, sky wheel, committed dice/cards for this table.
#
# ─────────────────────────────────────────────────────────────────────────────
class TrayTest(FunctionalTest):
def setUp(self): def setUp(self):
super().setUp() # Portrait viewport for T1T5 (768×1024). Use _make_browser so
from apps.epic.models import TarotCard # headless CI gets --width/--height args and the CSS orientation
# DeckVariant + TarotCard rows are flushed by TransactionTestCase — recreate # media query is correct from first paint.
self.earthman, _ = DeckVariant.objects.get_or_create( self.browser = self._make_browser(768, 1024)
slug="earthman", self.test_server = None
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
def _switch_to_landscape(self):
"""Recreate the browser, navigate to about:blank, then resize to
900×500 and wait until window.innerWidth > window.innerHeight confirms
the CSS orientation media query will fire correctly on the next page."""
self.browser.quit()
self.browser = self._make_browser(900, 500)
self.browser.get('about:blank')
self.browser.set_window_size(900, 500)
time.sleep(0.5) # allow Firefox to flush the resize before navigating
self.wait_for(lambda: self.assertTrue(
self.browser.execute_script(
'return window.innerWidth > window.innerHeight'
) )
# Seed enough cards so admin filter shows a meaningful count ))
# The "108 tarot cards" assertion relies on deck_variant.card_count reported
# by the admin, not on actual row count (admin shows real rows, so we seed def _simulate_drag(self, btn, offset_x):
# representative cards — 3 are enough to reach "The Schiz" in the list) """Dispatch JS pointer events directly — more reliable than GeckoDriver drag."""
for number, name, slug, group, correspondence in [ start_x = btn.rect['x'] + btn.rect['width'] / 2
(0, "The Schiz", "the-schiz-adm", "", "The Fool / Il Matto"), end_x = start_x + offset_x
(1, "Pope I: President","pope-i-president-adm","The Popes", "The Magician / Il Bagatto"), self.browser.execute_script("""
(50, "The Eagle", "the-eagle-adm", "", "Judgement / L'Angelo"), var btn = arguments[0], startX = arguments[1], endX = arguments[2];
]: btn.dispatchEvent(new PointerEvent("pointerdown", {clientX: startX, bubbles: true}));
TarotCard.objects.get_or_create( document.dispatchEvent(new PointerEvent("pointermove", {clientX: endX, bubbles: true}));
deck_variant=self.earthman, slug=slug, document.dispatchEvent(new PointerEvent("pointerup", {clientX: endX, bubbles: true}));
defaults={ """, btn, start_x, end_x)
"name": name, "arcana": "MAJOR", "number": number,
"group": group, "correspondence": correspondence, def _simulate_drag_y(self, btn, offset_y):
}, """Dispatch JS pointer events on the Y axis for landscape drag tests."""
start_y = btn.rect['y'] + btn.rect['height'] / 2
end_y = start_y + offset_y
self.browser.execute_script("""
var btn = arguments[0], startY = arguments[1], endY = arguments[2];
btn.dispatchEvent(new PointerEvent("pointerdown", {clientY: startY, clientX: 0, bubbles: true}));
document.dispatchEvent(new PointerEvent("pointermove", {clientY: endY, clientX: 0, bubbles: true}));
document.dispatchEvent(new PointerEvent("pointerup", {clientY: endY, clientX: 0, bubbles: true}));
""", btn, start_y, end_y)
def _make_role_select_room(self, founder_email="founder@test.io"):
from apps.epic.models import TableSeat
founder, _ = User.objects.get_or_create(email=founder_email)
room = Room.objects.create(name="Tray Test Room", owner=founder)
emails = [founder_email, "nc@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io"]
_fill_room_via_orm(room, emails)
room.table_status = Room.ROLE_SELECT
room.save()
for i, email in enumerate(emails, start=1):
gamer, _ = User.objects.get_or_create(email=email)
TableSeat.objects.get_or_create(room=room, gamer=gamer, slot_number=i)
return room
def _make_sig_select_room(self, founder_email="founder@test.io"):
founder, _ = User.objects.get_or_create(email=founder_email)
room = Room.objects.create(name="Tray Test Room", owner=founder)
_fill_room_via_orm(room, [
founder_email, "nc@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io",
])
_assign_all_roles(room)
return room
def _room_url(self, room):
return f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
# ------------------------------------------------------------------ #
# Test T1 — tray button is present and anchored to the right edge #
# ------------------------------------------------------------------ #
def test_tray_btn_is_present_on_room_page(self):
room = self._make_sig_select_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_tray_btn")
) )
self.superuser = User.objects.create_superuser( self.assertTrue(btn.is_displayed())
email="admin@example.com",
password="correct-password", # Button should be anchored near the right edge of the viewport
vp_width = self.browser.execute_script("return window.innerWidth")
btn_right = btn.location["x"] + btn.size["width"]
self.assertGreater(btn_right, vp_width * 0.8)
# ------------------------------------------------------------------ #
# Test T2 — tray is closed by default; clicking wobbles the handle #
# ------------------------------------------------------------------ #
def test_tray_is_closed_by_default_and_click_wobbles(self):
room = self._make_sig_select_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
# Tray panel not visible when closed
tray = self.browser.find_element(By.ID, "id_tray")
self.assertFalse(tray.is_displayed())
# Clicking the closed btn adds a wobble class to the wrap.
# Use a MutationObserver to capture the transient class change — in CI
# headless Firefox the 0.45s animation may complete before the first
# wait_for poll (0.5s), causing a false miss.
self.browser.execute_script("""
window._trayWobbled = false;
var wrap = document.getElementById('id_tray_wrap');
var obs = new MutationObserver(function(muts) {
muts.forEach(function(m) {
if (m.type === 'attributes' && m.attributeName === 'class') {
if (m.target.classList.contains('wobble')) {
window._trayWobbled = true;
obs.disconnect();
}
}
});
});
obs.observe(wrap, {attributes: true, attributeFilter: ['class']});
""")
self.browser.find_element(By.ID, "id_tray_btn").click()
self.wait_for(
lambda: self.assertTrue(
self.browser.execute_script("return window._trayWobbled;")
)
)
# Tray still not visible — a click alone must not open it
self.assertFalse(tray.is_displayed())
# ------------------------------------------------------------------ #
# Test T3 — dragging tray btn leftward opens the tray #
# ------------------------------------------------------------------ #
def test_dragging_tray_btn_left_opens_tray(self):
room = self._make_sig_select_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
tray = self.browser.find_element(By.ID, "id_tray")
self.assertFalse(tray.is_displayed())
self._simulate_drag(btn, -300)
self.wait_for(lambda: self.assertTrue(tray.is_displayed()))
# ------------------------------------------------------------------ #
# Test T4 — clicking btn while tray is open slides it closed #
# ------------------------------------------------------------------ #
def test_clicking_open_tray_btn_closes_tray(self):
room = self._make_sig_select_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
self._simulate_drag(btn, -300)
tray = self.browser.find_element(By.ID, "id_tray")
self.wait_for(lambda: self.assertTrue(tray.is_displayed()))
self.browser.find_element(By.ID, "id_tray_btn").click()
self.wait_for(lambda: self.assertFalse(tray.is_displayed()))
# ------------------------------------------------------------------ #
# Test T5 — tray reverts to closed on page reload #
# ------------------------------------------------------------------ #
def test_tray_reverts_to_closed_on_reload(self):
room = self._make_sig_select_room()
self.create_pre_authenticated_session("founder@test.io")
room_url = self._room_url(room)
self.browser.get(room_url)
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
self._simulate_drag(btn, -300)
tray = self.browser.find_element(By.ID, "id_tray")
self.wait_for(lambda: self.assertTrue(tray.is_displayed()))
# Reload — tray must start closed regardless of previous state
self.browser.get(room_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
tray = self.browser.find_element(By.ID, "id_tray")
self.assertFalse(tray.is_displayed())
# ------------------------------------------------------------------ #
# Test T6 — landscape: tray btn is near the top edge of the viewport #
# ------------------------------------------------------------------ #
@tag('two-browser')
def test_tray_btn_anchored_near_top_in_landscape(self):
room = self._make_sig_select_room()
self._switch_to_landscape()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_tray_btn")
)
self.assertTrue(btn.is_displayed())
# In landscape the handle sits at the top of the content area;
# btn bottom should be within the top 40% of the viewport.
vh = self.browser.execute_script("return window.innerHeight")
btn_bottom = btn.location["y"] + btn.size["height"]
self.assertLess(btn_bottom, vh * 0.4)
# ------------------------------------------------------------------ #
# Test T7 — landscape: dragging btn downward opens the tray #
# ------------------------------------------------------------------ #
@tag('two-browser')
def test_dragging_tray_btn_down_opens_tray_in_landscape(self):
room = self._make_sig_select_room()
self._switch_to_landscape()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
# In landscape, #id_tray is always display:block; position controls visibility.
# Use Tray.isOpen() to check logical state.
self.assertFalse(self.browser.execute_script("return Tray.isOpen()"))
self._simulate_drag_y(btn, 300)
self.wait_for(
lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()"))
) )
def _login_to_admin(self):
self.browser.get(self.live_server_url + "/admin/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_username"))
self.browser.find_element(By.ID, "id_username").send_keys("admin@example.com")
self.browser.find_element(By.ID, "id_password").send_keys("correct-password")
self.browser.find_element(By.CSS_SELECTOR, "input[type=submit]").click()
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Test 1aadmin home lists Tarot cards + Deck variants under Epic # # Test T8portrait: 1 column × 8 rows of square cells #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_admin_epic_section_shows_tarot_cards_and_deck_variants(self): @unittest.skip("portrait grid layout flaky in CI headless Firefox — revisit")
self._login_to_admin() @tag('two-browser')
def test_tray_grid_is_1_column_by_8_rows_in_portrait(self):
room = self._make_role_select_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
self.assertIn("Tarot cards", body.text) self._simulate_drag(btn, -300)
self.assertIn("Deck variants", body.text) self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_tray").is_displayed()
)
)
cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell")
self.assertEqual(len(cells), 8)
# 8 explicit rows set via grid-template-rows
row_count = self.browser.execute_script("""
var s = getComputedStyle(document.getElementById('id_tray_grid'));
return s.gridTemplateRows.trim().split(/\\s+/).length;
""")
self.assertEqual(row_count, 8)
# All 8 cells share the same x position — one column only
xs = {round(c.location['x']) for c in cells}
self.assertEqual(len(xs), 1)
# Cells are square
cell = cells[0]
self.assertAlmostEqual(cell.size['width'], cell.size['height'], delta=2)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Test 1bchangelist shows deck variant filter sidebar # # Test T9landscape: 8 columns × 1 row of square cells #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# T9a — column/row count (structure)
@unittest.skip("landscape grid layout flaky in CI headless Firefox — revisit")
@tag('two-browser')
def test_tray_grid_is_8_columns_by_1_row_in_landscape(self):
room = self._make_sig_select_room()
self._switch_to_landscape()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
def test_admin_tarot_card_list_shows_deck_variant_filter(self): btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
self._login_to_admin() self._simulate_drag_y(btn, 300)
self.wait_for(
lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()"))
)
self.browser.get(self.live_server_url + "/admin/epic/tarotcard/") cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell")
body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) self.assertEqual(len(cells), 8)
# Filter sidebar has a link for the Earthman deck
self.assertIn("Earthman Deck", body.text) # 8 explicit columns set via grid-template-columns
# Cards are listed — 3 seeded in setUp col_count = self.browser.execute_script("""
self.assertIn("3 tarot cards", body.text) var s = getComputedStyle(document.getElementById('id_tray_grid'));
return s.gridTemplateColumns.trim().split(/\\s+/).length;
""")
self.assertEqual(col_count, 8)
# All 8 cells share the same y position — one row only
ys = {round(c.location['y']) for c in cells}
self.assertEqual(len(ys), 1)
# Cells are square
cell = cells[0]
self.assertAlmostEqual(cell.size['width'], cell.size['height'], delta=2)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Test 1c — Earthman card detail shows name, group, and correspondence # # Test T9b — landscape: all 8 cells visible within the tray interior #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_admin_earthman_card_detail_shows_group_and_correspondence(self): @unittest.skip("landscape cell bounds flaky in CI headless Firefox — revisit with T9a")
self._login_to_admin() @tag('two-browser')
def test_landscape_tray_all_8_cells_visible(self):
room = self._make_sig_select_room()
self._switch_to_landscape()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
self.browser.get(self.live_server_url + "/admin/epic/tarotcard/") btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) self._simulate_drag_y(btn, 300)
self.wait_for(
lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()"))
)
# The Schiz is the Earthman Fool (card 0) tray = self.browser.find_element(By.ID, "id_tray")
self.browser.find_element(By.LINK_TEXT, "The Schiz").click() cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell")
self.assertEqual(len(cells), 8)
body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) tray_right = tray.location['x'] + tray.size['width']
self.assertIn("Major Arcana", body.text) # arcana dropdown tray_bottom = tray.location['y'] + tray.size['height']
self.assertIn("the-schiz-adm", body.text) # slug (readonly → rendered as text)
self.assertIn("The Fool / Il Matto", body.text) # correspondence (readonly → text) # Each cell must fit within the tray interior (2px rounding slack)
for cell in cells:
self.assertLessEqual(
cell.location['x'] + cell.size['width'], tray_right + 2,
msg="Cell overflows tray right edge"
)
self.assertLessEqual(
cell.location['y'] + cell.size['height'], tray_bottom + 2,
msg="Cell overflows tray bottom edge"
)
# ─────────────────────────────────────────────────────────────────────────────
#
# Tarot deck + Game Kit FTs — migrated from the legacy
# test_component_cards_tarot.py (2026-05-12). These exercise the in-room tarot
# deck page (Celtic Cross deal), the Game Kit deck-variant selection w. hover
# tooltips + Equip/Equipped state, and the dedicated game-kit page w. its
# four applet rows + tarot fan modal. The admin-side tarot browse FT split
# off into test_admin_tarot.py at the same time.
#
# ─────────────────────────────────────────────────────────────────────────────
class TarotDeckTest(FunctionalTest): class TarotDeckTest(FunctionalTest):
@@ -453,4 +752,3 @@ class GameKitPageTest(FunctionalTest):
self.wait_for(lambda: self.assertTrue(dialog.is_displayed())) self.wait_for(lambda: self.assertTrue(dialog.is_displayed()))
dialog.send_keys(Keys.ESCAPE) dialog.send_keys(Keys.ESCAPE)
self.wait_for(lambda: self.assertFalse(dialog.is_displayed())) self.wait_for(lambda: self.assertFalse(dialog.is_displayed()))

View File

@@ -1,177 +0,0 @@
"""FT for the gatekeeper invite via #id_bud_btn slide-out.
Replaces the legacy inline `<form action="invite_gamer">` panel inside
the gatekeeper modal. The bud-btn lives at the upper-right corner of
the right sidebar (footer in landscape); slide-out hosts the email/
username field + OK btn. Submit fires async POST to
epic:invite_gamer w. Accept: application/json — server returns
{brief, recipient_display}, JS shows the slide-down Brief banner.
"""
from selenium.webdriver.common.by import By
from apps.billboard.models import Brief
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat
from apps.lyric.models import User
from .base import FunctionalTest
class GatekeeperBudBtnPresenceTest(FunctionalTest):
"""The bud-btn renders for the room owner during gate phase, and is
absent for non-owners (friend invites are owner-only)."""
def setUp(self):
super().setUp()
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.gamer = User.objects.create(email="gamer@test.io", username="gamer")
self.room = Room.objects.create(name="Bingobango", owner=self.owner)
self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
def test_bud_btn_renders_for_owner(self):
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
def test_bud_btn_absent_for_non_owner(self):
# A registered non-owner viewer doesn't see the invite affordance.
self.create_pre_authenticated_session("gamer@test.io")
self.browser.get(self.room_url)
# Gatekeeper-specific element confirms page rendered
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal"))
self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn"))
def test_legacy_invite_email_input_is_gone(self):
"""Sanity: the old inline form has been removed."""
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal"))
self.assertFalse(self.browser.find_elements(By.ID, "id_invite_email"))
class GatekeeperBudBtnAsyncInviteTest(FunctionalTest):
"""OK on the bud-btn slide-out fires the async invite — RoomInvite
persisted, Brief w/ kind=GAME_INVITE created, slide-down banner shown."""
def setUp(self):
super().setUp()
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.room = Room.objects.create(name="Bingobango", owner=self.owner)
self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
def _open_panel_and_invite(self, recipient):
bud_btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
bud_btn.click()
recipient_input = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_recipient")
)
recipient_input.send_keys(recipient)
self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
).click()
return bud_btn
def test_invite_creates_room_invite(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertEqual(
RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io"
).count(),
1,
))
def test_invite_spawns_game_invite_brief(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertEqual(
Brief.objects.filter(
owner=self.owner, kind=Brief.KIND_GAME_INVITE,
).count(),
1,
))
def test_invite_renders_slide_down_banner(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner"))
def test_invite_closes_panel_after_success(self):
bud_btn = self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertNotIn("active", bud_btn.get_attribute("class")))
def test_invite_username_resolves_to_user_email(self):
"""Username-typed invite stores the resolved User's email."""
self._open_panel_and_invite("alice")
self.wait_for(lambda: self.assertEqual(
RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io"
).count(),
1,
))
def test_invite_auto_adds_recipient_to_owner_buds(self):
self._open_panel_and_invite("alice@test.io")
self.wait_for(lambda: self.assertIn(
self.alice, list(self.owner.buds.all())
))
class GatekeeperBudBtnDuplicateInviteErrorTest(FunctionalTest):
"""Re-inviting a recipient already seated in the room triggers the
error Brief titled `@<username> is already present`. FYI on the Brief
dismisses + adds .bud-duplicate-flash to the existing
.gate-slot.filled[data-user-id=…] element. Pending-but-unseated
duplicates also surface the Brief but FYI has no slot to highlight."""
def setUp(self):
super().setUp()
self.owner = User.objects.create(email="owner@test.io", username="owner")
self.alice = User.objects.create(email="alice@test.io", username="alice")
self.room = Room.objects.create(name="Dup Room", owner=self.owner)
# Seat alice via a GateSlot — _gate_positions renders .gate-slot.filled
# cells from GateSlot records (TableSeat spins up later at SIG SELECT),
# so the duplicate-highlight target lives there during gatekeeper phase.
GateSlot.objects.create(
room=self.room, gamer=self.alice, slot_number=1,
status=GateSlot.FILLED,
)
self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
self.create_pre_authenticated_session("owner@test.io")
self.browser.get(self.room_url)
def test_duplicate_invite_shows_error_brief_and_fyi_flashes_slot(self):
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
btn.click()
recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
recipient.send_keys("alice@test.io")
self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm").click()
title = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__title"
))
self.assertEqual(title.text, "@alice is already present")
# No new RoomInvite or Brief persisted server-side on duplicate
self.assertFalse(RoomInvite.objects.filter(
room=self.room, invitee_email="alice@test.io",
).exists())
self.assertEqual(
Brief.objects.filter(owner=self.owner, kind=Brief.KIND_GAME_INVITE).count(),
0,
)
slot = self.browser.find_element(
By.CSS_SELECTOR, f".gate-slot.filled[data-user-id='{self.alice.id}']"
)
self.assertNotIn("bud-duplicate-flash", slot.get_attribute("class") or "")
self.browser.find_element(
By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__fyi"
).click()
self.wait_for(lambda: self.assertEqual(
self.browser.find_elements(By.CSS_SELECTOR, ".note-banner--duplicate"),
[],
))
self.wait_for(lambda: self.assertIn(
"bud-duplicate-flash", slot.get_attribute("class") or ""
))

View File

@@ -1,377 +0,0 @@
import time
import unittest
from django.test import tag
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from .room_page import _assign_all_roles, _fill_room_via_orm
from apps.epic.models import Room
from apps.lyric.models import User
# ── Seat Tray ────────────────────────────────────────────────────────────────
#
# The Tray is a per-seat, per-room slide-out panel anchored to the right edge
# of the viewport. #id_tray_btn is a drawer-handle-shaped button: a circle
# with an icon (the "ivory centre") with decorative lines curving from its top
# and bottom to the right edge of the screen.
#
# Behaviour:
# - Closed by default; tray panel (#id_tray) is not visible.
# - Clicking the button while closed: wobbles the handle (adds "wobble"
# class) but does NOT open the tray.
# - Dragging the button leftward: reveals the tray.
# - Clicking the button while open: slides the tray closed.
# - On page reload: tray always starts closed (JS in-memory only).
#
# Contents (populated in later sprints): Role card, Significator, Celtic Cross
# draw, sky wheel, committed dice/cards for this table.
#
# ─────────────────────────────────────────────────────────────────────────────
class TrayTest(FunctionalTest):
def setUp(self):
# Portrait viewport for T1T5 (768×1024). Use _make_browser so
# headless CI gets --width/--height args and the CSS orientation
# media query is correct from first paint.
self.browser = self._make_browser(768, 1024)
self.test_server = None
def _switch_to_landscape(self):
"""Recreate the browser, navigate to about:blank, then resize to
900×500 and wait until window.innerWidth > window.innerHeight confirms
the CSS orientation media query will fire correctly on the next page."""
self.browser.quit()
self.browser = self._make_browser(900, 500)
self.browser.get('about:blank')
self.browser.set_window_size(900, 500)
time.sleep(0.5) # allow Firefox to flush the resize before navigating
self.wait_for(lambda: self.assertTrue(
self.browser.execute_script(
'return window.innerWidth > window.innerHeight'
)
))
def _simulate_drag(self, btn, offset_x):
"""Dispatch JS pointer events directly — more reliable than GeckoDriver drag."""
start_x = btn.rect['x'] + btn.rect['width'] / 2
end_x = start_x + offset_x
self.browser.execute_script("""
var btn = arguments[0], startX = arguments[1], endX = arguments[2];
btn.dispatchEvent(new PointerEvent("pointerdown", {clientX: startX, bubbles: true}));
document.dispatchEvent(new PointerEvent("pointermove", {clientX: endX, bubbles: true}));
document.dispatchEvent(new PointerEvent("pointerup", {clientX: endX, bubbles: true}));
""", btn, start_x, end_x)
def _simulate_drag_y(self, btn, offset_y):
"""Dispatch JS pointer events on the Y axis for landscape drag tests."""
start_y = btn.rect['y'] + btn.rect['height'] / 2
end_y = start_y + offset_y
self.browser.execute_script("""
var btn = arguments[0], startY = arguments[1], endY = arguments[2];
btn.dispatchEvent(new PointerEvent("pointerdown", {clientY: startY, clientX: 0, bubbles: true}));
document.dispatchEvent(new PointerEvent("pointermove", {clientY: endY, clientX: 0, bubbles: true}));
document.dispatchEvent(new PointerEvent("pointerup", {clientY: endY, clientX: 0, bubbles: true}));
""", btn, start_y, end_y)
def _make_role_select_room(self, founder_email="founder@test.io"):
from apps.epic.models import TableSeat
founder, _ = User.objects.get_or_create(email=founder_email)
room = Room.objects.create(name="Tray Test Room", owner=founder)
emails = [founder_email, "nc@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io"]
_fill_room_via_orm(room, emails)
room.table_status = Room.ROLE_SELECT
room.save()
for i, email in enumerate(emails, start=1):
gamer, _ = User.objects.get_or_create(email=email)
TableSeat.objects.get_or_create(room=room, gamer=gamer, slot_number=i)
return room
def _make_sig_select_room(self, founder_email="founder@test.io"):
founder, _ = User.objects.get_or_create(email=founder_email)
room = Room.objects.create(name="Tray Test Room", owner=founder)
_fill_room_via_orm(room, [
founder_email, "nc@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io",
])
_assign_all_roles(room)
return room
def _room_url(self, room):
return f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
# ------------------------------------------------------------------ #
# Test T1 — tray button is present and anchored to the right edge #
# ------------------------------------------------------------------ #
def test_tray_btn_is_present_on_room_page(self):
room = self._make_sig_select_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_tray_btn")
)
self.assertTrue(btn.is_displayed())
# Button should be anchored near the right edge of the viewport
vp_width = self.browser.execute_script("return window.innerWidth")
btn_right = btn.location["x"] + btn.size["width"]
self.assertGreater(btn_right, vp_width * 0.8)
# ------------------------------------------------------------------ #
# Test T2 — tray is closed by default; clicking wobbles the handle #
# ------------------------------------------------------------------ #
def test_tray_is_closed_by_default_and_click_wobbles(self):
room = self._make_sig_select_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
# Tray panel not visible when closed
tray = self.browser.find_element(By.ID, "id_tray")
self.assertFalse(tray.is_displayed())
# Clicking the closed btn adds a wobble class to the wrap.
# Use a MutationObserver to capture the transient class change — in CI
# headless Firefox the 0.45s animation may complete before the first
# wait_for poll (0.5s), causing a false miss.
self.browser.execute_script("""
window._trayWobbled = false;
var wrap = document.getElementById('id_tray_wrap');
var obs = new MutationObserver(function(muts) {
muts.forEach(function(m) {
if (m.type === 'attributes' && m.attributeName === 'class') {
if (m.target.classList.contains('wobble')) {
window._trayWobbled = true;
obs.disconnect();
}
}
});
});
obs.observe(wrap, {attributes: true, attributeFilter: ['class']});
""")
self.browser.find_element(By.ID, "id_tray_btn").click()
self.wait_for(
lambda: self.assertTrue(
self.browser.execute_script("return window._trayWobbled;")
)
)
# Tray still not visible — a click alone must not open it
self.assertFalse(tray.is_displayed())
# ------------------------------------------------------------------ #
# Test T3 — dragging tray btn leftward opens the tray #
# ------------------------------------------------------------------ #
def test_dragging_tray_btn_left_opens_tray(self):
room = self._make_sig_select_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
tray = self.browser.find_element(By.ID, "id_tray")
self.assertFalse(tray.is_displayed())
self._simulate_drag(btn, -300)
self.wait_for(lambda: self.assertTrue(tray.is_displayed()))
# ------------------------------------------------------------------ #
# Test T4 — clicking btn while tray is open slides it closed #
# ------------------------------------------------------------------ #
def test_clicking_open_tray_btn_closes_tray(self):
room = self._make_sig_select_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
self._simulate_drag(btn, -300)
tray = self.browser.find_element(By.ID, "id_tray")
self.wait_for(lambda: self.assertTrue(tray.is_displayed()))
self.browser.find_element(By.ID, "id_tray_btn").click()
self.wait_for(lambda: self.assertFalse(tray.is_displayed()))
# ------------------------------------------------------------------ #
# Test T5 — tray reverts to closed on page reload #
# ------------------------------------------------------------------ #
def test_tray_reverts_to_closed_on_reload(self):
room = self._make_sig_select_room()
self.create_pre_authenticated_session("founder@test.io")
room_url = self._room_url(room)
self.browser.get(room_url)
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
self._simulate_drag(btn, -300)
tray = self.browser.find_element(By.ID, "id_tray")
self.wait_for(lambda: self.assertTrue(tray.is_displayed()))
# Reload — tray must start closed regardless of previous state
self.browser.get(room_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
tray = self.browser.find_element(By.ID, "id_tray")
self.assertFalse(tray.is_displayed())
# ------------------------------------------------------------------ #
# Test T6 — landscape: tray btn is near the top edge of the viewport #
# ------------------------------------------------------------------ #
@tag('two-browser')
def test_tray_btn_anchored_near_top_in_landscape(self):
room = self._make_sig_select_room()
self._switch_to_landscape()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
btn = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_tray_btn")
)
self.assertTrue(btn.is_displayed())
# In landscape the handle sits at the top of the content area;
# btn bottom should be within the top 40% of the viewport.
vh = self.browser.execute_script("return window.innerHeight")
btn_bottom = btn.location["y"] + btn.size["height"]
self.assertLess(btn_bottom, vh * 0.4)
# ------------------------------------------------------------------ #
# Test T7 — landscape: dragging btn downward opens the tray #
# ------------------------------------------------------------------ #
@tag('two-browser')
def test_dragging_tray_btn_down_opens_tray_in_landscape(self):
room = self._make_sig_select_room()
self._switch_to_landscape()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
# In landscape, #id_tray is always display:block; position controls visibility.
# Use Tray.isOpen() to check logical state.
self.assertFalse(self.browser.execute_script("return Tray.isOpen()"))
self._simulate_drag_y(btn, 300)
self.wait_for(
lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()"))
)
# ------------------------------------------------------------------ #
# Test T8 — portrait: 1 column × 8 rows of square cells #
# ------------------------------------------------------------------ #
@unittest.skip("portrait grid layout flaky in CI headless Firefox — revisit")
@tag('two-browser')
def test_tray_grid_is_1_column_by_8_rows_in_portrait(self):
room = self._make_role_select_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
self._simulate_drag(btn, -300)
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_tray").is_displayed()
)
)
cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell")
self.assertEqual(len(cells), 8)
# 8 explicit rows set via grid-template-rows
row_count = self.browser.execute_script("""
var s = getComputedStyle(document.getElementById('id_tray_grid'));
return s.gridTemplateRows.trim().split(/\\s+/).length;
""")
self.assertEqual(row_count, 8)
# All 8 cells share the same x position — one column only
xs = {round(c.location['x']) for c in cells}
self.assertEqual(len(xs), 1)
# Cells are square
cell = cells[0]
self.assertAlmostEqual(cell.size['width'], cell.size['height'], delta=2)
# ------------------------------------------------------------------ #
# Test T9 — landscape: 8 columns × 1 row of square cells #
# ------------------------------------------------------------------ #
# T9a — column/row count (structure)
@unittest.skip("landscape grid layout flaky in CI headless Firefox — revisit")
@tag('two-browser')
def test_tray_grid_is_8_columns_by_1_row_in_landscape(self):
room = self._make_sig_select_room()
self._switch_to_landscape()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
self._simulate_drag_y(btn, 300)
self.wait_for(
lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()"))
)
cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell")
self.assertEqual(len(cells), 8)
# 8 explicit columns set via grid-template-columns
col_count = self.browser.execute_script("""
var s = getComputedStyle(document.getElementById('id_tray_grid'));
return s.gridTemplateColumns.trim().split(/\\s+/).length;
""")
self.assertEqual(col_count, 8)
# All 8 cells share the same y position — one row only
ys = {round(c.location['y']) for c in cells}
self.assertEqual(len(ys), 1)
# Cells are square
cell = cells[0]
self.assertAlmostEqual(cell.size['width'], cell.size['height'], delta=2)
# ------------------------------------------------------------------ #
# Test T9b — landscape: all 8 cells visible within the tray interior #
# ------------------------------------------------------------------ #
@unittest.skip("landscape cell bounds flaky in CI headless Firefox — revisit with T9a")
@tag('two-browser')
def test_landscape_tray_all_8_cells_visible(self):
room = self._make_sig_select_room()
self._switch_to_landscape()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
self._simulate_drag_y(btn, 300)
self.wait_for(
lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()"))
)
tray = self.browser.find_element(By.ID, "id_tray")
cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell")
self.assertEqual(len(cells), 8)
tray_right = tray.location['x'] + tray.size['width']
tray_bottom = tray.location['y'] + tray.size['height']
# Each cell must fit within the tray interior (2px rounding slack)
for cell in cells:
self.assertLessEqual(
cell.location['x'] + cell.size['width'], tray_right + 2,
msg="Cell overflows tray right edge"
)
self.assertLessEqual(
cell.location['y'] + cell.size['height'], tray_bottom + 2,
msg="Cell overflows tray bottom edge"
)