344 lines
11 KiB
Python
344 lines
11 KiB
Python
"""Фикстуры 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()
|