import pytest from django.urls import reverse from playwright.sync_api import expect from apps.accounts.models import TicketMagicLink, User from apps.core.models import SiteBranding from apps.customers.models import CustomerProfile from apps.messaging.models import Message from apps.staff.models import StaffProfile from apps.tickets.models import TicketCategory from apps.tickets.services import create_ticket from tests.e2e.pages.auth import AuthenticationPage from tests.e2e.pages.tickets import TicketConversationPage pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)] PASSWORD = "E2e-safe-password-2026" def assert_workspace_fits_visual_viewport(page): metrics = page.evaluate( """() => { const box = document.querySelector('.ticket-workspace').getBoundingClientRect(); const viewportHeight = window.visualViewport?.height || window.innerHeight; return { top: box.top, bottom: box.bottom, viewportHeight, pageWidth: document.documentElement.scrollWidth, viewportWidth: document.documentElement.clientWidth, }; }""" ) assert metrics["top"] >= -1 assert metrics["bottom"] <= metrics["viewportHeight"] + 1 assert metrics["pageWidth"] <= metrics["viewportWidth"] + 1 def assert_reply_input_is_visible(page): metrics = page.evaluate( """() => { const input = document.querySelector('form[data-ticket-composer] textarea'); const composer = document.querySelector('[data-composer-shell]'); const inputBox = input.getBoundingClientRect(); const composerBox = composer.getBoundingClientRect(); const viewportTop = window.visualViewport?.offsetTop || 0; const viewportBottom = viewportTop + (window.visualViewport?.height || window.innerHeight); const visibleTop = Math.max(inputBox.top, composerBox.top, viewportTop); const visibleBottom = Math.min(inputBox.bottom, composerBox.bottom, viewportBottom); return {visibleHeight: Math.max(0, visibleBottom - visibleTop), inputHeight: inputBox.height}; }""" ) assert metrics["visibleHeight"] >= min(64, metrics["inputHeight"] * 0.6) def test_magic_link_mobile_viewport_keeps_composer_messages_and_credit_visible(live_server, browser_page): branding = SiteBranding.load() branding.developer_credit_text = "امضای مقدس توسعه‌دهنده E2E" branding.save() category = TicketCategory.objects.create(title="موبایل لینک امن", slug="magic-mobile-viewport") customer = User.objects.create_user(username="magic-mobile", mobile="09120009101", account_type=User.AccountType.CUSTOMER) CustomerProfile.objects.create(user=customer, first_name="مشتری", last_name="لینک امن") staff = User.objects.create_user(username="magic-mobile-staff", mobile="09120009102", account_type=User.AccountType.STAFF) StaffProfile.objects.create(user=staff, display_name="کارشناس لینک امن") ticket = create_ticket(customer=customer, category=category, subject="بررسی موبایل لینک امن", body="پیام آغازین") for index in range(12): Message.objects.create(ticket=ticket, author=staff, body=f"پاسخ کارشناس شماره {index + 1}") token = TicketMagicLink.issue(ticket) with browser_page() as page: page.set_viewport_size({"width": 390, "height": 844}) page.goto(f"{live_server.url}{reverse('accounts:magic', args=[token])}", wait_until="domcontentloaded") conversation = TicketConversationPage(page, live_server.url) credit = page.locator(".ticket-detail-credit") expect(credit).to_be_visible() expect(credit).to_contain_text("امضای مقدس توسعه‌دهنده E2E") assert_workspace_fits_visual_viewport(page) conversation.expect_latest_message_above_composer() conversation.open_composer() page.set_viewport_size({"width": 390, "height": 430}) expect(page.locator('form[data-ticket-composer] textarea')).to_be_focused() expect(page.locator("[data-composer-shell]")).to_have_attribute("data-keyboard-open", "true") page.locator('form[data-ticket-composer] textarea').fill("متن پاسخ باید هنگام تایپ دیده شود") assert_reply_input_is_visible(page) expect(credit).to_be_visible() assert_workspace_fits_visual_viewport(page) page.locator("[data-composer-close]").click() expect(page.locator("[data-composer-shell].is-collapsed")).to_be_visible() expect(credit).to_be_visible() assert_workspace_fits_visual_viewport(page) conversation.expect_latest_message_above_composer() def test_staff_ticket_mobile_viewport_survives_open_close_and_rotation(live_server, browser_page): category = TicketCategory.objects.create(title="موبایل کارشناس", slug="staff-mobile-viewport") customer = User.objects.create_user(username="staff-mobile-customer", mobile="09120009103", account_type=User.AccountType.CUSTOMER) CustomerProfile.objects.create(user=customer, first_name="مشتری", last_name="پنل کارشناس") staff = User.objects.create_user(username="staff-mobile-user", mobile="09120009104", password=PASSWORD, account_type=User.AccountType.STAFF) StaffProfile.objects.create(user=staff, display_name="کارشناس موبایل") ticket = create_ticket(customer=customer, category=category, subject="بررسی موبایل پنل کارشناس", selected_expert=staff, body="شرح درخواست مشتری") for index in range(12): Message.objects.create(ticket=ticket, author=customer, body=f"پیام مشتری شماره {index + 1}") with browser_page() as page: page.set_viewport_size({"width": 390, "height": 844}) AuthenticationPage(page, live_server.url).login_staff(staff, PASSWORD) conversation = TicketConversationPage(page, live_server.url) conversation.goto(reverse("staff_tickets:detail", args=[ticket.public_id])) expect(page.get_by_role("button", name="ثبت اقدام جدید در تیکت")).to_be_visible() assert_workspace_fits_visual_viewport(page) conversation.expect_latest_message_above_composer() conversation.open_composer() page.set_viewport_size({"width": 844, "height": 390}) assert_workspace_fits_visual_viewport(page) expect(page.locator("[data-composer-shell]")).to_have_attribute("data-keyboard-open", "true") page.locator('form[data-ticket-composer] textarea').fill("پاسخ کارشناس هنگام باز بودن کیبورد") assert_reply_input_is_visible(page) expect(page.locator("[data-composer-close]")).to_be_visible() page.locator("[data-composer-close]").click() expect(page.locator("[data-composer-shell].is-collapsed")).to_be_visible() assert_workspace_fits_visual_viewport(page) conversation.expect_latest_message_above_composer()