diff --git a/.woodpecker/main.yaml b/.woodpecker/main.yaml index c8b151b..e162d39 100644 --- a/.woodpecker/main.yaml +++ b/.woodpecker/main.yaml @@ -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 diff --git a/src/functional_tests/test_admin_post_readonly.py b/src/functional_tests/test_admin_post_readonly.py index 8c27cc6..a7e081c 100644 --- a/src/functional_tests/test_admin_post_readonly.py +++ b/src/functional_tests/test_admin_post_readonly.py @@ -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() diff --git a/src/functional_tests/test_admin_tarot.py b/src/functional_tests/test_admin_tarot.py new file mode 100644 index 0000000..9dd3e37 --- /dev/null +++ b/src/functional_tests/test_admin_tarot.py @@ -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) diff --git a/src/functional_tests/test_my_buds.py b/src/functional_tests/test_bill_my_buds.py similarity index 100% rename from src/functional_tests/test_my_buds.py rename to src/functional_tests/test_bill_my_buds.py diff --git a/src/functional_tests/test_applet_my_notes.py b/src/functional_tests/test_bill_my_notes.py similarity index 100% rename from src/functional_tests/test_applet_my_notes.py rename to src/functional_tests/test_bill_my_notes.py diff --git a/src/functional_tests/test_applet_my_posts.py b/src/functional_tests/test_bill_my_posts.py similarity index 100% rename from src/functional_tests/test_applet_my_posts.py rename to src/functional_tests/test_bill_my_posts.py diff --git a/src/functional_tests/test_applet_new_post.py b/src/functional_tests/test_bill_new_post.py similarity index 100% rename from src/functional_tests/test_applet_new_post.py rename to src/functional_tests/test_bill_new_post.py diff --git a/src/functional_tests/test_applet_new_post_line_validation.py b/src/functional_tests/test_bill_new_post_line_validation.py similarity index 100% rename from src/functional_tests/test_applet_new_post_line_validation.py rename to src/functional_tests/test_bill_new_post_line_validation.py diff --git a/src/functional_tests/test_bud_btn.py b/src/functional_tests/test_core_bud_btn.py similarity index 69% rename from src/functional_tests/test_bud_btn.py rename to src/functional_tests/test_core_bud_btn.py index e6aa8c6..b673dbe 100644 --- a/src/functional_tests/test_bud_btn.py +++ b/src/functional_tests/test_core_bud_btn.py @@ -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 () 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 `
` 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 `@ 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 "" + )) diff --git a/src/functional_tests/test_login.py b/src/functional_tests/test_core_login.py similarity index 100% rename from src/functional_tests/test_login.py rename to src/functional_tests/test_core_login.py diff --git a/src/functional_tests/test_navbar.py b/src/functional_tests/test_core_navbar.py similarity index 100% rename from src/functional_tests/test_navbar.py rename to src/functional_tests/test_core_navbar.py diff --git a/src/functional_tests/test_sharing.py b/src/functional_tests/test_core_sharing.py similarity index 100% rename from src/functional_tests/test_sharing.py rename to src/functional_tests/test_core_sharing.py diff --git a/src/functional_tests/test_layout_and_styling.py b/src/functional_tests/test_core_styling.py similarity index 100% rename from src/functional_tests/test_layout_and_styling.py rename to src/functional_tests/test_core_styling.py diff --git a/src/functional_tests/test_applet_my_sky.py b/src/functional_tests/test_dash_my_sky.py similarity index 100% rename from src/functional_tests/test_applet_my_sky.py rename to src/functional_tests/test_dash_my_sky.py diff --git a/src/functional_tests/test_applet_palette.py b/src/functional_tests/test_dash_palette.py similarity index 100% rename from src/functional_tests/test_applet_palette.py rename to src/functional_tests/test_dash_palette.py diff --git a/src/functional_tests/test_wallet.py b/src/functional_tests/test_dash_wallet.py similarity index 100% rename from src/functional_tests/test_wallet.py rename to src/functional_tests/test_dash_wallet.py diff --git a/src/functional_tests/test_deck_contribution.py b/src/functional_tests/test_game_room_deck_contrib.py similarity index 100% rename from src/functional_tests/test_deck_contribution.py rename to src/functional_tests/test_game_room_deck_contrib.py diff --git a/src/functional_tests/test_room_gatekeeper.py b/src/functional_tests/test_game_room_gatekeeper.py similarity index 100% rename from src/functional_tests/test_room_gatekeeper.py rename to src/functional_tests/test_game_room_gatekeeper.py diff --git a/src/functional_tests/test_game_invite.py b/src/functional_tests/test_game_room_invite.py similarity index 100% rename from src/functional_tests/test_game_invite.py rename to src/functional_tests/test_game_room_invite.py diff --git a/src/functional_tests/test_room_role_select.py b/src/functional_tests/test_game_room_select_role.py similarity index 100% rename from src/functional_tests/test_room_role_select.py rename to src/functional_tests/test_game_room_select_role.py diff --git a/src/functional_tests/test_room_sea_select.py b/src/functional_tests/test_game_room_select_sea.py similarity index 100% rename from src/functional_tests/test_room_sea_select.py rename to src/functional_tests/test_game_room_select_sea.py diff --git a/src/functional_tests/test_room_sig_select.py b/src/functional_tests/test_game_room_select_sig.py similarity index 100% rename from src/functional_tests/test_room_sig_select.py rename to src/functional_tests/test_game_room_select_sig.py diff --git a/src/functional_tests/test_room_sky_select.py b/src/functional_tests/test_game_room_select_sky.py similarity index 100% rename from src/functional_tests/test_room_sky_select.py rename to src/functional_tests/test_game_room_select_sky.py diff --git a/src/functional_tests/test_component_cards_tarot.py b/src/functional_tests/test_game_room_tray.py similarity index 51% rename from src/functional_tests/test_component_cards_tarot.py rename to src/functional_tests/test_game_room_tray.py index bd2fa1b..e0c4469 100644 --- a/src/functional_tests/test_component_cards_tarot.py +++ b/src/functional_tests/test_game_room_tray.py @@ -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())) - diff --git a/src/functional_tests/test_component_tray_tooltip.py b/src/functional_tests/test_game_room_tray_tooltip.py similarity index 100% rename from src/functional_tests/test_component_tray_tooltip.py rename to src/functional_tests/test_game_room_tray_tooltip.py diff --git a/src/functional_tests/test_gatekeeper_bud_btn.py b/src/functional_tests/test_gatekeeper_bud_btn.py deleted file mode 100644 index 7f9a62a..0000000 --- a/src/functional_tests/test_gatekeeper_bud_btn.py +++ /dev/null @@ -1,177 +0,0 @@ -"""FT for the gatekeeper invite via #id_bud_btn slide-out. - -Replaces the legacy inline `` 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 `@ 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 "" - )) diff --git a/src/functional_tests/test_room_tray.py b/src/functional_tests/test_room_tray.py deleted file mode 100644 index c15beb6..0000000 --- a/src/functional_tests/test_room_tray.py +++ /dev/null @@ -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" - )