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
|
||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||
depends_on:
|
||||
- test-UTs-n-ITs
|
||||
environment:
|
||||
HEADLESS: 1
|
||||
CELERY_BROKER_URL: redis://redis:6379/0
|
||||
@@ -49,6 +51,8 @@ steps:
|
||||
commands:
|
||||
- pip install -r requirements.dev.txt
|
||||
- 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 test functional_tests --tag=two-browser
|
||||
- python manage.py test functional_tests --tag=sequential
|
||||
@@ -60,8 +64,24 @@ steps:
|
||||
- "requirements.txt"
|
||||
- ".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
|
||||
depends_on:
|
||||
- test-two-browser-FTs
|
||||
environment:
|
||||
HEADLESS: 1
|
||||
CELERY_BROKER_URL: redis://redis:6379/0
|
||||
@@ -74,8 +94,37 @@ steps:
|
||||
commands:
|
||||
- pip install -r requirements.dev.txt
|
||||
- cd ./src
|
||||
- python manage.py collectstatic --noinput
|
||||
- python manage.py test functional_tests --parallel --exclude-tag=channels --exclude-tag=two-browser
|
||||
# Every FT file EXCEPT test_game_room_* — that cluster runs in
|
||||
# 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:
|
||||
- event: push
|
||||
path:
|
||||
@@ -85,6 +134,9 @@ steps:
|
||||
|
||||
- name: screendumps
|
||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||
depends_on:
|
||||
- test-FTs-non-room
|
||||
- test-FTs-room
|
||||
commands:
|
||||
- cat ./src/functional_tests/screendumps/*.html || echo "No screendumps found"
|
||||
when:
|
||||
@@ -97,6 +149,9 @@ steps:
|
||||
|
||||
- name: build-and-push
|
||||
image: docker:cli
|
||||
depends_on:
|
||||
- test-FTs-non-room
|
||||
- test-FTs-room
|
||||
environment:
|
||||
REGISTRY_PASSWORD:
|
||||
from_secret: gitea_registry_password
|
||||
@@ -115,6 +170,8 @@ steps:
|
||||
|
||||
- name: deploy-staging
|
||||
image: alpine
|
||||
depends_on:
|
||||
- build-and-push
|
||||
environment:
|
||||
SSH_KEY:
|
||||
from_secret: deploy_ssh_key
|
||||
@@ -136,6 +193,8 @@ steps:
|
||||
|
||||
- name: deploy-prod
|
||||
image: alpine
|
||||
depends_on:
|
||||
- build-and-push
|
||||
environment:
|
||||
SSH_KEY:
|
||||
from_secret: deploy_ssh_key
|
||||
|
||||
@@ -62,7 +62,7 @@ class AdminPostInputReadonlyTest(FunctionalTest):
|
||||
class AdminPostHasNoBudBtnTest(FunctionalTest):
|
||||
"""Admin-Post (note-unlock thread) suppresses #id_bud_btn — friend
|
||||
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):
|
||||
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
|
||||
agent (or future Disco) can land the feature without losing intent. Run:
|
||||
• post.html (bottom-left) — async POST to billboard:share_post
|
||||
• 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
|
||||
@@ -92,6 +103,7 @@ from selenium.webdriver.common.keys import Keys
|
||||
|
||||
from apps.applets.models import Applet
|
||||
from apps.billboard.models import Brief, Line, Post
|
||||
from apps.epic.models import GateSlot, Room, RoomInvite
|
||||
from apps.lyric.models import User
|
||||
|
||||
from .base import FunctionalTest
|
||||
@@ -425,3 +437,176 @@ class BudBtnDuplicateShareErrorTest(FunctionalTest):
|
||||
self.wait_for(lambda: self.assertIn(
|
||||
"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
|
||||
|
||||
from django.test import tag
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from .base import FunctionalTest
|
||||
from .room_page import _assign_all_roles, _fill_room_via_orm
|
||||
from apps.applets.models import Applet
|
||||
from apps.epic.models import DeckVariant, Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class TarotAdminTest(FunctionalTest):
|
||||
"""Admin can browse tarot cards by deck variant via Django admin."""
|
||||
# ── 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):
|
||||
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,
|
||||
},
|
||||
# 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'
|
||||
)
|
||||
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):
|
||||
self._login_to_admin()
|
||||
@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))
|
||||
|
||||
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)
|
||||
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 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):
|
||||
self._login_to_admin()
|
||||
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()"))
|
||||
)
|
||||
|
||||
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)
|
||||
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 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):
|
||||
self._login_to_admin()
|
||||
@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))
|
||||
|
||||
self.browser.get(self.live_server_url + "/admin/epic/tarotcard/")
|
||||
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._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)
|
||||
self.browser.find_element(By.LINK_TEXT, "The Schiz").click()
|
||||
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)
|
||||
|
||||
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)
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# 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):
|
||||
@@ -453,4 +752,3 @@ class GameKitPageTest(FunctionalTest):
|
||||
self.wait_for(lambda: self.assertTrue(dialog.is_displayed()))
|
||||
dialog.send_keys(Keys.ESCAPE)
|
||||
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