@@ -0,0 +1,343 @@
|
||||
"""Фикстуры 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()
|
||||
Reference in New Issue
Block a user