"""Фикстуры Playwright E2E: live_server, auth, seed данных /recipes и zootech.""" from __future__ import annotations import os import socket import tempfile import threading from typing import Generator, Tuple import pytest from werkzeug.serving import make_server pytest.importorskip("playwright") from app import create_app, db from app.models import Component, FeedDispenser, Ingredient, Recipe, UnloadingGroup from app.services.setup_state import mark_setup_complete from config import TestingConfig from tests.helpers.dispenser_recipe_fixtures import ( E2E_DISP_ID, E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2, E2E_PERIOD_A, E2E_PERIOD_B, reset_e2e_dispenser_period_state, seed_dispenser_period_recipes, ) E2E_MILL_ID = "e2e-mill-1" E2E_RECIPE_ID = "e2e-recipe-1" E2E_COMP_A = "e2e-comp-a" E2E_COMP_B = "e2e-comp-b" E2E_ING_A = "e2e-ing-a" E2E_ING_B = "e2e-ing-b" E2E_LOGIN = "recipes-e2e-admin" E2E_PASSWORD = "recipes-e2e-secret" class RecipesE2EConfig(TestingConfig): _TMP_DIR = tempfile.mkdtemp(prefix="wesp-recipes-e2e-") SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'recipes_e2e.db')}" SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_e2e.db')}"} AUTH_LOGIN = E2E_LOGIN AUTH_PASSWORD = E2E_PASSWORD TESTING = True def _free_port() -> int: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind(("127.0.0.1", 0)) port = sock.getsockname()[1] sock.close() return port def _seed_e2e_recipes_data() -> None: db.session.add_all( [ FeedDispenser( id=E2E_MILL_ID, name="E2E Кормоцех", farm="Ферма", operator="Тест", type="mill", content_hash="", ), Component( id=E2E_COMP_A, name="E2E Компонент A", type="grain", dry_matter=50.0, protein=0.0, energy=0.0, price=0.0, ), Component( id=E2E_COMP_B, name="E2E Компонент B", type="grain", dry_matter=60.0, protein=0.0, energy=0.0, price=0.0, ), Recipe( id=E2E_RECIPE_ID, name="E2E Два компонента", heads_per_trip=10, mixing_time=5, trip_percent=100.0, target_component_id=E2E_COMP_A, content_hash="", ), Ingredient( id=E2E_ING_A, name="Ing A", weight_per_head=1.0, amount=10.0, dry_matter=50.0, component_id=E2E_COMP_A, order=1, recipe_id=E2E_RECIPE_ID, created_by="system", updated_by="system", ), Ingredient( id=E2E_ING_B, name="Ing B", weight_per_head=2.0, amount=20.0, dry_matter=60.0, component_id=E2E_COMP_B, order=2, recipe_id=E2E_RECIPE_ID, created_by="system", updated_by="system", ), UnloadingGroup( id="e2e-grp-1", name="E2E Группа", distribution_type="percent", value=100.0, weight=30.0, order=1, recipe_id=E2E_RECIPE_ID, created_by="system", updated_by="system", ), ] ) db.session.commit() @pytest.fixture(autouse=True) def _reset_e2e_dispenser_state(e2e_app) -> None: with e2e_app.app_context(): reset_e2e_dispenser_period_state() for rid, default_name in ( (E2E_RECIPE_ID, "E2E Два компонента"), (E2E_DISP_RECIPE_1, "E2E Рейс 1"), (E2E_DISP_RECIPE_2, "E2E Рейс 2"), ): recipe = db.session.get(Recipe, rid) if recipe is not None: recipe.name = default_name recipe.unloading_link_broken = False db.session.commit() @pytest.fixture(scope="session") def e2e_app(): app = create_app(RecipesE2EConfig) with app.app_context(): db.create_all() mark_setup_complete(app) _seed_e2e_recipes_data() seed_dispenser_period_recipes() yield app with app.app_context(): db.session.remove() db.drop_all() @pytest.fixture(scope="session") def live_server_url(e2e_app) -> Generator[str, None, None]: port = _free_port() server = make_server("127.0.0.1", port, e2e_app, threaded=True) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() yield f"http://127.0.0.1:{port}" server.shutdown() @pytest.fixture def zootech_authenticated_page(page, live_server_url): """Залогиненная страница для zootech-страниц (не только /recipes).""" resp = page.request.post( f"{live_server_url}/api/auth/login", headers={"Content-Type": "application/json"}, data='{"login":"%s","password":"%s"}' % (E2E_LOGIN, E2E_PASSWORD), ) assert resp.ok, resp.text() return page @pytest.fixture def authenticated_page(page, live_server_url): """Страница с сессией зоотехника после POST /api/auth/login.""" resp = page.request.post( f"{live_server_url}/api/auth/login", headers={"Content-Type": "application/json"}, data='{"login":"%s","password":"%s"}' % (E2E_LOGIN, E2E_PASSWORD), ) assert resp.ok, resp.text() page.goto(f"{live_server_url}/recipes") page.wait_for_selector("#recipesDashboard", state="visible", timeout=15000) return page @pytest.fixture def mill_recipe_open(authenticated_page, live_server_url): """Открытый редактор рецепта кормоцеха с двумя ингредиентами.""" page = authenticated_page page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_MILL_ID}"]').click() recipe_sel = f'[data-action="select-recipe"][data-recipe-id="{E2E_RECIPE_ID}"]' page.wait_for_selector(recipe_sel, timeout=10000) page.locator(recipe_sel).click() page.wait_for_selector("#recipeEdit", state="visible", timeout=15000) page.wait_for_selector("#ingredientsTableBody .zt-data-grid__row", timeout=10000) return page @pytest.fixture def dispenser_dashboard_open(authenticated_page): """Dashboard кормораздатчика: выбран dispenser, виден period.""" page = authenticated_page page.locator(f'[data-action="select-dispenser"][data-dispenser-id="{E2E_DISP_ID}"]').click() page.wait_for_selector( f'[data-action="select-period"][data-period-id="{E2E_PERIOD_A}"]', timeout=10000 ) return page @pytest.fixture def dispenser_recipe_open(dispenser_dashboard_open): """Редактор рецепта кормораздатчика в period A.""" page = dispenser_dashboard_open page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_A}"]').click() recipe_sel = f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]' page.wait_for_selector(recipe_sel, timeout=10000) page.locator(recipe_sel).click() page.wait_for_selector("#recipeEdit", state="visible", timeout=15000) page.wait_for_selector("#unloadingGroupsTableBody .unloading-group-row", timeout=10000) return page @pytest.fixture def two_periods_setup(dispenser_dashboard_open): """Dispenser с двумя периодами — period A выбран, список рейсов виден.""" page = dispenser_dashboard_open page.locator(f'[data-action="select-period"][data-period-id="{E2E_PERIOD_A}"]').click() page.wait_for_selector( f'[data-action="select-recipe"][data-recipe-id="{E2E_DISP_RECIPE_1}"]', timeout=10000 ) return page # --- Dual-terminal E2E (SyncDualInstanceHarness + два Werkzeug-сервера) --- from tests.helpers.sync_dual_harness import SyncDualInstanceHarness def _dual_free_port() -> int: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind(("127.0.0.1", 0)) port = sock.getsockname()[1] sock.close() return port def _dual_start_server(app, port: int): server = make_server("127.0.0.1", port, app, threaded=True) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() return server def _dual_login_page(page, base_url: str) -> None: login = SyncDualInstanceHarness.AUTH_LOGIN password = SyncDualInstanceHarness.AUTH_PASSWORD resp = page.request.post( f"{base_url}/api/auth/login", headers={"Content-Type": "application/json"}, data='{"login":"%s","password":"%s"}' % (login, password), ) assert resp.ok, resp.text() @pytest.fixture(scope="module") def dual_harness() -> Generator[SyncDualInstanceHarness, None, None]: harness = SyncDualInstanceHarness() harness.start() disp_id, period_a, period_b, r1, r2 = harness.seed_dispenser_two_periods_two_recipes() harness._e2e_disp_id = disp_id # type: ignore[attr-defined] harness._e2e_period_a = period_a # type: ignore[attr-defined] harness._e2e_period_b = period_b # type: ignore[attr-defined] harness._e2e_r1 = r1 # type: ignore[attr-defined] harness._e2e_r2 = r2 # type: ignore[attr-defined] yield harness harness.stop() @pytest.fixture(scope="module") def live_server_a_url(dual_harness) -> Generator[str, None, None]: port = _dual_free_port() server = _dual_start_server(dual_harness.term_a_app, port) yield f"http://127.0.0.1:{port}" server.shutdown() @pytest.fixture(scope="module") def live_server_b_url(dual_harness) -> Generator[str, None, None]: port = _dual_free_port() server = _dual_start_server(dual_harness.term_b_app, port) yield f"http://127.0.0.1:{port}" server.shutdown() @pytest.fixture def page_a(browser, live_server_a_url) -> Generator: context = browser.new_context(viewport={"width": 1280, "height": 800}) page = context.new_page() _dual_login_page(page, live_server_a_url) page.goto(f"{live_server_a_url}/recipes") page.wait_for_selector("#recipesDashboard", timeout=15000) yield page context.close() @pytest.fixture def page_b(browser, live_server_b_url) -> Generator: context = browser.new_context(viewport={"width": 1280, "height": 800}) page = context.new_page() _dual_login_page(page, live_server_b_url) page.goto(f"{live_server_b_url}/recipes") page.wait_for_selector("#recipesDashboard", timeout=15000) yield page context.close() @pytest.fixture def dual_dispenser_period_a(page_a, dual_harness, live_server_a_url): disp_id = dual_harness._e2e_disp_id # type: ignore[attr-defined] period_a = dual_harness._e2e_period_a # type: ignore[attr-defined] page_a.locator(f'[data-action="select-dispenser"][data-dispenser-id="{disp_id}"]').click() page_a.locator(f'[data-action="select-period"][data-period-id="{period_a}"]').click() page_a.wait_for_selector("#recipesList .list-item", timeout=10000) return page_a, dual_harness, live_server_a_url, period_a def sync_both(dual_harness: SyncDualInstanceHarness) -> None: dual_harness.drain_sync()