Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+343
View File
@@ -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()