Files
site/WESP_REL/tests/e2e/conftest.py
T
2026-07-17 12:57:18 +03:00

344 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Фикстуры 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()