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
.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:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
100
src/functional_tests/test_admin_tarot.py
Normal file
100
src/functional_tests/test_admin_tarot.py
Normal 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)
|
||||||
@@ -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 ""
|
||||||
|
))
|
||||||
@@ -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 T1–T5 (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):
|
||||||
# Seed enough cards so admin filter shows a meaningful count
|
"""Recreate the browser, navigate to about:blank, then resize to
|
||||||
# The "108 tarot cards" assertion relies on deck_variant.card_count reported
|
900×500 and wait until window.innerWidth > window.innerHeight confirms
|
||||||
# by the admin, not on actual row count (admin shows real rows, so we seed
|
the CSS orientation media query will fire correctly on the next page."""
|
||||||
# representative cards — 3 are enough to reach "The Schiz" in the list)
|
self.browser.quit()
|
||||||
for number, name, slug, group, correspondence in [
|
self.browser = self._make_browser(900, 500)
|
||||||
(0, "The Schiz", "the-schiz-adm", "", "The Fool / Il Matto"),
|
self.browser.get('about:blank')
|
||||||
(1, "Pope I: President","pope-i-president-adm","The Popes", "The Magician / Il Bagatto"),
|
self.browser.set_window_size(900, 500)
|
||||||
(50, "The Eagle", "the-eagle-adm", "", "Judgement / L'Angelo"),
|
time.sleep(0.5) # allow Firefox to flush the resize before navigating
|
||||||
]:
|
self.wait_for(lambda: self.assertTrue(
|
||||||
TarotCard.objects.get_or_create(
|
self.browser.execute_script(
|
||||||
deck_variant=self.earthman, slug=slug,
|
'return window.innerWidth > window.innerHeight'
|
||||||
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 _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()"))
|
||||||
)
|
)
|
||||||
|
|
||||||
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 #
|
# Test T8 — portrait: 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 1b — changelist shows deck variant filter sidebar #
|
# 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))
|
||||||
|
|
||||||
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()))
|
||||||
|
|
||||||
@@ -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 ""
|
|
||||||
))
|
|
||||||
@@ -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 T1–T5 (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"
|
|
||||||
)
|
|
||||||
Reference in New Issue
Block a user